feat(ebook): 풀이 진행상황 유지 + 처음부터 다시 풀기 + 복습 등록 복원
- 마운트 시 GET /study-logs 로 이미 등록된 문제 복원 (registeredProblems) - selectedAnswers/revealed/pageIndex localStorage 영속화 - '처음부터 다시 풀기' 버튼 (복습 등록은 유지, 풀이만 초기화) - 진행률 표시: 풀이 N/30 · 복습 등록 N/30 - title 형식에 문제 번호 포함 ([grade] #N question)
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user