feat: 밀린 복습 자동 이월 + 페르소나 재계산 + PDF OCR 문제 분리

- 복습 큐 조회 시 하루 이상 밀린 pending 항목을 오늘 09:00 KST로 자동 reschedule
- 페르소나/학습강도 변경 시 모든 pending 복습 스케줄을 새 설정 기반으로 재계산
- PDF 모의고사 업로드 → Codex Vision OCR로 문제별 분리 → 태그 배정 → 선택적 복습 큐 등록

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
reloop
2026-04-17 08:50:32 +09:00
parent 8aa87ed257
commit 45cb02f3fc
8 changed files with 1044 additions and 6 deletions

View File

@@ -6,6 +6,7 @@ import { MathUnit, Persona, Prisma, ReviewIntensity } from '@prisma/client';
import { unlink } from 'fs/promises';
import { join } from 'path';
import { PrismaService } from '../prisma/prisma.service';
import { PersonaForgetService } from '../forget/persona-forget.service';
export interface OnboardingInput {
nickname: string;
@@ -35,7 +36,10 @@ interface ProfileInput {
@Injectable()
export class MeService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly forget: PersonaForgetService,
) {}
async updateOnboarding(userId: number, input: OnboardingInput) {
const data: Prisma.UserUpdateInput = {
@@ -60,6 +64,11 @@ export class MeService {
}
async updateProfile(userId: number, input: ProfileInput) {
const oldUser = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { persona: true, reviewIntensity: true },
});
const data: Prisma.UserUpdateInput = {};
if (input.nickname !== undefined) data.nickname = input.nickname;
@@ -77,9 +86,94 @@ export class MeService {
where: { id: userId },
data,
});
const personaChanged =
input.persona !== undefined && input.persona !== oldUser.persona;
const intensityChanged =
input.reviewIntensity !== undefined &&
input.reviewIntensity !== oldUser.reviewIntensity;
if (personaChanged || intensityChanged) {
await this.recalculatePendingReviews(userId, user.persona, user.reviewIntensity);
}
return this.toView(user);
}
private async recalculatePendingReviews(
userId: number,
newPersona: Persona,
newIntensity: ReviewIntensity,
): Promise<void> {
const pendingReviews = await this.prisma.reviewSchedule.findMany({
where: {
userId,
status: 'pending',
},
include: {
studyLog: {
select: {
tagId: true,
difficulty: true,
baseCorrectRate: true,
},
},
},
});
for (const review of pendingReviews) {
const { tagId, difficulty, baseCorrectRate } = review.studyLog;
let snap: { s0: number; lastUpdatedAt: Date } | null = null;
if (tagId !== null) {
snap = await this.prisma.skillSnapshot.findUnique({
where: { userId_tagId: { userId, tagId } },
select: { s0: true, lastUpdatedAt: true },
});
}
// SM-2 경로: SkillSnapshot.s0 가 EF 범위(1.3 ~ 4.0)에 있는 경우
if (snap !== null && snap.s0 >= 1.3 && snap.s0 <= 4.0) {
const sm2 = this.forget.sm2Schedule({
iteration: review.iteration,
selfDifficulty: 'medium',
currentEF: snap.s0,
persona: newPersona,
now: review.createdAt,
});
await this.prisma.reviewSchedule.update({
where: { id: review.id },
data: { scheduledAt: sm2.scheduledAt },
});
} else {
// 망각곡선 경로
const D =
baseCorrectRate !== null && baseCorrectRate !== undefined
? 1 - baseCorrectRate
: difficulty;
const s0 = snap?.s0 ?? 0.3;
const lastUpdatedAt = snap?.lastUpdatedAt ?? review.createdAt;
const scheduled = this.forget.schedule({
s0,
persona: newPersona,
intensity: newIntensity,
difficulty: D,
lastUpdatedAt,
});
await this.prisma.reviewSchedule.update({
where: { id: review.id },
data: {
scheduledAt: scheduled.scheduledAt,
predictedP: scheduled.predictedP,
},
});
}
}
}
async updateAvatar(
userId: number,
file?: {

View File

@@ -19,12 +19,18 @@ export class ReviewsService {
* Queue of reviews that should be done around "now". Includes anything
* whose scheduledAt is in the past OR within the next 24h so the UI can
* show "곧 해야 할 것들" too.
*
* Before fetching, auto-reschedules overdue pending items (scheduled before
* today 00:00 KST) to today 09:00 KST and returns the count in
* `rescheduledCount`.
*/
async queue(userId: number) {
const now = new Date();
const rescheduledCount = await this.rescheduleOverdue(userId, now);
const soon = new Date(now.getTime() + 24 * 3_600_000);
return this.prisma.reviewSchedule.findMany({
const items = await this.prisma.reviewSchedule.findMany({
where: {
userId,
status: ReviewStatus.pending,
@@ -45,6 +51,51 @@ export class ReviewsService {
orderBy: { scheduledAt: 'asc' },
take: 100,
});
return { items, rescheduledCount };
}
/**
* 밀린 복습(pending + scheduledAt < 오늘 00:00 KST)을 오늘 09:00 KST로 이동.
* 당일 내 밀린 것(오늘 예정이지만 아직 안 한 것)은 대상에서 제외.
* 이동한 건수를 반환한다.
*/
async rescheduleOverdue(userId: number, now: Date): Promise<number> {
// KST = UTC+9
const kstOffset = 9 * 3_600_000;
const kstNow = new Date(now.getTime() + kstOffset);
// 오늘 KST 기준 자정 (UTC 표현)
const utcTodayStartKST = new Date(
Date.UTC(kstNow.getUTCFullYear(), kstNow.getUTCMonth(), kstNow.getUTCDate()) - kstOffset,
);
// 오늘 KST 09:00 (UTC 표현)
const utcTodayNineAMKST = new Date(utcTodayStartKST.getTime() + 9 * 3_600_000);
// Prisma updateMany는 relation filter를 지원하지 않으므로
// findMany로 대상 ID 목록을 먼저 조회 후 updateMany { id: { in: ids } } 패턴 사용
const overdue = await this.prisma.reviewSchedule.findMany({
where: {
userId,
status: ReviewStatus.pending,
scheduledAt: { lt: utcTodayStartKST },
studyLog: {
psProblemId: null,
subject: { name: '수학' },
},
},
select: { id: true },
});
if (overdue.length === 0) return 0;
const ids = overdue.map((r) => r.id);
const result = await this.prisma.reviewSchedule.updateMany({
where: { id: { in: ids } },
data: { scheduledAt: utcTodayNineAMKST },
});
return result.count;
}
async submit(

View File

@@ -153,6 +153,47 @@ class CreateFromProblemSetDto {
answers: ProblemSetAnswerDto[];
}
class ImportFromPdfProblemDto {
@IsInt()
number: number;
@IsString()
title: string;
@IsNumber()
@Min(0)
@Max(1)
difficulty: number;
@IsOptional()
@IsString()
bodyText?: string;
@IsOptional()
@IsInt()
tagId?: number;
@IsEnum(StudyResult)
result: StudyResult;
@IsOptional()
@IsIn(['hard', 'medium', 'easy'])
selfDifficulty?: 'hard' | 'medium' | 'easy';
}
class ImportFromPdfDto {
@IsInt()
subjectId: number;
@IsString()
pdfTitle: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => ImportFromPdfProblemDto)
problems: ImportFromPdfProblemDto[];
}
class UpdateStudyLogDto implements UpdateStudyLogInput {
@IsOptional()
@Type(() => Number)
@@ -232,6 +273,48 @@ export class StudyLogsController {
return this.svc.purgeNonMath(user.id);
}
@Post('parse-pdf')
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: (_req, _file, cb) => {
const dir = join(__dirname, '..', '..', 'uploads', 'problems');
mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename: (_req, file, cb) => {
const ext = extname(file.originalname).toLowerCase();
cb(null, `${Date.now()}-${randomBytes(4).toString('hex')}${ext}`);
},
}),
fileFilter: (_req, file, cb) => {
if (file.mimetype !== 'application/pdf') {
cb(new BadRequestException('PDF 파일만 업로드할 수 있어.'), false);
} else {
cb(null, true);
}
},
limits: { fileSize: 50 * 1024 * 1024 },
}),
)
async parsePdf(
@CurrentUser() _user: AuthUser,
@UploadedFile()
file: { filename: string; path: string; originalname: string; mimetype: string; size: number } | undefined,
@Body('title') title?: string,
) {
if (!file) throw new BadRequestException('파일이 없어.');
return this.svc.parsePdfToProblems(file.path, title || file.originalname);
}
@Post('import-from-pdf')
importFromPdf(
@CurrentUser() user: AuthUser,
@Body() dto: ImportFromPdfDto,
) {
return this.svc.importFromParsedPdf(user.id, dto);
}
@Post('upload-pdf')
@UseInterceptors(
FileInterceptor('file', {

View File

@@ -288,6 +288,102 @@ export class StudyLogsService {
});
}
async parsePdfToProblems(pdfPath: string, title: string) {
const { ocrProblemPage } = await import('../problem-sets/parsing/ocr-fallback/index');
const { execFileSync } = await import('child_process');
let pageCount = 1;
try {
const output = execFileSync('pdfinfo', [pdfPath], { encoding: 'utf-8' });
const match = output.match(/^Pages:\s+(\d+)/m);
if (match) pageCount = Number(match[1]);
} catch {
// pdfinfo 미설치 시 1페이지로 시도
}
const allProblems: Array<{ number: number; bodyText: string; choices: Record<string, string> }> = [];
for (let page = 1; page <= pageCount; page++) {
try {
const problems = await ocrProblemPage(pdfPath, page);
allProblems.push(...problems);
} catch {
// 페이지 파싱 실패 시 건너뜀
}
}
// 중복 번호 제거 (첫 번째 유지)
const seen = new Set<number>();
const unique = allProblems.filter((p) => {
if (seen.has(p.number)) return false;
seen.add(p.number);
return true;
});
return {
title,
problems: unique.sort((a, b) => a.number - b.number),
totalPages: pageCount,
};
}
async importFromParsedPdf(
userId: number,
input: {
subjectId: number;
pdfTitle: string;
problems: Array<{
number: number;
title: string;
difficulty: number;
bodyText?: string;
tagId?: number;
result: StudyResult;
selfDifficulty?: 'hard' | 'medium' | 'easy';
}>;
},
) {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { persona: true, reviewIntensity: true },
});
// subjectId 소유권 검증
const subject = await this.prisma.subject.findFirst({
where: { id: input.subjectId, userId },
});
if (!subject) throw new ForbiddenException('subject');
const results: Array<{ studyLog: { id: number }; nextReview: { id: number } }> = [];
for (const problem of input.problems) {
const createInput: CreateStudyLogInput = {
subjectId: input.subjectId,
tagId: problem.tagId,
title: `[${input.pdfTitle}] ${problem.number}번 - ${problem.title}`,
difficulty: problem.difficulty,
result: problem.result,
selfDifficulty: problem.selfDifficulty,
memo: problem.bodyText || undefined,
};
// tagId 검증
if (problem.tagId) {
const tag = await this.prisma.tag.findFirst({
where: { id: problem.tagId, subjectId: input.subjectId },
});
if (!tag) throw new NotFoundException(`tag ${problem.tagId}`);
}
const result = await this.prisma.$transaction(async (tx) => {
return this.createInTransaction(tx, userId, user, createInput);
});
results.push(result);
}
return { imported: results.length, results };
}
async deleteByPrefix(userId: number, prefix: string) {
if (!prefix || prefix.length < 2) throw new BadRequestException('prefix too short');

View File

@@ -304,12 +304,12 @@ function ProfileBody() {
function handlePersonaSelect(value: Persona) {
if (!user || value === user.persona) return;
void patchProfile({ persona: value }, 'persona');
void patchProfile({ persona: value }, 'persona', '페르소나 변경됨 — 복습 스케줄을 새 설정에 맞게 재계산했어요');
}
function handleIntensitySelect(value: ReviewIntensity) {
if (!user || value === user.reviewIntensity) return;
void patchProfile({ reviewIntensity: value }, 'intensity');
void patchProfile({ reviewIntensity: value }, 'intensity', '학습강도 변경됨 — 복습 스케줄을 새 설정에 맞게 재계산했어요');
}
function handleTargetExamYearSave() {

View File

@@ -121,14 +121,22 @@ export default function ReviewPage() {
try {
const [meRes, queueRes, summaryRes, activityRes] = await Promise.all([
api.get<MeUser>('/auth/me'),
api.get<QueueItem[]>('/reviews/queue'),
api.get<{ items: QueueItem[]; rescheduledCount: number }>('/reviews/queue'),
api.get<DashboardSummary>('/dashboard/summary').catch(() => null),
api.get<StudyLog[]>('/study-logs', { params: { limit: 200 } }).catch(() => null),
]);
void meRes;
const nextQueue = queueRes.data;
const { items: nextQueue, rescheduledCount } = queueRes.data;
if (rescheduledCount > 0) {
showToast({
variant: 'info',
message: `밀린 복습 ${rescheduledCount}개가 오늘로 넘어왔어요`,
durationMs: 4000,
});
}
setQueue(nextQueue);
setInitialTotal(nextQueue.length);

View File

@@ -0,0 +1,700 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import styled, { css } from 'styled-components';
import AppShell from '@/components/layout/AppShell';
import { Icon } from '@/components/ui/Icon';
import { useToast } from '@/components/ui/Toast';
import {
Button,
Card,
ErrorText,
HelpText,
Label,
PageHeader,
SectionTitle,
Select,
Stack,
} from '@/components/ui/primitives';
import { api, type StudyResult, type Subject, type Tag } from '@/lib/api';
import { theme } from '@/styles/theme';
interface OcrProblem {
number: number;
bodyText: string;
choices: Record<string, string>;
}
interface ParseResult {
title: string;
problems: OcrProblem[];
totalPages: number;
}
interface ProblemImportState {
selected: boolean;
tagId: number | '';
result: StudyResult;
difficulty: number;
selfDifficulty: 'hard' | 'medium' | 'easy' | '';
}
export default function ImportPdfPage() {
return (
<AppShell>
<ImportPdfBody />
</AppShell>
);
}
function ImportPdfBody() {
const router = useRouter();
const { showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const [subjects, setSubjects] = useState<Subject[] | null>(null);
const [subjectId, setSubjectId] = useState<number | ''>('');
const [pdfFile, setPdfFile] = useState<File | null>(null);
const [customTitle, setCustomTitle] = useState('');
const [parsing, setParsing] = useState(false);
const [parseResult, setParseResult] = useState<ParseResult | null>(null);
const [problemStates, setProblemStates] = useState<ProblemImportState[]>([]);
const [importing, setImporting] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
api
.get<Subject[]>('/subjects')
.then((r) => {
setSubjects(r.data);
if (r.data[0]) setSubjectId(r.data[0].id);
})
.catch(() => {
setSubjects([]);
showToast({ message: '과목 목록을 불러오지 못했습니다', variant: 'danger' });
});
}, [showToast]);
const currentSubject = subjects?.find((s) => s.id === subjectId) ?? null;
const tags: Tag[] = currentSubject?.tags ?? [];
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null;
setPdfFile(file);
if (file && !customTitle) {
setCustomTitle(file.name.replace(/\.pdf$/i, ''));
}
setParseResult(null);
setProblemStates([]);
setErr(null);
};
const handleParse = async () => {
if (!pdfFile) {
setErr('PDF 파일을 선택해줘.');
return;
}
if (!subjectId) {
setErr('과목을 선택해줘.');
return;
}
setErr(null);
setParsing(true);
try {
const formData = new FormData();
formData.append('file', pdfFile);
if (customTitle) formData.append('title', customTitle);
const res = await api.post<ParseResult>('/study-logs/parse-pdf', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const result = res.data;
setParseResult(result);
setProblemStates(
result.problems.map(() => ({
selected: false,
tagId: '',
result: 'incorrect' as StudyResult,
difficulty: 0.6,
selfDifficulty: '',
})),
);
} catch {
setErr('PDF 분석에 실패했어. 파일을 확인해줘.');
} finally {
setParsing(false);
}
};
const updateProblemState = (
index: number,
patch: Partial<ProblemImportState>,
) => {
setProblemStates((prev) =>
prev.map((state, i) => (i === index ? { ...state, ...patch } : state)),
);
};
const toggleAll = (selected: boolean) => {
setProblemStates((prev) => prev.map((state) => ({ ...state, selected })));
};
const selectedCount = problemStates.filter((s) => s.selected).length;
const handleImport = async () => {
if (!parseResult || !subjectId) return;
const selectedProblems = parseResult.problems
.map((problem, i) => ({ problem, state: problemStates[i] }))
.filter(({ state }) => state.selected);
if (selectedProblems.length === 0) {
setErr('등록할 문제를 하나 이상 선택해줘.');
return;
}
setErr(null);
setImporting(true);
try {
await api.post('/study-logs/import-from-pdf', {
subjectId,
pdfTitle: parseResult.title,
problems: selectedProblems.map(({ problem, state }) => ({
number: problem.number,
title: `${problem.number}`,
difficulty: state.difficulty,
bodyText: problem.bodyText || undefined,
tagId: state.tagId || undefined,
result: state.result,
selfDifficulty: state.selfDifficulty || undefined,
})),
});
showToast({
message: `${selectedProblems.length}개 문제를 복습 큐에 등록했어.`,
variant: 'success',
});
router.push('/dashboard');
} catch {
setErr('등록에 실패했어. 다시 시도해줘.');
} finally {
setImporting(false);
}
};
if (subjects === null) {
return <Loading> ...</Loading>;
}
return (
<Wrap>
<HeaderCard>
<PageHeader
eyebrow="Import"
title="PDF 가져오기"
subtitle="모의고사 PDF를 업로드하면 OCR로 문제를 분리해서 복습 큐에 등록해줘."
right={
<HeaderBadge>
<Icon name="file-pdf" size={16} />
OCR
</HeaderBadge>
}
/>
</HeaderCard>
<StepCard>
<Stack $gap={theme.space.md}>
<SectionTitle>1단계: 파일 </SectionTitle>
<RowGrid>
<Field>
<Label></Label>
<FieldSelect
value={subjectId}
onChange={(e) => {
setSubjectId(Number(e.target.value));
setParseResult(null);
setProblemStates([]);
}}
>
{subjects.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</FieldSelect>
</Field>
<Field>
<Label> ()</Label>
<TitleInput
type="text"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder="예) 2026 수능 수학"
/>
</Field>
</RowGrid>
<UploadArea
$hasFile={!!pdfFile}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="application/pdf"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{pdfFile ? (
<>
<Icon name="file-pdf" size={32} weight="duotone" />
<UploadFileName>{pdfFile.name}</UploadFileName>
<HelpText> </HelpText>
</>
) : (
<>
<Icon name="file-pdf" size={32} weight="regular" />
<UploadLabel>PDF </UploadLabel>
<HelpText> 50MB · PDF만 </HelpText>
</>
)}
</UploadArea>
{err && !parseResult && (
<ErrorBox role="alert">
<Icon name="info" size={16} weight="bold" />
<ErrorText as="span">{err}</ErrorText>
</ErrorBox>
)}
<ButtonRow>
<Button
type="button"
$variant="primary"
$size="md"
disabled={!pdfFile || parsing}
onClick={handleParse}
>
{parsing ? '분석 중...' : '분석하기'}
</Button>
</ButtonRow>
</Stack>
</StepCard>
{parseResult && (
<StepCard>
<Stack $gap={theme.space.md}>
<SectionTitleRow>
<SectionTitle>
2단계: 문제 ({parseResult.problems.length} )
</SectionTitle>
<SelectAllRow>
<SmallButton type="button" onClick={() => toggleAll(true)}>
</SmallButton>
<SmallButton type="button" onClick={() => toggleAll(false)}>
</SmallButton>
</SelectAllRow>
</SectionTitleRow>
{parseResult.problems.length === 0 ? (
<EmptyResult>
<Icon name="info" size={24} />
<span> . OCR이 .</span>
</EmptyResult>
) : (
parseResult.problems.map((problem, index) => {
const state = problemStates[index];
if (!state) return null;
return (
<ProblemRow key={problem.number} $selected={state.selected}>
<ProblemCheckbox
type="checkbox"
checked={state.selected}
onChange={(e) =>
updateProblemState(index, { selected: e.target.checked })
}
/>
<ProblemContent>
<ProblemNumber>{problem.number}</ProblemNumber>
{problem.bodyText && (
<ProblemPreview>{problem.bodyText.slice(0, 80)}...</ProblemPreview>
)}
</ProblemContent>
<ProblemControls>
<MiniField>
<MiniLabel></MiniLabel>
<MiniSelect
value={state.tagId}
onChange={(e) =>
updateProblemState(index, {
tagId: e.target.value === '' ? '' : Number(e.target.value),
})
}
>
<option value=""></option>
{tags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
))}
</MiniSelect>
</MiniField>
<MiniField>
<MiniLabel></MiniLabel>
<MiniSelect
value={state.result}
onChange={(e) =>
updateProblemState(index, {
result: e.target.value as StudyResult,
})
}
>
<option value="correct"></option>
<option value="incorrect"></option>
<option value="partial"></option>
</MiniSelect>
</MiniField>
<MiniField>
<MiniLabel> </MiniLabel>
<MiniSelect
value={state.selfDifficulty}
onChange={(e) =>
updateProblemState(index, {
selfDifficulty: e.target.value as
| 'hard'
| 'medium'
| 'easy'
| '',
})
}
>
<option value=""></option>
<option value="hard"></option>
<option value="medium"></option>
<option value="easy"></option>
</MiniSelect>
</MiniField>
</ProblemControls>
</ProblemRow>
);
})
)}
{err && (
<ErrorBox role="alert">
<Icon name="info" size={16} weight="bold" />
<ErrorText as="span">{err}</ErrorText>
</ErrorBox>
)}
<ButtonRow>
<Button
type="button"
$variant="ghost"
disabled={importing}
onClick={() => {
setParseResult(null);
setProblemStates([]);
setErr(null);
}}
>
</Button>
<Button
type="button"
$variant="primary"
$size="lg"
disabled={importing || selectedCount === 0}
onClick={handleImport}
>
{importing
? '등록 중...'
: `${selectedCount}개 문제 복습 큐에 등록`}
</Button>
</ButtonRow>
</Stack>
</StepCard>
)}
</Wrap>
);
}
// ─── Styled Components ────────────────────────────────────────────────────────
const Wrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
`;
const HeaderCard = styled(Card)`
background:
radial-gradient(circle at top right, rgba(79, 70, 229, 0.16), transparent 30%),
${theme.color.surfaceDeep};
border-color: ${theme.color.borderSoftAlpha};
`;
const HeaderBadge = styled.span`
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: ${theme.radius.pill};
background: rgba(79, 70, 229, 0.14);
border: 1px solid rgba(129, 140, 248, 0.3);
color: ${theme.color.textBright};
font-size: 12px;
font-weight: 700;
`;
const StepCard = styled(Card)`
background: rgba(255, 255, 255, 0.02);
border-color: ${theme.color.borderSoftAlpha};
`;
const RowGrid = styled.div`
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: ${theme.space.md};
@media (max-width: ${theme.breakpoint.tablet}) {
grid-template-columns: 1fr;
}
`;
const Field = 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 TitleInput = styled.input`
width: 100%;
height: 40px;
padding: 0 12px;
border-radius: ${theme.radius.md};
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255, 255, 255, 0.03);
color: ${theme.color.textBright};
font-size: 14px;
outline: none;
&:focus {
border-color: ${theme.color.brandIndigo};
}
&::placeholder {
color: ${theme.color.textSub};
}
`;
const UploadArea = styled.div<{ $hasFile: boolean }>`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 160px;
border-radius: ${theme.radius.lg};
border: 2px dashed
${({ $hasFile }) =>
$hasFile ? theme.color.brandIndigo : theme.color.borderSoftAlpha};
background: ${({ $hasFile }) =>
$hasFile ? 'rgba(79, 70, 229, 0.06)' : 'rgba(255, 255, 255, 0.02)'};
color: ${theme.color.textSub};
cursor: pointer;
transition:
border-color 0.2s ease,
background 0.2s ease;
&:hover {
border-color: ${theme.color.brandIndigo};
background: rgba(79, 70, 229, 0.06);
}
`;
const UploadLabel = styled.span`
font-size: 15px;
font-weight: 600;
color: ${theme.color.textBright};
`;
const UploadFileName = styled.span`
font-size: 14px;
font-weight: 600;
color: ${theme.color.textBright};
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const ErrorBox = styled.div`
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 12px 14px;
border-radius: ${theme.radius.md};
border: 1px solid rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.08);
color: ${theme.color.danger};
`;
const ButtonRow = styled.div`
display: flex;
align-items: center;
gap: 12px;
justify-content: flex-end;
flex-wrap: wrap;
`;
const SectionTitleRow = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
`;
const SelectAllRow = styled.div`
display: flex;
gap: 8px;
`;
const SmallButton = styled.button`
padding: 4px 12px;
border-radius: ${theme.radius.md};
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255, 255, 255, 0.04);
color: ${theme.color.textSub};
font-size: 12px;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 0.08);
color: ${theme.color.textBright};
}
`;
const ProblemRow = styled.div<{ $selected: boolean }>`
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px;
border-radius: ${theme.radius.lg};
border: 1px solid
${({ $selected }) =>
$selected ? 'rgba(129, 140, 248, 0.4)' : theme.color.borderSoftAlpha};
background: ${({ $selected }) =>
$selected ? 'rgba(79, 70, 229, 0.06)' : 'rgba(255, 255, 255, 0.02)'};
transition:
border-color 0.15s ease,
background 0.15s ease;
`;
const ProblemCheckbox = styled.input`
margin-top: 2px;
width: 16px;
height: 16px;
flex-shrink: 0;
accent-color: ${theme.color.brandIndigo};
cursor: pointer;
`;
const ProblemContent = styled.div`
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
`;
const ProblemNumber = styled.span`
font-weight: 700;
font-size: 14px;
color: ${theme.color.textBright};
`;
const ProblemPreview = styled.p`
font-size: 12px;
color: ${theme.color.textSub};
margin: 0;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const ProblemControls = styled.div`
display: flex;
gap: 8px;
flex-wrap: wrap;
flex-shrink: 0;
`;
const MiniField = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
min-width: 90px;
`;
const MiniLabel = styled.span`
font-size: 11px;
color: ${theme.color.textSub};
font-weight: 500;
`;
const MiniSelect = styled.select`
height: 32px;
padding: 0 8px;
border-radius: ${theme.radius.md};
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255, 255, 255, 0.03);
color: ${theme.color.textBright};
font-size: 12px;
outline: none;
cursor: pointer;
&:focus {
border-color: ${theme.color.brandIndigo};
}
`;
const EmptyResult = styled.div`
display: flex;
align-items: center;
gap: 10px;
padding: 24px;
color: ${theme.color.textSub};
font-size: 14px;
`;
const Loading = styled.div`
min-height: 40vh;
display: grid;
place-items: center;
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
`;

View File

@@ -54,6 +54,12 @@ const BASE_NAV_ITEMS: Array<{
icon: 'folders',
match: (pathname) => pathname.startsWith('/subjects'),
},
{
href: '/study/import-pdf',
label: 'PDF 가져오기',
icon: 'file-pdf',
match: (pathname) => pathname.startsWith('/study/import-pdf'),
},
{
href: '/pricing',
label: '플랜',