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:
reloop
2026-04-17 09:35:08 +09:00
parent 8ede8efbec
commit 2b9111a3f7
8 changed files with 419 additions and 100 deletions

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE `study_logs` ADD COLUMN `correctAnswer` INTEGER NULL,
ADD COLUMN `imageUrl` VARCHAR(191) NULL;

View File

@@ -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?

View File

@@ -384,6 +384,7 @@ export class ReviewsService {
id: true,
bodyText: true,
choices: true,
answerNumber: true,
},
},
tag: {

View File

@@ -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')

View File

@@ -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,
},

View File

@@ -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 =

View File

@@ -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;

View File

@@ -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;