feat(prototype): 중1~고3 샘플 ebook 6권 + PS 제거 + 복습캘린더 + 태그 삭제

### 샘플 ebook (대회 시연용 B2B 데모)
- /exams 카탈로그: API 호출/필터 제거, EBOOK_LIST 기반 6카드 정적 렌더
- /exams/sample-ebook/[id] 동적 라우트: 중1~고3 수학 6권 × 30문항 + 해설
- 교육과정별 단원 커버 (중1: 정수·일차방정식 ~ 고3: 극한·벡터)

### PS (Solved.ac) 도메인 제거
- backend: PsModule/PsService/PsController/SolvedAcClient 전체 삭제, app.module 정리
- frontend: /ps 라우트, PsTabs, SideNav/BottomNav PS 항목, api.ts PS 타입 전부 제거
- onboarding: ps.sync() 호출 제거

### 복습큐 → 복습캘린더
- backend: GET /reviews/calendar?year&month, GET /reviews/day?date API 추가
- frontend: 대시보드 "오늘 복습 큐" 카드 → ReviewCalendar 위젯 교체
  - 월간 7열 그리드, 날짜 클릭 시 해당일 복습 목록 인라인 패널
  - 밀린 날 warning / 완료 날 success / 오늘 하이라이트
  - 0건이면 "쉬어가자" 안내

### 태그 삭제 기능
- subjects/page.tsx: 태그 행 × 아이콘 → ConfirmDialog (danger) → DELETE /tags/:id
- 낙관적 업데이트 + 실패 시 롤백

backend tsc + frontend tsc 클린.
This commit is contained in:
reloop
2026-04-16 10:44:55 +09:00
parent 57cd305166
commit 03ce2c64fd
30 changed files with 2424 additions and 3080 deletions

View File

@@ -17,7 +17,6 @@ import { ProblemSetsModule } from "./problem-sets/problem-sets.module";
import { OrganizationsModule } from "./organizations/organizations.module";
import { ClassesModule } from "./classes/classes.module";
import { AssignmentsModule } from "./assignments/assignments.module";
import { PsModule } from "./ps/ps.module";
class AppThrottlerGuard extends ThrottlerGuard {
protected async throwThrottlingException(): Promise<void> {
@@ -50,7 +49,6 @@ class AppThrottlerGuard extends ThrottlerGuard {
OrganizationsModule,
ClassesModule,
AssignmentsModule,
PsModule,
],
providers: [
{

View File

@@ -1,129 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { IsInt, IsOptional, IsString, Matches, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { AuthUser } from '../auth/jwt.strategy';
import { PsService } from './ps.service';
class SearchQueryDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(30)
level?: number;
@IsOptional()
@IsString()
tag?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
}
class BookmarkDto {
@Type(() => Number)
@IsInt()
bojId: number;
@IsOptional()
@IsString()
memo?: string;
}
class SyncRequestDto {
@IsOptional()
@Matches(/^[a-zA-Z0-9_-]{3,20}$/)
bojHandle?: string;
}
class SolvedListQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number;
}
@Controller('ps')
@UseGuards(JwtAuthGuard)
export class PsController {
constructor(private readonly ps: PsService) {}
@Get('search')
@Throttle({ default: { limit: 30, ttl: 60_000 } })
search(@Query() query: SearchQueryDto) {
return this.ps.search({
query: query.q,
level: query.level,
tag: query.tag,
page: query.page,
});
}
@Get('problems/:bojId')
@Throttle({ default: { limit: 30, ttl: 60_000 } })
getProblem(@Param('bojId', ParseIntPipe) bojId: number) {
return this.ps.getProblem(bojId);
}
@Get('bookmarks')
@Throttle({ default: { limit: 30, ttl: 60_000 } })
listBookmarks(@CurrentUser() user: AuthUser) {
return this.ps.listBookmarks(user.id);
}
@Post('bookmarks')
@Throttle({ default: { limit: 30, ttl: 60_000 } })
bookmark(@CurrentUser() user: AuthUser, @Body() dto: BookmarkDto) {
return this.ps.bookmark(user.id, dto.bojId, dto.memo);
}
@Delete('bookmarks/:bojId')
@Throttle({ default: { limit: 30, ttl: 60_000 } })
async removeBookmark(
@CurrentUser() user: AuthUser,
@Param('bojId', ParseIntPipe) bojId: number,
) {
await this.ps.removeBookmark(user.id, bojId);
return { success: true };
}
@Post('sync')
@Throttle({ default: { limit: 3, ttl: 60_000 } })
sync(@CurrentUser() user: AuthUser, @Body() dto: SyncRequestDto) {
return this.ps.sync(user.id, dto.bojHandle);
}
@Get('solved')
@Throttle({ default: { limit: 30, ttl: 60_000 } })
listSolved(@CurrentUser() user: AuthUser, @Query() query: SolvedListQueryDto) {
return this.ps.listSolvedProblems(user.id, query.page, query.pageSize);
}
}

View File

@@ -1,17 +0,0 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { StudyLogsModule } from '../study-logs/study-logs.module';
import { PsController } from './ps.controller';
import { PsService } from './ps.service';
import SolvedAcClient from './solved-ac.client';
@Module({
imports: [PrismaModule, StudyLogsModule],
controllers: [PsController],
providers: [
PsService,
{ provide: SolvedAcClient, useFactory: () => new SolvedAcClient() },
],
exports: [PsService],
})
export class PsModule {}

View File

@@ -1,339 +0,0 @@
import {
BadRequestException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { Prisma, PsBookmark, PsProblem, StudyResult } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import SolvedAcClient, {
SolvedAcClientError,
type SolvedAcProblem,
type SolvedAcSearchResponse,
type SolvedAcUser,
type SolvedAcUserProblemStat,
} from './solved-ac.client';
import { StudyLogsService } from '../study-logs/study-logs.service';
interface SearchParams {
query?: string;
level?: number;
tag?: string;
page?: number;
}
export interface PsProblemTagSummary {
key: string;
displayName: string;
}
export interface PsProblemSummary {
bojId: number;
title: string;
titleKo: string | null;
level: number;
tags: PsProblemTagSummary[] | null;
}
@Injectable()
export class PsService {
private static readonly SUBJECT_NAME = 'PS';
private static readonly SUBJECT_COLOR = '#10b981';
constructor(
private readonly prisma: PrismaService,
private readonly solvedAc: SolvedAcClient,
private readonly studyLogs: StudyLogsService,
) {}
async search(params: SearchParams): Promise<SolvedAcSearchResponse> {
try {
const response = await this.solvedAc.searchProblems(params);
await Promise.all(response.items.map((item) => this.upsertPsProblem(item)));
return response;
} catch (error) {
throw this.translateClientError(error);
}
}
async getProblem(bojId: number): Promise<PsProblem> {
const cached = await this.prisma.psProblem.findUnique({
where: { bojId },
});
if (cached) return cached;
try {
const problem = await this.solvedAc.getProblem(bojId);
return this.upsertPsProblem(problem);
} catch (error) {
throw this.translateClientError(error);
}
}
async bookmark(userId: number, bojId: number, memo?: string): Promise<PsBookmark> {
const psProblem = await this.getProblem(bojId);
return this.prisma.psBookmark.upsert({
where: {
userId_psProblemId: {
userId,
psProblemId: psProblem.id,
},
},
update: { memo: memo ?? null },
create: {
userId,
psProblemId: psProblem.id,
memo: memo ?? null,
},
include: { psProblem: true },
});
}
async removeBookmark(userId: number, bojId: number) {
await this.prisma.psBookmark.deleteMany({
where: {
userId,
psProblem: { bojId },
},
});
}
async listBookmarks(userId: number) {
return this.prisma.psBookmark.findMany({
where: { userId },
include: { psProblem: true },
orderBy: { createdAt: 'desc' },
});
}
async sync(userId: number, handleOverride?: string) {
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId } });
const requestedHandle = handleOverride?.trim();
const storedHandle = user.bojHandle?.trim();
const handle = requestedHandle || storedHandle;
if (!handle) throw new BadRequestException('bojHandle is required');
if (requestedHandle && requestedHandle !== storedHandle) {
await this.prisma.user.update({
where: { id: userId },
data: { bojHandle: requestedHandle },
});
}
const syncState = await this.prisma.psSyncState.upsert({
where: { userId },
create: { userId },
update: {},
});
let stats: SolvedAcUserProblemStat[];
let topProblems: SolvedAcProblem[];
let profile: SolvedAcUser;
try {
[stats, topProblems, profile] = await Promise.all([
this.solvedAc.getUserProblemStats(handle),
this.solvedAc.getUserTop100(handle),
this.solvedAc.getUser(handle),
]);
} catch (error) {
throw this.translateClientError(error);
}
const solvedFromStats = stats.reduce((sum, entry) => sum + (entry.solved ?? 0), 0);
const totalSolved = Math.max(profile?.solvedCount ?? 0, solvedFromStats);
const previousSolved = syncState.lastSolvedCount ?? 0;
const diff = Math.max(0, totalSolved - previousSolved);
const subject = await this.ensurePsSubject(userId);
const limit = previousSolved === 0 ? topProblems.length : Math.min(diff, topProblems.length);
const targetProblems = topProblems.slice(0, limit);
const psRecords = await Promise.all(
targetProblems.map((problem) => this.upsertPsProblem(problem)),
);
const psProblemIds = psRecords.map((record) => record.id);
const existingIds = new Set<number>();
if (psProblemIds.length > 0) {
const existing = await this.prisma.studyLog.findMany({
where: {
userId,
psProblemId: { in: psProblemIds },
},
select: { psProblemId: true },
});
for (const row of existing) {
if (row.psProblemId) existingIds.add(row.psProblemId);
}
}
let importedCount = 0;
const importedProblems: PsProblemSummary[] = [];
for (const record of psRecords) {
if (existingIds.has(record.id)) continue;
await this.studyLogs.create(userId, {
subjectId: subject.id,
title: record.titleKo ?? record.title,
difficulty: this.normalizeDifficulty(record.level),
result: StudyResult.correct,
psProblemId: record.id,
});
importedCount += 1;
importedProblems.push(this.toProblemSummary(record));
}
const skippedCount = psRecords.length - importedCount;
await this.prisma.psSyncState.update({
where: { userId },
data: {
lastSolvedCount: totalSolved,
lastTier: profile?.tier ?? null,
lastSyncedAt: new Date(),
},
});
return {
handle,
importedCount,
skippedCount,
totalSolved,
importedProblems,
};
}
async listSolvedProblems(userId: number, page = 1, pageSize = 20) {
const take = Math.min(50, Math.max(1, pageSize));
const currentPage = Math.max(1, page);
const skip = (currentPage - 1) * take;
const [logs, total] = await Promise.all([
this.prisma.studyLog.findMany({
where: {
userId,
psProblemId: { not: null },
},
include: { psProblem: true },
orderBy: { studiedAt: 'desc' },
skip,
take,
}),
this.prisma.studyLog.count({
where: { userId, psProblemId: { not: null } },
}),
]);
return {
items: logs.map((log) => ({
studyLogId: log.id,
studiedAt: log.studiedAt,
psProblem: log.psProblem ? this.toProblemSummary(log.psProblem) : null,
})),
total,
};
}
private async upsertPsProblem(problem: SolvedAcProblem) {
const title = problem.title ?? problem.titleKo ?? `BOJ ${problem.problemId}`;
return this.prisma.psProblem.upsert({
where: { bojId: problem.problemId },
update: {
title,
titleKo: problem.titleKo ?? null,
level: problem.level ?? 0,
tags: this.serializeTags(problem.tags),
acceptedUserCount: problem.acceptedUserCount ?? null,
averageTries: problem.averageTries ?? null,
solvedacUpdatedAt: new Date(),
},
create: {
bojId: problem.problemId,
title,
titleKo: problem.titleKo ?? null,
level: problem.level ?? 0,
tags: this.serializeTags(problem.tags),
acceptedUserCount: problem.acceptedUserCount ?? null,
averageTries: problem.averageTries ?? null,
solvedacUpdatedAt: new Date(),
},
});
}
private async ensurePsSubject(userId: number) {
return this.prisma.subject.upsert({
where: {
userId_name: {
userId,
name: PsService.SUBJECT_NAME,
},
},
update: {},
create: {
userId,
name: PsService.SUBJECT_NAME,
color: PsService.SUBJECT_COLOR,
},
});
}
private normalizeDifficulty(level: number) {
const ratio = (level ?? 0) / 30;
return Math.min(1, Math.max(0.1, ratio));
}
private serializeTags(tags?: SolvedAcProblem['tags']) {
if (!tags) return Prisma.JsonNull;
return tags as unknown as Prisma.JsonValue;
}
private toProblemSummary(problem: PsProblem): PsProblemSummary {
return {
bojId: problem.bojId,
title: problem.title,
titleKo: problem.titleKo,
level: problem.level,
tags: this.mapProblemTags(problem.tags),
};
}
private mapProblemTags(value: Prisma.JsonValue | null): PsProblemTagSummary[] | null {
if (!Array.isArray(value)) return null;
const tags: PsProblemTagSummary[] = [];
for (const entry of value) {
if (!entry || typeof entry !== 'object') continue;
const key = (entry as { key?: unknown }).key;
if (typeof key !== 'string') continue;
const displayName = this.pickTagDisplayName((entry as { displayNames?: unknown }).displayNames);
tags.push({
key,
displayName: displayName ?? key,
});
}
return tags.length > 0 ? tags : null;
}
private pickTagDisplayName(raw: unknown): string | null {
if (!Array.isArray(raw)) return null;
let fallback: string | null = null;
for (const item of raw) {
if (!item || typeof item !== 'object') continue;
const name = (item as { name?: unknown }).name;
if (typeof name !== 'string') continue;
const language = (item as { language?: unknown }).language;
if (typeof language === 'string' && language.toLowerCase() === 'ko') {
return name;
}
if (!fallback) {
fallback = name;
}
}
return fallback;
}
private translateClientError(error: unknown): never {
if (error instanceof SolvedAcClientError) {
if (error.statusCode === 404) {
throw new NotFoundException('Solved.ac resource not found');
}
throw new ServiceUnavailableException('Solved.ac API is unavailable');
}
throw error;
}
}

View File

@@ -1,126 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import axios from 'axios';
import SolvedAcClient, {
SolvedAcClientError,
type SolvedAcProblem,
type SolvedAcSearchResponse,
type SolvedAcUser,
} from './solved-ac.client';
vi.mock('axios');
const mockedAxios = axios as unknown as {
create: vi.Mock;
isAxiosError: vi.Mock;
};
describe('SolvedAcClient', () => {
const mockGet = vi.fn();
beforeEach(() => {
mockGet.mockReset();
mockedAxios.create.mockReturnValue({
get: mockGet,
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
});
mockedAxios.isAxiosError.mockImplementation(
(error: unknown): error is Error & { response?: { status?: number } } =>
typeof error === 'object' && error !== null && 'response' in error,
);
});
it('searchProblems returns parsed response', async () => {
const response: SolvedAcSearchResponse = {
count: 1,
items: [{ problemId: 1000, level: 5, title: 'Two Sum' }],
};
mockGet.mockResolvedValue({ data: response });
const client = new SolvedAcClient({ sleep: () => Promise.resolve() });
const result = await client.searchProblems({ query: 'dp', level: 5, tag: 'math', page: 2 });
expect(mockGet).toHaveBeenCalledWith('/search/problem', {
params: { query: 'dp lv:5 #math', page: 2 },
});
expect(result).toEqual(response);
});
it('getProblem hits problem endpoint', async () => {
const problem: SolvedAcProblem = { problemId: 2557, level: 1, title: 'Hello World' };
mockGet.mockResolvedValue({ data: problem });
const client = new SolvedAcClient({ sleep: () => Promise.resolve() });
const result = await client.getProblem(2557);
expect(mockGet).toHaveBeenCalledWith('/problem/show', {
params: { problemId: 2557 },
});
expect(result).toEqual(problem);
});
it('getUser returns user profile', async () => {
const user: SolvedAcUser = { handle: 'reloop', solvedCount: 1234, tier: 15 };
mockGet.mockResolvedValue({ data: user });
const client = new SolvedAcClient({ sleep: () => Promise.resolve() });
const result = await client.getUser('reloop');
expect(mockGet).toHaveBeenCalledWith('/user/show', { params: { handle: 'reloop' } });
expect(result).toEqual(user);
});
it('getUserTop100 unwraps items array', async () => {
const items: SolvedAcProblem[] = [
{ problemId: 1000, level: 10, title: 'BOJ 1000' },
{ problemId: 1001, level: 12, title: 'BOJ 1001' },
];
mockGet.mockResolvedValue({ data: { items } });
const client = new SolvedAcClient({ sleep: () => Promise.resolve() });
const result = await client.getUserTop100('reloop');
expect(mockGet).toHaveBeenCalledWith('/user/top_100', { params: { handle: 'reloop' } });
expect(result).toEqual(items);
});
it('getUserProblemStats returns data array', async () => {
const stats = [
{ level: 5, total: 100, solved: 50 },
{ level: 10, total: 80, solved: 20 },
];
mockGet.mockResolvedValue({ data: stats });
const client = new SolvedAcClient({ sleep: () => Promise.resolve() });
const result = await client.getUserProblemStats('reloop');
expect(mockGet).toHaveBeenCalledWith('/user/problem_stats', { params: { handle: 'reloop' } });
expect(result).toEqual(stats);
});
it('retries on 429 responses and eventually succeeds', async () => {
mockGet
.mockRejectedValueOnce({ response: { status: 429 }, message: 'rate limit' })
.mockResolvedValueOnce({
data: { count: 0, items: [] } satisfies SolvedAcSearchResponse,
});
const sleepSpy = vi.fn().mockResolvedValue(undefined);
const client = new SolvedAcClient({ sleep: sleepSpy });
const result = await client.searchProblems({ query: 'graph' });
expect(result.items).toEqual([]);
expect(mockGet).toHaveBeenCalledTimes(2);
expect(sleepSpy).toHaveBeenCalled();
});
it('throws SolvedAcClientError on unrecoverable error', async () => {
mockGet.mockRejectedValue({ response: { status: 404 }, message: 'not found' });
const client = new SolvedAcClient({ sleep: () => Promise.resolve() });
await expect(client.getProblem(999999)).rejects.toBeInstanceOf(SolvedAcClientError);
});
});

View File

@@ -1,228 +0,0 @@
import { Injectable } from '@nestjs/common';
import axios, { AxiosHeaders, AxiosInstance } from 'axios';
export interface SolvedAcTag {
key: string;
displayNames?: Array<{ language: string; name: string }>;
}
export interface SolvedAcProblem {
problemId: number;
title?: string | null;
titleKo?: string | null;
level: number;
tags?: SolvedAcTag[];
acceptedUserCount?: number | null;
averageTries?: number | null;
}
export interface SolvedAcSearchResponse {
count: number;
items: SolvedAcProblem[];
}
export interface SolvedAcUser {
handle: string;
solvedCount: number;
tier: number;
}
export interface SolvedAcUserProblemStat {
level: number;
total: number;
solved: number;
}
export interface SolvedAcClientOptions {
maxRequestsPerSecond?: number;
timeout?: number;
sleep?: (ms: number) => Promise<void>;
}
interface QueueItem<T> {
task: () => Promise<T>;
resolve: (value: T) => void;
reject: (reason: unknown) => void;
}
export class SolvedAcClientError extends Error {
constructor(
message: string,
public readonly statusCode?: number,
public readonly cause?: unknown,
) {
super(message);
this.name = 'SolvedAcClientError';
}
}
@Injectable()
export default class SolvedAcClient {
private readonly http: AxiosInstance;
private readonly queue: QueueItem<unknown>[] = [];
private processing = false;
private tokens: number;
private lastRefill = Date.now();
private readonly maxRequestsPerSecond: number;
private readonly sleep: (ms: number) => Promise<void>;
private readonly maxRetries = 3;
constructor(options: SolvedAcClientOptions = {}) {
this.maxRequestsPerSecond = options.maxRequestsPerSecond ?? 3;
this.tokens = this.maxRequestsPerSecond;
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
this.http = axios.create({
baseURL: 'https://solved.ac/api/v3',
timeout: options.timeout ?? 10_000,
headers: {
'User-Agent': 'ReLoop/2.0 (+https://reloop.nabomhalang.co.kr)',
},
});
this.http.interceptors.request.use((config) => {
const headers = AxiosHeaders.from(config.headers);
headers.set('User-Agent', 'ReLoop/2.0 (+https://reloop.nabomhalang.co.kr)');
config.headers = headers;
return config;
});
this.http.interceptors.response.use(
(response) => response,
(error) => Promise.reject(this.toClientError(error)),
);
}
async searchProblems(params: {
query?: string;
level?: number;
tag?: string;
page?: number;
}): Promise<SolvedAcSearchResponse> {
const query = this.buildSearchQuery(params);
return this.enqueue(() =>
this.runWithRetry(() =>
this.http
.get<SolvedAcSearchResponse>('/search/problem', {
params: { query, page: params.page ?? 1 },
})
.then((res) => res.data),
),
);
}
async getProblem(bojId: number): Promise<SolvedAcProblem> {
return this.enqueue(() =>
this.runWithRetry(() =>
this.http
.get<SolvedAcProblem>('/problem/show', { params: { problemId: bojId } })
.then((res) => res.data),
),
);
}
async getUser(handle: string): Promise<SolvedAcUser> {
return this.enqueue(() =>
this.runWithRetry(() =>
this.http
.get<SolvedAcUser>('/user/show', { params: { handle } })
.then((res) => res.data),
),
);
}
async getUserProblemStats(handle: string): Promise<SolvedAcUserProblemStat[]> {
return this.enqueue(() =>
this.runWithRetry(() =>
this.http
.get<SolvedAcUserProblemStat[]>('/user/problem_stats', { params: { handle } })
.then((res) => res.data),
),
);
}
async getUserTop100(handle: string): Promise<SolvedAcProblem[]> {
return this.enqueue(() =>
this.runWithRetry(() =>
this.http
.get<{ items: SolvedAcProblem[] }>('/user/top_100', { params: { handle } })
.then((res) => res.data.items ?? []),
),
);
}
private buildSearchQuery(params: { query?: string; level?: number; tag?: string }) {
const parts: string[] = [];
if (params.query?.trim()) parts.push(params.query.trim());
if (params.level !== undefined) parts.push(`lv:${params.level}`);
if (params.tag?.trim()) {
const tag = params.tag.startsWith('#') ? params.tag.trim() : `#${params.tag.trim()}`;
parts.push(tag);
}
return parts.length > 0 ? parts.join(' ') : '*';
}
private async enqueue<T>(task: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.queue.push({ task, resolve, reject });
void this.processQueue();
});
}
private async processQueue() {
if (this.processing) return;
this.processing = true;
while (this.queue.length > 0) {
const now = Date.now();
if (now - this.lastRefill >= 1000) {
this.tokens = this.maxRequestsPerSecond;
this.lastRefill = now;
}
if (this.tokens <= 0) {
const wait = Math.max(0, 1000 - (now - this.lastRefill));
await this.sleep(wait);
continue;
}
const item = this.queue.shift()!;
this.tokens -= 1;
try {
const result = await item.task();
item.resolve(result);
} catch (error) {
item.reject(error);
}
}
this.processing = false;
}
private async runWithRetry<T>(fn: () => Promise<T>, attempt = 0): Promise<T> {
try {
return await fn();
} catch (error) {
const clientError = error instanceof SolvedAcClientError ? error : this.toClientError(error);
if (
attempt < this.maxRetries &&
clientError.statusCode !== undefined &&
(clientError.statusCode === 429 || clientError.statusCode >= 500)
) {
const delay = Math.pow(2, attempt) * 250;
await this.sleep(delay);
return this.runWithRetry(fn, attempt + 1);
}
throw clientError;
}
}
private toClientError(error: unknown) {
if (error instanceof SolvedAcClientError) return error;
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const message = error.response?.data?.message ?? error.message ?? 'Solved.ac request failed';
return new SolvedAcClientError(message, status, error);
}
return new SolvedAcClientError('Solved.ac request failed', undefined, error);
}
}

