feat: PDF 이미지 크롭 방식 문제 분리 + 해설지 정답 자동 태깅 + 복습 정답 표시
14. PDF 문제 분리 - OCR 텍스트 파싱 → bbox 기반 이미지 크롭(pdftoppm)으로 전면 교체 - 해설지 PDF 동시 업로드 시 pdftotext로 정답 표 자동 파싱 - StudyLog에 correctAnswer, imageUrl 컬럼 추가 (migration) - 프론트: 크롭 이미지 미리보기 + 정답 자동/수동 배정 UI 15. 복습 시 정답 표시 - 복습 카드에 "정답 보기" 버튼 추가 (클릭 시 정답 번호 표시) - ProblemSet answerNumber + StudyLog correctAnswer 양쪽 소스 지원 - 캘린더 일별 보기에도 answerNumber 포함 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `study_logs` ADD COLUMN `correctAnswer` INTEGER NULL,
|
||||
ADD COLUMN `imageUrl` VARCHAR(191) NULL;
|
||||
@@ -158,6 +158,8 @@ model StudyLog {
|
||||
baseCorrectRate Float?
|
||||
result StudyResult
|
||||
chosenAnswer Int?
|
||||
correctAnswer Int? // 정답 번호 (1~5), PDF 해설지에서 추출 또는 수동 입력
|
||||
imageUrl String? // 사용자 업로드 PDF에서 크롭된 문제 이미지 경로
|
||||
memo String? @db.Text
|
||||
studiedAt DateTime @default(now())
|
||||
timeSpent Int?
|
||||
|
||||
@@ -384,6 +384,7 @@ export class ReviewsService {
|
||||
id: true,
|
||||
bodyText: true,
|
||||
choices: true,
|
||||
answerNumber: true,
|
||||
},
|
||||
},
|
||||
tag: {
|
||||
|
||||
@@ -10,10 +10,11 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { FileInterceptor, FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { extname, join } from 'path';
|
||||
@@ -169,6 +170,17 @@ class ImportFromPdfProblemDto {
|
||||
@IsString()
|
||||
bodyText?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
correctAnswer?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
tagId?: number;
|
||||
@@ -275,36 +287,51 @@ export class StudyLogsController {
|
||||
|
||||
@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);
|
||||
FileFieldsInterceptor(
|
||||
[
|
||||
{ name: 'file', maxCount: 1 },
|
||||
{ name: 'answerFile', maxCount: 1 },
|
||||
],
|
||||
{
|
||||
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);
|
||||
}
|
||||
},
|
||||
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 },
|
||||
},
|
||||
limits: { fileSize: 50 * 1024 * 1024 },
|
||||
}),
|
||||
),
|
||||
)
|
||||
async parsePdf(
|
||||
@CurrentUser() _user: AuthUser,
|
||||
@UploadedFile()
|
||||
file: { filename: string; path: string; originalname: string; mimetype: string; size: number } | undefined,
|
||||
@UploadedFiles()
|
||||
files: {
|
||||
file?: Array<{ path: string; originalname: string; mimetype: string; size: number }>;
|
||||
answerFile?: Array<{ path: string; originalname: string; mimetype: string; size: number }>;
|
||||
},
|
||||
@Body('title') title?: string,
|
||||
) {
|
||||
if (!file) throw new BadRequestException('파일이 없어.');
|
||||
return this.svc.parsePdfToProblems(file.path, title || file.originalname);
|
||||
const mainFile = files?.file?.[0];
|
||||
if (!mainFile) throw new BadRequestException('문제 PDF 파일이 없어.');
|
||||
const answerFile = files?.answerFile?.[0];
|
||||
return this.svc.parsePdfToProblems(
|
||||
mainFile.path,
|
||||
title || mainFile.originalname,
|
||||
answerFile?.path,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('import-from-pdf')
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface CreateStudyLogInput {
|
||||
baseCorrectRate?: number | null;
|
||||
result: StudyResult;
|
||||
chosenAnswer?: number | null;
|
||||
correctAnswer?: number | null;
|
||||
imageUrl?: string | null;
|
||||
memo?: string;
|
||||
timeSpent?: number;
|
||||
selfDifficulty?: 'hard' | 'medium' | 'easy';
|
||||
@@ -288,42 +290,65 @@ export class StudyLogsService {
|
||||
});
|
||||
}
|
||||
|
||||
async parsePdfToProblems(pdfPath: string, title: string) {
|
||||
async parsePdfToProblems(pdfPath: string, title: string, answerPdfPath?: string) {
|
||||
const { cropProblemsFromPdf } = await import('../problem-sets/parsing/parse-image-based-exam');
|
||||
const { extractText } = await import('../problem-sets/parsing/extract-text');
|
||||
const { parseProblemPaper } = await import('../problem-sets/parsing/parse-problem-paper');
|
||||
const { parseAnswerTable } = await import('../problem-sets/parsing/parse-answer-table');
|
||||
const { kiceMathStrategy } = await import('../problem-sets/parsing/strategies/math');
|
||||
const { execFileSync } = await import('child_process');
|
||||
const { mkdirSync } = await import('fs');
|
||||
const { join } = await import('path');
|
||||
|
||||
let pageCount = 1;
|
||||
// 타임스탬프 기반 출력 디렉토리
|
||||
const ts = Date.now();
|
||||
const outputDir = join(__dirname, '..', '..', 'uploads', 'user-problems', String(ts));
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
// 이미지 크롭
|
||||
let cropResult;
|
||||
try {
|
||||
const output = execFileSync('pdfinfo', [pdfPath], { encoding: 'utf-8' });
|
||||
const match = output.match(/^Pages:\s+(\d+)/m);
|
||||
if (match) pageCount = Number(match[1]);
|
||||
cropResult = await cropProblemsFromPdf({
|
||||
paperPdfPath: pdfPath,
|
||||
outputDir,
|
||||
year: new Date().getFullYear(),
|
||||
subjectName: 'user-upload',
|
||||
dpi: 200,
|
||||
expectedProblemCount: 30,
|
||||
});
|
||||
} catch {
|
||||
// pdfinfo 미설치 시 fallback
|
||||
throw new BadRequestException('PDF에서 문제를 분리할 수 없어. 파일을 확인해줘.');
|
||||
}
|
||||
|
||||
// pdftotext 로 텍스트 추출 → 문제 파싱 (codex CLI 불필요)
|
||||
let rawText = '';
|
||||
try {
|
||||
rawText = extractText(pdfPath, 'raw');
|
||||
} catch {
|
||||
throw new BadRequestException('PDF에서 텍스트를 추출할 수 없어. 이미지 전용 PDF는 아직 지원하지 않아.');
|
||||
// 해설지에서 정답 추출
|
||||
let answers: Array<{ number: number; answerNumber: number }> = [];
|
||||
if (answerPdfPath) {
|
||||
try {
|
||||
const answerText = extractText(answerPdfPath, 'layout');
|
||||
const answerResult = parseAnswerTable(answerText, kiceMathStrategy);
|
||||
answers = answerResult.answers;
|
||||
} catch {
|
||||
// 해설지 파싱 실패 시 무시
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseProblemPaper(rawText, kiceMathStrategy);
|
||||
const answerMap = new Map(answers.map((a) => [a.number, a.answerNumber]));
|
||||
|
||||
const problems = parsed.problems.map((p) => ({
|
||||
const problems = cropResult.problems.map((p) => ({
|
||||
number: p.number,
|
||||
bodyText: p.bodyText,
|
||||
choices: p.choices,
|
||||
imageUrl: `/uploads/user-problems/${ts}/${String(p.number).padStart(3, '0')}.png`,
|
||||
correctAnswer: answerMap.get(p.number) ?? null,
|
||||
}));
|
||||
|
||||
const totalPages =
|
||||
cropResult.problems.length > 0
|
||||
? Math.max(...cropResult.problems.map((p) => p.pageNumber))
|
||||
: 1;
|
||||
|
||||
return {
|
||||
title,
|
||||
problems,
|
||||
totalPages: pageCount,
|
||||
warnings: parsed.warnings,
|
||||
totalPages,
|
||||
warnings: cropResult.warnings,
|
||||
answersFound: answers.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -337,6 +362,8 @@ export class StudyLogsService {
|
||||
title: string;
|
||||
difficulty: number;
|
||||
bodyText?: string;
|
||||
imageUrl?: string;
|
||||
correctAnswer?: number;
|
||||
tagId?: number;
|
||||
result: StudyResult;
|
||||
selfDifficulty?: 'hard' | 'medium' | 'easy';
|
||||
@@ -365,6 +392,8 @@ export class StudyLogsService {
|
||||
result: problem.result,
|
||||
selfDifficulty: problem.selfDifficulty,
|
||||
memo: problem.bodyText || undefined,
|
||||
imageUrl: problem.imageUrl || undefined,
|
||||
correctAnswer: problem.correctAnswer || undefined,
|
||||
};
|
||||
|
||||
// tagId 검증
|
||||
@@ -642,6 +671,8 @@ export class StudyLogsService {
|
||||
baseCorrectRate: input.baseCorrectRate ?? null,
|
||||
result: input.result,
|
||||
chosenAnswer: input.chosenAnswer ?? null,
|
||||
correctAnswer: input.correctAnswer ?? null,
|
||||
imageUrl: input.imageUrl ?? null,
|
||||
memo: input.memo ?? null,
|
||||
timeSpent: input.timeSpent ?? null,
|
||||
},
|
||||
|
||||
@@ -95,6 +95,7 @@ export default function ReviewPage() {
|
||||
const [detailCache, setDetailCache] = useState<Record<number, StudyLogDetail>>({});
|
||||
const [focusMode, setFocusMode] = useState(false);
|
||||
const [lightboxOpenFor, setLightboxOpenFor] = useState<number | null>(null);
|
||||
const [answerRevealed, setAnswerRevealed] = useState<Record<number, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -680,6 +681,35 @@ export default function ReviewPage() {
|
||||
)}
|
||||
</MemoShell>
|
||||
|
||||
{(() => {
|
||||
const correctAnswer =
|
||||
currentDetail?.problem?.answerNumber ?? currentDetail?.correctAnswer ?? null;
|
||||
if (correctAnswer === null) return null;
|
||||
return (
|
||||
<AnswerRevealBlock>
|
||||
{answerRevealed[currentItem.id] ? (
|
||||
<AnswerShown>
|
||||
<AnswerLabel>정답</AnswerLabel>
|
||||
<AnswerNumber>{correctAnswer}</AnswerNumber>
|
||||
</AnswerShown>
|
||||
) : (
|
||||
<RevealButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setAnswerRevealed((prev) => ({
|
||||
...prev,
|
||||
[currentItem.id]: true,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<Icon name="check-circle" size={16} />
|
||||
정답 보기
|
||||
</RevealButton>
|
||||
)}
|
||||
</AnswerRevealBlock>
|
||||
);
|
||||
})()}
|
||||
|
||||
<InfoStrip>
|
||||
<InfoPill>
|
||||
<Icon name="clock-counter-clockwise" size={14} />
|
||||
@@ -1665,6 +1695,64 @@ const ReviewDeleteBtn = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
const AnswerRevealBlock = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 0;
|
||||
`;
|
||||
|
||||
const RevealButton = styled.button`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
padding: 10px 20px;
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: ${theme.color.textBright};
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
`;
|
||||
|
||||
const AnswerShown = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 12px;
|
||||
background: rgba(52, 211, 153, 0.1);
|
||||
border: 1px solid rgba(52, 211, 153, 0.25);
|
||||
`;
|
||||
|
||||
const AnswerLabel = styled.span`
|
||||
color: rgba(52, 211, 153, 0.8);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const AnswerNumber = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: rgba(52, 211, 153, 0.2);
|
||||
color: #34d399;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const clean = hex.replace('#', '');
|
||||
const normalized =
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import styled, { css } from 'styled-components';
|
||||
import styled from 'styled-components';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
@@ -20,16 +20,18 @@ import {
|
||||
import { api, type StudyResult, type Subject, type Tag } from '@/lib/api';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
interface OcrProblem {
|
||||
interface ParsedProblem {
|
||||
number: number;
|
||||
bodyText: string;
|
||||
choices: Record<string, string>;
|
||||
imageUrl: string;
|
||||
correctAnswer: number | null;
|
||||
}
|
||||
|
||||
interface ParseResult {
|
||||
title: string;
|
||||
problems: OcrProblem[];
|
||||
problems: ParsedProblem[];
|
||||
totalPages: number;
|
||||
warnings?: string[];
|
||||
answersFound: number;
|
||||
}
|
||||
|
||||
interface ProblemImportState {
|
||||
@@ -38,6 +40,7 @@ interface ProblemImportState {
|
||||
result: StudyResult;
|
||||
difficulty: number;
|
||||
selfDifficulty: 'hard' | 'medium' | 'easy' | '';
|
||||
correctAnswer: number | '';
|
||||
}
|
||||
|
||||
export default function ImportPdfPage() {
|
||||
@@ -52,10 +55,12 @@ function ImportPdfBody() {
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const answerFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [subjects, setSubjects] = useState<Subject[] | null>(null);
|
||||
const [subjectId, setSubjectId] = useState<number | ''>('');
|
||||
const [pdfFile, setPdfFile] = useState<File | null>(null);
|
||||
const [answerPdfFile, setAnswerPdfFile] = useState<File | null>(null);
|
||||
const [customTitle, setCustomTitle] = useState('');
|
||||
|
||||
const [parsing, setParsing] = useState(false);
|
||||
@@ -92,9 +97,17 @@ function ImportPdfBody() {
|
||||
setErr(null);
|
||||
};
|
||||
|
||||
const handleAnswerFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
setAnswerPdfFile(file);
|
||||
setParseResult(null);
|
||||
setProblemStates([]);
|
||||
setErr(null);
|
||||
};
|
||||
|
||||
const handleParse = async () => {
|
||||
if (!pdfFile) {
|
||||
setErr('PDF 파일을 선택해줘.');
|
||||
setErr('문제 PDF 파일을 선택해줘.');
|
||||
return;
|
||||
}
|
||||
if (!subjectId) {
|
||||
@@ -108,6 +121,7 @@ function ImportPdfBody() {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', pdfFile);
|
||||
if (answerPdfFile) formData.append('answerFile', answerPdfFile);
|
||||
if (customTitle) formData.append('title', customTitle);
|
||||
|
||||
const res = await api.post<ParseResult>('/study-logs/parse-pdf', formData, {
|
||||
@@ -117,14 +131,22 @@ function ImportPdfBody() {
|
||||
const result = res.data;
|
||||
setParseResult(result);
|
||||
setProblemStates(
|
||||
result.problems.map(() => ({
|
||||
result.problems.map((p) => ({
|
||||
selected: false,
|
||||
tagId: '',
|
||||
result: 'incorrect' as StudyResult,
|
||||
difficulty: 0.6,
|
||||
selfDifficulty: '',
|
||||
correctAnswer: p.correctAnswer ?? '',
|
||||
})),
|
||||
);
|
||||
|
||||
if (result.answersFound > 0) {
|
||||
showToast({
|
||||
message: `해설지에서 ${result.answersFound}개 정답을 자동으로 추출했어.`,
|
||||
variant: 'success',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setErr('PDF 분석에 실패했어. 파일을 확인해줘.');
|
||||
} finally {
|
||||
@@ -170,7 +192,8 @@ function ImportPdfBody() {
|
||||
number: problem.number,
|
||||
title: `${problem.number}번`,
|
||||
difficulty: state.difficulty,
|
||||
bodyText: problem.bodyText || undefined,
|
||||
imageUrl: problem.imageUrl || undefined,
|
||||
correctAnswer: state.correctAnswer !== '' ? state.correctAnswer : undefined,
|
||||
tagId: state.tagId || undefined,
|
||||
result: state.result,
|
||||
selfDifficulty: state.selfDifficulty || undefined,
|
||||
@@ -199,11 +222,11 @@ function ImportPdfBody() {
|
||||
<PageHeader
|
||||
eyebrow="Import"
|
||||
title="PDF 가져오기"
|
||||
subtitle="모의고사 PDF를 업로드하면 OCR로 문제를 분리해서 복습 큐에 등록해줘."
|
||||
subtitle="모의고사 PDF를 업로드하면 이미지 크롭으로 문제를 분리해서 복습 큐에 등록해줘."
|
||||
right={
|
||||
<HeaderBadge>
|
||||
<Icon name="file-pdf" size={16} />
|
||||
OCR 자동 분리
|
||||
이미지 크롭 분리
|
||||
</HeaderBadge>
|
||||
}
|
||||
/>
|
||||
@@ -243,31 +266,69 @@ function ImportPdfBody() {
|
||||
</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>
|
||||
<UploadGrid>
|
||||
<UploadBlock>
|
||||
<UploadBlockLabel>문제 PDF (필수)</UploadBlockLabel>
|
||||
<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={28} weight="duotone" />
|
||||
<UploadFileName>{pdfFile.name}</UploadFileName>
|
||||
<HelpText>클릭해서 변경</HelpText>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="file-pdf" size={28} weight="regular" />
|
||||
<UploadLabel>문제 PDF 선택</UploadLabel>
|
||||
<HelpText>최대 50MB · PDF만 가능</HelpText>
|
||||
</>
|
||||
)}
|
||||
</UploadArea>
|
||||
</UploadBlock>
|
||||
|
||||
<UploadBlock>
|
||||
<UploadBlockLabel>
|
||||
해설지 PDF (선택)
|
||||
<OptionalBadge>정답 자동 추출</OptionalBadge>
|
||||
</UploadBlockLabel>
|
||||
<UploadArea
|
||||
$hasFile={!!answerPdfFile}
|
||||
$isOptional={!answerPdfFile}
|
||||
onClick={() => answerFileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={answerFileInputRef}
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleAnswerFileChange}
|
||||
/>
|
||||
{answerPdfFile ? (
|
||||
<>
|
||||
<Icon name="file-pdf" size={28} weight="duotone" />
|
||||
<UploadFileName>{answerPdfFile.name}</UploadFileName>
|
||||
<HelpText>클릭해서 변경</HelpText>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="file-pdf" size={28} weight="regular" />
|
||||
<UploadLabel>해설지 PDF 선택</UploadLabel>
|
||||
<HelpText>없으면 수동으로 정답 입력 가능</HelpText>
|
||||
</>
|
||||
)}
|
||||
</UploadArea>
|
||||
</UploadBlock>
|
||||
</UploadGrid>
|
||||
|
||||
{err && !parseResult && (
|
||||
<ErrorBox role="alert">
|
||||
@@ -307,10 +368,17 @@ function ImportPdfBody() {
|
||||
</SelectAllRow>
|
||||
</SectionTitleRow>
|
||||
|
||||
{parseResult.warnings && parseResult.warnings.length > 0 && (
|
||||
<WarningBox>
|
||||
<Icon name="info" size={16} weight="bold" />
|
||||
<span>{parseResult.warnings.join(' / ')}</span>
|
||||
</WarningBox>
|
||||
)}
|
||||
|
||||
{parseResult.problems.length === 0 ? (
|
||||
<EmptyResult>
|
||||
<Icon name="info" size={24} />
|
||||
<span>감지된 문제가 없어. OCR이 내용을 읽지 못했을 수 있어.</span>
|
||||
<span>감지된 문제가 없어. 문제 번호가 포함된 PDF인지 확인해줘.</span>
|
||||
</EmptyResult>
|
||||
) : (
|
||||
parseResult.problems.map((problem, index) => {
|
||||
@@ -327,9 +395,13 @@ function ImportPdfBody() {
|
||||
/>
|
||||
<ProblemContent>
|
||||
<ProblemNumber>{problem.number}번</ProblemNumber>
|
||||
{problem.bodyText && (
|
||||
<ProblemPreview>{problem.bodyText.slice(0, 80)}...</ProblemPreview>
|
||||
)}
|
||||
<ProblemImageWrap>
|
||||
<ProblemImage
|
||||
src={problem.imageUrl}
|
||||
alt={`${problem.number}번 문제`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</ProblemImageWrap>
|
||||
</ProblemContent>
|
||||
<ProblemControls>
|
||||
<MiniField>
|
||||
@@ -387,6 +459,31 @@ function ImportPdfBody() {
|
||||
<option value="easy">쉬움</option>
|
||||
</MiniSelect>
|
||||
</MiniField>
|
||||
|
||||
<MiniField>
|
||||
<MiniLabel>
|
||||
정답
|
||||
{problem.correctAnswer !== null && (
|
||||
<AutoBadge>자동</AutoBadge>
|
||||
)}
|
||||
</MiniLabel>
|
||||
<MiniSelect
|
||||
value={state.correctAnswer}
|
||||
onChange={(e) =>
|
||||
updateProblemState(index, {
|
||||
correctAnswer:
|
||||
e.target.value === '' ? '' : Number(e.target.value),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">미지정</option>
|
||||
<option value={1}>1</option>
|
||||
<option value={2}>2</option>
|
||||
<option value={3}>3</option>
|
||||
<option value={4}>4</option>
|
||||
<option value={5}>5</option>
|
||||
</MiniSelect>
|
||||
</MiniField>
|
||||
</ProblemControls>
|
||||
</ProblemRow>
|
||||
);
|
||||
@@ -475,6 +572,41 @@ const RowGrid = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const UploadGrid = 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 UploadBlock = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const UploadBlockLabel = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${theme.color.textBright};
|
||||
`;
|
||||
|
||||
const OptionalBadge = styled.span`
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: ${theme.radius.pill};
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
color: #10b981;
|
||||
`;
|
||||
|
||||
const Field = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -507,17 +639,21 @@ const TitleInput = styled.input`
|
||||
}
|
||||
`;
|
||||
|
||||
const UploadArea = styled.div<{ $hasFile: boolean }>`
|
||||
const UploadArea = styled.div<{ $hasFile: boolean; $isOptional?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 160px;
|
||||
min-height: 130px;
|
||||
border-radius: ${theme.radius.lg};
|
||||
border: 2px dashed
|
||||
${({ $hasFile }) =>
|
||||
$hasFile ? theme.color.brandIndigo : theme.color.borderSoftAlpha};
|
||||
${({ $hasFile, $isOptional }) =>
|
||||
$hasFile
|
||||
? theme.color.brandIndigo
|
||||
: $isOptional
|
||||
? 'rgba(255,255,255,0.1)'
|
||||
: theme.color.borderSoftAlpha};
|
||||
background: ${({ $hasFile }) =>
|
||||
$hasFile ? 'rgba(79, 70, 229, 0.06)' : 'rgba(255, 255, 255, 0.02)'};
|
||||
color: ${theme.color.textSub};
|
||||
@@ -533,16 +669,16 @@ const UploadArea = styled.div<{ $hasFile: boolean }>`
|
||||
`;
|
||||
|
||||
const UploadLabel = styled.span`
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${theme.color.textBright};
|
||||
`;
|
||||
|
||||
const UploadFileName = styled.span`
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${theme.color.textBright};
|
||||
max-width: 300px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -560,6 +696,18 @@ const ErrorBox = styled.div`
|
||||
color: ${theme.color.danger};
|
||||
`;
|
||||
|
||||
const WarningBox = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: ${theme.radius.md};
|
||||
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
color: #f59e0b;
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const ButtonRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -627,7 +775,7 @@ const ProblemContent = styled.div`
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const ProblemNumber = styled.span`
|
||||
@@ -636,36 +784,53 @@ const ProblemNumber = styled.span`
|
||||
color: ${theme.color.textBright};
|
||||
`;
|
||||
|
||||
const ProblemPreview = styled.p`
|
||||
font-size: 12px;
|
||||
color: ${theme.color.textSub};
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
const ProblemImageWrap = styled.div`
|
||||
border-radius: ${theme.radius.md};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
background: #fff;
|
||||
max-width: 480px;
|
||||
`;
|
||||
|
||||
const ProblemImage = styled.img`
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
`;
|
||||
|
||||
const ProblemControls = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
min-width: 110px;
|
||||
`;
|
||||
|
||||
const MiniField = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 90px;
|
||||
`;
|
||||
|
||||
const MiniLabel = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: ${theme.color.textSub};
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const AutoBadge = styled.span`
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: ${theme.radius.pill};
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
color: #10b981;
|
||||
`;
|
||||
|
||||
const MiniSelect = styled.select`
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
|
||||
@@ -294,6 +294,7 @@ export interface StudyLog {
|
||||
baseCorrectRate: number | null;
|
||||
result: StudyResult;
|
||||
chosenAnswer?: number | null;
|
||||
correctAnswer?: number | null;
|
||||
memo: string | null;
|
||||
studiedAt: string;
|
||||
timeSpent: number | null;
|
||||
@@ -315,6 +316,7 @@ export interface StudyLogDetail {
|
||||
studiedAt: string;
|
||||
result: StudyResult;
|
||||
chosenAnswer: number | null;
|
||||
correctAnswer: number | null;
|
||||
memo: string | null;
|
||||
timeSpent: number | null;
|
||||
difficulty: number;
|
||||
|
||||
Reference in New Issue
Block a user