feat: 업로드 문제집 전용 뷰어 페이지 (/exams/uploaded/[id])
이미지 기반 문제 뷰어: 필기모드, 5지선다 답안, 정답확인, 메모, 난이도 자기평가→복습큐 등록. 기존 sample-ebook은 텍스트 기반이라 별도 페이지 필요. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
646
frontend/src/app/exams/uploaded/[id]/page.tsx
Normal file
646
frontend/src/app/exams/uploaded/[id]/page.tsx
Normal 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};
|
||||
`;
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user