feat(ebook): 풀이 진행상황 유지 + 처음부터 다시 풀기 + 복습 등록 복원

- 마운트 시 GET /study-logs 로 이미 등록된 문제 복원 (registeredProblems)
- selectedAnswers/revealed/pageIndex localStorage 영속화
- '처음부터 다시 풀기' 버튼 (복습 등록은 유지, 풀이만 초기화)
- 진행률 표시: 풀이 N/30 · 복습 등록 N/30
- title 형식에 문제 번호 포함 ([grade] #N question)
This commit is contained in:
reloop
2026-04-16 13:29:27 +09:00
parent 3070f3308a
commit cf16243e96

View File

@@ -9,7 +9,7 @@ import AppShell from '@/components/layout/AppShell';
import { Icon } from '@/components/ui/Icon';
import { Badge, Button, Card } from '@/components/ui/primitives';
import { theme } from '@/styles/theme';
import { api, type Subject } from '@/lib/api';
import { api, type Subject, type StudyLog } from '@/lib/api';
import { EBOOK_MAP } from '../data/index';
import type { SampleProblem } from '../data/types';
@@ -41,6 +41,7 @@ export default function SampleEbookDynamicPage() {
return (
<AppShell>
<EbookViewer
id={id}
title={ebook.title}
grade={ebook.grade}
problems={ebook.problems}
@@ -52,15 +53,39 @@ export default function SampleEbookDynamicPage() {
// ─── 뷰어 ─────────────────────────────────────────────────────────────────────
interface EbookViewerProps {
id: string;
title: string;
grade: string;
problems: SampleProblem[];
}
function EbookViewer({ title, grade, problems }: EbookViewerProps) {
const [pageIndex, setPageIndex] = useState(0);
const [selectedAnswers, setSelectedAnswers] = useState<Record<number, number>>({});
const [revealed, setRevealed] = useState<Record<number, boolean>>({});
function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
const storageKey = `reloop-ebook-${id}`;
const [pageIndex, setPageIndex] = useState<number>(() => {
if (typeof window === 'undefined') return 0;
try {
const saved = localStorage.getItem(`${storageKey}-page`);
return saved ? parseInt(saved, 10) : 0;
} catch { return 0; }
});
const [selectedAnswers, setSelectedAnswers] = useState<Record<number, number>>(() => {
if (typeof window === 'undefined') return {};
try {
const saved = localStorage.getItem(`${storageKey}-answers`);
return saved ? (JSON.parse(saved) as Record<number, number>) : {};
} catch { return {}; }
});
const [revealed, setRevealed] = useState<Record<number, boolean>>(() => {
if (typeof window === 'undefined') return {};
try {
const saved = localStorage.getItem(`${storageKey}-revealed`);
return saved ? (JSON.parse(saved) as Record<number, boolean>) : {};
} catch { return {}; }
});
const [fading, setFading] = useState(false);
// 자기 평가 관련 state
@@ -72,6 +97,19 @@ function EbookViewer({ title, grade, problems }: EbookViewerProps) {
const total = problems.length;
const problem = problems[pageIndex];
// localStorage 동기화
useEffect(() => {
localStorage.setItem(`${storageKey}-answers`, JSON.stringify(selectedAnswers));
}, [storageKey, selectedAnswers]);
useEffect(() => {
localStorage.setItem(`${storageKey}-revealed`, JSON.stringify(revealed));
}, [storageKey, revealed]);
useEffect(() => {
localStorage.setItem(`${storageKey}-page`, String(pageIndex));
}, [storageKey, pageIndex]);
// 마운트 시 수학 subject ID 조회
useEffect(() => {
api.get<Subject[]>('/subjects').then((res) => {
@@ -82,6 +120,25 @@ function EbookViewer({ title, grade, problems }: EbookViewerProps) {
});
}, []);
// 마운트 시 기존 복습 등록 상태 복원
useEffect(() => {
const prefix = `[${grade}]`;
api.get<StudyLog[]>('/study-logs', { params: { limit: 200 } })
.then((res) => {
const registered = new Set<number>();
for (const log of res.data) {
if (log.title.startsWith(prefix)) {
const match = log.title.match(/^\[.+?\]\s*#(\d+)/);
if (match) registered.add(parseInt(match[1], 10));
}
}
setRegisteredProblems(registered);
})
.catch(() => {
// 실패해도 빈 Set 유지
});
}, [grade]);
const goTo = useCallback(
(index: number) => {
if (index < 0 || index >= total || fading) return;
@@ -124,7 +181,7 @@ function EbookViewer({ title, grade, problems }: EbookViewerProps) {
try {
const res = await api.post<{ nextReview: { scheduledAt: string } }>('/study-logs', {
subjectId: mathSubjectId,
title: `[${grade}] ${problem.question.slice(0, 50)}`,
title: `[${grade}] #${problem.number} ${problem.question.slice(0, 40)}`,
difficulty: DIFFICULTY_SCORE[problem.difficulty],
result: isCorrect ? 'correct' : 'incorrect',
selfDifficulty: sd,
@@ -141,6 +198,17 @@ function EbookViewer({ title, grade, problems }: EbookViewerProps) {
}
}, [mathSubjectId, registeredProblems, registeringProblem, problem, grade, isCorrect, chosen]);
const handleReset = () => {
if (!window.confirm('모든 풀이 기록을 초기화할까? 복습 등록은 유지돼.')) return;
setSelectedAnswers({});
setRevealed({});
setPageIndex(0);
localStorage.removeItem(`${storageKey}-answers`);
localStorage.removeItem(`${storageKey}-revealed`);
localStorage.removeItem(`${storageKey}-page`);
};
const solvedCount = Object.keys(revealed).filter((k) => revealed[parseInt(k, 10)]).length;
const alreadyRegistered = registeredProblems.has(problem.number);
const scheduledDate = registeredDates[problem.number];
@@ -153,6 +221,15 @@ function EbookViewer({ title, grade, problems }: EbookViewerProps) {
</BackLink>
<HeaderRight>
<ResetButton
type="button"
$variant="ghost"
$size="sm"
onClick={handleReset}
>
<Icon name="clock-counter-clockwise" size={13} />
</ResetButton>
<PreviewBadge $variant="default">
<Icon name="book-open-text" size={11} />
@@ -322,9 +399,14 @@ function EbookViewer({ title, grade, problems }: EbookViewerProps) {
<Icon name="caret-left" size={16} />
</NavButton>
<PageIndicator>
{pageIndex + 1} / {total}
</PageIndicator>
<PageNavCenter>
<PageIndicator>
{pageIndex + 1} / {total}
</PageIndicator>
<ProgressText>
{solvedCount}/{total} · {registeredProblems.size}/{total}
</ProgressText>
</PageNavCenter>
<NavButton
type="button"
$variant="ghost"
@@ -406,6 +488,16 @@ const BackLink = styled(Link)`
const HeaderRight = styled.div`
display: flex;
align-items: center;
gap: ${theme.space.sm};
`;
const ResetButton = styled(Button)`
color: ${theme.color.textMute};
font-size: 12px;
&:hover {
color: ${theme.color.textSub};
}
`;
const PreviewBadge = styled(Badge)`
@@ -758,6 +850,13 @@ const NavButton = styled(Button)`
min-width: 110px;
`;
const PageNavCenter = styled.div`
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
`;
const PageIndicator = styled.span`
font-size: 15px;
font-weight: 600;
@@ -766,6 +865,13 @@ const PageIndicator = styled.span`
text-align: center;
`;
const ProgressText = styled.span`
font-size: 11px;
color: ${theme.color.textMute};
text-align: center;
white-space: nowrap;
`;
const InfoBox = styled(Card)`
display: flex;
align-items: flex-start;