View File

@@ -8,7 +8,7 @@ import {
Query,
UseGuards,
} from '@nestjs/common';
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { StudyResult } from '@prisma/client';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -30,6 +30,25 @@ class HistoryQuery {
limit?: number;
}
class CalendarQuery {
@Type(() => Number)
@IsInt()
@Min(2020)
@Max(2100)
year: number;
@Type(() => Number)
@IsInt()
@Min(1)
@Max(12)
month: number;
}
class DayQuery {
@IsString()
date: string; // YYYY-MM-DD
}
@Controller('reviews')
@UseGuards(JwtAuthGuard)
export class ReviewsController {
@@ -61,4 +80,14 @@ export class ReviewsController {
history(@CurrentUser() user: AuthUser, @Query() q: HistoryQuery) {
return this.svc.history(user.id, q.limit);
}
@Get('calendar')
calendar(@CurrentUser() user: AuthUser, @Query() q: CalendarQuery) {
return this.svc.calendar(user.id, q.year, q.month);
}
@Get('day')
day(@CurrentUser() user: AuthUser, @Query() q: DayQuery) {
return this.svc.day(user.id, q.date);
}
}

View File

@@ -189,4 +189,85 @@ export class ReviewsService {
take: limit,
});
}
/**
* 월간 캘린더 데이터: 해당 월에 스케줄된 복습을 날짜별로 집계
* 밀린 복습(과거 날짜 + pending) 도 포함
*/
async calendar(userId: number, year: number, month: number) {
const start = new Date(Date.UTC(year, month - 1, 1));
const end = new Date(Date.UTC(year, month, 1)); // exclusive
const rows = await this.prisma.reviewSchedule.findMany({
where: {
userId,
scheduledAt: { gte: start, lt: end },
},
select: {
scheduledAt: true,
status: true,
},
});
// 날짜별 집계
const map = new Map<string, { total: number; completed: number }>();
for (const row of rows) {
const dateKey = row.scheduledAt.toISOString().slice(0, 10); // YYYY-MM-DD
const existing = map.get(dateKey) ?? { total: 0, completed: 0 };
existing.total += 1;
if (row.status === ReviewStatus.done) {
existing.completed += 1;
}
map.set(dateKey, existing);
}
const days = Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, counts]) => ({ date, ...counts }));
return { days };
}
/**
* 특정 날짜의 복습 스케줄 상세 목록
*/
async day(userId: number, date: string) {
// date: YYYY-MM-DD
const [yearStr, monthStr, dayStr] = date.split('-');
const year = parseInt(yearStr, 10);
const month = parseInt(monthStr, 10);
const day = parseInt(dayStr, 10);
const start = new Date(Date.UTC(year, month - 1, day));
const end = new Date(Date.UTC(year, month - 1, day + 1));
const reviews = await this.prisma.reviewSchedule.findMany({
where: {
userId,
scheduledAt: { gte: start, lt: end },
},
include: {
studyLog: {
include: {
problem: {
select: {
id: true,
bodyText: true,
choices: true,
},
},
tag: {
select: {
name: true,
subject: { select: { name: true } },
},
},
},
},
},
orderBy: [{ status: 'asc' }, { scheduledAt: 'asc' }],
});
return { reviews };
}
}

View File

