feat: 업로드 문제집 전용 뷰어 페이지 (/exams/uploaded/[id])

이미지 기반 문제 뷰어: 필기모드, 5지선다 답안, 정답확인, 메모,
난이도 자기평가→복습큐 등록. 기존 sample-ebook은 텍스트 기반이라
별도 페이지 필요.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
reloop
2026-04-17 10:33:47 +09:00
parent 683e41be72
commit 2467c6c8ac
3 changed files with 648 additions and 2 deletions

View File

@@ -769,7 +769,7 @@ function MyProblemSetCard({
<Button
type="button"
$variant="primary"
onClick={() => router.push(`/exams/sample-ebook/${ps.id}`)}
onClick={() => router.push(`/exams/uploaded/${ps.id}`)}
>
</Button>

View File

@@ -0,0 +1,646 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import styled, { css } from 'styled-components';
import AppShell from '@/components/layout/AppShell';
import DrawingCanvas, { PEN_COLORS, type PenColor } from '@/components/ui/DrawingCanvas';
import { Icon } from '@/components/ui/Icon';
import { useToast } from '@/components/ui/Toast';
import { Badge, Button, Card } from '@/components/ui/primitives';
import {
api,
getProblemSet,
resolveUploadUrl,
submitProblemSetStudy,
type Problem,
type ProblemSetDetail,
type Subject,
type StudyLog,
} from '@/lib/api';
import { theme } from '@/styles/theme';
type SelfDifficulty = 'hard' | 'medium' | 'easy';
const INITIAL_INTERVAL_DAYS: Record<SelfDifficulty, number> = {
hard: 1,
medium: 3,
easy: 7,
};
export default function UploadedEbookPage() {
return (
<AppShell>
<UploadedEbookBody />
</AppShell>
);
}
function UploadedEbookBody() {
const params = useParams();
const router = useRouter();
const { showToast } = useToast();
const id = Number(params.id);
const [problemSet, setProblemSet] = useState<ProblemSetDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
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 [drawMode, setDrawMode] = useState(false);
const [penColor, setPenColor] = useState<PenColor>('#ef4444');
const [clearSignal, setClearSignal] = useState(0);
// 메모
const [memos, setMemos] = useState<Record<number, string>>({});
const [memoOpen, setMemoOpen] = useState(false);
// 자기 평가
const [mathSubjectId, setMathSubjectId] = useState<number | null | undefined>(undefined);
const [registeredProblems, setRegisteredProblems] = useState<Set<number>>(new Set());
const [registeringProblem, setRegisteringProblem] = useState<number | null>(null);
const [registeredDates, setRegisteredDates] = useState<Record<number, Date>>({});
useEffect(() => {
if (!Number.isFinite(id)) {
setError('잘못된 문제집 ID');
setLoading(false);
return;
}
getProblemSet(id)
.then((ps) => {
setProblemSet(ps);
// localStorage에서 상태 복원
try {
const saved = localStorage.getItem(`reloop-uploaded-${id}-answers`);
if (saved) setSelectedAnswers(JSON.parse(saved));
const savedR = localStorage.getItem(`reloop-uploaded-${id}-revealed`);
if (savedR) setRevealed(JSON.parse(savedR));
const savedP = localStorage.getItem(`reloop-uploaded-${id}-page`);
if (savedP) setPageIndex(parseInt(savedP, 10));
const savedM = localStorage.getItem(`reloop-uploaded-${id}-memos`);
if (savedM) setMemos(JSON.parse(savedM));
} catch { /* ignore */ }
})
.catch(() => setError('문제집을 불러오지 못했어.'))
.finally(() => setLoading(false));
}, [id]);
// 수학 subject ID 조회
useEffect(() => {
api.get<Subject[]>('/subjects').then((res) => {
const math = res.data.find((s) => s.name === '수학');
setMathSubjectId(math?.id ?? null);
}).catch(() => setMathSubjectId(null));
}, []);
// 기존 복습 등록 상태 복원
useEffect(() => {
if (!problemSet) return;
const prefix = `${problemSet.title}`;
api.get<StudyLog[]>('/study-logs', { params: { limit: 200 } })
.then((res) => {
const registered = new Set<number>();
for (const log of res.data) {
if (log.title.includes(prefix)) {
const match = log.title.match(/(\d+)번$/);
if (match) registered.add(parseInt(match[1], 10));
}
}
setRegisteredProblems(registered);
}).catch(() => {});
}, [problemSet]);
// localStorage 동기화
useEffect(() => {
if (!problemSet) return;
localStorage.setItem(`reloop-uploaded-${id}-answers`, JSON.stringify(selectedAnswers));
}, [id, selectedAnswers, problemSet]);
useEffect(() => {
if (!problemSet) return;
localStorage.setItem(`reloop-uploaded-${id}-revealed`, JSON.stringify(revealed));
}, [id, revealed, problemSet]);
useEffect(() => {
if (!problemSet) return;
localStorage.setItem(`reloop-uploaded-${id}-page`, String(pageIndex));
}, [id, pageIndex, problemSet]);
useEffect(() => {
if (!problemSet) return;
localStorage.setItem(`reloop-uploaded-${id}-memos`, JSON.stringify(memos));
}, [id, memos, problemSet]);
// 필기모드 스크롤 방지
useEffect(() => {
if (drawMode) document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, [drawMode]);
const problems = problemSet?.problems ?? [];
const total = problems.length;
const problem = problems[pageIndex] as Problem | undefined;
const goTo = useCallback((index: number) => {
if (index < 0 || index >= total || fading) return;
setFading(true);
setMemoOpen(false);
setTimeout(() => { setPageIndex(index); setFading(false); }, 160);
}, [total, fading]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') goTo(pageIndex - 1);
if (e.key === 'ArrowRight') goTo(pageIndex + 1);
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [pageIndex, goTo]);
const handleSelfDifficulty = useCallback(async (sd: SelfDifficulty) => {
if (!mathSubjectId || !problem || registeredProblems.has(problem.number) || registeringProblem !== null) return;
setRegisteringProblem(problem.number);
try {
const chosen = selectedAnswers[problem.number];
const isCorrect = chosen !== undefined && problem.answerNumber !== null && chosen === problem.answerNumber;
const res = await api.post<{ studyLog: { id: number }; nextReview: { scheduledAt: string } }>('/study-logs', {
subjectId: mathSubjectId,
problemId: problem.id,
title: `${problemSet!.title} ${problem.number}`,
difficulty: 0.5,
result: isCorrect ? 'correct' : 'incorrect',
selfDifficulty: sd,
chosenAnswer: chosen ?? undefined,
memo: memos[problem.number] || undefined,
});
const scheduledAt = new Date(res.data.nextReview.scheduledAt);
setRegisteredProblems((prev) => new Set(prev).add(problem.number));
setRegisteredDates((prev) => ({ ...prev, [problem.number]: scheduledAt }));
} catch {
showToast({ message: '복습 등록에 실패했어.', variant: 'danger' });
} finally {
setRegisteringProblem(null);
}
}, [mathSubjectId, problem, problemSet, registeredProblems, registeringProblem, selectedAnswers, memos, showToast]);
if (loading) return <StateBox> ...</StateBox>;
if (error || !problemSet || !problem) {
return (
<StateBox>
<Icon name="info" size={18} />
{error ?? '문제를 찾을 수 없어.'}
<Button $variant="ghost" onClick={() => router.push('/exams')}></Button>
</StateBox>
);
}
const imageUrl = resolveUploadUrl(problem.imageUrl);
const chosen = selectedAnswers[problem.number];
const isRevealed = revealed[problem.number] ?? false;
const isCorrect = chosen !== undefined && problem.answerNumber !== null && chosen === problem.answerNumber;
const alreadyRegistered = registeredProblems.has(problem.number);
const scheduledDate = registeredDates[problem.number];
const solvedCount = Object.keys(revealed).filter((k) => revealed[parseInt(k, 10)]).length;
return (
<Wrap>
<Header>
<BackLink href="/exams">
<Icon name="arrow-left" size={16} />
</BackLink>
<HeaderTitle>{problemSet.title}</HeaderTitle>
</Header>
{/* 필기 도구바 */}
<Toolbar>
<ToolBtn type="button" $active={drawMode} onClick={() => setDrawMode((p) => !p)}>
<Icon name="pencil-simple" size={15} weight={drawMode ? 'fill' : 'regular'} />
{drawMode ? '필기 중' : '필기'}
</ToolBtn>
{drawMode && (
<>
{PEN_COLORS.map((c) => (
<PenDot key={c.value} type="button" $color={c.value} $active={penColor === c.value} onClick={() => setPenColor(c.value)} />
))}
<ToolBtn type="button" $active={false} onClick={() => setClearSignal((s) => s + 1)}>
<Icon name="eraser" size={14} />
</ToolBtn>
</>
)}
</Toolbar>
{/* 문제 이미지 */}
<ProblemArea $fading={fading}>
<ProblemLabel>{problem.number}</ProblemLabel>
{imageUrl ? (
<ImageWrap>
<ProblemImg src={imageUrl} alt={`${problem.number}번 문제`} />
<DrawingCanvas
storageKey={`reloop-uploaded-${id}-draw-${problem.number}`}
visible={drawMode}
active={drawMode}
penColor={penColor}
clearSignal={clearSignal}
/>
</ImageWrap>
) : (
<NoImage> </NoImage>
)}
</ProblemArea>
{/* 답안 선택 (5지선다) */}
<AnswerSection>
<ChoiceRow>
{[1, 2, 3, 4, 5].map((n) => (
<ChoiceBtn
key={n}
type="button"
$selected={chosen === n}
$correct={isRevealed && problem.answerNumber === n}
$wrong={isRevealed && chosen === n && problem.answerNumber !== n}
disabled={isRevealed}
onClick={() => setSelectedAnswers((prev) => ({ ...prev, [problem.number]: n }))}
>
{n}
</ChoiceBtn>
))}
</ChoiceRow>
{!isRevealed ? (
<RevealRow>
<span>{chosen !== undefined ? `내 답: ${chosen}` : '답을 선택해줘'}</span>
<Button
type="button"
$variant="secondary"
$size="sm"
disabled={chosen === undefined}
onClick={() => setRevealed((prev) => ({ ...prev, [problem.number]: true }))}
>
</Button>
</RevealRow>
) : (
<ResultBox $correct={isCorrect}>
<Icon name={isCorrect ? 'check-circle' : 'x'} size={18} weight="fill" />
{isCorrect ? '정답!' : `오답 (정답: ${problem.answerNumber ?? '미등록'})`}
</ResultBox>
)}
</AnswerSection>
{/* 메모 */}
<MemoSection>
<MemoToggle type="button" onClick={() => setMemoOpen((p) => !p)}>
<Icon name="pencil-simple" size={14} />
<Icon name={memoOpen ? 'caret-up' : 'caret-down'} size={12} weight="bold" />
</MemoToggle>
{memoOpen && (
<MemoArea
value={memos[problem.number] ?? ''}
onChange={(e) => setMemos((prev) => ({ ...prev, [problem.number]: e.target.value }))}
placeholder="풀이 전략이나 실수 포인트를 적어둬."
/>
)}
</MemoSection>
{/* 자기 평가 */}
{isRevealed && mathSubjectId !== undefined && (
<EvalSection>
{mathSubjectId === null ? (
<EvalNote><Icon name="info" size={14} /> '수학' .</EvalNote>
) : alreadyRegistered ? (
<EvalDone>
<Icon name="check-circle" size={15} />
!{' '}
{scheduledDate && <strong>{scheduledDate.getMonth() + 1} {scheduledDate.getDate()} .</strong>}
</EvalDone>
) : (
<>
<EvalTitle> ?</EvalTitle>
<EvalBtns>
{(['hard', 'medium', 'easy'] as SelfDifficulty[]).map((sd) => (
<EvalBtn
key={sd}
type="button"
$tone={sd}
disabled={registeringProblem !== null}
onClick={() => void handleSelfDifficulty(sd)}
>
<span>{sd === 'hard' ? '🔴' : sd === 'medium' ? '🟡' : '🟢'}</span>
<EvalLabel>{sd === 'hard' ? '어려웠어' : sd === 'medium' ? '괜찮았어' : '쉬웠어'}</EvalLabel>
<EvalHint>{INITIAL_INTERVAL_DAYS[sd]} </EvalHint>
</EvalBtn>
))}
</EvalBtns>
</>
)}
</EvalSection>
)}
{/* 네비게이션 */}
<Nav>
<NavBtn type="button" disabled={pageIndex === 0} onClick={() => goTo(pageIndex - 1)}>
<Icon name="caret-left" size={16} />
</NavBtn>
<NavCenter>
<NavPage>{pageIndex + 1} / {total}</NavPage>
<NavProgress> {solvedCount}/{total} · {registeredProblems.size}/{total}</NavProgress>
</NavCenter>
<NavBtn type="button" disabled={pageIndex === total - 1} onClick={() => goTo(pageIndex + 1)}>
<Icon name="caret-right" size={16} />
</NavBtn>
</Nav>
</Wrap>
);
}
// ─── Styled Components ────────────────────────────────────────────────
const Wrap = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
const Header = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const BackLink = styled(Link)`
display: inline-flex;
align-items: center;
gap: 6px;
color: ${theme.color.textSub};
font-size: 13px;
&:hover { color: ${theme.color.textBright}; }
`;
const HeaderTitle = styled.h1`
font-size: 24px;
font-weight: 700;
color: ${theme.color.textBright};
`;
const Toolbar = styled.div`
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
`;
const ToolBtn = styled.button<{ $active: boolean }>`
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid ${({ $active }) => ($active ? 'rgba(129,140,248,0.5)' : theme.color.borderSoftAlpha)};
background: ${({ $active }) => ($active ? 'rgba(79,70,229,0.15)' : 'rgba(255,255,255,0.03)')};
color: ${({ $active }) => ($active ? '#a5b4fc' : theme.color.textSub)};
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
&:hover { background: rgba(79,70,229,0.12); color: ${theme.color.textBright}; }
`;
const PenDot = styled.button<{ $color: string; $active: boolean }>`
width: 24px; height: 24px; border-radius: 50%;
border: 2px solid ${({ $active }) => ($active ? '#fff' : 'transparent')};
background: ${({ $color }) => $color}; cursor: pointer;
box-shadow: ${({ $active }) => ($active ? '0 0 0 2px rgba(255,255,255,0.3)' : 'none')};
`;
const ProblemArea = styled.div<{ $fading: boolean }>`
display: flex;
flex-direction: column;
gap: 8px;
transition: opacity 0.15s;
opacity: ${({ $fading }) => ($fading ? 0.3 : 1)};
`;
const ProblemLabel = styled.div`
font-size: 16px;
font-weight: 700;
color: ${theme.color.textBright};
`;
const ImageWrap = styled.div`
position: relative;
border-radius: ${theme.radius.lg};
overflow: hidden;
border: 1px solid ${theme.color.borderSoftAlpha};
background: #fff;
`;
const ProblemImg = styled.img`
display: block;
width: 100%;
height: auto;
object-fit: contain;
`;
const NoImage = styled.div`
padding: 60px;
text-align: center;
color: ${theme.color.textSub};
background: rgba(255,255,255,0.03);
border-radius: ${theme.radius.lg};
border: 1px solid ${theme.color.borderSoftAlpha};
`;
const AnswerSection = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`;
const ChoiceRow = styled.div`
display: flex;
gap: 10px;
justify-content: center;
`;
const ChoiceBtn = styled.button<{ $selected: boolean; $correct: boolean; $wrong: boolean }>`
width: 48px; height: 48px;
border-radius: 50%;
font-size: 18px; font-weight: 700;
cursor: pointer;
transition: all 0.15s;
border: 2px solid ${({ $selected, $correct, $wrong }) =>
$correct ? '#10b981' : $wrong ? '#f43f5e' : $selected ? theme.color.brandIndigo : theme.color.borderSoftAlpha};
background: ${({ $selected, $correct, $wrong }) =>
$correct ? 'rgba(16,185,129,0.15)' : $wrong ? 'rgba(244,63,94,0.12)' : $selected ? 'rgba(79,70,229,0.15)' : 'rgba(255,255,255,0.03)'};
color: ${({ $correct, $wrong }) =>
$correct ? '#10b981' : $wrong ? '#f43f5e' : theme.color.textBright};
&:disabled { cursor: default; }
&:not(:disabled):hover { border-color: ${theme.color.brandIndigo}; background: rgba(79,70,229,0.1); }
`;
const RevealRow = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
color: ${theme.color.textSub};
font-size: 13px;
`;
const ResultBox = styled.div<{ $correct: boolean }>`
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-radius: 12px;
font-weight: 700;
border: 1px solid ${({ $correct }) => ($correct ? 'rgba(16,185,129,0.4)' : 'rgba(244,63,94,0.4)')};
background: ${({ $correct }) => ($correct ? 'rgba(16,185,129,0.1)' : 'rgba(244,63,94,0.08)')};
color: ${({ $correct }) => ($correct ? '#10b981' : '#f43f5e')};
`;
const MemoSection = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const MemoToggle = styled.button`
display: inline-flex;
align-items: center;
gap: 6px;
background: none; border: none;
color: ${theme.color.textSub};
font-size: 13px; font-weight: 600;
cursor: pointer; padding: 4px 0;
&:hover { color: ${theme.color.textBright}; }
`;
const MemoArea = styled.textarea`
width: 100%; min-height: 80px;
padding: 10px 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: 13px; resize: vertical; outline: none;
&:focus { border-color: ${theme.color.brandIndigo}; }
&::placeholder { color: ${theme.color.textSub}; }
`;
const EvalSection = styled.div`
padding: 16px;
border-radius: ${theme.radius.lg};
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255,255,255,0.02);
`;
const EvalNote = styled.div`
display: flex; align-items: center; gap: 8px;
color: ${theme.color.textSub}; font-size: 13px;
`;
const EvalDone = styled.div`
display: flex; align-items: center; gap: 8px;
color: #10b981; font-size: 14px; font-weight: 600;
`;
const EvalTitle = styled.div`
font-size: 14px; font-weight: 700;
color: ${theme.color.textBright};
margin-bottom: 10px;
`;
const EvalBtns = styled.div`
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
`;
const EvalBtn = styled.button<{ $tone: SelfDifficulty }>`
display: flex; flex-direction: column;
align-items: center; gap: 4px;
padding: 12px 8px;
border-radius: 12px;
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255,255,255,0.03);
cursor: pointer;
transition: all 0.15s;
&:hover {
border-color: ${({ $tone }) =>
$tone === 'hard' ? 'rgba(239,68,68,0.5)' : $tone === 'medium' ? 'rgba(245,158,11,0.5)' : 'rgba(16,185,129,0.5)'};
background: ${({ $tone }) =>
$tone === 'hard' ? 'rgba(239,68,68,0.08)' : $tone === 'medium' ? 'rgba(245,158,11,0.08)' : 'rgba(16,185,129,0.08)'};
}
&:disabled { opacity: 0.5; cursor: not-allowed; }
`;
const EvalLabel = styled.span`
font-size: 13px; font-weight: 700;
color: ${theme.color.textBright};
`;
const EvalHint = styled.span`
font-size: 11px;
color: ${theme.color.textSub};
`;
const Nav = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
border-top: 1px solid ${theme.color.borderSoftAlpha};
`;
const NavBtn = styled.button`
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 10px;
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255,255,255,0.03);
color: ${theme.color.textSub};
font-size: 13px; font-weight: 600;
cursor: pointer;
transition: all 0.15s;
&:hover:not(:disabled) { background: rgba(255,255,255,0.06); color: ${theme.color.textBright}; }
&:disabled { opacity: 0.3; cursor: not-allowed; }
`;
const NavCenter = styled.div`
display: flex; flex-direction: column;
align-items: center; gap: 4px;
`;
const NavPage = styled.span`
font-size: 14px; font-weight: 700;
color: ${theme.color.textBright};
font-family: ${theme.font.mono};
`;
const NavProgress = styled.span`
font-size: 11px;
color: ${theme.color.textSub};
`;
const StateBox = styled.div`
min-height: 40vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: ${theme.color.textSub};
`;

View File

@@ -164,7 +164,7 @@ function ImportPdfBody() {
message: `"${result.problemSet.title}" 문제집이 만들어졌어! (${selectedProblems.length}문제)`,
variant: 'success',
});
router.push(`/exams/sample-ebook/${result.problemSet.id}`);
router.push(`/exams/uploaded/${result.problemSet.id}`);
} catch {
setErr('문제집 생성에 실패했어. 다시 시도해줘.');
} finally {