fix: auto-create subjects for review registration
This commit is contained in:
@@ -6,7 +6,9 @@ import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import styled, { css, keyframes } from 'styled-components';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import TagPrompt from '@/components/ui/TagPrompt';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { Badge, Button, Card } from '@/components/ui/primitives';
|
||||
import { theme } from '@/styles/theme';
|
||||
import { api, type Subject, type StudyLog } from '@/lib/api';
|
||||
@@ -257,6 +259,8 @@ interface EbookViewerProps {
|
||||
|
||||
function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
|
||||
const storageKey = `reloop-ebook-${id}`;
|
||||
const { showToast } = useToast();
|
||||
const recommendedSubjectName = grade;
|
||||
|
||||
const [pageIndex, setPageIndex] = useState<number>(() => {
|
||||
if (typeof window === 'undefined') return 0;
|
||||
@@ -290,10 +294,12 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
|
||||
const [clearSignal, setClearSignal] = useState(0);
|
||||
|
||||
// 자기 평가 관련 state
|
||||
const [mathSubjectId, setMathSubjectId] = useState<number | null | undefined>(undefined);
|
||||
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||||
const [selectedSubjectId, setSelectedSubjectId] = useState<number | ''>('');
|
||||
const [registeredProblems, setRegisteredProblems] = useState<Set<number>>(new Set());
|
||||
const [registeringProblem, setRegisteringProblem] = useState<number | null>(null);
|
||||
const [registeredDates, setRegisteredDates] = useState<Record<number, Date>>({});
|
||||
const [tagPromptFor, setTagPromptFor] = useState<{ studyLogId: number; subjectId: number } | null>(null);
|
||||
|
||||
const total = problems.length;
|
||||
const problem = problems[pageIndex];
|
||||
@@ -311,15 +317,20 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
|
||||
localStorage.setItem(`${storageKey}-page`, String(pageIndex));
|
||||
}, [storageKey, pageIndex]);
|
||||
|
||||
// 마운트 시 수학 subject ID 조회
|
||||
// 마운트 시 과목 조회 및 추천 과목 선택
|
||||
useEffect(() => {
|
||||
api.get<Subject[]>('/subjects').then((res) => {
|
||||
const math = res.data.find((s) => s.name === '수학');
|
||||
setMathSubjectId(math?.id ?? null);
|
||||
}).catch(() => {
|
||||
setMathSubjectId(null);
|
||||
});
|
||||
}, []);
|
||||
api
|
||||
.get<Subject[]>('/subjects')
|
||||
.then((res) => {
|
||||
setSubjects(res.data);
|
||||
const matched = res.data.find((subject) => subject.name === recommendedSubjectName);
|
||||
setSelectedSubjectId(matched?.id ?? '');
|
||||
})
|
||||
.catch(() => {
|
||||
setSubjects([]);
|
||||
setSelectedSubjectId('');
|
||||
});
|
||||
}, [recommendedSubjectName]);
|
||||
|
||||
// 마운트 시 기존 복습 등록 상태 복원
|
||||
useEffect(() => {
|
||||
@@ -387,13 +398,26 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
|
||||
const isRevealed = revealed[problem.number] ?? false;
|
||||
const isCorrect = chosen !== undefined && chosen === problem.answer;
|
||||
|
||||
const ensureSubjectId = useCallback(async () => {
|
||||
if (selectedSubjectId) return selectedSubjectId;
|
||||
|
||||
const created = await api.post<Subject>('/subjects', {
|
||||
name: recommendedSubjectName,
|
||||
color: '#6366f1',
|
||||
});
|
||||
setSubjects((prev) => [...prev, created.data]);
|
||||
setSelectedSubjectId(created.data.id);
|
||||
return created.data.id;
|
||||
}, [recommendedSubjectName, selectedSubjectId]);
|
||||
|
||||
const handleSelfDifficulty = useCallback(async (sd: SelfDifficulty) => {
|
||||
if (!mathSubjectId || registeredProblems.has(problem.number) || registeringProblem !== null) return;
|
||||
if (registeredProblems.has(problem.number) || registeringProblem !== null) return;
|
||||
|
||||
setRegisteringProblem(problem.number);
|
||||
try {
|
||||
const res = await api.post<{ nextReview: { scheduledAt: string } }>('/study-logs', {
|
||||
subjectId: mathSubjectId,
|
||||
const subjectId = await ensureSubjectId();
|
||||
const res = await api.post<{ studyLog: { id: number }; nextReview: { scheduledAt: string } }>('/study-logs', {
|
||||
subjectId,
|
||||
title: `[${grade}] #${problem.number} ${problem.question.slice(0, 40)}`,
|
||||
difficulty: DIFFICULTY_SCORE[problem.difficulty],
|
||||
result: isCorrect ? 'correct' : 'incorrect',
|
||||
@@ -404,12 +428,13 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
|
||||
const scheduledAt = new Date(res.data.nextReview.scheduledAt);
|
||||
setRegisteredProblems((prev) => new Set(prev).add(problem.number));
|
||||
setRegisteredDates((prev) => ({ ...prev, [problem.number]: scheduledAt }));
|
||||
setTagPromptFor({ studyLogId: res.data.studyLog.id, subjectId });
|
||||
} catch {
|
||||
// 등록 실패 시 조용히 무시 (서버 오류 등)
|
||||
showToast({ message: '복습 등록에 실패했어.', variant: 'danger' });
|
||||
} finally {
|
||||
setRegisteringProblem(null);
|
||||
}
|
||||
}, [mathSubjectId, registeredProblems, registeringProblem, problem, grade, isCorrect, chosen]);
|
||||
}, [ensureSubjectId, registeredProblems, registeringProblem, problem, grade, isCorrect, chosen, showToast]);
|
||||
|
||||
const handleReset = () => {
|
||||
if (!window.confirm('모든 풀이 기록을 초기화할까? 복습 등록은 유지돼.')) return;
|
||||
@@ -584,14 +609,9 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
|
||||
)}
|
||||
|
||||
{/* 자기 평가 UI */}
|
||||
{isRevealed && mathSubjectId !== undefined && (
|
||||
{isRevealed && (
|
||||
<SelfEvalBox>
|
||||
{mathSubjectId === null ? (
|
||||
<SelfEvalNoSubject>
|
||||
<Icon name="info" size={14} />
|
||||
복습 등록하려면 과목 페이지에서 먼저 '수학' 과목을 만들어줘.
|
||||
</SelfEvalNoSubject>
|
||||
) : alreadyRegistered ? (
|
||||
{alreadyRegistered ? (
|
||||
<SelfEvalRegistered>
|
||||
<Icon name="check-circle" size={15} />
|
||||
복습 등록됨!{' '}
|
||||
@@ -603,6 +623,24 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
|
||||
</SelfEvalRegistered>
|
||||
) : (
|
||||
<>
|
||||
<SubjectAssistRow>
|
||||
<SubjectAssistLabel>복습 과목</SubjectAssistLabel>
|
||||
<SubjectAssistSelect
|
||||
value={selectedSubjectId}
|
||||
onChange={(event) =>
|
||||
setSelectedSubjectId(
|
||||
event.target.value === '' ? '' : Number(event.target.value),
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">자동 생성: {recommendedSubjectName}</option>
|
||||
{subjects.map((subject) => (
|
||||
<option key={subject.id} value={subject.id}>
|
||||
{subject.name}
|
||||
</option>
|
||||
))}
|
||||
</SubjectAssistSelect>
|
||||
</SubjectAssistRow>
|
||||
<SelfEvalTitle>이 문제 어땠어?</SelfEvalTitle>
|
||||
<SelfEvalButtons>
|
||||
<SelfEvalButton
|
||||
@@ -704,6 +742,15 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
|
||||
</InfoDesc>
|
||||
</InfoContent>
|
||||
</InfoBox>
|
||||
|
||||
{tagPromptFor && (
|
||||
<TagPrompt
|
||||
studyLogId={tagPromptFor.studyLogId}
|
||||
preferredSubjectId={tagPromptFor.subjectId}
|
||||
onClose={() => setTagPromptFor(null)}
|
||||
onSaved={() => setTagPromptFor(null)}
|
||||
/>
|
||||
)}
|
||||
</ViewerWrap>
|
||||
);
|
||||
}
|
||||
@@ -1192,12 +1239,30 @@ const SelfEvalRegistered = styled.div`
|
||||
color: ${theme.color.success};
|
||||
`;
|
||||
|
||||
const SelfEvalNoSubject = styled.div`
|
||||
const SubjectAssistRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const SubjectAssistLabel = styled.span`
|
||||
font-size: 12px;
|
||||
color: ${theme.color.textMute};
|
||||
font-weight: 700;
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
const SubjectAssistSelect = styled.select`
|
||||
min-width: 180px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
// ── 네비게이션 스타일 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -63,11 +63,12 @@ function UploadedEbookBody() {
|
||||
const [memoOpen, setMemoOpen] = useState(false);
|
||||
|
||||
// 자기 평가
|
||||
const [mathSubjectId, setMathSubjectId] = useState<number | null | undefined>(undefined);
|
||||
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||||
const [selectedSubjectId, setSelectedSubjectId] = useState<number | ''>('');
|
||||
const [registeredProblems, setRegisteredProblems] = useState<Set<number>>(new Set());
|
||||
const [registeringProblem, setRegisteringProblem] = useState<number | null>(null);
|
||||
const [registeredDates, setRegisteredDates] = useState<Record<number, Date>>({});
|
||||
const [tagPromptFor, setTagPromptFor] = useState<number | null>(null);
|
||||
const [tagPromptFor, setTagPromptFor] = useState<{ studyLogId: number; subjectId: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(id)) {
|
||||
@@ -94,13 +95,21 @@ function UploadedEbookBody() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
// 수학 subject ID 조회
|
||||
// 과목 조회 및 기본 subject 선택
|
||||
useEffect(() => {
|
||||
api.get<Subject[]>('/subjects').then((res) => {
|
||||
const math = res.data.find((s) => s.name === '수학');
|
||||
setMathSubjectId(math?.id ?? null);
|
||||
}).catch(() => setMathSubjectId(null));
|
||||
}, []);
|
||||
api
|
||||
.get<Subject[]>('/subjects')
|
||||
.then((res) => {
|
||||
setSubjects(res.data);
|
||||
const preferredName = problemSet?.subjectName?.trim() || '수학';
|
||||
const matched = res.data.find((subject) => subject.name === preferredName);
|
||||
setSelectedSubjectId(matched?.id ?? '');
|
||||
})
|
||||
.catch(() => {
|
||||
setSubjects([]);
|
||||
setSelectedSubjectId('');
|
||||
});
|
||||
}, [problemSet?.subjectName]);
|
||||
|
||||
// 기존 복습 등록 상태 복원
|
||||
useEffect(() => {
|
||||
@@ -163,15 +172,29 @@ function UploadedEbookBody() {
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [pageIndex, goTo]);
|
||||
|
||||
const ensureSubjectId = useCallback(async () => {
|
||||
if (selectedSubjectId) return selectedSubjectId;
|
||||
|
||||
const subjectName = problemSet?.subjectName?.trim() || '수학';
|
||||
const created = await api.post<Subject>('/subjects', {
|
||||
name: subjectName,
|
||||
color: '#6366f1',
|
||||
});
|
||||
setSubjects((prev) => [...prev, created.data]);
|
||||
setSelectedSubjectId(created.data.id);
|
||||
return created.data.id;
|
||||
}, [problemSet?.subjectName, selectedSubjectId]);
|
||||
|
||||
const handleSelfDifficulty = useCallback(async (sd: SelfDifficulty) => {
|
||||
if (!mathSubjectId || !problem || registeredProblems.has(problem.number) || registeringProblem !== null) return;
|
||||
if (!problem || registeredProblems.has(problem.number) || registeringProblem !== null) return;
|
||||
|
||||
setRegisteringProblem(problem.number);
|
||||
try {
|
||||
const subjectId = await ensureSubjectId();
|
||||
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,
|
||||
subjectId,
|
||||
problemId: problem.id,
|
||||
title: `${problemSet!.title} ${problem.number}번`,
|
||||
difficulty: 0.5,
|
||||
@@ -185,13 +208,13 @@ function UploadedEbookBody() {
|
||||
const createdStudyLogId = res.data.studyLog.id;
|
||||
setRegisteredProblems((prev) => new Set(prev).add(problem.number));
|
||||
setRegisteredDates((prev) => ({ ...prev, [problem.number]: scheduledAt }));
|
||||
setTagPromptFor(createdStudyLogId);
|
||||
setTagPromptFor({ studyLogId: createdStudyLogId, subjectId });
|
||||
} catch {
|
||||
showToast({ message: '복습 등록에 실패했어.', variant: 'danger' });
|
||||
} finally {
|
||||
setRegisteringProblem(null);
|
||||
}
|
||||
}, [mathSubjectId, problem, problemSet, registeredProblems, registeringProblem, selectedAnswers, memos, showToast]);
|
||||
}, [ensureSubjectId, problem, problemSet, registeredProblems, registeringProblem, selectedAnswers, memos, showToast]);
|
||||
|
||||
if (loading) return <StateBox>로딩 중...</StateBox>;
|
||||
if (error || !problemSet || !problem) {
|
||||
@@ -314,11 +337,9 @@ function UploadedEbookBody() {
|
||||
</MemoSection>
|
||||
|
||||
{/* 자기 평가 */}
|
||||
{isRevealed && mathSubjectId !== undefined && (
|
||||
{isRevealed && (
|
||||
<EvalSection>
|
||||
{mathSubjectId === null ? (
|
||||
<EvalNote><Icon name="info" size={14} /> 과목 페이지에서 '수학' 과목을 먼저 만들어줘.</EvalNote>
|
||||
) : alreadyRegistered ? (
|
||||
{alreadyRegistered ? (
|
||||
<EvalDone>
|
||||
<Icon name="check-circle" size={15} />
|
||||
복습 등록됨!{' '}
|
||||
@@ -326,6 +347,26 @@ function UploadedEbookBody() {
|
||||
</EvalDone>
|
||||
) : (
|
||||
<>
|
||||
<EvalSubjectRow>
|
||||
<EvalSubjectLabel>복습 과목</EvalSubjectLabel>
|
||||
<EvalSubjectSelect
|
||||
value={selectedSubjectId}
|
||||
onChange={(event) =>
|
||||
setSelectedSubjectId(
|
||||
event.target.value === '' ? '' : Number(event.target.value),
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
자동 생성: {problemSet.subjectName?.trim() || '수학'}
|
||||
</option>
|
||||
{subjects.map((subject) => (
|
||||
<option key={subject.id} value={subject.id}>
|
||||
{subject.name}
|
||||
</option>
|
||||
))}
|
||||
</EvalSubjectSelect>
|
||||
</EvalSubjectRow>
|
||||
<EvalTitle>이 문제 어땠어?</EvalTitle>
|
||||
<EvalBtns>
|
||||
{(['hard', 'medium', 'easy'] as SelfDifficulty[]).map((sd) => (
|
||||
@@ -384,7 +425,8 @@ function UploadedEbookBody() {
|
||||
|
||||
{tagPromptFor && (
|
||||
<TagPrompt
|
||||
studyLogId={tagPromptFor}
|
||||
studyLogId={tagPromptFor.studyLogId}
|
||||
preferredSubjectId={tagPromptFor.subjectId}
|
||||
onClose={() => setTagPromptFor(null)}
|
||||
onSaved={() => setTagPromptFor(null)}
|
||||
/>
|
||||
@@ -575,9 +617,30 @@ const EvalSection = styled.div`
|
||||
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 EvalSubjectRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const EvalSubjectLabel = styled.span`
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const EvalSubjectSelect = styled.select`
|
||||
min-width: 180px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
const EvalDone = styled.div`
|
||||
|
||||
@@ -11,13 +11,21 @@ interface TagPromptProps {
|
||||
studyLogId: number;
|
||||
/** 현재 이미 할당된 tagId (있으면 프롬프트 안 띄움) */
|
||||
currentTagId?: number | null;
|
||||
/** 기본 선택할 subject */
|
||||
preferredSubjectId?: number | null;
|
||||
/** 닫기 */
|
||||
onClose: () => void;
|
||||
/** 태그 저장 성공 */
|
||||
onSaved?: (tag: { id: number; name: string }) => void;
|
||||
}
|
||||
|
||||
export default function TagPrompt({ studyLogId, currentTagId, onClose, onSaved }: TagPromptProps) {
|
||||
export default function TagPrompt({
|
||||
studyLogId,
|
||||
currentTagId,
|
||||
preferredSubjectId,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: TagPromptProps) {
|
||||
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||||
const [selectedSubjectId, setSelectedSubjectId] = useState<number | ''>('');
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
@@ -28,12 +36,17 @@ export default function TagPrompt({ studyLogId, currentTagId, onClose, onSaved }
|
||||
useEffect(() => {
|
||||
api.get<Subject[]>('/subjects').then((r) => {
|
||||
setSubjects(r.data);
|
||||
if (r.data[0]) {
|
||||
setSelectedSubjectId(r.data[0].id);
|
||||
setTags(r.data[0].tags ?? []);
|
||||
const preferred =
|
||||
preferredSubjectId !== null && preferredSubjectId !== undefined
|
||||
? r.data.find((subject) => subject.id === preferredSubjectId)
|
||||
: null;
|
||||
const initialSubject = preferred ?? r.data[0];
|
||||
if (initialSubject) {
|
||||
setSelectedSubjectId(initialSubject.id);
|
||||
setTags(initialSubject.tags ?? []);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
}, [preferredSubjectId]);
|
||||
|
||||
const handleSubjectChange = (subjectId: number) => {
|
||||
setSelectedSubjectId(subjectId);
|
||||
|
||||
Reference in New Issue
Block a user