@@ -13,6 +13,8 @@ import {
getAssignments,
getMyClasses,
getMyOrganizations,
getReviewCalendar,
type CalendarDay,
type DashboardSummary,
type MeUser,
type MasteryPathResponse,
@@ -20,6 +22,7 @@ import {
type Persona,
type StudyLog,
} from '@/lib/api';
import ReviewCalendar from '@/components/review/ReviewCalendar';
import { canManageSchool, formatOrganizationType, pendingAssignmentsForClass } from '@/lib/school';
import { animations, theme } from '@/styles/theme';
@@ -83,6 +86,9 @@ function DashboardBody() {
>(new Map());
const [coachDismissed, setCoachDismissed] = useState(false);
const [error, setError] = useState<string | null>(null);
const [calendarDays, setCalendarDays] = useState<CalendarDay[]>([]);
const [calendarYear] = useState(() => new Date().getFullYear());
const [calendarMonth] = useState(() => new Date().getMonth() + 1);
useEffect(() => {
if (typeof window === 'undefined') return;
@@ -96,7 +102,8 @@ function DashboardBody() {
setError(null);
try {
const [meRes, summaryRes, recentRes, activityRes, subjectsRes, orgsRes, classesRes, assignmentsRes] = await Promise.all([
const now = new Date();
const [meRes, summaryRes, recentRes, activityRes, subjectsRes, orgsRes, classesRes, assignmentsRes, calendarRes] = await Promise.all([
api.get<MeUser>('/auth/me'),
api.get<DashboardSummary>('/dashboard/summary'),
api.get<StudyLog[]>('/study-logs', { params: { limit: 5 } }),
@@ -105,6 +112,7 @@ function DashboardBody() {
getMyOrganizations(),
getMyClasses(),
getAssignments(),
getReviewCalendar(now.getFullYear(), now.getMonth() + 1).catch(() => ({ days: [] as CalendarDay[] })),
]);
if (cancelled) return;
@@ -119,6 +127,7 @@ function DashboardBody() {
setRecentLogs(nextRecentLogs);
setActivityLogs(nextActivityLogs);
setSubjects(nextSubjects);
setCalendarDays(calendarRes.days);
setStudentOrganizations(orgsRes.filter((organization) => organization.myRole === 'student'));
setStudentClasses(classesRes.filter((classItem) => classItem.teacherId !== meRes.data.id));
@@ -198,6 +207,9 @@ function DashboardBody() {
return <Loading> ...</Loading>;
}
const todayDateKey = new Date().toISOString().slice(0, 10);
const todayCalDay = calendarDays.find((d) => d.date === todayDateKey);
const todayReviewCount = todayCalDay?.total ?? data.queue.total;
const queueCount = data.queue.total;
const streakDays = Math.max(activityStreakDays(activityLogs), 1);
const weeklyAccuracy = computeWeeklyAccuracy(data.weekly);
@@ -235,8 +247,18 @@ function DashboardBody() {
<div>
<DateText>{todayLabel}</DateText>
<Title>
{data.user.nickname}, <GradientCount>{queueCount}</GradientCount>{' '}
.
{todayReviewCount > 0 ? (
<>
{data.user.nickname}, {' '}
<GradientCount>{todayReviewCount}</GradientCount>{' '}
.
</>
) : (
<>
{data.user.nickname},{' '}
!
</>
)}
</Title>
</div>
<StreakBadge>
@@ -266,22 +288,17 @@ function DashboardBody() {
</MetricCard>
) : null}
<ReviewQueueCard>
<CalendarCard>
<CardHeader>
<CardTitle> </CardTitle>
<Icon name="stack" size={18} color={theme.color.textMute} />
<CardTitle> </CardTitle>
<Icon name="calendar-blank" size={18} color={theme.color.textMute} />
</CardHeader>
<BigValueRow>
<BigValue>{queueCount}</BigValue>
<ValueUnit></ValueUnit>
</BigValueRow>
<ActionLink href="/review">
<Button as="span" $variant="white" $block>
<Icon name="arrow-right" weight="bold" size={16} />
</Button>
</ActionLink>
</ReviewQueueCard>
<ReviewCalendar
initialDays={calendarDays}
initialYear={calendarYear}
initialMonth={calendarMonth}
/>
</CalendarCard>
<MetricCard>
<CardHeader>
@@ -693,7 +710,8 @@ function resultIcon(result: StudyLog['result']) {
function iconForSubject(subjectName: string): MasteryPreviewItem['iconName'] {
const name = subjectName.toLowerCase();
if (name.includes('ps')) return 'code';
if (name.includes('영어') || name.includes('english')) return 'book-bookmark';
if (name.includes('생물') || name.includes('biology')) return 'dna';
return 'function';
}
@@ -819,7 +837,7 @@ const StatsGrid = styled.section`
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: ${theme.space.lg};
@media (max-width: ${theme.breakpoint.desktop}) {
@@ -847,30 +865,16 @@ const MetricCard = styled(Card)`
gap: ${theme.space.md};
`;
const ReviewQueueCard = styled(MetricCard)`
overflow: hidden;
const CalendarCard = styled(Card)`
${baseMetricCard};
display: flex;
flex-direction: column;
gap: ${theme.space.md};
min-height: 0;
grid-column: span 2;
&::before {
content: '';
position: absolute;
top: -44px;
right: -44px;
width: 160px;
height: 160px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(79, 70, 229, 0.18) 0%,
rgba(79, 70, 229, 0.04) 45%,
transparent 72%
);
filter: blur(10px);
transition: transform 0.2s ease, opacity 0.2s ease;
}
&:hover::before {
transform: scale(1.12);
opacity: 1;
@media (max-width: ${theme.breakpoint.desktop}) {
grid-column: span 1;
}
`;

View File

@@ -1,20 +1,14 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import React from 'react';
import { useRouter } from 'next/navigation';
import styled from 'styled-components';
import AppShell from '@/components/layout/AppShell';
import { Icon } from '@/components/ui/Icon';
import Select from '@/components/ui/Select';
import { Badge, Button, Card, Label, PageHeader } from '@/components/ui/primitives';
import { api, type ProblemSetSummary } from '@/lib/api';
import { Badge, Button, Card, PageHeader } from '@/components/ui/primitives';
import { theme } from '@/styles/theme';
const YEAR_FILTERS = [2026, 2025] as const;
const SUBJECT_FILTERS = ['전체', '수학'] as const;
type YearFilter = number | 'all';
type SubjectFilter = (typeof SUBJECT_FILTERS)[number];
import { EBOOK_LIST } from './sample-ebook/data/index';
import type { EbookMeta } from './sample-ebook/data/index';
export default function ExamsPage() {
return (
@@ -26,40 +20,6 @@ export default function ExamsPage() {
function ExamsBody() {
const router = useRouter();
const [problemSets, setProblemSets] = useState<ProblemSetSummary[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectedYear, setSelectedYear] = useState<YearFilter>('all');
const [selectedSubject, setSelectedSubject] = useState<SubjectFilter>('전체');
const [reloadKey, setReloadKey] = useState(0);
const loadProblemSets = useCallback(() => {
let active = true;
setProblemSets(null);
setError(null);
api
.get<ProblemSetSummary[]>('/problem-sets', {
params: {
year: selectedYear === 'all' ? undefined : selectedYear,
subjectName: selectedSubject === '전체' ? undefined : selectedSubject,
},
})
.then((response) => {
if (!active) return;
setProblemSets(response.data);
})
.catch(() => {
if (!active) return;
setError('문제집 목록을 불러오지 못했어. 다시 시도해줘.');
});
return () => {
active = false;
};
}, [selectedSubject, selectedYear]);
useEffect(() => loadProblemSets(), [loadProblemSets, reloadKey]);
return (
<Wrap>
@@ -67,126 +27,61 @@ function ExamsBody() {
<PageHeader
eyebrow="Exams"
title="모의고사 문제집"
subtitle="기출 문제집을 선택해 실전처럼 풀어봐"
subtitle="학년별 샘플 ebook을 선택해 문제를 풀어봐"
/>
</HeaderCard>
<FilterCard>
<FilterRow>
<FilterField>
<Label></Label>
<FieldSelect
value={selectedYear}
onChange={(value) => setSelectedYear(value as YearFilter)}
options={[
{ label: '전체', value: 'all' as const },
...YEAR_FILTERS.map((year) => ({ label: String(year), value: year })),
]}
aria-label="연도 필터"
/>
</FilterField>
<FilterField>
<Label></Label>
<FieldSelect
value={selectedSubject}
onChange={(value) => setSelectedSubject(value as SubjectFilter)}
options={SUBJECT_FILTERS.map((subject) => ({
label: subject,
value: subject,
}))}
aria-label="과목 필터"
/>
</FilterField>
</FilterRow>
</FilterCard>
{error ? (
<>
<Grid>
<SampleEbookCard router={router} />
</Grid>
<StateCard role="alert">
<StateIcon>
<Icon name="info" size={24} />
</StateIcon>
<StateTitle> </StateTitle>
<StateText>{error}</StateText>
<Button type="button" $variant="secondary" onClick={() => setReloadKey((prev) => prev + 1)}>
</Button>
</StateCard>
</>
) : problemSets === null ? (
<>
<Grid>
<SampleEbookCard router={router} />
</Grid>
<StateCard>
<StateIcon>
<Icon name="clock" size={24} />
</StateIcon>
<StateTitle> </StateTitle>
<StateText> .</StateText>
</StateCard>
</>
) : problemSets.length === 0 ? (
<>
<Grid>
<SampleEbookCard router={router} />
</Grid>
<StateCard>
<StateIcon>
<Icon name="book-open-text" size={24} />
</StateIcon>
<StateTitle> </StateTitle>
<StateText> .</StateText>
</StateCard>
</>
) : (
<Grid>
<SampleEbookCard router={router} />
{problemSets.map((problemSet) => {
const isAutoGradeUnavailable =
(problemSet.problems?.length ?? 0) > 0 &&
problemSet.problems?.every((problem) => problem.needsReview);
return (
<ExamCard key={problemSet.id}>
<CardHeader>
<CardTitle>{problemSet.title}</CardTitle>
{isAutoGradeUnavailable ? (
<MutedBadge $variant="default"> </MutedBadge>
) : null}
</CardHeader>
<CardMeta>
{problemSet.year} · {problemSet.subjectName} ·{' '}
{(problemSet._count?.problems ?? 0).toLocaleString()}
</CardMeta>
<CardFooter>
<InfoLine>
<Icon name="clock" size={16} />
</InfoLine>
<Button
type="button"
$variant="secondary"
onClick={() => router.push(`/study/exam/${problemSet.id}`)}
>
</Button>
</CardFooter>
</ExamCard>
);
})}
</Grid>
)}
<Grid>
{EBOOK_LIST.map((ebook) => (
<SampleEbookCard key={ebook.id} ebook={ebook} router={router} />
))}
</Grid>
</Wrap>
);
}
// ─── 샘플 ebook 카드 ─────────────────────────────────────────────────────────
function SampleEbookCard({
ebook,
router,
}: {
ebook: EbookMeta;
router: ReturnType<typeof useRouter>;
}) {
return (
<SampleCard>
<SampleCardHeader>
<CardTitle>{ebook.title}</CardTitle>
<SampleBadge $variant="default">
<Icon name="book-open-text" size={11} />
·
</SampleBadge>
</SampleCardHeader>
<CardMeta>
{ebook.grade} · {ebook.subtitle.split(' ').slice(2).join(' ')} · {ebook.problemCount}
</CardMeta>
<CardFooter>
<InfoLine>
<Icon name="book-open-text" size={16} />
</InfoLine>
<Button
type="button"
$variant="primary"
onClick={() => router.push(`/exams/sample-ebook/${ebook.id}`)}
>
</Button>
</CardFooter>
</SampleCard>
);
}
// ─── 스타일 ──────────────────────────────────────────────────────────────────
const Wrap = styled.div`
display: flex;
flex-direction: column;
@@ -200,33 +95,6 @@ const HeaderCard = styled(Card)`
border-color: ${theme.color.borderSoftAlpha};
`;
const FilterCard = styled(Card)`
background: rgba(255, 255, 255, 0.02);
border-color: ${theme.color.borderSoftAlpha};
`;
const FilterRow = styled.div`
display: grid;
grid-template-columns: repeat(2, minmax(0, 240px));
gap: 16px;
@media (max-width: ${theme.breakpoint.tablet}) {
grid-template-columns: 1fr;
}
`;
const FilterField = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const FieldSelect = styled(Select)`
width: 100%;
background: rgba(255, 255, 255, 0.03);
border-color: ${theme.color.borderSoftAlpha};
`;
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -237,131 +105,6 @@ const Grid = styled.div`
}
`;
const ExamCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 16px;
min-height: 220px;
justify-content: space-between;
background:
radial-gradient(circle at top right, rgba(79, 70, 229, 0.12), transparent 30%),
rgba(255, 255, 255, 0.02);
border-color: ${theme.color.borderSoftAlpha};
`;
const CardHeader = styled.div`
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
@media (max-width: ${theme.breakpoint.mobile}) {
flex-direction: column;
}
`;
const CardTitle = styled.h2`
font-size: 20px;
line-height: 1.45;
color: ${theme.color.textBright};
`;
const MutedBadge = styled(Badge)`
color: ${theme.color.textSub};
background: rgba(255, 255, 255, 0.06);
border-color: ${theme.color.borderSoftAlpha};
`;
const CardMeta = styled.p`
color: ${theme.color.textSub};
font-size: 14px;
line-height: 1.6;
`;
const CardFooter = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: auto;
@media (max-width: ${theme.breakpoint.mobile}) {
flex-direction: column;
align-items: stretch;
}
`;
const InfoLine = styled.span`
display: inline-flex;
align-items: center;
gap: 8px;
color: ${theme.color.textSub};
font-size: 13px;
`;
const StateCard = styled(Card)`
min-height: 260px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
text-align: center;
background: rgba(255, 255, 255, 0.02);
border-color: ${theme.color.borderSoftAlpha};
`;
const StateIcon = styled.div`
width: 52px;
height: 52px;
display: grid;
place-items: center;
border-radius: 16px;
background: rgba(79, 70, 229, 0.12);
color: ${theme.color.textBright};
`;
const StateTitle = styled.h2`
font-size: 20px;
color: ${theme.color.textBright};
`;
const StateText = styled.p`
color: ${theme.color.textSub};
`;
// ─── 샘플 ebook 카드 ─────────────────────────────────────────────────────────
function SampleEbookCard({ router }: { router: ReturnType<typeof useRouter> }) {
return (
<SampleCard>
<SampleCardHeader>
<CardTitle>[] ebook</CardTitle>
<SampleBadge $variant="default">
<Icon name="book-open-text" size={11} />
·
</SampleBadge>
</SampleCardHeader>
<CardMeta>2026 · · 5</CardMeta>
<CardFooter>
<InfoLine>
<Icon name="book-open-text" size={16} />
</InfoLine>
<Button
type="button"
$variant="primary"
onClick={() => router.push('/exams/sample-ebook')}
>
</Button>
</CardFooter>
</SampleCard>
);
}
const SampleCard = styled(Card)`
display: flex;
flex-direction: column;
@@ -389,14 +132,54 @@ const SampleCard = styled(Card)`
}
`;
const SampleCardHeader = styled(CardHeader)`
const SampleCardHeader = styled.div`
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
@media (max-width: ${theme.breakpoint.mobile}) {
flex-direction: column;
}
`;
const SampleBadge = styled(MutedBadge)`
const CardTitle = styled.h2`
font-size: 20px;
line-height: 1.45;
color: ${theme.color.textBright};
`;
const SampleBadge = styled(Badge)`
background: rgba(124, 58, 237, 0.18);
border-color: rgba(124, 58, 237, 0.4);
color: ${theme.color.accent2};
white-space: nowrap;
flex-shrink: 0;
`;
const CardMeta = styled.p`
color: ${theme.color.textSub};
font-size: 14px;
line-height: 1.6;
`;
const CardFooter = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: auto;
@media (max-width: ${theme.breakpoint.mobile}) {
flex-direction: column;
align-items: stretch;
}
`;
const InfoLine = styled.span`
display: inline-flex;
align-items: center;
gap: 8px;
color: ${theme.color.textSub};
font-size: 13px;
`;

View File

@@ -1,89 +1,53 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import styled, { css, keyframes } from 'styled-components';
import AppShell from '@/components/layout/AppShell';
import { Icon } from '@/components/ui/Icon';
import { Badge, Button, Card } from '@/components/ui/primitives';
import { theme } from '@/styles/theme';
// ─── 샘플 문제 데이터 ──────────────────────────────────────────────────────────
interface Problem {
number: number;
question: string;
choices: string[];
answer: number; // 1-based
explanation: string;
}
const SAMPLE_PROBLEMS: Problem[] = [
{
number: 1,
question:
'함수 f(x) = x² 4x + 3 의 최솟값을 구하시오.',
choices: ['3', '2', '1', '0', '1'],
answer: 3,
explanation:
'꼭짓점 공식: f(x) = (x 2)² 1. 꼭짓점 x = 2 에서 최솟값 f(2) = 4 8 + 3 = 1.',
},
{
number: 2,
question:
'등차수열 {aₙ} 에서 a₁ = 3, 공차 d = 4 일 때, a₁₀ 의 값은?',
choices: ['35', '38', '39', '40', '43'],
answer: 3,
explanation:
'aₙ = a₁ + (n 1)d. a₁₀ = 3 + 9 × 4 = 3 + 36 = 39.',
},
{
number: 3,
question:
'lim(x→2) (x² 4) / (x 2) 의 값은?',
choices: ['0', '2', '4', '6', '수렴하지 않음'],
answer: 3,
explanation:
'분자를 인수분해: (x² 4) = (x 2)(x + 2). 약분하면 lim(x→2) (x + 2) = 4.',
},
{
number: 4,
question:
'주머니 속에 빨간 공 3개, 파란 공 2개가 있다. 한 개를 꺼낼 때 빨간 공일 확률은?',
choices: ['1/5', '2/5', '3/5', '4/5', '1'],
answer: 3,
explanation:
'전체 5개 중 빨간 공 3개. 확률 = 3/5.',
},
{
number: 5,
question:
'∫₀² (2x + 1) dx 의 값은?',
choices: ['4', '5', '6', '7', '8'],
answer: 3,
explanation:
'∫(2x + 1) dx = x² + x + C. [x² + x]₀² = (4 + 2) 0 = 6.',
},
];
import { EBOOK_MAP } from '../data/index';
import type { SampleProblem } from '../data/types';
// ─── 페이지 컴포넌트 ────────────────────────────────────────────────────────────
export default function SampleEbookPage() {
export default function SampleEbookDynamicPage() {
const params = useParams();
const id = typeof params.id === 'string' ? params.id : '';
const ebook = EBOOK_MAP[id];
if (!ebook) {
notFound();
}
return (
<AppShell>
<EbookViewer />
<EbookViewer
title={ebook.title}
problems={ebook.problems}
/>
</AppShell>
);
}
function EbookViewer() {
// ─── 뷰어 ─────────────────────────────────────────────────────────────────────
interface EbookViewerProps {
title: string;
problems: SampleProblem[];
}
function EbookViewer({ title, problems }: EbookViewerProps) {
const [pageIndex, setPageIndex] = useState(0);
const [selectedAnswers, setSelectedAnswers] = useState<Record<number, number>>({});
const [revealed, setRevealed] = useState<Record<number, boolean>>({});
const [fading, setFading] = useState(false);
const total = SAMPLE_PROBLEMS.length;
const problem = SAMPLE_PROBLEMS[pageIndex];
const total = problems.length;
const problem = problems[pageIndex];
const goTo = useCallback(
(index: number) => {
@@ -109,7 +73,6 @@ function EbookViewer() {
const selectAnswer = (choiceIndex: number) => {
setSelectedAnswers((prev) => ({ ...prev, [problem.number]: choiceIndex }));
// 답 바꾸면 해설 숨김
setRevealed((prev) => ({ ...prev, [problem.number]: false }));
};
@@ -117,7 +80,7 @@ function EbookViewer() {
setRevealed((prev) => ({ ...prev, [problem.number]: true }));
};
const chosen = selectedAnswers[problem.number]; // undefined | number (1-based)
const chosen = selectedAnswers[problem.number];
const isRevealed = revealed[problem.number] ?? false;
const isCorrect = chosen !== undefined && chosen === problem.answer;
@@ -137,7 +100,7 @@ function EbookViewer() {
</HeaderRight>
</ViewerHeader>
<ViewerTitle>[] ebook</ViewerTitle>
<ViewerTitle>{title}</ViewerTitle>
<ViewerDivider />
{/* 책 펼침 영역 */}
@@ -156,7 +119,7 @@ function EbookViewer() {
<PageLabel></PageLabel>
<ChoiceList>
{problem.choices.map((choice, idx) => {
const choiceNum = idx + 1; // 1-based
const choiceNum = idx + 1;
const isSelected = chosen === choiceNum;
const isAnswerChoice = problem.answer === choiceNum;
@@ -346,7 +309,6 @@ const ViewerDivider = styled.hr`
margin: 0;
`;
// 책 펼침 레이아웃
const BookSpread = styled.div<{ $fading: boolean }>`
display: grid;
grid-template-columns: 1fr 2px 1fr;
@@ -373,7 +335,6 @@ const BookPage = styled(Card)<{ $side: 'left' | 'right' }>`
padding: ${theme.space.xl};
min-height: 380px;
/* cream tint */
background: linear-gradient(
160deg,
rgba(20, 18, 40, 0.98) 0%,
@@ -434,7 +395,6 @@ const QuestionText = styled.p`
flex: 1;
`;
// 선택지
const ChoiceList = styled.div`
display: flex;
flex-direction: column;
@@ -527,7 +487,6 @@ const CorrectMark = styled.span`
animation: ${fadeIn} 0.2s ease;
`;
// 답 선택 상태
const AnswerStatusRow = styled.div`
display: flex;
align-items: center;
@@ -541,7 +500,6 @@ const AnswerChosen = styled.span<{ $muted?: boolean }>`
color: ${({ $muted }) => ($muted ? theme.color.textMute : theme.color.textSub)};
`;
// 해설 박스
const ExplanationBox = styled.div<{ $correct: boolean }>`
padding: ${theme.space.md};
border-radius: ${theme.radius.md};
@@ -568,7 +526,6 @@ const ExplanationText = styled.p`
color: ${theme.color.textSub};
`;
// 페이지 네비게이션
const PageNav = styled.div`
display: flex;
align-items: center;
@@ -588,7 +545,6 @@ const PageIndicator = styled.span`
text-align: center;
`;
// B2B 안내 박스
const InfoBox = styled(Card)`
display: flex;
align-items: flex-start;

View File

@@ -0,0 +1,265 @@
import type { SampleProblem } from './types';
export const PROBLEMS: SampleProblem[] = [
// ── 다항식 (1~6) ──────────────────────────────────────────────────────────────
{
number: 1,
question: '(x + 2)³ 를 전개하면?',
choices: [
'x³ + 6x² + 12x + 8',
'x³ + 3x² + 3x + 8',
'x³ + 8',
'x³ + 6x + 8',
'x³ + 2x² + 4x + 8',
],
answer: 1,
explanation: '(a+b)³ = a³ + 3a²b + 3ab² + b³. a=x, b=2 대입: x³ + 3·x²·2 + 3·x·4 + 8 = x³ + 6x² + 12x + 8.',
},
{
number: 2,
question: '다항식 f(x) = 2x³ 3x² + x 5 를 (x 2) 로 나눌 때 나머지는?',
choices: ['3', '1', '1', '3', '5'],
answer: 3,
explanation: '나머지 정리: f(2) = 2·8 3·4 + 2 5 = 16 12 + 2 5 = 1.',
},
{
number: 3,
question: '(a + b)(a b) 를 전개하면?',
choices: ['a² + b²', 'a² b²', 'a² + 2ab + b²', 'a² 2ab + b²', '2a² 2b²'],
answer: 2,
explanation: '합차 공식: (a+b)(ab) = a² b².',
},
{
number: 4,
question: 'x³ 8 을 인수분해하면?',
choices: [
'(x2)(x²+2x+4)',
'(x2)(x²2x+4)',
'(x+2)(x²2x+4)',
'(x2)³',
'(x2)(x+2)²',
],
answer: 1,
explanation: 'a³ b³ = (ab)(a²+ab+b²). x³8 = x³2³ = (x2)(x²+2x+4).',
},
{
number: 5,
question: '다항식 x³ 3x² + 3x 1 을 인수분해하면?',
choices: ['(x1)³', '(x+1)³', '(x1)(x²+x+1)', '(x+1)(x²x+1)', '(x1)²(x+1)'],
answer: 1,
explanation: '(ab)³ = a³ 3a²b + 3ab² b³. a=x, b=1 대입하면 (x1)³.',
},
{
number: 6,
question: 'f(x) = x³ x + 2 를 (x + 1) 로 나눌 때 나머지는?',
choices: [
'0',
'1',
'2',
'1',
'4',
],
answer: 3,
explanation: '나머지 정리: f(1) = (1)³ (1) + 2 = 1 + 1 + 2 = 2. 나머지 = 2.',
},
// ── 방정식과 부등식 (7~12) ───────────────────────────────────────────────────
{
number: 7,
question: '이차부등식 x² 3x + 2 ≤ 0 의 해는?',
choices: ['x ≤ 1 또는 x ≥ 2', '1 ≤ x ≤ 2', 'x ≤ 1 또는 x ≥ 2', '2 ≤ x ≤ 1', 'x < 0'],
answer: 2,
explanation: '(x1)(x2) ≤ 0. 두 근 사이에서 성립: 1 ≤ x ≤ 2.',
},
{
number: 8,
question: '연립부등식 { 2x + 1 > 5, x 3 < 4 } 의 해는?',
choices: ['2 < x < 7', 'x > 2', 'x < 7', 'x ≥ 2', '2 ≤ x ≤ 7'],
answer: 1,
explanation: '첫 부등식: 2x > 4 → x > 2. 둘째: x < 7. 교집합: 2 < x < 7.',
},
{
number: 9,
question: '이차방정식 x² 5x + k = 0 이 중근을 가지려면 k의 값은?',
choices: ['5/4', '25/4', '5', '25', '25/4'],
answer: 2,
explanation: '중근 조건: 판별식 D = 0. D = 25 4k = 0 → k = 25/4.',
},
{
number: 10,
question: '방정식 x² + 2x + 5 = 0 의 두 근의 곱은?',
choices: ['5', '2', '2', '5', '10'],
answer: 4,
explanation: '비에타 공식: 두 근의 곱 = c/a = 5/1 = 5.',
},
{
number: 11,
question: '부등식 |2x 3| < 5 의 해는?',
choices: ['1 < x < 4', 'x > 1', 'x < 4', '2 < x < 4', '1 < x < 4'],
answer: 1,
explanation: '5 < 2x 3 < 5 → 2 < 2x < 8 → 1 < x < 4.',
},
{
number: 12,
question: '이차부등식 x² x 6 > 0 의 해는?',
choices: ['2 < x < 3', 'x < 2 또는 x > 3', 'x > 3', 'x < 2', '3 < x < 2'],
answer: 2,
explanation: '(x3)(x+2) > 0. 두 근 바깥쪽: x < 2 또는 x > 3.',
},
// ── 집합과 명제 (13~18) ──────────────────────────────────────────────────────
{
number: 13,
question: 'A = {1, 2, 3, 4}, B = {3, 4, 5} 일 때 A B 의 원소 개수는?',
choices: ['2', '4', '5', '7', '8'],
answer: 3,
explanation: 'A B = {1, 2, 3, 4, 5}. 원소 개수 = 5.',
},
{
number: 14,
question: 'A = {1, 2, 3, 4}, B = {3, 4, 5} 일 때 A ∩ B 의 원소 개수는?',
choices: ['1', '2', '3', '4', '5'],
answer: 2,
explanation: 'A ∩ B = {3, 4}. 원소 개수 = 2.',
},
{
number: 15,
question: '"p이면 q이다"의 대우는?',
choices: ['p이면 ¬q이다', '¬p이면 q이다', '¬q이면 ¬p이다', 'q이면 p이다', '¬p이면 ¬q이다'],
answer: 3,
explanation: '명제 p→q의 대우는 ¬q→¬p. 대우는 원명제와 동치.',
},
{
number: 16,
question: '명제 "소수이면 홀수이다"의 반례는?',
choices: ['3', '5', '7', '2', '11'],
answer: 4,
explanation: '2는 소수이지만 짝수. 따라서 반례 = 2.',
},
{
number: 17,
question: '전체집합 U = {1,2,3,4,5}, A = {1,3,5}일 때 Aᶜ(A의 여집합)는?',
choices: ['{1,3,5}', '{2,4}', '{1,2,3,4,5}', '∅', '{2,3,4}'],
answer: 2,
explanation: 'Aᶜ = U A = {1,2,3,4,5} {1,3,5} = {2,4}.',
},
{
number: 18,
question: '다음 중 동치인 것끼리 묶은 것은?',
choices: [
'원명제와 역',
'역과 이',
'이와 대우',
'원명제와 대우',
'역과 대우',
],
answer: 4,
explanation: '원명제와 대우는 항상 진릿값이 같다(동치). 역과 이도 서로 동치.',
},
// ── 함수 (19~24) ─────────────────────────────────────────────────────────────
{
number: 19,
question: 'f(x) = 2x + 3 일 때 f(1) 의 값은?',
choices: ['1', '0', '1', '2', '5'],
answer: 3,
explanation: 'f(1) = 2 × (1) + 3 = 2 + 3 = 1.',
},
{
number: 20,
question: '함수 f: A → B 가 일대일대응(전단사)이 되려면?',
choices: [
'단사이기만 하면 된다',
'전사이기만 하면 된다',
'단사이고 전사이어야 한다',
'f(a) = f(b)이면 a = b',
'공역과 치역이 달라야 한다',
],
answer: 3,
explanation: '전단사 = 단사(일대일) AND 전사(onto). 두 조건을 동시에 만족해야 한다.',
},
{
number: 21,
question: 'f(x) = x², g(x) = 2x + 1 일 때 (g∘f)(3) 의 값은?',
choices: ['13', '17', '18', '19', '22'],
answer: 4,
explanation: '(g∘f)(3) = g(f(3)) = g(9) = 2·9 + 1 = 19.',
},
{
number: 22,
question: 'f(x) = 3x 2 의 역함수 f⁻¹(x) 는?',
choices: [
'(x + 2) / 3',
'(x 2) / 3',
'3x + 2',
'(2 x) / 3',
'3 / (x 2)',
],
answer: 1,
explanation: 'y = 3x 2 → 3x = y + 2 → x = (y+2)/3. x와 y를 바꾸면 f⁻¹(x) = (x+2)/3.',
},
{
number: 23,
question: '유리함수 y = 1/x 의 그래프의 점근선은?',
choices: [
'x = 1, y = 1',
'x = 0, y = 1',
'x = 0, y = 0 (x축·y축)',
'x = 1, y = 0',
'점근선 없음',
],
answer: 3,
explanation: 'y = 1/x는 x→0에서 y→±∞, y→0에서 x→±∞. 점근선은 x축(y=0)과 y축(x=0).',
},
{
number: 24,
question: '무리함수 y = √(x 1) 의 정의역은?',
choices: ['x ≥ 0', 'x > 0', 'x ≥ 1', 'x > 1', '모든 실수'],
answer: 3,
explanation: '루트 안이 0 이상이어야 한다. x 1 ≥ 0 → x ≥ 1.',
},
// ── 경우의 수 (25~30) ─────────────────────────────────────────────────────────
{
number: 25,
question: '5명 중 2명을 선택해 순서 있게 배열하는 경우의 수는?',
choices: ['10', '15', '20', '25', '30'],
answer: 3,
explanation: '순열: P(5,2) = 5 × 4 = 20.',
},
{
number: 26,
question: '5명 중 2명을 선택하는(순서 무관) 경우의 수는?',
choices: ['5', '8', '10', '15', '20'],
answer: 3,
explanation: '조합: C(5,2) = 5!/(2!3!) = (5×4)/(2×1) = 10.',
},
{
number: 27,
question: '서로 다른 6권의 책을 일렬로 나열하는 경우의 수는?',
choices: ['120', '360', '480', '720', '1440'],
answer: 4,
explanation: '6! = 6 × 5 × 4 × 3 × 2 × 1 = 720.',
},
{
number: 28,
question: '4개의 자리에 A, B, C, D를 배열할 때 A가 맨 앞에 오는 경우의 수는?',
choices: ['4', '6', '8', '12', '24'],
answer: 2,
explanation: 'A가 고정되면 나머지 B, C, D를 3자리에 배열: 3! = 6.',
},
{
number: 29,
question: '동전 3개를 던질 때 나올 수 있는 전체 경우의 수는?',
choices: ['3', '4', '6', '8', '12'],
answer: 4,
explanation: '각 동전이 2가지(앞/뒤). 2³ = 8.',
},
{
number: 30,
question: 'C(10, 3) 의 값은?',
choices: ['60', '90', '120', '180', '720'],
answer: 3,
explanation: 'C(10,3) = 10!/(3!·7!) = (10×9×8)/(3×2×1) = 720/6 = 120.',
},
];

View File

@@ -0,0 +1,223 @@
import type { SampleProblem } from './types';
export const PROBLEMS: SampleProblem[] = [
// ── 지수/로그 (1~6) ───────────────────────────────────────────────────────────
{
number: 1,
question: '2³ × 2⁴ 의 값은?',
choices: ['2⁷', '2¹²', '4⁷', '8⁷', '2⁻¹'],
answer: 1,
explanation: '지수 법칙: aᵐ × aⁿ = aᵐ⁺ⁿ. 2³ × 2⁴ = 2⁷ = 128.',
},
{
number: 2,
question: 'log₂ 8 의 값은?',
choices: ['1', '2', '3', '4', '8'],
answer: 3,
explanation: 'log₂ 8 = log₂ 2³ = 3.',
},
{
number: 3,
question: 'log₁₀ 100 + log₁₀ 10 의 값은?',
choices: ['1', '2', '3', '4', '5'],
answer: 3,
explanation: 'log₁₀ 100 = 2, log₁₀ 10 = 1. 합 = 3. (로그의 덧셈 = log(곱): log(1000) = 3)',
},
{
number: 4,
question: 'log 2 ≈ 0.3010 일 때 log 8 의 값은?',
choices: ['0.6020', '0.7525', '0.9030', '1.2040', '1.5050'],
answer: 3,
explanation: 'log 8 = log 2³ = 3 log 2 = 3 × 0.3010 = 0.9030.',
},
{
number: 5,
question: '지수함수 y = 2ˣ 에서 x = 2일 때 y의 값은?',
choices: ['4', '1/4', '1/4', '4', '8'],
answer: 3,
explanation: 'y = 2⁻² = 1/2² = 1/4.',
},
{
number: 6,
question: '자연로그 ln e 의 값은? (e는 자연상수)',
choices: ['0', '1', 'e', '1/e', '2'],
answer: 2,
explanation: 'lnₑ e = log_e e = 1. 로그의 정의에 의해 eˣ = e이면 x = 1.',
},
// ── 삼각함수 (7~12) ──────────────────────────────────────────────────────────
{
number: 7,
question: 'sin²θ + cos²θ 의 값은?',
choices: ['0', '1/2', '1', '2', 'θ'],
answer: 3,
explanation: '피타고라스 항등식: sin²θ + cos²θ = 1 (항상 성립).',
},
{
number: 8,
question: 'sin 90° 의 값은?',
choices: ['1', '0', '1/2', '√2/2', '1'],
answer: 5,
explanation: '단위원 정의: 90°일 때 y 좌표 = 1. sin 90° = 1.',
},
{
number: 9,
question: 'cos(−θ) = ? (코사인의 대칭성)',
choices: ['cos θ', 'cos θ', 'sin θ', 'sin θ', '1/cos θ'],
answer: 2,
explanation: '코사인은 우함수: cos(−θ) = cos θ.',
},
{
number: 10,
question: '각도 π/6 (라디안)을 도(°)로 변환하면?',
choices: ['15°', '30°', '45°', '60°', '90°'],
answer: 2,
explanation: 'π 라디안 = 180°. π/6 × (180/π) = 30°.',
},
{
number: 11,
question: 'y = sin x 의 주기는?',
choices: ['π/2', 'π', '2π', '4π', '1'],
answer: 3,
explanation: 'sin 함수의 주기 = 2π. y = sin(bx)의 주기는 2π/b.',
},
{
number: 12,
question: 'tan θ = sin θ / cos θ 에서 sin θ = 3/5, cos θ = 4/5일 때 tan θ는?',
choices: ['3/4', '4/3', '3/5', '5/3', '5/4'],
answer: 1,
explanation: 'tan θ = (3/5) ÷ (4/5) = (3/5) × (5/4) = 3/4.',
},
// ── 수열 (13~18) ─────────────────────────────────────────────────────────────
{
number: 13,
question: '등차수열 2, 5, 8, 11, … 의 공차는?',
choices: ['1', '2', '3', '4', '5'],
answer: 3,
explanation: '연속하는 항의 차: 5 2 = 3. 공차 d = 3.',
},
{
number: 14,
question: '등차수열의 첫째 항이 1, 공차가 3일 때 제10항은?',
choices: ['25', '27', '28', '29', '31'],
answer: 3,
explanation: 'aₙ = a₁ + (n1)d. a₁₀ = 1 + 9 × 3 = 1 + 27 = 28.',
},
{
number: 15,
question: '등비수열 3, 6, 12, 24, … 의 공비는?',
choices: ['1', '2', '3', '4', '6'],
answer: 2,
explanation: '연속하는 항의 비: 6/3 = 2. 공비 r = 2.',
},
{
number: 16,
question: '등비수열 첫째 항 2, 공비 3일 때 제4항은?',
choices: ['18', '27', '54', '81', '162'],
answer: 3,
explanation: 'aₙ = a₁ × r^(n1). a₄ = 2 × 3³ = 2 × 27 = 54.',
},
{
number: 17,
question: 'Σ(k=1 to 5) k 의 값은?',
choices: ['10', '12', '15', '18', '20'],
answer: 3,
explanation: '1 + 2 + 3 + 4 + 5 = 15. 공식: n(n+1)/2 = 5×6/2 = 15.',
},
{
number: 18,
question: '수열 {aₙ}에서 S₅ = 20, S₄ = 14이면 a₅는?',
choices: ['4', '5', '6', '7', '8'],
answer: 3,
explanation: 'aₙ = Sₙ S(n1). a₅ = S₅ S₄ = 20 14 = 6.',
},
// ── 미분 (도함수) (19~24) ────────────────────────────────────────────────────
{
number: 19,
question: 'f(x) = x³ 를 미분하면?',
choices: ['x²', '2x²', '3x²', '3x³', '4x³'],
answer: 3,
explanation: '멱함수 미분: (x^n)의 도함수 = n*x^(n-1). (x³)의 도함수 = 3x².',
},
{
number: 20,
question: 'f(x) = 2x² 3x + 1 의 도함수 f´(x) 는?',
choices: ['2x 3', '4x 3', '4x + 3', '2x + 3', 'x² 3'],
answer: 2,
explanation: '각 항을 미분: (2x²)의 도함수 = 4x, (3x)의 도함수 = 3, (1)의 도함수 = 0. f´(x) = 4x 3.',
},
{
number: 21,
question: 'f(x) = x³ 3x 에서 x=1에서의 미분계수 값은?',
choices: ['3', '2', '0', '2', '3'],
answer: 3,
explanation: 'f´(x) = 3x² 3. f´(1) = 3 × 1 3 = 0.',
},
{
number: 22,
question: 'y = 5 (상수)의 도함수는?',
choices: ['5x', '5', '1', '0', '5'],
answer: 4,
explanation: '상수의 도함수는 0. 상수 c를 미분하면 항상 0.',
},
{
number: 23,
question: 'f(x) = x³ 3x² + 2 가 극소가 되는 x의 값은?',
choices: ['2', '1', '0', '2', '3'],
answer: 4,
explanation: 'f´(x) = 3x² 6x = 3x(x2) = 0 → x = 0 또는 x = 2. x = 0은 극대, x = 2는 극소.',
},
{
number: 24,
question: '함수 f(x) = x² 4x + 3 의 x = 1에서의 접선의 기울기는?',
choices: ['4', '3', '2', '0', '2'],
answer: 3,
explanation: 'f´(x) = 2x 4. x = 1: f´(1) = 2 4 = 2.',
},
// ── 적분 (부정/정적분) (25~30) ───────────────────────────────────────────────
{
number: 25,
question: '∫ 3x² dx 의 값은?',
choices: ['x² + C', 'x³ + C', '6x + C', '3x³ + C', 'x³/3 + C'],
answer: 2,
explanation: '∫ x^n dx = x^(n+1)/(n+1) + C. ∫ 3x² dx = 3 × x³/3 + C = x³ + C.',
},
{
number: 26,
question: '∫₀² 2x dx 의 값은?',
choices: ['1', '2', '3', '4', '6'],
answer: 4,
explanation: '[x²]₀² = 4 0 = 4.',
},
{
number: 27,
question: '∫₁³ (2x 1) dx 의 값은?',
choices: ['4', '5', '6', '7', '8'],
answer: 3,
explanation: '[x² x]₁³ = (9 3) (1 1) = 6 0 = 6.',
},
{
number: 28,
question: '∫ (x + 1)² dx = ?',
choices: ['(x+1)³/3 + C', '2(x+1) + C', '(x+1)² + C', '(x+1)³ + C', 'x²/2 + x + C'],
answer: 1,
explanation: '∫ (x+1)² dx. t = x+1로 치환하면 ∫t² dt = t³/3 + C = (x+1)³/3 + C.',
},
{
number: 29,
question: '∫₀¹ x² dx 의 값은?',
choices: ['1/4', '1/3', '1/2', '1', '2'],
answer: 2,
explanation: '[x³/3]₀¹ = 1/3 0 = 1/3.',
},
{
number: 30,
question: 'y = x² 와 y = x 로 둘러싸인 넓이는?',
choices: ['1/6', '1/4', '1/3', '1/2', '1'],
answer: 1,
explanation: '교점: x² = x → x = 0, 1. 넓이 = ∫₀¹ (x x²) dx = [x²/2 x³/3]₀¹ = 1/2 1/3 = 1/6.',
},
];

View File

@@ -0,0 +1,229 @@
import type { SampleProblem } from './types';
export const PROBLEMS: SampleProblem[] = [
// ── 극한 (1~5) ────────────────────────────────────────────────────────────────
{
number: 1,
question: 'lim(x→2) (x² 4) / (x 2) 의 값은?',
choices: ['0', '2', '4', '6', '수렴하지 않음'],
answer: 3,
explanation: '분자 인수분해: (x2)(x+2). 약분 후 lim(x→2) (x+2) = 2 + 2 = 4.',
},
{
number: 2,
question: 'lim(x→∞) (3x² + 2x) / (x² 1) 의 값은?',
choices: ['0', '1', '2', '3', '∞'],
answer: 4,
explanation: '분자 분모를 최고차항 x²으로 나누면 lim = (3 + 2/x) / (1 1/x²) → 3/1 = 3.',
},
{
number: 3,
question: 'lim(x→0) sin x / x 의 값은?',
choices: ['0', '1', 'π', '∞', '존재하지 않음'],
answer: 2,
explanation: '기본 극한: lim(x→0) sin x / x = 1 (중요 표준 극한).',
},
{
number: 4,
question: 'lim(n→∞) (1 + 1/n)^n 의 값은?',
choices: ['1', '2', '3', 'e', 'π'],
answer: 4,
explanation: '자연상수 e의 정의: e = lim(n→∞) (1 + 1/n)^n ≈ 2.718…',
},
{
number: 5,
question: 'f(x) = x² 에서 x = 1에서의 미분계수 lim(h→0) [f(1+h)f(1)]/h 의 값은?',
choices: ['0', '1', '2', '3', '4'],
answer: 3,
explanation: 'f(1+h) = (1+h)² = 1 + 2h + h². [f(1+h)f(1)]/h = (2h + h²)/h = 2 + h → h→0이면 2.',
},
// ── 미분법 심화 (6~11) ────────────────────────────────────────────────────────
{
number: 6,
question: 'f(x) = e^x 의 도함수는?',
choices: ['e^(x1)', 'x·e^x', 'e^x', '1/e^x', 'e'],
answer: 3,
explanation: '지수함수의 미분: e^x의 도함수 = e^x. 자기 자신이 도함수.',
},
{
number: 7,
question: 'f(x) = ln x 의 도함수는? (x > 0)',
choices: ['ln x', '1/x', 'x', 'e^x', '1/(x ln x)'],
answer: 2,
explanation: '자연로그 미분: ln x의 도함수 = 1/x.',
},
{
number: 8,
question: 'f(x) = sin x 의 도함수는?',
choices: ['sin x', 'sin x', 'cos x', 'cos x', 'tan x'],
answer: 4,
explanation: '삼각함수 미분: sin x의 도함수 = cos x.',
},
{
number: 9,
question: 'f(x) = x² · sin x 의 도함수는? (곱의 미분)',
choices: [
'2x · cos x',
'2x · sin x + x² · cos x',
'x² · cos x',
'2x · sin x x² · cos x',
'x² · sin x + 2x',
],
answer: 2,
explanation: '곱의 미분: (uv)의 도함수 = u´v + uv´. u = x², u´ = 2x; v = sin x, v´ = cos x. 결과: 2x sin x + x² cos x.',
},
{
number: 10,
question: '합성함수 f(x) = (2x + 1)³ 의 도함수는?',
choices: ['3(2x+1)²', '6(2x+1)²', '3(2x+1)', '6(2x+1)', '2(2x+1)³'],
answer: 2,
explanation: '연쇄 법칙: (u³)의 도함수 = 3u² × u´. u = 2x+1, u´ = 2. 결과: 3(2x+1)² × 2 = 6(2x+1)².',
},
{
number: 11,
question: 'f(x) = x³ 6x² + 9x 의 극대값은?',
choices: ['0', '2', '4', '6', '9'],
answer: 3,
explanation: 'f´(x) = 3x² 12x + 9 = 3(x1)(x3) = 0 → x = 1(극대), x = 3(극소). f(1) = 1 6 + 9 = 4.',
},
// ── 적분법 심화 (12~17) ───────────────────────────────────────────────────────
{
number: 12,
question: '∫ e^x dx = ?',
choices: ['e^(x+1) + C', 'e^x + C', 'x·e^x + C', '1/e^x + C', 'e^x/x + C'],
answer: 2,
explanation: '∫ e^x dx = e^x + C. e^x는 자신이 미분/적분의 고유함수.',
},
{
number: 13,
question: '∫ 1/x dx = ? (x > 0)',
choices: ['1/x² + C', 'x + C', 'ln x + C', '1/(x+1) + C', 'e^x + C'],
answer: 3,
explanation: '∫ (1/x) dx = ln |x| + C. x > 0이면 ln x + C.',
},
{
number: 14,
question: '∫ cos x dx = ?',
choices: ['sin x + C', 'sin x + C', 'cos x + C', 'cos x + C', 'tan x + C'],
answer: 2,
explanation: '∫ cos x dx = sin x + C. sin x의 도함수가 cos x이므로 역연산.',
},
{
number: 15,
question: '∫₀^π sin x dx 의 값은?',
choices: ['0', '1', '2', 'π', '2'],
answer: 3,
explanation: '[cos x]₀^π = cos π (cos 0) = (1) (1) = 1 + 1 = 2.',
},
{
number: 16,
question: '부분적분을 이용해 ∫ x·e^x dx 를 구하면?',
choices: ['x·e^x + C', 'x·e^x e^x + C', 'e^x + C', 'x²·e^x/2 + C', '(x1)·e^x + C'],
answer: 2,
explanation: '부분적분: u=x, dv=e^x dx → du=dx, v=e^x. ∫x·e^x dx = x·e^x ∫e^x dx = x·e^x e^x + C.',
},
{
number: 17,
question: 'y = x³ x 와 x 축으로 둘러싸인 넓이는? (1 ≤ x ≤ 1 구간)',
choices: ['0', '1/2', '1', '3/2', '2'],
answer: 2,
explanation: '|∫₋₁⁰ (x³x) dx| + |∫₀¹ (x³x) dx| = 1/4 + 1/4 = 1/2. 대칭 활용.',
},
// ── 확률과 통계 (18~24) ───────────────────────────────────────────────────────
{
number: 18,
question: '5명을 일렬로 세울 때 특정 2명이 항상 이웃하는 경우의 수는?',
choices: ['24', '36', '48', '60', '72'],
answer: 3,
explanation: '2명을 묶어 1명으로 보면 4명 배열: 4! = 24. 묶음 내부 순서: 2! = 2. 24 × 2 = 48.',
},
{
number: 19,
question: 'C(6, 2) 의 값은?',
choices: ['10', '12', '15', '18', '30'],
answer: 3,
explanation: 'C(6,2) = 6!/(2!·4!) = (6×5)/2 = 15.',
},
{
number: 20,
question: '이항분포 B(10, 0.5)의 평균은?',
choices: ['2', '4', '5', '6', '10'],
answer: 3,
explanation: '이항분포 B(n,p)의 평균 = np = 10 × 0.5 = 5.',
},
{
number: 21,
question: '정규분포 N(50, 10²)에서 P(40 ≤ X ≤ 60)은? (표준정규표준: P(0≤Z≤1) = 0.3413)',
choices: ['0.3413', '0.5', '0.6826', '0.9544', '1'],
answer: 3,
explanation: 'Z = (X50)/10. P(40≤X≤60) = P(1≤Z≤1) = 2×P(0≤Z≤1) = 2×0.3413 = 0.6826.',
},
{
number: 22,
question: '두 사건 A, B가 독립이고 P(A) = 0.4, P(B) = 0.5이면 P(A∩B)는?',
choices: ['0.1', '0.2', '0.4', '0.5', '0.9'],
answer: 2,
explanation: '독립사건: P(A∩B) = P(A) × P(B) = 0.4 × 0.5 = 0.2.',
},
{
number: 23,
question: '조건부 확률 P(A|B) = P(A∩B)/P(B) 에서 P(A∩B) = 0.12, P(B) = 0.4이면 P(A|B)는?',
choices: ['0.2', '0.3', '0.4', '0.5', '0.6'],
answer: 2,
explanation: 'P(A|B) = 0.12 / 0.4 = 0.3.',
},
{
number: 24,
question: '표본 크기 n = 100, 표본 평균 x̄ = 75, 모표준편차 σ = 10일 때 모평균의 95% 신뢰구간 폭은? (z₀.₀₂₅ ≈ 1.96)',
choices: ['1.96', '2.96', '3.92', '4.90', '19.6'],
answer: 3,
explanation: '신뢰구간 폭 = 2 × z × σ/√n = 2 × 1.96 × 10/10 = 2 × 1.96 = 3.92.',
},
// ── 벡터/공간좌표 (25~30) ─────────────────────────────────────────────────────
{
number: 25,
question: '벡터 a→ = (3, 4) 의 크기 |a→|는?',
choices: ['5', '6', '7', '12', '25'],
answer: 1,
explanation: '|a→| = √(3² + 4²) = √(9+16) = √25 = 5.',
},
{
number: 26,
question: 'a→ = (1, 2), b→ = (3, 1) 일 때 a→ + b→ 의 y 성분은?',
choices: ['1', '0', '1', '2', '3'],
answer: 3,
explanation: 'a→ + b→ = (1+3, 2+(1)) = (4, 1). y 성분 = 1.',
},
{
number: 27,
question: 'a→ = (2, 1), b→ = (1, 3) 일 때 내적 a→·b→ 는?',
choices: ['3', '4', '5', '6', '7'],
answer: 3,
explanation: 'a→·b→ = 2×1 + 1×3 = 2 + 3 = 5.',
},
{
number: 28,
question: '두 벡터 a→ = (1, 0), b→ = (0, 1) 의 내적은?',
choices: ['1', '0', '1', '√2', '2'],
answer: 2,
explanation: 'a→·b→ = 1×0 + 0×1 = 0. 수직(직교)이면 내적 = 0.',
},
{
number: 29,
question: '점 A(1, 2, 3), B(4, 6, 3) 사이의 거리는?',
choices: ['3', '4', '5', '6', '7'],
answer: 3,
explanation: 'd = √((41)² + (62)² + (33)²) = √(9+16+0) = √25 = 5.',
},
{
number: 30,
question: '공간에서 직선 x/1 = y/2 = z/3 의 방향벡터는?',
choices: ['(1,1,1)', '(1,2,3)', '(3,2,1)', '(2,3,1)', '(0,0,0)'],
answer: 2,
explanation: '대칭형 방정식 x/a = y/b = z/c에서 방향벡터 = (a, b, c) = (1, 2, 3).',
},
];

View File

@@ -0,0 +1,71 @@
import type { SampleProblem } from './types';
import { PROBLEMS as MIDDLE1_PROBLEMS } from './middle-1';
import { PROBLEMS as MIDDLE2_PROBLEMS } from './middle-2';
import { PROBLEMS as MIDDLE3_PROBLEMS } from './middle-3';
import { PROBLEMS as HIGH1_PROBLEMS } from './high-1';
import { PROBLEMS as HIGH2_PROBLEMS } from './high-2';
import { PROBLEMS as HIGH3_PROBLEMS } from './high-3';
export type { SampleProblem };
export interface EbookMeta {
id: string;
title: string;
grade: string;
subtitle: string;
problemCount: number;
problems: SampleProblem[];
}
export const EBOOK_MAP: Record<string, EbookMeta> = {
'middle-1': {
id: 'middle-1',
title: '[샘플] 중1 수학 문제집',
grade: '중1',
subtitle: '중학교 1학년 수학 교과과정',
problemCount: 30,
problems: MIDDLE1_PROBLEMS,
},
'middle-2': {
id: 'middle-2',
title: '[샘플] 중2 수학 문제집',
grade: '중2',
subtitle: '중학교 2학년 수학 교과과정',
problemCount: 30,
problems: MIDDLE2_PROBLEMS,
},
'middle-3': {
id: 'middle-3',
title: '[샘플] 중3 수학 문제집',
grade: '중3',
subtitle: '중학교 3학년 수학 교과과정',
problemCount: 30,
problems: MIDDLE3_PROBLEMS,
},
'high-1': {
id: 'high-1',
title: '[샘플] 고1 수학 문제집',
grade: '고1',
subtitle: '고등학교 1학년 수학 교과과정',
problemCount: 30,
problems: HIGH1_PROBLEMS,
},
'high-2': {
id: 'high-2',
title: '[샘플] 고2 수학 문제집',
grade: '고2',
subtitle: '고등학교 2학년 수학 교과과정',
problemCount: 30,
problems: HIGH2_PROBLEMS,
},
'high-3': {
id: 'high-3',
title: '[샘플] 고3 수학 문제집',
grade: '고3',
subtitle: '고등학교 3학년 수학 교과과정',
problemCount: 30,
problems: HIGH3_PROBLEMS,
},
};
export const EBOOK_LIST: EbookMeta[] = Object.values(EBOOK_MAP);

View File

@@ -0,0 +1,223 @@
import type { SampleProblem } from './types';
export const PROBLEMS: SampleProblem[] = [
// ── 자연수와 정수 (1~6) ──────────────────────────────────────────────────────
{
number: 1,
question: '절댓값 |7| 의 값은?',
choices: ['7', '1', '0', '1', '7'],
answer: 5,
explanation: '절댓값은 수직선에서 원점까지의 거리이므로 음수 기호를 제거한다. |7| = 7.',
},
{
number: 2,
question: '다음 중 가장 작은 수는?\n3, 2, 5, 0, 4',
choices: ['3', '2', '5', '0', '4'],
answer: 3,
explanation: '수직선에서 왼쪽에 있을수록 작다. 5 < 3 < 0 < 2 < 4 이므로 5가 가장 작다.',
},
{
number: 3,
question: '(3) + (8) 의 값은?',
choices: ['11', '5', '5', '11', '24'],
answer: 1,
explanation: '부호가 같은 두 음수의 합은 절댓값의 합에 음수 부호를 붙인다. (3) + (8) = 11.',
},
{
number: 4,
question: '(4) × (6) 의 값은?',
choices: ['24', '10', '10', '24', '48'],
answer: 4,
explanation: '음수 × 음수 = 양수. 4 × 6 = 24이므로 (4) × (6) = 24.',
},
{
number: 5,
question: '(+5) ÷ (1/2) 의 값은?',
choices: ['10', '5/2', '5/2', '5', '10'],
answer: 1,
explanation: '÷ (1/2) = × (2). 5 × (2) = 10. 양수 ÷ 음수이므로 결과는 음수.',
},
{
number: 6,
question: '2³ + (1)⁴ 의 값은?',
choices: ['9', '7', '6', '7', '9'],
answer: 2,
explanation: '2³ = 8, (1)⁴ = 1. 따라서 8 + 1 = 7.',
},
// ── 일차방정식 (7~12) ────────────────────────────────────────────────────────
{
number: 7,
question: '방정식 2x 3 = 7 의 해는?',
choices: ['2', '4', '5', '7', '10'],
answer: 3,
explanation: '2x = 7 + 3 = 10. x = 10 ÷ 2 = 5.',
},
{
number: 8,
question: '방정식 3(x + 1) = 12 의 해는?',
choices: ['1', '2', '3', '4', '5'],
answer: 3,
explanation: '3x + 3 = 12. 3x = 9. x = 3.',
},
{
number: 9,
question: '방정식 x/2 + 1 = 4 의 해는?',
choices: ['2', '3', '5', '6', '8'],
answer: 4,
explanation: 'x/2 = 4 1 = 3. 양변에 2를 곱하면 x = 6.',
},
{
number: 10,
question: '방정식 5x 4 = 2x + 8 의 해는?',
choices: ['2', '3', '4', '5', '6'],
answer: 3,
explanation: '5x 2x = 8 + 4. 3x = 12. x = 4.',
},
{
number: 11,
question: '어떤 수의 3배에서 5를 빼면 16이다. 이 어떤 수는?',
choices: ['5', '6', '7', '8', '9'],
answer: 3,
explanation: '3x 5 = 16. 3x = 21. x = 7.',
},
{
number: 12,
question: '방정식 2(x 3) = x + 1 의 해는?',
choices: ['5', '6', '7', '8', '9'],
answer: 3,
explanation: '2x 6 = x + 1. 2x x = 1 + 6. x = 7.',
},
// ── 좌표평면과 그래프 (13~18) ────────────────────────────────────────────────
{
number: 13,
question: '점 (3, 2) 는 몇 사분면에 위치하는가?',
choices: ['제1사분면', '제2사분면', '제3사분면', '제4사분면', '어느 사분면도 아님'],
answer: 2,
explanation: 'x < 0, y > 0 이면 제2사분면. (3, 2)는 x = 3 < 0, y = 2 > 0이므로 제2사분면.',
},
{
number: 14,
question: '두 점 A(1, 3), B(4, 7) 사이의 x 좌표 차이는?',
choices: ['1', '2', '3', '4', '5'],
answer: 3,
explanation: '4 1 = 3. x 좌표끼리의 차이를 구한다.',
},
{
number: 15,
question: 'y = 2x + 1 에서 x = 3일 때 y의 값은?',
choices: ['5', '6', '7', '8', '9'],
answer: 3,
explanation: 'y = 2 × 3 + 1 = 6 + 1 = 7.',
},
{
number: 16,
question: 'y = x + 4 의 그래프가 지나는 점은?',
choices: ['(0, 4)', '(2, 2)', '(4, 0)', '(1, 3)', '(3, 0)'],
answer: 2,
explanation: 'x = 2일 때 y = 2 + 4 = 2. 따라서 점 (2, 2)를 지난다.',
},
{
number: 17,
question: '좌표축 위의 점 (5, 0) 이 위치하는 곳은?',
choices: ['제1사분면', '제2사분면', 'x 축', 'y 축', '원점'],
answer: 3,
explanation: 'y = 0인 점은 x 축 위에 있다. (5, 0)은 x 축 위의 점.',
},
{
number: 18,
question: 'y = 3x 그래프의 기울기는?',
choices: ['3', '0', '1', '3', '9'],
answer: 4,
explanation: 'y = mx 형태에서 m이 기울기. y = 3x에서 기울기는 3.',
},
// ── 기본 도형 (19~24) ────────────────────────────────────────────────────────
{
number: 19,
question: '삼각형 세 내각의 합은?',
choices: ['90°', '120°', '180°', '270°', '360°'],
answer: 3,
explanation: '삼각형 세 내각의 합은 항상 180°이다.',
},
{
number: 20,
question: '정삼각형 한 각의 크기는?',
choices: ['45°', '60°', '90°', '108°', '120°'],
answer: 2,
explanation: '정삼각형은 세 각이 모두 같고 합이 180°. 180° ÷ 3 = 60°.',
},
{
number: 21,
question: '반지름이 4 cm인 원의 둘레는? (π 사용)',
choices: ['4π cm', '8π cm', '12π cm', '16π cm', '32π cm'],
answer: 2,
explanation: '원의 둘레 = 2πr. 2 × π × 4 = 8π cm.',
},
{
number: 22,
question: '직각삼각형에서 한 예각이 35°이면 다른 예각은?',
choices: ['35°', '45°', '55°', '65°', '90°'],
answer: 3,
explanation: '세 각의 합 = 180°. 90° + 35° + ? = 180°. 나머지 각 = 55°.',
},
{
number: 23,
question: '평행사변형의 두 대각선은 서로 어떤 관계인가?',
choices: ['수직이등분', '수직', '서로 이등분', '같은 길이', '평행'],
answer: 3,
explanation: '평행사변형에서 두 대각선은 서로 이등분(교점에서 각각 반으로 나뉨)한다.',
},
{
number: 24,
question: '밑변 6 cm, 높이 4 cm인 삼각형의 넓이는?',
choices: ['10 cm²', '12 cm²', '18 cm²', '24 cm²', '48 cm²'],
answer: 2,
explanation: '삼각형 넓이 = (밑변 × 높이) ÷ 2 = (6 × 4) ÷ 2 = 12 cm².',
},
// ── 비례/반비례 (25~30) ──────────────────────────────────────────────────────
{
number: 25,
question: 'y 가 x 에 정비례하고 x = 3일 때 y = 12이다. x = 5일 때 y는?',
choices: ['15', '18', '20', '24', '30'],
answer: 3,
explanation: '정비례: y = kx. 12 = k × 3 → k = 4. y = 4 × 5 = 20.',
},
{
number: 26,
question: 'y 가 x 에 반비례하고 x = 2일 때 y = 6이다. x = 4일 때 y는?',
choices: ['2', '3', '4', '6', '12'],
answer: 2,
explanation: '반비례: xy = k. 2 × 6 = 12 = k. x = 4이면 y = 12/4 = 3.',
},
{
number: 27,
question: 'y = 6/x 에서 x = 2일 때 y의 값은?',
choices: ['1', '2', '3', '6', '12'],
answer: 3,
explanation: 'y = 6/2 = 3.',
},
{
number: 28,
question: '시속 60 km로 달리는 자동차가 3시간 동안 이동하는 거리는?',
choices: ['120 km', '150 km', '180 km', '200 km', '240 km'],
answer: 3,
explanation: '거리 = 속력 × 시간. 60 × 3 = 180 km.',
},
{
number: 29,
question: '비 3:4에서 전체를 70으로 할 때, 3에 해당하는 양은?',
choices: ['21', '28', '30', '35', '42'],
answer: 3,
explanation: '전체 비율 3 + 4 = 7. 3에 해당하는 양 = 70 × (3/7) = 30.',
},
{
number: 30,
question: '가로 세로 비가 3:5인 직사각형에서 가로가 9 cm이면 세로는?',
choices: ['12 cm', '13 cm', '14 cm', '15 cm', '18 cm'],
answer: 4,
explanation: '3:5 = 9:x. 3x = 45. x = 15 cm.',
},
];

View File

@@ -0,0 +1,241 @@
import type { SampleProblem } from './types';
export const PROBLEMS: SampleProblem[] = [
// ── 유리수/무리수 (1~6) ───────────────────────────────────────────────────────
{
number: 1,
question: '다음 중 무리수인 것은?',
choices: ['√4', '0.333…', '5/3', '√7', '1.25'],
answer: 4,
explanation: '√4 = 2(유리수), 0.333… = 1/3(유리수), 5/3(유리수), 1.25(유리수). √7은 유한소수나 순환소수로 나타낼 수 없으므로 무리수.',
},
{
number: 2,
question: '√18 을 가장 간단한 꼴로 나타내면?',
choices: ['3√2', '2√3', '3√6', '6√2', '9√2'],
answer: 1,
explanation: '√18 = √(9 × 2) = √9 × √2 = 3√2.',
},
{
number: 3,
question: '√2 × √8 의 값은?',
choices: ['2', '4', '√10', '2√4', '8'],
answer: 2,
explanation: '√2 × √8 = √(2 × 8) = √16 = 4.',
},
{
number: 4,
question: '2√3 + 5√3 의 값은?',
choices: ['7', '7√3', '10√3', '7√6', '√21'],
answer: 2,
explanation: '같은 무리수끼리 계수를 더한다. (2 + 5)√3 = 7√3.',
},
{
number: 5,
question: '√5 ≈ 2.236 일 때, 3√5 의 근삿값은?',
choices: ['5.618', '6.618', '6.708', '7.236', '8.236'],
answer: 3,
explanation: '3 × 2.236 = 6.708.',
},
{
number: 6,
question: '다음 중 유리수와 무리수에 대한 설명으로 옳은 것은?',
choices: [
'무리수는 음수가 될 수 없다',
'유리수와 무리수의 합은 항상 무리수이다',
'모든 순환소수는 무리수이다',
'√9 는 무리수이다',
'두 무리수의 합은 항상 무리수이다',
],
answer: 2,
explanation: '유리수 + 무리수 = 무리수. 예: 1 + √2 = 1 + √2(무리수). 순환소수는 유리수, √9 = 3(유리수).',
},
// ── 연립방정식 (7~12) ────────────────────────────────────────────────────────
{
number: 7,
question: '연립방정식 { x + y = 5, x y = 1 } 을 풀면 x의 값은?',
choices: ['1', '2', '3', '4', '5'],
answer: 3,
explanation: '두 식을 더하면 2x = 6. x = 3. (y = 5 3 = 2 검증 가능)',
},
{
number: 8,
question: '연립방정식 { 2x + y = 7, x + 2y = 8 } 을 풀면 y의 값은?',
choices: ['1', '2', '3', '4', '5'],
answer: 3,
explanation: '첫 식에서 y = 7 2x. 대입하면 x + 2(7 2x) = 8 → x + 14 4x = 8 → 3x = 6 → x = 2. y = 7 4 = 3.',
},
{
number: 9,
question: '연립방정식 { x y = 4, 2x + y = 8 } 을 풀면 x + y의 값은?',
choices: ['2', '3', '4', '5', '6'],
answer: 3,
explanation: '더하면 3x = 12 → x = 4. y = 4 4 = 0. x + y = 4.',
},
{
number: 10,
question: '연립방정식 { 3x 2y = 1, x + y = 7 } 에서 x의 값은?',
choices: ['1', '2', '3', '4', '5'],
answer: 3,
explanation: 'y = 7 x를 대입: 3x 2(7 x) = 1 → 3x 14 + 2x = 1 → 5x = 15 → x = 3.',
},
{
number: 11,
question: '어른 2명과 어린이 3명의 입장료 합이 13,000원, 어른 1명과 어린이 1명의 합이 6,000원일 때 어른 1명의 입장료는?',
choices: ['3,000원', '4,000원', '5,000원', '6,000원', '7,000원'],
answer: 3,
explanation: 'a + c = 6000에서 c = 6000 a. 2a + 3(6000 a) = 13000 → 2a + 18000 3a = 13000 → a = 5000 → a = 5,000원.',
},
{
number: 12,
question: '연립방정식 { 2x y = 3, 4x 2y = 6 } 의 해의 개수는?',
choices: ['0개(불능)', '1개', '2개', '무한히 많다(부정)', '판단 불가'],
answer: 4,
explanation: '두 번째 식은 첫 번째 식에 2를 곱한 것과 같다. 두 식이 일치하므로 해가 무한히 많다(부정).',
},
// ── 일차함수 (13~18) ─────────────────────────────────────────────────────────
{
number: 13,
question: '일차함수 y = 3x 2 에서 y 절편(x = 0)의 값은?',
choices: ['3', '2', '0', '2', '3'],
answer: 2,
explanation: 'x = 0 대입: y = 3 × 0 2 = 2. y 절편은 2.',
},
{
number: 14,
question: '일차함수 y = 2x + 5 의 기울기는?',
choices: ['5', '2', '1', '2', '5'],
answer: 2,
explanation: 'y = mx + b 형태에서 m이 기울기. y = 2x + 5에서 기울기 = 2.',
},
{
number: 15,
question: '두 점 (1, 3), (3, 7)을 지나는 직선의 기울기는?',
choices: ['1', '2', '3', '4', '5'],
answer: 2,
explanation: '기울기 = (y₂ y₁) / (x₂ x₁) = (7 3) / (3 1) = 4 / 2 = 2.',
},
{
number: 16,
question: 'y = ax + b 에서 a > 0, b < 0이면 그래프는?',
choices: [
'오른쪽 위로 기울고 y 절편이 양수',
'오른쪽 아래로 기울고 y 절편이 양수',
'오른쪽 위로 기울고 y 절편이 음수',
'오른쪽 아래로 기울고 y 절편이 음수',
'수평선',
],
answer: 3,
explanation: 'a > 0 이면 오른쪽 위로 증가, b < 0 이면 y 절편이 음수(원점 아래를 지남).',
},
{
number: 17,
question: 'y = 2x + 1 과 y = 2x 3 의 관계는?',
choices: ['한 점에서 만난다', '두 점에서 만난다', '평행하다', '일치한다', '수직이다'],
answer: 3,
explanation: '두 직선의 기울기가 같고 (2 = 2) y 절편이 다르므로 평행하다.',
},
{
number: 18,
question: 'y = x + 2 와 y = x + 4 의 교점의 x 좌표는?',
choices: ['0', '1', '2', '3', '4'],
answer: 2,
explanation: 'x + 2 = x + 4 → 2x = 2 → x = 1.',
},
// ── 삼각형 성질 (19~24) ──────────────────────────────────────────────────────
{
number: 19,
question: '이등변삼각형에서 꼭짓각이 40°이면 밑각 하나의 크기는?',
choices: ['50°', '60°', '70°', '80°', '90°'],
answer: 3,
explanation: '세 각의 합 = 180°. 두 밑각이 같으므로 2a + 40° = 180° → 2a = 140° → a = 70°.',
},
{
number: 20,
question: '삼각형의 외각은 그 이웃하지 않는 두 내각의 합과 어떤 관계인가?',
choices: ['같다', '크다', '작다', '두 배이다', '절반이다'],
answer: 1,
explanation: '삼각형 외각 정리: 한 외각의 크기 = 그 이웃하지 않는 두 내각의 합.',
},
{
number: 21,
question: 'SSS 합동 조건이란?',
choices: [
'두 변과 그 끼인각이 각각 같다',
'두 각과 그 끼인변이 각각 같다',
'세 변의 길이가 각각 같다',
'한 변과 두 각이 같다',
'빗변과 한 예각이 같다',
],
answer: 3,
explanation: 'SSS(Side-Side-Side) 합동 조건: 세 쌍의 대응 변의 길이가 모두 같을 때.',
},
{
number: 22,
question: '직각삼각형에서 두 직각변이 3, 4일 때 빗변의 길이는?',
choices: ['3', '4', '5', '6', '7'],
answer: 3,
explanation: '피타고라스 정리: 3² + 4² = 9 + 16 = 25 = 5². 빗변 = 5.',
},
{
number: 23,
question: '삼각형 내각의 이등분선의 교점을 무엇이라 하는가?',
choices: ['외심', '무게중심', '수심', '내심', '꼭짓점'],
answer: 4,
explanation: '내각의 이등분선 교점 = 내심(내접원의 중심). 외각이등분선 교점은 방심.',
},
{
number: 24,
question: '세 중선의 교점(무게중심)은 각 중선을 꼭짓점으로부터 몇 대 몇으로 나누는가?',
choices: ['1:1', '1:2', '2:1', '3:1', '1:3'],
answer: 3,
explanation: '무게중심은 각 중선을 꼭짓점에서부터 2:1로 나눈다.',
},
// ── 확률 (25~30) ─────────────────────────────────────────────────────────────
{
number: 25,
question: '주사위 한 개를 던질 때 짝수가 나올 확률은?',
choices: ['1/6', '1/3', '1/2', '2/3', '5/6'],
answer: 3,
explanation: '짝수의 눈: 2, 4, 6 → 3가지. 전체 6가지. 확률 = 3/6 = 1/2.',
},
{
number: 26,
question: '10장의 카드(1~10)에서 한 장을 뽑을 때 3의 배수일 확률은?',
choices: ['1/10', '2/10', '3/10', '4/10', '5/10'],
answer: 3,
explanation: '3의 배수: 3, 6, 9 → 3가지. 확률 = 3/10.',
},
{
number: 27,
question: '동전 2개를 동시에 던질 때 둘 다 앞면일 확률은?',
choices: ['1/4', '1/2', '3/4', '1/3', '2/3'],
answer: 1,
explanation: '전체 경우: HH, HT, TH, TT → 4가지. 둘 다 앞면: HH → 1가지. 확률 = 1/4.',
},
{
number: 28,
question: 'P(A) = 1/3 일 때, A가 일어나지 않을 확률은?',
choices: ['1/6', '1/4', '1/3', '2/3', '5/6'],
answer: 4,
explanation: '여사건 확률 = 1 P(A) = 1 1/3 = 2/3.',
},
{
number: 29,
question: '어떤 사건 A와 B가 서로 배반이고 P(A) = 0.4, P(B) = 0.3이면 P(A 또는 B)는?',
choices: ['0.1', '0.3', '0.4', '0.7', '0.12'],
answer: 4,
explanation: '배반사건이면 P(A B) = P(A) + P(B) = 0.4 + 0.3 = 0.7.',
},
{
number: 30,
question: '빨간 공 4개, 파란 공 6개가 든 주머니에서 공 1개를 꺼낼 때 파란 공일 확률은?',
choices: ['2/5', '3/5', '4/10', '1/4', '1/2'],
answer: 2,
explanation: '파란 공 6개 / 전체 10개 = 6/10 = 3/5.',
},
];

View File

@@ -0,0 +1,235 @@
import type { SampleProblem } from './types';
export const PROBLEMS: SampleProblem[] = [
// ── 인수분해 (1~5) ────────────────────────────────────────────────────────────
{
number: 1,
question: 'x² + 5x + 6 을 인수분해하면?',
choices: ['(x+1)(x+6)', '(x+2)(x+3)', '(x2)(x3)', '(x+3)(x+2)', '(x1)(x6)'],
answer: 2,
explanation: '두 수의 합 = 5, 곱 = 6인 수: 2와 3. 따라서 (x+2)(x+3). ※ ②와 ④는 동일해서 ②가 정답.',
},
{
number: 2,
question: 'x² 9 를 인수분해하면?',
choices: ['(x3)²', '(x+3)²', '(x3)(x+3)', '(x9)(x+1)', '인수분해 불가'],
answer: 3,
explanation: '합차 공식: a² b² = (ab)(a+b). x² 9 = x² 3² = (x3)(x+3).',
},
{
number: 3,
question: '2x² + 4x 를 인수분해하면?',
choices: ['2(x+2)', '2x(x+2)', 'x(2x+4)', '2(x²+2x)', '(2x+1)(x+2)'],
answer: 2,
explanation: '공통인수 2x를 묶는다. 2x² + 4x = 2x(x + 2).',
},
{
number: 4,
question: 'x² 6x + 9 를 인수분해하면?',
choices: ['(x3)(x+3)', '(x3)²', '(x+3)²', '(x9)(x+1)', '(x1)(x9)'],
answer: 2,
explanation: '완전제곱식: x² 2·3·x + 3² = (x3)². 완전제곱 공식 (ab)² = a²2ab+b².',
},
{
number: 5,
question: '3x² 12 를 인수분해하면?',
choices: ['3(x²4)', '3(x2)(x+2)', '(3x6)(x+2)', '3(x+2)²', '(x2)(3x+6)'],
answer: 2,
explanation: '공통인수 3을 묶은 후 합차 공식: 3(x²4) = 3(x2)(x+2).',
},
// ── 이차방정식 (6~10) ─────────────────────────────────────────────────────────
{
number: 6,
question: 'x² 5x + 6 = 0 의 두 근의 합은?',
choices: ['6', '5', '1', '5', '6'],
answer: 4,
explanation: '(x2)(x3) = 0 → x = 2 또는 x = 3. 두 근의 합 = 2 + 3 = 5. (비에타: (5)/1 = 5)',
},
{
number: 7,
question: 'x² 4 = 0 의 해는?',
choices: ['x = 2', 'x = 2', 'x = ±2', 'x = 4', 'x = ±4'],
answer: 3,
explanation: 'x² = 4. x = ±√4 = ±2.',
},
{
number: 8,
question: '이차방정식 x² + 2x 8 = 0 의 두 근 중 큰 값은?',
choices: ['4', '2', '2', '4', '8'],
answer: 3,
explanation: '(x+4)(x2) = 0 → x = 4 또는 x = 2. 큰 값은 2.',
},
{
number: 9,
question: '근의 공식을 이용해 2x² 3x 2 = 0 을 풀면 해는?',
choices: ['x = 2 또는 x = 1/2', 'x = 1 또는 x = 2', 'x = 3 또는 x = 1', 'x = 2 또는 x = 1', 'x = 2 또는 x = 1/2'],
answer: 1,
explanation: 'x = (3 ± √(9+16)) / 4 = (3 ± 5) / 4. x = 8/4 = 2 또는 x = 2/4 = 1/2.',
},
{
number: 10,
question: '판별식 D = b² 4ac 에서 D > 0이면 이차방정식의 근의 개수는?',
choices: ['0개(허수)', '1개(중근)', '2개(서로 다른 실근)', '무한개', '판단 불가'],
answer: 3,
explanation: 'D > 0이면 서로 다른 두 실근, D = 0이면 중근(1개), D < 0이면 실근 없음.',
},
// ── 이차함수 (11~15) ──────────────────────────────────────────────────────────
{
number: 11,
question: 'y = x² 4x + 3 의 꼭짓점 좌표는?',
choices: ['(2, 1)', '(2, 1)', '(4, 3)', '(2, 1)', '(4, 3)'],
answer: 1,
explanation: 'y = (x2)² 1로 변형. 꼭짓점은 (2, 1).',
},
{
number: 12,
question: 'y = (x 1)² + 4 의 최댓값은?',
choices: ['4', '1', '1', '4', '5'],
answer: 4,
explanation: '위로 볼록 포물선. 꼭짓점 (1, 4)에서 최댓값 = 4.',
},
{
number: 13,
question: 'y = 2x² 의 그래프의 특징으로 옳은 것은?',
choices: [
'아래로 볼록, 꼭짓점 (0,0)',
'위로 볼록, 꼭짓점 (0,0)',
'아래로 볼록, 꼭짓점 (2,0)',
'y = x²보다 폭이 넓다',
'기울기가 2인 직선',
],
answer: 1,
explanation: 'a = 2 > 0이므로 아래로 볼록, 꼭짓점은 (0, 0). a의 절댓값이 클수록 폭이 좁다.',
},
{
number: 14,
question: 'y = x² + 2x 3 의 x 절편의 개수는?',
choices: ['0', '1', '2', '3', '무한'],
answer: 3,
explanation: 'x² + 2x 3 = (x+3)(x1) = 0 → x = 3 또는 x = 1. x 절편 2개.',
},
{
number: 15,
question: 'y = a(xp)² + q 에서 꼭짓점이 (3, 2)이고 a = 1이면 이 함수의 식은?',
choices: [
'y = (x+3)² 2',
'y = (x3)² + 2',
'y = (x3)² 2',
'y = (x+3)² + 2',
'y = (x2)² 3',
],
answer: 3,
explanation: 'y = a(xp)² + q에 a=1, p=3, q=2를 대입하면 y = (x3)² 2.',
},
// ── 피타고라스 / 삼각비 (16~22) ──────────────────────────────────────────────
{
number: 16,
question: '직각삼각형에서 두 다리가 5, 12일 때 빗변은?',
choices: ['10', '11', '13', '14', '15'],
answer: 3,
explanation: '5² + 12² = 25 + 144 = 169 = 13². 빗변 = 13.',
},
{
number: 17,
question: '빗변이 10, 한 다리가 6인 직각삼각형에서 나머지 다리의 길이는?',
choices: ['4', '6', '7', '8', '9'],
answer: 4,
explanation: '6² + b² = 10² → 36 + b² = 100 → b² = 64 → b = 8.',
},
{
number: 18,
question: '직각삼각형에서 각 A의 맞은편 변이 3, 빗변이 5이면 sin A는?',
choices: ['3/5', '4/5', '3/4', '5/3', '5/4'],
answer: 1,
explanation: 'sin = (맞은편 변) / (빗변) = 3/5.',
},
{
number: 19,
question: '각 B에서 인접변이 4, 빗변이 5이면 cos B는?',
choices: ['3/5', '4/5', '3/4', '5/4', '5/3'],
answer: 2,
explanation: 'cos = (인접변) / (빗변) = 4/5.',
},
{
number: 20,
question: 'tan 30° 의 값은?',
choices: ['√3', '1/√3 (= √3/3)', '√2/2', '1', '√3/2'],
answer: 2,
explanation: 'tan 30° = sin 30° / cos 30° = (1/2) / (√3/2) = 1/√3 = √3/3.',
},
{
number: 21,
question: 'sin 45° 의 값은?',
choices: ['1/2', '√2/2', '√3/2', '1', '√2'],
answer: 2,
explanation: '45-45-90 삼각형에서 sin 45° = 1/√2 = √2/2.',
},
{
number: 22,
question: '각 C = 90°인 직각삼각형에서 tan A = 4/3이면 sin A는? (빗변 = 5 기준)',
choices: ['3/5', '4/5', '3/4', '4/3', '5/4'],
answer: 2,
explanation: 'tan A = 맞은편/인접 = 4/3. 빗변 = √(4²+3²) = 5. sin A = 4/5.',
},
// ── 원의 성질 (23~30) ─────────────────────────────────────────────────────────
{
number: 23,
question: '중심각이 60°인 부채꼴에서 같은 원 위의 원주각의 크기는?',
choices: ['15°', '30°', '60°', '90°', '120°'],
answer: 2,
explanation: '원주각 = 중심각 / 2. 60° / 2 = 30°.',
},
{
number: 24,
question: '반지름 6 cm인 원에서 중심각 90°인 부채꼴의 호의 길이는?',
choices: ['2π cm', '3π cm', '4π cm', '6π cm', '9π cm'],
answer: 2,
explanation: '호의 길이 = 2πr × (중심각/360°) = 2π × 6 × (90/360) = 12π × 1/4 = 3π cm.',
},
{
number: 25,
question: '원에서 원주각이 같은 호에 대해 원주각의 크기는?',
choices: ['호의 길이에 비례', '항상 같다', '중심각의 2배', '중심각과 같다', '호의 길이와 무관'],
answer: 2,
explanation: '같은 호에 대한 원주각은 모두 같다(원주각의 크기 일정성).',
},
{
number: 26,
question: '원에 내접하는 사각형에서 마주보는 두 각의 합은?',
choices: ['90°', '120°', '180°', '270°', '360°'],
answer: 3,
explanation: '원에 내접하는 사각형(원내접 사각형)에서 대각의 합 = 180°.',
},
{
number: 27,
question: '원의 접선과 현이 이루는 각(접선-현의 각)의 크기는 그 현에 대한?',
choices: ['중심각과 같다', '원주각과 같다', '원주각의 2배', '중심각의 절반', '접선의 길이와 같다'],
answer: 2,
explanation: '접선과 현의 각(접현각) = 그 현이 대하는 원주각. 접선-현의 각 정리.',
},
{
number: 28,
question: '두 원이 외부에서 접할 때 공통 접선의 개수는?',
choices: ['1', '2', '3', '4', '0'],
answer: 3,
explanation: '두 원이 외접(외부에서 접)할 때 공통 접선은 3개(외부 공통 접선 2 + 내부 공통 접선 1).',
},
{
number: 29,
question: '원의 중심으로부터 현까지의 수선은 현을 어떻게 나누는가?',
choices: ['1:2로', '2:1로', '이등분한다', '황금비로', '나누지 않는다'],
answer: 3,
explanation: '원의 중심에서 현에 내린 수선은 현을 이등분한다(원과 현의 성질).',
},
{
number: 30,
question: '반지름 5 cm인 원에서 반지름에 수직인 현의 길이가 8 cm이면 중심에서 현까지의 거리는?',
choices: ['2 cm', '3 cm', '4 cm', '5 cm', '6 cm'],
answer: 2,
explanation: '현의 절반 = 4 cm. 피타고라스: 5² = 4² + d² → d² = 9 → d = 3 cm.',
},
];

View File

@@ -0,0 +1,7 @@
export interface SampleProblem {
number: number;
question: string;
choices: string[];
answer: number; // 1-based
explanation: string;
}

View File

@@ -7,7 +7,7 @@ import AppShell from '@/components/layout/AppShell';
import { Icon } from '@/components/ui/Icon';
import GradeCardCarousel from '@/components/ui/GradeCardCarousel';
import { useToast } from '@/components/ui/Toast';
import { api, MATH_UNIT_LABEL, ps } from '@/lib/api';
import { api, MATH_UNIT_LABEL } from '@/lib/api';
import type { GoalType, MathUnit, MeUser, Persona, ReviewIntensity } from '@/lib/api';
import { PERSONA_META } from '@/lib/constants';
import { getDefaultTargetYear } from '@/lib/exam-date';
@@ -232,20 +232,6 @@ function OnboardingBody() {
try {
await api.patch<MeUser>('/me/onboarding', requestBody);
if (goalType === 'ps' && syncAfterSave) {
try {
const result = await ps.sync(trimmedHandle || undefined);
showToast({
message: `동기화 완료 · ${result.importedCount}건 추가`,
variant: 'success',
});
} catch (syncError) {
showToast({
message: 'Solved.ac 동기화에 실패했어요. 나중에 다시 시도해 주세요.',
variant: 'danger',
});
}
}
showToast({ message: '온보딩 설정을 저장했습니다', variant: 'success' });
router.replace('/dashboard');
} catch {

View File

@@ -1,231 +0,0 @@
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import AppShell from '@/components/layout/AppShell';
import PsTabs from '@/components/ps/PsTabs';
import { Icon } from '@/components/ui/Icon';
import { Badge, Button, Card, PageHeader } from '@/components/ui/primitives';
import { useToast } from '@/components/ui/Toast';
import { ps, type PsBookmark } from '@/lib/api';
import { theme } from '@/styles/theme';
export default function PsBookmarksPage() {
return (
<AppShell>
<BookmarksBody />
</AppShell>
);
}
function BookmarksBody() {
const { showToast } = useToast();
const [bookmarks, setBookmarks] = useState<PsBookmark[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [removingId, setRemovingId] = useState<number | null>(null);
useEffect(() => {
void loadBookmarks();
}, []);
async function loadBookmarks() {
setLoading(true);
setError(null);
try {
const items = await ps.listBookmarks();
setBookmarks(items);
} catch {
setError('북마크 목록을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.');
} finally {
setLoading(false);
}
}
async function handleRemove(bojId: number) {
setRemovingId(bojId);
try {
await ps.removeBookmark(bojId);
setBookmarks((prev) => prev.filter((bookmark) => bookmark.psProblem.bojId !== bojId));
showToast({ message: '북마크를 삭제했습니다', variant: 'success' });
} catch {
showToast({ message: '삭제에 실패했습니다', variant: 'danger' });
} finally {
setRemovingId(null);
}
}
return (
<PageWrap>
<HeaderCard>
<PageHeader
eyebrow="Solved.ac"
title="북마크"
subtitle="중요한 PS 문제를 저장해 두고 다시 풀어보세요."
/>
<TabsWrap>
<PsTabs />
</TabsWrap>
</HeaderCard>
{error ? (
<StateCard>
<StateIcon>
<Icon name="info" size={24} />
</StateIcon>
<StateTitle>{error}</StateTitle>
</StateCard>
) : loading ? (
<StateCard>
<StateIcon>
<Icon name="clock" size={24} />
</StateIcon>
<StateTitle> </StateTitle>
</StateCard>
) : bookmarks.length === 0 ? (
<StateCard>
<StateIcon>
<Icon name="bookmark-simple" size={24} />
</StateIcon>
<StateTitle> </StateTitle>
<StateText> .</StateText>
</StateCard>
) : (
<BookmarkCard>
<BookmarkList>
{bookmarks.map((bookmark) => (
<BookmarkRow key={bookmark.id}>
<BookmarkInfo>
<BookmarkTitle>{bookmark.psProblem.titleKo ?? bookmark.psProblem.title}</BookmarkTitle>
<BookmarkMeta>
<Badge>Lv {bookmark.psProblem.level}</Badge>
<Badge>BOJ {bookmark.psProblem.bojId}</Badge>
</BookmarkMeta>
{bookmark.memo ? <BookmarkMemo>{bookmark.memo}</BookmarkMemo> : null}
</BookmarkInfo>
<BookmarkActions>
<Button
as="a"
href={`https://www.acmicpc.net/problem/${bookmark.psProblem.bojId}`}
target="_blank"
rel="noopener noreferrer"
$variant="secondary"
>
</Button>
<Button
type="button"
$variant="ghost"
onClick={() => handleRemove(bookmark.psProblem.bojId)}
disabled={removingId === bookmark.psProblem.bojId}
>
</Button>
</BookmarkActions>
</BookmarkRow>
))}
</BookmarkList>
</BookmarkCard>
)}
</PageWrap>
);
}
const PageWrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
`;
const HeaderCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 12px;
background: rgba(255, 255, 255, 0.02);
border-color: ${theme.color.borderSoftAlpha};
`;
const TabsWrap = styled.div`
margin-top: 8px;
`;
const BookmarkCard = styled(Card)`
border-color: ${theme.color.borderSoftAlpha};
`;
const BookmarkList = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
const BookmarkRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding-bottom: 16px;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
`;
const BookmarkInfo = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
min-width: 260px;
`;
const BookmarkTitle = styled.h3`
margin: 0;
font-size: 16px;
color: ${theme.color.textBright};
`;
const BookmarkMeta = styled.div`
display: flex;
gap: 8px;
flex-wrap: wrap;
color: ${theme.color.textSub};
`;
const BookmarkMemo = styled.p`
margin: 0;
color: ${theme.color.textSub};
font-size: 13px;
line-height: 1.5;
`;
const BookmarkActions = styled.div`
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
`;
const StateCard = styled(Card)`
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 8px;
border-color: ${theme.color.borderSoftAlpha};
`;
const StateIcon = styled.div`
color: ${theme.color.textSub};
`;
const StateTitle = styled.div`
color: ${theme.color.textBright};
font-weight: 600;
`;
const StateText = styled.div`
color: ${theme.color.textSub};
font-size: 14px;
`;

View File

@@ -1,532 +0,0 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import AppShell from '@/components/layout/AppShell';
import PsTabs from '@/components/ps/PsTabs';
import { Icon } from '@/components/ui/Icon';
import { Badge, Button, Card, Label, PageHeader } from '@/components/ui/primitives';
import { useToast } from '@/components/ui/Toast';
import { ps, type PsSearchProblem, type PsSearchResponse } from '@/lib/api';
import { theme } from '@/styles/theme';
const SEARCH_PAGE_SIZE = 20;
const LEVEL_PRESETS: Array<{ label: string; value: number | null }> = [
{ label: '전체', value: null },
{ label: '쉬움', value: 5 },
{ label: '중간', value: 15 },
{ label: '어려움', value: 25 },
];
export default function PsSearchPage() {
return (
<AppShell>
<SearchBody />
</AppShell>
);
}
function SearchBody() {
const { showToast } = useToast();
const [queryInput, setQueryInput] = useState('');
const [submittedQuery, setSubmittedQuery] = useState('');
const [level, setLevel] = useState<number | null>(null);
const [page, setPage] = useState(1);
const [result, setResult] = useState<PsSearchResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [bookmarkedIds, setBookmarkedIds] = useState<Set<number>>(new Set());
const [bookmarkingId, setBookmarkingId] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
ps
.listBookmarks()
.then((items) => {
if (cancelled) return;
setBookmarkedIds(new Set(items.map((bookmark) => bookmark.psProblem.bojId)));
})
.catch(() => {
/* ignore */
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
ps
.search({
q: submittedQuery || undefined,
level: level ?? undefined,
page,
})
.then((data) => {
if (!cancelled) {
setResult(data);
}
})
.catch(() => {
if (!cancelled) {
setError('Solved.ac 문제를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.');
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [submittedQuery, level, page]);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmittedQuery(queryInput.trim());
setPage(1);
}
function handlePresetSelect(value: number | null) {
setLevel(value);
setPage(1);
}
function handleSliderChange(event: React.ChangeEvent<HTMLInputElement>) {
const nextValue = Number(event.target.value);
setLevel(nextValue === 0 ? null : nextValue);
setPage(1);
}
const totalPages = useMemo(() => {
if (!result) return 1;
return Math.max(1, Math.ceil(result.count / SEARCH_PAGE_SIZE));
}, [result]);
const currentItems = result?.items ?? [];
async function toggleBookmark(bojId: number) {
setBookmarkingId(bojId);
const isBookmarked = bookmarkedIds.has(bojId);
try {
if (isBookmarked) {
await ps.removeBookmark(bojId);
setBookmarkedIds((prev) => {
const next = new Set(prev);
next.delete(bojId);
return next;
});
showToast({ message: '북마크에서 제거했어요', variant: 'success' });
} else {
await ps.bookmark(bojId);
setBookmarkedIds((prev) => {
const next = new Set(prev);
next.add(bojId);
return next;
});
showToast({ message: '북마크에 추가했어요', variant: 'success' });
}
} catch {
showToast({ message: '북마크 업데이트에 실패했습니다', variant: 'danger' });
} finally {
setBookmarkingId((current) => (current === bojId ? null : current));
}
}
return (
<PageWrap>
<HeaderCard>
<PageHeader
eyebrow="Solved.ac"
title="PS 문제 검색"
subtitle="레벨과 태그를 걸러 Solved.ac 문제를 빠르게 찾고 북마크하세요."
/>
<TabsWrap>
<PsTabs />
</TabsWrap>
</HeaderCard>
<FilterCard as="form" onSubmit={handleSubmit}>
<FilterField>
<Label htmlFor="ps-query"></Label>
<SearchInput
id="ps-query"
value={queryInput}
onChange={(event) => setQueryInput(event.target.value)}
placeholder="문제 번호, 태그, 키워드를 입력하세요"
/>
</FilterField>
<FilterField>
<Label></Label>
<PresetRow>
{LEVEL_PRESETS.map((preset) => (
<PresetButton
key={preset.label}
type="button"
$active={preset.value === level}
onClick={() => handlePresetSelect(preset.value)}
>
{preset.label}
</PresetButton>
))}
</PresetRow>
<SliderRow>
<LevelValue>{level ? `Lv ${level}` : '전체'}</LevelValue>
<LevelSlider min={0} max={30} value={level ?? 0} onChange={handleSliderChange} />
</SliderRow>
</FilterField>
<FilterActions>
<Button type="submit"></Button>
</FilterActions>
</FilterCard>
{error ? (
<StateCard>
<StateIcon>
<Icon name="info" size={24} />
</StateIcon>
<StateTitle> </StateTitle>
<StateText>{error}</StateText>
</StateCard>
) : loading ? (
<StateCard>
<StateIcon>
<Icon name="clock" size={24} />
</StateIcon>
<StateTitle> </StateTitle>
<StateText>Solved.ac .</StateText>
</StateCard>
) : currentItems.length === 0 ? (
<StateCard>
<StateIcon>
<Icon name="magnifying-glass" size={24} />
</StateIcon>
<StateTitle> </StateTitle>
<StateText> .</StateText>
</StateCard>
) : (
<ResultCard>
<ResultHeader>
<strong>{result?.count.toLocaleString()}</strong> .
</ResultHeader>
<ProblemList>
{currentItems.map((problem) => (
<ProblemRow key={problem.problemId}>
<ProblemInfo>
<ProblemTitle>{displayTitle(problem)}</ProblemTitle>
<ProblemMeta>
<TierBadge $color={tierColor(problem.level)}>{tierLabel(problem.level)}</TierBadge>
<Badge>BOJ {problem.problemId}</Badge>
</ProblemMeta>
<TagChips>
{extractTags(problem).map((tag) => (
<TagChip key={`${problem.problemId}-${tag}`}>{tag}</TagChip>
))}
</TagChips>
</ProblemInfo>
<ProblemActions>
<BookmarkButton
type="button"
disabled={bookmarkingId === problem.problemId}
$active={bookmarkedIds.has(problem.problemId)}
onClick={() => toggleBookmark(problem.problemId)}
>
<Icon
name="bookmark-simple"
size={16}
weight={bookmarkedIds.has(problem.problemId) ? 'fill' : 'regular'}
/>
{bookmarkedIds.has(problem.problemId) ? '북마크됨' : '북마크'}
</BookmarkButton>
<Button
as="a"
href={`https://www.acmicpc.net/problem/${problem.problemId}`}
target="_blank"
rel="noopener noreferrer"
$variant="secondary"
>
</Button>
</ProblemActions>
</ProblemRow>
))}
</ProblemList>
<Pagination>
<PaginationButton type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1}>
</PaginationButton>
<PaginationStatus>
{page} / {totalPages}
</PaginationStatus>
<PaginationButton
type="button"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
</PaginationButton>
</Pagination>
</ResultCard>
)}
</PageWrap>
);
}
function displayTitle(problem: PsSearchProblem) {
return problem.titleKo ?? problem.title ?? `BOJ ${problem.problemId}`;
}
function extractTags(problem: PsSearchProblem) {
const tags = problem.tags ?? [];
return tags
.map((tag) => tag.displayNames?.[0]?.name ?? tag.key)
.filter(Boolean)
.slice(0, 4);
}
function tierLabel(level: number) {
const meta = tierMeta(level);
return meta.label;
}
function tierColor(level: number) {
const meta = tierMeta(level);
return meta.color;
}
function tierMeta(level: number) {
const tierNames = ['Bronze', 'Silver', 'Gold', 'Platinum', 'Diamond', 'Ruby'];
const division = ['V', 'IV', 'III', 'II', 'I'];
if (level <= 0) {
return { label: 'Unrated', color: theme.color.border };
}
const tierIndex = Math.min(tierNames.length - 1, Math.max(0, Math.floor((level - 1) / 5)));
const divisionIndex = Math.max(0, (level - 1) % 5);
return {
label: `${tierNames[tierIndex]} ${division[divisionIndex]}`,
color: TIER_COLORS[tierIndex] ?? theme.color.brandIndigo,
};
}
const TIER_COLORS = ['#cd7f32', '#b0bec5', '#fbbf24', '#38bdf8', '#60a5fa', '#f472b6'];
const PageWrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
`;
const HeaderCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 12px;
background: rgba(255, 255, 255, 0.02);
border-color: ${theme.color.borderSoftAlpha};
`;
const TabsWrap = styled.div`
margin-top: 8px;
`;
const FilterCard = styled(Card)`
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
align-items: end;
border-color: ${theme.color.borderSoftAlpha};
`;
const FilterField = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const SearchInput = styled.input`
padding: 12px 16px;
border-radius: 14px;
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255, 255, 255, 0.03);
color: ${theme.color.textBright};
`;
const PresetRow = styled.div`
display: flex;
gap: 8px;
flex-wrap: wrap;
`;
const PresetButton = styled.button<{ $active: boolean }>`
border-radius: 12px;
padding: 6px 12px;
border: 1px solid
${({ $active }) => ($active ? 'rgba(99, 102, 241, 0.5)' : theme.color.borderSoftAlpha)};
background: ${({ $active }) => ($active ? 'rgba(79, 70, 229, 0.2)' : 'transparent')};
color: ${({ $active }) => ($active ? theme.color.textBright : theme.color.textSub)};
font-size: 13px;
`;
const SliderRow = styled.div`
display: flex;
align-items: center;
gap: 12px;
`;
const LevelValue = styled.span`
min-width: 64px;
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
`;
const LevelSlider = styled.input.attrs({ type: 'range' })`
flex: 1;
`;
const FilterActions = styled.div`
display: flex;
justify-content: flex-end;
grid-column: 1 / -1;
`;
const StateCard = styled(Card)`
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 8px;
border-color: ${theme.color.borderSoftAlpha};
`;
const StateIcon = styled.div`
color: ${theme.color.textSub};
`;
const StateTitle = styled.div`
color: ${theme.color.textBright};
font-weight: 600;
`;
const StateText = styled.div`
color: ${theme.color.textSub};
font-size: 14px;
`;
const ResultCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 16px;
border-color: ${theme.color.borderSoftAlpha};
`;
const ResultHeader = styled.div`
color: ${theme.color.textSub};
font-size: 14px;
`;
const ProblemList = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
const ProblemRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding-bottom: 16px;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
`;
const ProblemInfo = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
min-width: 260px;
`;
const ProblemTitle = styled.h3`
margin: 0;
font-size: 16px;
color: ${theme.color.textBright};
`;
const ProblemMeta = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
`;
const TierBadge = styled(Badge)<{ $color: string }>`
background: ${({ $color }) => `${$color}33`};
color: ${({ $color }) => $color};
`;
const TagChips = styled.div`
display: flex;
flex-wrap: wrap;
gap: 6px;
`;
const TagChip = styled.span`
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
color: ${theme.color.textSub};
font-size: 12px;
`;
const ProblemActions = styled.div`
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
`;
const BookmarkButton = styled.button<{ $active: boolean }>`
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid
${({ $active }) => ($active ? 'rgba(99, 102, 241, 0.5)' : theme.color.borderSoftAlpha)};
background: ${({ $active }) => ($active ? 'rgba(79, 70, 229, 0.14)' : 'transparent')};
color: ${({ $active }) => ($active ? theme.color.textBright : theme.color.textSub)};
`;
const Pagination = styled.div`
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
`;
const PaginationButton = styled.button<{ disabled?: boolean }>`
padding: 8px 16px;
border-radius: 12px;
border: 1px solid ${theme.color.borderSoftAlpha};
background: transparent;
color: ${theme.color.textBright};
opacity: ${({ disabled }) => (disabled ? 0.4 : 1)};
`;
const PaginationStatus = styled.span`
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
`;

View File

@@ -1,347 +0,0 @@
'use client';
import Link from 'next/link';
import React, { useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import AppShell from '@/components/layout/AppShell';
import PsTabs from '@/components/ps/PsTabs';
import { Icon } from '@/components/ui/Icon';
import { Button, Card, PageHeader } from '@/components/ui/primitives';
import { getBojTierLabel, ps, type PsSolvedItem } from '@/lib/api';
import { theme } from '@/styles/theme';
const PAGE_SIZE = 20;
export default function PsSolvedPage() {
return (
<AppShell>
<SolvedBody />
</AppShell>
);
}
function SolvedBody() {
const [items, setItems] = useState<PsSolvedItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
ps
.listSolved({ page, pageSize: PAGE_SIZE })
.then((response) => {
if (cancelled) return;
setItems(response.items);
setTotal(response.total);
})
.catch(() => {
if (cancelled) return;
setError('내 PS 풀이 목록을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.');
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [page]);
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / PAGE_SIZE)), [total]);
const dateFormatter = useMemo(
() => new Intl.DateTimeFormat('ko-KR', { dateStyle: 'medium', timeStyle: 'short' }),
[],
);
function formatDate(value: string) {
try {
return dateFormatter.format(new Date(value));
} catch {
return value;
}
}
function changePage(next: number) {
setPage((current) => {
const target = Math.min(Math.max(1, next), totalPages);
return target === current ? current : target;
});
}
return (
<PageWrap>
<HeaderCard>
<PageHeader
eyebrow="Solved.ac"
title="내 PS 풀이"
subtitle="동기화한 백준 풀이 내역을 한곳에서 확인하고 바로 복습할 수 있어요."
/>
<TabsWrap>
<PsTabs />
</TabsWrap>
</HeaderCard>
{error ? (
<StateCard>
<Icon name="info" size={24} />
<StateText>{error}</StateText>
</StateCard>
) : loading ? (
<StateCard>
<Icon name="clock" size={24} />
<StateText> ...</StateText>
</StateCard>
) : items.length === 0 ? (
<EmptyCard>
<Icon name="code" size={28} />
<EmptyTitle> PS </EmptyTitle>
<EmptyText> BOJ .</EmptyText>
<Button as={Link} href="/ps/sync">
</Button>
</EmptyCard>
) : (
<SolvedCard>
<ProblemList>
{items.map((item) => {
const problem = item.psProblem;
const tierColor = getTierColor(problem?.level ?? 0);
const tierLabel = problem ? getBojTierLabel(problem.level) : 'Unrated';
return (
<ProblemItem key={item.studyLogId}>
<ProblemHead>
<TierBadge $tierColor={tierColor}>{tierLabel}</TierBadge>
<ProblemTitle>
{problem ? problem.titleKo ?? problem.title : '연결된 문제를 찾을 수 없어요'}
</ProblemTitle>
</ProblemHead>
<ProblemMeta>
<MetaText>{formatDate(item.studiedAt)}</MetaText>
<MetaActions>
<MetaInfo>BOJ {problem?.bojId ?? '-'}</MetaInfo>
{problem ? (
<Button
as="a"
href={`https://www.acmicpc.net/problem/${problem.bojId}`}
target="_blank"
rel="noopener noreferrer"
$variant="secondary"
$size="sm"
>
</Button>
) : (
<Button type="button" $variant="secondary" $size="sm" disabled>
</Button>
)}
</MetaActions>
</ProblemMeta>
{problem?.tags && problem.tags.length > 0 ? (
<TagList>
{problem.tags.slice(0, 3).map((tag) => (
<TagChip key={`${item.studyLogId}-${tag.key}`}>#{tag.displayName}</TagChip>
))}
</TagList>
) : null}
</ProblemItem>
);
})}
</ProblemList>
{totalPages > 1 ? (
<Pagination>
<PaginationButton type="button" onClick={() => changePage(page - 1)} disabled={page === 1}>
</PaginationButton>
<PaginationStatus>
{page} / {totalPages}
</PaginationStatus>
<PaginationButton
type="button"
onClick={() => changePage(page + 1)}
disabled={page === totalPages}
>
</PaginationButton>
</Pagination>
) : null}
</SolvedCard>
)}
</PageWrap>
);
}
function getTierColor(level: number) {
if (!level || level <= 0) return theme.color.border;
const tierIndex = Math.min(TIER_COLORS.length - 1, Math.max(0, Math.floor((level - 1) / 5)));
return TIER_COLORS[tierIndex] ?? theme.color.brandIndigo;
}
const PageWrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
`;
const HeaderCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 12px;
background: rgba(255, 255, 255, 0.02);
border-color: ${theme.color.borderSoftAlpha};
`;
const TabsWrap = styled.div`
margin-top: 8px;
`;
const StateCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
text-align: center;
border-color: ${theme.color.borderSoftAlpha};
color: ${theme.color.textSub};
`;
const StateText = styled.span`
color: ${theme.color.textSub};
`;
const EmptyCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
text-align: center;
border-color: ${theme.color.borderSoftAlpha};
`;
const EmptyTitle = styled.h3`
margin: 0;
color: ${theme.color.textBright};
`;
const EmptyText = styled.p`
margin: 0;
color: ${theme.color.textSub};
font-size: 14px;
`;
const SolvedCard = styled(Card)`
border-color: ${theme.color.borderSoftAlpha};
display: flex;
flex-direction: column;
gap: 20px;
`;
const ProblemList = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
const ProblemItem = styled.div`
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 16px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
`;
const ProblemHead = styled.div`
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
`;
const TierBadge = styled.span<{ $tierColor: string }>`
padding: 4px 10px;
border-radius: 999px;
background: ${({ $tierColor }) => `${$tierColor}33`};
border: 1px solid ${({ $tierColor }) => `${$tierColor}aa`};
font-size: 12px;
font-weight: 600;
color: ${theme.color.textBright};
`;
const ProblemTitle = styled.span`
color: ${theme.color.textBright};
font-weight: 600;
font-size: 15px;
`;
const ProblemMeta = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const MetaText = styled.span`
color: ${theme.color.textSub};
font-size: 13px;
`;
const MetaActions = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
`;
const MetaInfo = styled.span`
color: ${theme.color.textSub};
font-size: 13px;
`;
const TagList = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
`;
const TagChip = styled.span`
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
font-size: 12px;
color: ${theme.color.textSub};
`;
const Pagination = styled.div`
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
`;
const PaginationButton = styled.button<{ disabled?: boolean }>`
padding: 8px 16px;
border-radius: 999px;
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255, 255, 255, 0.02);
color: ${theme.color.textBright};
font-weight: 600;
opacity: ${({ disabled }) => (disabled ? 0.4 : 1)};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
`;
const PaginationStatus = styled.span`
color: ${theme.color.textSub};
font-size: 14px;
`;
const TIER_COLORS = ['#cd7f32', '#b0bec5', '#fbbf24', '#38bdf8', '#60a5fa', '#f472b6'];

View File

@@ -1,447 +0,0 @@
'use client';
import Link from 'next/link';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import AppShell from '@/components/layout/AppShell';
import PsTabs from '@/components/ps/PsTabs';
import { Icon } from '@/components/ui/Icon';
import { Button, Card, Label, PageHeader } from '@/components/ui/primitives';
import { useToast } from '@/components/ui/Toast';
import { api, getBojTierLabel, ps, type MeUser, type PsSyncProblem, type PsSyncResult } from '@/lib/api';
import { theme } from '@/styles/theme';
const BOJ_HANDLE_REGEX = /^[a-zA-Z0-9_-]{3,20}$/;
export default function PsSyncPage() {
return (
<AppShell>
<SyncBody />
</AppShell>
);
}
function SyncBody() {
const { showToast } = useToast();
const [user, setUser] = useState<MeUser | null>(null);
const [handleInput, setHandleInput] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<PsSyncResult | null>(null);
useEffect(() => {
api
.get<MeUser>('/auth/me')
.then((response) => {
setUser(response.data);
setHandleInput(response.data.bojHandle ?? '');
})
.catch(() => {
setError('계정 정보를 불러오지 못했습니다. 다시 로그인해 주세요.');
})
.finally(() => setLoading(false));
}, []);
async function saveHandle() {
if (!user) return;
const trimmed = handleInput.trim();
if (trimmed && !BOJ_HANDLE_REGEX.test(trimmed)) {
showToast({ message: 'BOJ 핸들을 다시 확인해 주세요.', variant: 'warning' });
return;
}
if ((user.bojHandle ?? '') === trimmed) {
showToast({ message: '이미 저장된 핸들입니다', variant: 'info' });
return;
}
setSaving(true);
try {
const response = await api.patch<MeUser>('/me/profile', {
bojHandle: trimmed || null,
});
setUser(response.data);
showToast({ message: '핸들을 저장했습니다', variant: 'success' });
} catch {
showToast({ message: '핸들을 저장하지 못했습니다', variant: 'danger' });
} finally {
setSaving(false);
}
}
async function runSync() {
if (!user) return;
const activeHandle = handleInput.trim() || user.bojHandle || '';
if (!activeHandle) {
showToast({ message: '먼저 BOJ 핸들을 입력해 주세요.', variant: 'warning' });
return;
}
if (!BOJ_HANDLE_REGEX.test(activeHandle)) {
showToast({ message: 'BOJ 핸들을 다시 확인해 주세요.', variant: 'warning' });
return;
}
setSyncing(true);
setError(null);
try {
const result = await ps.sync(activeHandle);
setSyncResult(result);
showToast({ message: `${result.importedCount}건을 새로 가져왔어요`, variant: 'success' });
} catch (syncError) {
setSyncResult(null);
showToast({ message: '동기화에 실패했습니다. 잠시 후 다시 시도해 주세요.', variant: 'danger' });
} finally {
setSyncing(false);
}
}
if (loading) {
return (
<PageWrap>
<StateCard>
<Icon name="clock" size={24} />
<StateText> ...</StateText>
</StateCard>
</PageWrap>
);
}
if (error) {
return (
<PageWrap>
<StateCard>
<Icon name="info" size={24} />
<StateText>{error}</StateText>
</StateCard>
</PageWrap>
);
}
return (
<PageWrap>
<HeaderCard>
<PageHeader
eyebrow="Solved.ac"
title="BOJ 동기화"
subtitle="핸들을 저장하고 최신 풀이 기록을 복습 큐에 자동으로 추가합니다."
/>
<TabsWrap>
<PsTabs />
</TabsWrap>
</HeaderCard>
<SyncCard>
<FieldGroup>
<Label htmlFor="boj-handle">BOJ </Label>
<HandleInput
id="boj-handle"
value={handleInput}
onChange={(event) => setHandleInput(event.target.value)}
placeholder="예) reloop_ps"
maxLength={20}
/>
<FieldHint>//-/_ 3~20</FieldHint>
</FieldGroup>
<ActionRow>
<Button type="button" onClick={saveHandle} disabled={saving}>
{saving ? '저장 중...' : '핸들 저장'}
</Button>
<Button type="button" onClick={runSync} disabled={syncing} $variant="secondary">
{syncing ? '동기화 중...' : '지금 동기화'}
</Button>
</ActionRow>
<ResultGrid>
<ResultCardItem>
<ResultValue>{syncResult ? syncResult.importedCount : '-'}</ResultValue>
<ResultLabel> </ResultLabel>
</ResultCardItem>
<ResultCardItem>
<ResultValue>{syncResult ? syncResult.skippedCount : '-'}</ResultValue>
<ResultLabel> </ResultLabel>
</ResultCardItem>
<ResultCardItem>
<ResultValue>{syncResult ? syncResult.totalSolved : '-'}</ResultValue>
<ResultLabel> </ResultLabel>
</ResultCardItem>
</ResultGrid>
<ImportedCard>
<ImportedHeader>
<HeaderText>
<HeaderTitle> </HeaderTitle>
<HeaderSubtitle>
{syncResult
? syncResult.importedCount > 0
? `${Math.min(syncResult.importedProblems.length, 20)}개 표시 중`
: '이번 동기화에서 새로 가져온 문제가 없습니다'
: '동기화하면 최신 풀이 내역을 바로 볼 수 있어요'}
</HeaderSubtitle>
</HeaderText>
{syncResult && syncResult.importedProblems.length > 20 ? (
<ViewAllLink href="/ps/solved"> </ViewAllLink>
) : null}
</ImportedHeader>
{renderImportedList(syncResult?.importedProblems ?? null)}
</ImportedCard>
</SyncCard>
</PageWrap>
);
}
function renderImportedList(imported: PsSyncProblem[] | null) {
if (!imported) {
return <EmptyState> .</EmptyState>;
}
if (imported.length === 0) {
return <EmptyState> . .</EmptyState>;
}
const visible = imported.slice(0, 20);
return (
<ProblemList>
{visible.map((problem) => (
<ProblemItem key={problem.bojId}>
<ProblemHead>
<TierBadge $tierColor={getTierColor(problem.level)}>{getBojTierLabel(problem.level)}</TierBadge>
<ProblemTitle>{problem.titleKo ?? problem.title}</ProblemTitle>
</ProblemHead>
<ProblemMeta>
<MetaText>BOJ {problem.bojId}</MetaText>
<Button
as="a"
href={`https://www.acmicpc.net/problem/${problem.bojId}`}
target="_blank"
rel="noopener noreferrer"
$variant="secondary"
$size="sm"
>
</Button>
</ProblemMeta>
{problem.tags && problem.tags.length > 0 ? (
<TagList>
{problem.tags.slice(0, 3).map((tag) => (
<TagChip key={tag.key}>#{tag.displayName}</TagChip>
))}
</TagList>
) : null}
</ProblemItem>
))}
</ProblemList>
);
}
function getTierColor(level: number) {
if (!level || level <= 0) return theme.color.border;
const tierIndex = Math.min(TIER_COLORS.length - 1, Math.max(0, Math.floor((level - 1) / 5)));
return TIER_COLORS[tierIndex] ?? theme.color.brandIndigo;
}
const PageWrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
`;
const HeaderCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 12px;
background: rgba(255, 255, 255, 0.02);
border-color: ${theme.color.borderSoftAlpha};
`;
const TabsWrap = styled.div`
margin-top: 8px;
`;
const SyncCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 16px;
border-color: ${theme.color.borderSoftAlpha};
`;
const FieldGroup = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
`;
const HandleInput = styled.input`
width: 260px;
max-width: 100%;
padding: 12px 16px;
border-radius: 14px;
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255, 255, 255, 0.03);
color: ${theme.color.textBright};
`;
const FieldHint = styled.span`
color: ${theme.color.textSub};
font-size: 12px;
`;
const ActionRow = styled.div`
display: flex;
gap: 12px;
flex-wrap: wrap;
`;
const ResultGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
`;
const ResultCardItem = styled.div`
border: 1px solid ${theme.color.borderSoftAlpha};
border-radius: 16px;
padding: 16px;
background: rgba(255, 255, 255, 0.02);
`;
const ResultValue = styled.div`
font-size: 24px;
font-weight: 700;
color: ${theme.color.textBright};
`;
const ResultLabel = styled.div`
color: ${theme.color.textSub};
font-size: 12px;
`;
const ImportedCard = styled.div`
border: 1px solid ${theme.color.borderSoftAlpha};
border-radius: 18px;
padding: 18px;
background: rgba(255, 255, 255, 0.02);
display: flex;
flex-direction: column;
gap: 16px;
`;
const ImportedHeader = styled.div`
display: flex;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
align-items: center;
`;
const HeaderText = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
`;
const HeaderTitle = styled.h3`
margin: 0;
color: ${theme.color.textBright};
font-size: 16px;
`;
const HeaderSubtitle = styled.span`
color: ${theme.color.textSub};
font-size: 13px;
`;
const ViewAllLink = styled(Link)`
color: ${theme.color.brandIndigo};
font-weight: 600;
font-size: 14px;
`;
const ProblemList = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
const ProblemItem = styled.div`
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 16px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
`;
const ProblemHead = styled.div`
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
`;
const TierBadge = styled.span<{ $tierColor: string }>`
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
color: ${theme.color.textBright};
background: ${({ $tierColor }) => `${$tierColor}33`};
border: 1px solid ${({ $tierColor }) => `${$tierColor}aa`};
`;
const ProblemTitle = styled.span`
color: ${theme.color.textBright};
font-weight: 600;
font-size: 15px;
`;
const ProblemMeta = styled.div`
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
align-items: center;
`;
const MetaText = styled.span`
color: ${theme.color.textSub};
font-size: 13px;
`;
const TagList = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
`;
const TagChip = styled.span`
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
font-size: 12px;
color: ${theme.color.textSub};
`;
const EmptyState = styled.div`
color: ${theme.color.textSub};
font-size: 13px;
padding: 8px 0;
`;
const TIER_COLORS = ['#cd7f32', '#b0bec5', '#fbbf24', '#38bdf8', '#60a5fa', '#f472b6'];
const StateCard = styled(Card)`
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
border-color: ${theme.color.borderSoftAlpha};
`;
const StateText = styled.div`
color: ${theme.color.textSub};
font-size: 14px;
`;

View File

@@ -91,7 +91,7 @@ function SubjectsBody() {
const [editingTagName, setEditingTagName] = useState('');
const [savingTagId, setSavingTagId] = useState<number | null>(null);
const [archiveCandidate, setArchiveCandidate] = useState<{
const [deleteCandidate, setDeleteCandidate] = useState<{
tag: TagWithP;
subjectId: number;
} | null>(null);
@@ -301,12 +301,13 @@ function SubjectsBody() {
}
};
const confirmArchiveTag = async () => {
if (!archiveCandidate) return;
const confirmDeleteTag = async () => {
if (!deleteCandidate) return;
const { tag, subjectId } = archiveCandidate;
setArchiveCandidate(null);
const { tag, subjectId } = deleteCandidate;
setDeleteCandidate(null);
// 낙관적 업데이트: 즉시 목록에서 제거
setSubjects((prev) =>
prev?.map((subject) =>
subject.id === subjectId
@@ -322,14 +323,10 @@ function SubjectsBody() {
await api.delete(`/tags/${tag.id}`);
showToast({
variant: 'success',
message: '태그 보관됨',
undoLabel: '되돌리기',
onUndo: () => {
void recreateArchivedTag(subjectId, tag);
},
durationMs: 3000,
message: `'${tag.name}' 태그를 삭제했어요.`,
});
} catch {
// 실패 시 롤백
setSubjects((prev) =>
prev?.map((subject) =>
subject.id === subjectId
@@ -339,39 +336,7 @@ function SubjectsBody() {
);
showToast({
variant: 'danger',
message: '태그 보관에 실패했어요.',
});
}
};
const recreateArchivedTag = async (subjectId: number, tag: TagWithP) => {
try {
const response = await api.post<Tag>('/tags', {
subjectId,
name: tag.name,
});
const restored = response.data;
setSubjects((prev) =>
prev?.map((subject) =>
subject.id === subjectId
? {
...subject,
tags: sortTags([
...subject.tags,
{ ...restored, currentP: tag.currentP },
]),
}
: subject,
) ?? null,
);
showToast({
variant: 'info',
message: '태그를 다시 만들었어요.',
});
} catch {
showToast({
variant: 'warning',
message: '실행 취소는 실패했고 새로 추가해 주세요.',
message: '태그 삭제에 실패했어요.',
});
}
};
@@ -400,13 +365,14 @@ function SubjectsBody() {
return (
<PageWrap>
<ConfirmDialog
open={archiveCandidate !== null}
title="태그를 보관할까요?"
body="보관한 태그는 목록에서 즉시 사라집니다. 실행 취소로 같은 이름의 태그를 다시 만들 수 있어요."
confirmLabel="보관"
open={deleteCandidate !== null}
title="태그 삭제"
body={`'${deleteCandidate?.tag.name}' 태그를 삭제할까? 이 태그에 연결된 학습 기록도 함께 삭제돼.`}
confirmLabel="삭제"
cancelLabel="취소"
onConfirm={() => void confirmArchiveTag()}
onCancel={() => setArchiveCandidate(null)}
tone="danger"
onConfirm={() => void confirmDeleteTag()}
onCancel={() => setDeleteCandidate(null)}
/>
<SubjectModal
@@ -614,12 +580,13 @@ function SubjectsBody() {
</IconActionButton>
<IconActionButton
type="button"
$danger
onClick={() =>
setArchiveCandidate({ tag, subjectId: subject.id })
setDeleteCandidate({ tag, subjectId: subject.id })
}
aria-label={`${tag.name} 보관`}
aria-label={`${tag.name} 삭제`}
>
<Icon name="archive" size={16} />
<Icon name="x" size={16} />
</IconActionButton>
</ActionsCell>
</>
@@ -1230,7 +1197,7 @@ const ActionsCell = styled.div`
}
`;
const IconActionButton = styled.button`
const IconActionButton = styled.button<{ $danger?: boolean }>`
display: inline-flex;
align-items: center;
justify-content: center;
@@ -1243,9 +1210,15 @@ const IconActionButton = styled.button`
transition: all 0.16s ease;
&:hover {
color: ${theme.color.textBright};
border-color: ${theme.color.borderSoftAlpha};
background: rgba(255, 255, 255, 0.05);
color: ${({ $danger }) => ($danger ? theme.color.danger : theme.color.textBright)};
border-color: ${({ $danger }) =>
$danger ? 'rgba(239, 68, 68, 0.35)' : theme.color.borderSoftAlpha};
background: ${({ $danger }) =>
$danger ? 'rgba(239, 68, 68, 0.08)' : 'rgba(255, 255, 255, 0.05)'};
}
@media (max-width: ${theme.breakpoint.mobile}) {
opacity: ${({ $danger }) => ($danger ? '1' : undefined)};
}
`;

View File

@@ -12,7 +12,6 @@ import { theme } from '@/styles/theme';
const BASE_TABS = [
{ href: '/dashboard', label: '대시보드', icon: 'squares-four' as const },
{ href: '/review', label: '복습', icon: 'arrows-clockwise' as const },
{ href: '/ps', label: 'PS', icon: 'code' as const },
{ href: '/exams', label: '문제집', icon: 'books' as const },
{ href: '/subjects', label: '과목', icon: 'folders' as const },
{ href: '/stats', label: '통계', icon: 'trend-up' as const },

View File

@@ -42,12 +42,6 @@ const BASE_NAV_ITEMS: Array<{
match: (pathname) => pathname.startsWith('/review'),
showBadge: true,
},
{
href: '/ps',
label: 'PS',
icon: 'code',
match: (pathname) => pathname === '/ps' || pathname.startsWith('/ps/'),
},
{
href: '/stats',
label: '통계',

View File

@@ -1,49 +0,0 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
const TABS = [
{ href: '/ps', label: '검색' },
{ href: '/ps/bookmarks', label: '북마크' },
{ href: '/ps/solved', label: '풀이' },
{ href: '/ps/sync', label: '동기화' },
];
export default function PsTabs() {
const pathname = usePathname();
return (
<Tabs>
{TABS.map((tab) => {
const active = tab.href === '/ps' ? pathname === '/ps' : pathname.startsWith(tab.href);
return (
<Tab key={tab.href} href={tab.href} $active={active}>
{tab.label}
</Tab>
);
})}
</Tabs>
);
}
const Tabs = styled.div`
display: inline-flex;
border-radius: 999px;
padding: 4px;
gap: 4px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid ${theme.color.borderSoftAlpha};
`;
const Tab = styled(Link)<{ $active: boolean }>`
padding: 8px 18px;
border-radius: 999px;
color: ${({ $active }) => ($active ? theme.color.textBright : theme.color.textSub)};
font-weight: 600;
font-size: 14px;
background: ${({ $active }) => ($active ? 'rgba(99, 102, 241, 0.18)' : 'transparent')};
transition: background 0.15s ease, color 0.15s ease;
`;

View File

@@ -0,0 +1,571 @@
'use client';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import styled from 'styled-components';
import { Icon } from '@/components/ui/Icon';
import { Button } from '@/components/ui/primitives';
import {
getReviewCalendar,
getReviewDay,
type CalendarDay,
type DayReview,
} from '@/lib/api';
import { theme } from '@/styles/theme';
// ─── 날짜 유틸 ──────────────────────────────────────────────────────────────
function toDateKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
function todayKey(): string {
return toDateKey(new Date());
}
function buildCalendarGrid(year: number, month: number): Array<Date | null> {
// month: 1-based
const firstDay = new Date(year, month - 1, 1);
const lastDay = new Date(year, month, 0);
const startDow = firstDay.getDay(); // 0=일
const totalDays = lastDay.getDate();
const cells: Array<Date | null> = [];
for (let i = 0; i < startDow; i++) cells.push(null);
for (let d = 1; d <= totalDays; d++) {
cells.push(new Date(year, month - 1, d));
}
// 7의 배수로 채우기
while (cells.length % 7 !== 0) cells.push(null);
return cells;
}
const MONTH_NAMES = [
'1월', '2월', '3월', '4월', '5월', '6월',
'7월', '8월', '9월', '10월', '11월', '12월',
];
const DOW_LABELS = ['일', '월', '화', '수', '목', '금', '토'];
// ─── 컴포넌트 ────────────────────────────────────────────────────────────────
interface ReviewCalendarProps {
/** 초기 injectDays — 대시보드 로드 시 이미 패치한 데이터가 있으면 넘김 */
initialDays?: CalendarDay[];
initialYear?: number;
initialMonth?: number;
}
export default function ReviewCalendar({
initialDays,
initialYear,
initialMonth,
}: ReviewCalendarProps) {
const today = new Date();
const [year, setYear] = useState(initialYear ?? today.getFullYear());
const [month, setMonth] = useState(initialMonth ?? today.getMonth() + 1);
const [dayMap, setDayMap] = useState<Map<string, CalendarDay>>(() => {
const m = new Map<string, CalendarDay>();
if (initialDays) {
for (const d of initialDays) m.set(d.date, d);
}
return m;
});
const [loading, setLoading] = useState(!initialDays);
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [dayReviews, setDayReviews] = useState<DayReview[]>([]);
const [dayLoading, setDayLoading] = useState(false);
const loadMonth = useCallback(async (y: number, m: number) => {
setLoading(true);
try {
const data = await getReviewCalendar(y, m);
setDayMap((prev) => {
const next = new Map(prev);
for (const d of data.days) next.set(d.date, d);
return next;
});
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadMonth(year, month);
}, [year, month, loadMonth]);
const cells = useMemo(() => buildCalendarGrid(year, month), [year, month]);
const goMonth = (delta: number) => {
setSelectedDate(null);
setDayReviews([]);
let m = month + delta;
let y = year;
if (m < 1) { m = 12; y -= 1; }
if (m > 12) { m = 1; y += 1; }
setMonth(m);
setYear(y);
};
const handleDateClick = async (dateKey: string) => {
if (selectedDate === dateKey) {
setSelectedDate(null);
setDayReviews([]);
return;
}
setSelectedDate(dateKey);
setDayLoading(true);
try {
const data = await getReviewDay(dateKey);
setDayReviews(data.reviews);
} finally {
setDayLoading(false);
}
};
const todayStr = todayKey();
return (
<Wrap>
{/* 헤더 네비 */}
<CalHeader>
<NavBtn onClick={() => goMonth(-1)} aria-label="이전 달">
<Icon name="caret-left" size={16} weight="bold" color="currentColor" />
</NavBtn>
<MonthLabel>
{MONTH_NAMES[month - 1]} {year}
</MonthLabel>
<NavBtn onClick={() => goMonth(1)} aria-label="다음 달">
<Icon name="caret-right" size={16} weight="bold" color="currentColor" />
</NavBtn>
</CalHeader>
{/* 요일 헤더 */}
<DowRow>
{DOW_LABELS.map((d) => (
<DowCell key={d}>{d}</DowCell>
))}
</DowRow>
{/* 날짜 그리드 */}
<Grid $loading={loading}>
{cells.map((cell, idx) => {
if (!cell) {
return <EmptyCell key={`empty-${idx}`} />;
}
const key = toDateKey(cell);
const info = dayMap.get(key);
const isToday = key === todayStr;
const isPast = key < todayStr;
const isSelected = key === selectedDate;
const isCompleted = info && info.total > 0 && info.completed >= info.total;
const isOverdue = isPast && info && info.total > info.completed && !isCompleted;
return (
<DayCell
key={key}
$isToday={isToday}
$isSelected={isSelected}
$isCompleted={!!isCompleted}
$isOverdue={!!isOverdue}
$hasReviews={!!info && info.total > 0}
onClick={() => void handleDateClick(key)}
aria-label={`${cell.getDate()}${info ? ` 복습 ${info.total}` : ''}`}
>
<DayNum $isToday={isToday}>{cell.getDate()}</DayNum>
{info && info.total > 0 && (
<ReviewBadge $isCompleted={!!isCompleted} $isOverdue={!!isOverdue}>
{isCompleted ? (
<Icon name="check" size={9} weight="bold" color="currentColor" />
) : (
<span>{info.total - info.completed}</span>
)}
</ReviewBadge>
)}
</DayCell>
);
})}
</Grid>
{/* 범례 */}
<Legend>
<LegendItem><LegendDot $type="overdue" /> </LegendItem>
<LegendItem><LegendDot $type="pending" /></LegendItem>
<LegendItem><LegendDot $type="done" /></LegendItem>
</Legend>
{/* 날짜 클릭 시 복습 목록 패널 */}
{selectedDate && (
<DayPanel>
<DayPanelHeader>
<DayPanelTitle>
{selectedDate.slice(5).replace('-', '월 ')}
</DayPanelTitle>
<CloseBtn onClick={() => { setSelectedDate(null); setDayReviews([]); }}>
<Icon name="x" size={14} weight="bold" color="currentColor" />
</CloseBtn>
</DayPanelHeader>
{dayLoading && <PanelMsg> ...</PanelMsg>}
{!dayLoading && selectedDate > todayStr && dayReviews.length === 0 && (
<PanelMsg> ( )</PanelMsg>
)}
{!dayLoading && selectedDate > todayStr && dayReviews.length > 0 && (
<FutureTip>
<Icon name="calendar-blank" size={14} color={theme.color.textMute} />
. !
</FutureTip>
)}
{!dayLoading && dayReviews.length === 0 && selectedDate <= todayStr && (
<PanelMsg> </PanelMsg>
)}
{!dayLoading && dayReviews.length > 0 && (
<ReviewList>
{dayReviews.map((review) => (
<ReviewItem key={review.id}>
<ReviewMeta>
{review.studyLog.tag && (
<>
<SubjectBadge>{review.studyLog.tag.subject.name}</SubjectBadge>
<TagName>{review.studyLog.tag.name}</TagName>
</>
)}
{!review.studyLog.tag && review.studyLog.subject && (
<SubjectBadge>{review.studyLog.subject.name}</SubjectBadge>
)}
<StatusBadge $status={review.status}>{statusLabel(review.status)}</StatusBadge>
</ReviewMeta>
<ReviewTitle>{review.studyLog.title}</ReviewTitle>
{review.studyLog.problem?.bodyText && (
<ReviewPreview>
{review.studyLog.problem.bodyText.slice(0, 60)}
{review.studyLog.problem.bodyText.length > 60 ? '…' : ''}
</ReviewPreview>
)}
{selectedDate <= todayStr && review.status === 'pending' && (
<Link href={`/review`} passHref>
<Button as="span" $variant="white" style={{ marginTop: 8, fontSize: 13 }}>
<Icon name="arrow-right" size={13} weight="bold" />
</Button>
</Link>
)}
</ReviewItem>
))}
</ReviewList>
)}
</DayPanel>
)}
</Wrap>
);
}
function statusLabel(status: string): string {
if (status === 'pending') return '미완';
if (status === 'done') return '완료';
if (status === 'skipped') return '건너뜀';
return status;
}
// ─── 스타일 ──────────────────────────────────────────────────────────────────
const Wrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.sm};
`;
const CalHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2px;
`;
const NavBtn = styled.button`
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: ${theme.radius.sm};
border: 1px solid ${theme.color.borderSoftAlpha};
background: transparent;
color: ${theme.color.textSub};
cursor: pointer;
transition: background 0.15s, color 0.15s;
&:hover {
background: ${theme.color.surfaceHoverDeep};
color: ${theme.color.textBright};
}
`;
const MonthLabel = styled.span`
font-size: 15px;
font-weight: 600;
color: ${theme.color.textBright};
letter-spacing: -0.02em;
`;
const DowRow = styled.div`
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
`;
const DowCell = styled.div`
text-align: center;
font-size: 11px;
font-weight: 600;
color: ${theme.color.textMute};
padding: 4px 0;
`;
const Grid = styled.div<{ $loading: boolean }>`
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
opacity: ${({ $loading }) => ($loading ? 0.5 : 1)};
transition: opacity 0.2s;
`;
const EmptyCell = styled.div`
aspect-ratio: 1;
`;
const DayCell = styled.button<{
$isToday: boolean;
$isSelected: boolean;
$isCompleted: boolean;
$isOverdue: boolean;
$hasReviews: boolean;
}>`
position: relative;
aspect-ratio: 1;
border-radius: ${theme.radius.sm};
border: 1px solid
${({ $isToday, $isSelected }) =>
$isSelected
? theme.color.brandIndigo
: $isToday
? `rgba(79, 70, 229, 0.5)`
: theme.color.borderSoftAlpha};
background: ${({ $isSelected }) =>
$isSelected ? 'rgba(79, 70, 229, 0.15)' : 'transparent'};
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 2px;
transition: background 0.15s, border-color 0.15s;
&:hover {
background: ${theme.color.surfaceHoverDeep};
border-color: ${theme.color.borderBrightAlpha};
}
`;
const DayNum = styled.span<{ $isToday: boolean }>`
font-size: 12px;
font-weight: ${({ $isToday }) => ($isToday ? '700' : '400')};
color: ${({ $isToday }) => ($isToday ? theme.color.brandIndigo : theme.color.textSub)};
line-height: 1;
`;
const ReviewBadge = styled.div<{ $isCompleted: boolean; $isOverdue: boolean }>`
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 3px;
border-radius: 99px;
font-size: 9px;
font-weight: 700;
line-height: 1;
background: ${({ $isCompleted, $isOverdue }) =>
$isCompleted
? 'rgba(34, 197, 94, 0.2)'
: $isOverdue
? 'rgba(245, 158, 11, 0.2)'
: 'rgba(79, 70, 229, 0.2)'};
color: ${({ $isCompleted, $isOverdue }) =>
$isCompleted
? theme.color.success
: $isOverdue
? theme.color.warning
: theme.color.brandIndigo};
`;
const Legend = styled.div`
display: flex;
gap: ${theme.space.md};
justify-content: flex-end;
padding: 2px 4px;
`;
const LegendItem = styled.div`
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: ${theme.color.textMute};
`;
const LegendDot = styled.div<{ $type: 'overdue' | 'pending' | 'done' }>`
width: 8px;
height: 8px;
border-radius: 50%;
background: ${({ $type }) =>
$type === 'done'
? theme.color.success
: $type === 'overdue'
? theme.color.warning
: theme.color.brandIndigo};
opacity: 0.7;
`;
const DayPanel = styled.div`
margin-top: ${theme.space.sm};
background: rgba(21, 21, 28, 0.9);
border: 1px solid ${theme.color.borderSoftAlpha};
border-radius: ${theme.radius.lg};
padding: ${theme.space.md};
display: flex;
flex-direction: column;
gap: ${theme.space.sm};
`;
const DayPanelHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
const DayPanelTitle = styled.h3`
margin: 0;
font-size: 14px;
font-weight: 600;
color: ${theme.color.textBright};
`;
const CloseBtn = styled.button`
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: ${theme.radius.sm};
border: none;
background: transparent;
color: ${theme.color.textMute};
cursor: pointer;
&:hover {
color: ${theme.color.textSub};
background: ${theme.color.surfaceHoverDeep};
}
`;
const PanelMsg = styled.p`
margin: 0;
font-size: 13px;
color: ${theme.color.textMute};
text-align: center;
padding: ${theme.space.md} 0;
`;
const FutureTip = styled.div`
display: flex;
align-items: center;
gap: ${theme.space.xs};
font-size: 13px;
color: ${theme.color.textMute};
padding: ${theme.space.sm} 0;
`;
const ReviewList = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.sm};
`;
const ReviewItem = styled.div`
padding: ${theme.space.sm} ${theme.space.md};
background: rgba(255, 255, 255, 0.03);
border: 1px solid ${theme.color.borderSoftAlpha};
border-radius: ${theme.radius.md};
display: flex;
flex-direction: column;
gap: 4px;
`;
const ReviewMeta = styled.div`
display: flex;
align-items: center;
gap: ${theme.space.xs};
flex-wrap: wrap;
`;
const SubjectBadge = styled.span`
display: inline-block;
padding: 2px 8px;
border-radius: ${theme.radius.pill};
background: rgba(79, 70, 229, 0.15);
color: ${theme.color.brandIndigo};
font-size: 11px;
font-weight: 600;
`;
const TagName = styled.span`
font-size: 12px;
color: ${theme.color.textMute};
`;
const StatusBadge = styled.span<{ $status: string }>`
display: inline-block;
padding: 2px 6px;
border-radius: ${theme.radius.pill};
font-size: 10px;
font-weight: 600;
background: ${({ $status }) =>
$status === 'done'
? 'rgba(34, 197, 94, 0.15)'
: $status === 'skipped'
? 'rgba(100, 116, 139, 0.15)'
: 'rgba(245, 158, 11, 0.15)'};
color: ${({ $status }) =>
$status === 'done'
? theme.color.success
: $status === 'skipped'
? theme.color.textMute
: theme.color.warning};
`;
const ReviewTitle = styled.p`
margin: 0;
font-size: 13px;
font-weight: 500;
color: ${theme.color.textBright};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const ReviewPreview = styled.p`
margin: 0;
font-size: 12px;
color: ${theme.color.textMute};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;

View File

@@ -271,91 +271,6 @@ export interface ProblemSetDetail extends ProblemSetSummary {
passages: Passage[];
}
export interface PsTagDisplayName {
language: string;
name: string;
}
export interface PsTag {
key: string;
displayNames?: PsTagDisplayName[];
}
export interface PsSearchProblem {
problemId: number;
title?: string | null;
titleKo?: string | null;
level: number;
tags?: PsTag[];
acceptedUserCount?: number | null;
averageTries?: number | null;
}
export interface PsSearchResponse {
count: number;
items: PsSearchProblem[];
}
export interface PsProblem {
id: number;
bojId: number;
title: string;
titleKo: string | null;
level: number;
tags?: PsTag[] | null;
acceptedUserCount?: number | null;
averageTries?: number | null;
solvedacUpdatedAt?: string | null;
createdAt: string;
updatedAt: string;
}
export interface PsBookmark {
id: number;
psProblemId: number;
memo: string | null;
createdAt: string;
psProblem: PsProblem;
}
export interface PsProblemTagSummary {
key: string;
displayName: string;
}
export interface PsSyncProblem {
bojId: number;
title: string;
titleKo: string | null;
level: number;
tags?: PsProblemTagSummary[] | null;
}
export interface PsSyncResult {
handle: string;
importedCount: number;
skippedCount: number;
totalSolved: number;
importedProblems: PsSyncProblem[];
}
export interface PsSolvedItem {
studyLogId: number;
studiedAt: string;
psProblem: PsSyncProblem | null;
}
export interface PsSolvedResponse {
items: PsSolvedItem[];
total: number;
}
export interface PsSearchParams {
q?: string;
level?: number;
tag?: string;
page?: number;
}
export interface Subject {
id: number;
@@ -767,53 +682,59 @@ export async function getAssignmentSubmissions(id: number) {
return response.data;
}
export const ps = {
async search(params: PsSearchParams = {}) {
const query: Record<string, string | number> = {};
if (params.q?.trim()) query.q = params.q.trim();
if (typeof params.level === 'number') query.level = params.level;
if (params.tag?.trim()) query.tag = params.tag.trim();
if (typeof params.page === 'number' && params.page > 0) query.page = params.page;
const response = await api.get<PsSearchResponse>('/ps/search', {
params: query,
});
return response.data;
},
// ─── Review Calendar ───────────────────────────────────────────────────────
async bookmark(bojId: number, memo?: string) {
const payload = memo ? { bojId, memo } : { bojId };
const response = await api.post<PsBookmark>('/ps/bookmarks', payload);
return response.data;
},
export interface CalendarDay {
date: string; // YYYY-MM-DD
total: number;
completed: number;
}
async removeBookmark(bojId: number) {
await api.delete(`/ps/bookmarks/${bojId}`);
},
export interface CalendarResponse {
days: CalendarDay[];
}
async listBookmarks() {
const response = await api.get<PsBookmark[]>('/ps/bookmarks');
return response.data;
},
export interface DayReview {
id: number;
scheduledAt: string;
status: string;
studyLog: {
id: number;
title: string;
problem?: {
id: number;
bodyText?: string | null;
choices?: unknown;
} | null;
tag?: {
name: string;
subject: { name: string };
} | null;
subject?: {
id: number;
name: string;
color: string;
};
};
}
async listSolved(params: { page?: number; pageSize?: number } = {}) {
const query: Record<string, number> = {};
if (typeof params.page === 'number' && params.page > 0) query.page = params.page;
if (typeof params.pageSize === 'number' && params.pageSize > 0) query.pageSize = params.pageSize;
const response = await api.get<PsSolvedResponse>('/ps/solved', { params: query });
return response.data;
},
export interface DayResponse {
reviews: DayReview[];
}
async sync(bojHandle?: string) {
const payload = bojHandle?.trim() ? { bojHandle: bojHandle.trim() } : {};
const response = await api.post<PsSyncResult>('/ps/sync', payload);
return response.data;
},
export async function getReviewCalendar(year: number, month: number): Promise<CalendarResponse> {
const response = await api.get<CalendarResponse>('/reviews/calendar', {
params: { year, month },
});
return response.data;
}
async getProblem(bojId: number) {
const response = await api.get<PsProblem>(`/ps/problems/${bojId}`);
return response.data;
},
};
export async function getReviewDay(date: string): Promise<DayResponse> {
const response = await api.get<DayResponse>('/reviews/day', { params: { date } });
return response.data;
}
// ─── BOJ Tier ──────────────────────────────────────────────────────────────
const BOJ_TIER_NAMES = ['Bronze', 'Silver', 'Gold', 'Platinum', 'Diamond', 'Ruby'] as const;
const BOJ_TIER_DIVISIONS = ['V', 'IV', 'III', 'II', 'I'] as const;