feat(data): chosenAnswer + solve route + profile tabs — 7I.2
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `study_logs` ADD COLUMN `chosenAnswer` INTEGER NULL;
|
||||
@@ -123,6 +123,7 @@ model StudyLog {
|
||||
difficulty Float
|
||||
baseCorrectRate Float?
|
||||
result StudyResult
|
||||
chosenAnswer Int?
|
||||
memo String? @db.Text
|
||||
studiedAt DateTime @default(now())
|
||||
timeSpent Int?
|
||||
|
||||
@@ -27,7 +27,10 @@ import { StudyResult } from '@prisma/client';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { AuthUser } from '../auth/jwt.strategy';
|
||||
import { StudyLogsService } from './study-logs.service';
|
||||
import {
|
||||
StudyLogsService,
|
||||
type UpdateStudyLogInput,
|
||||
} from './study-logs.service';
|
||||
|
||||
class CreateStudyLogDto {
|
||||
@IsInt()
|
||||
@@ -58,6 +61,13 @@ class CreateStudyLogDto {
|
||||
@IsEnum(StudyResult)
|
||||
result: StudyResult;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
chosenAnswer?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
memo?: string;
|
||||
@@ -129,7 +139,18 @@ class CreateFromProblemSetDto {
|
||||
answers: ProblemSetAnswerDto[];
|
||||
}
|
||||
|
||||
class UpdateStudyLogDto {
|
||||
class UpdateStudyLogDto implements UpdateStudyLogInput {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
chosenAnswer?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(StudyResult)
|
||||
result?: StudyResult;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
memo?: string;
|
||||
|
||||
@@ -16,10 +16,17 @@ export interface CreateStudyLogInput {
|
||||
difficulty: number;
|
||||
baseCorrectRate?: number | null;
|
||||
result: StudyResult;
|
||||
chosenAnswer?: number | null;
|
||||
memo?: string;
|
||||
timeSpent?: number;
|
||||
}
|
||||
|
||||
export interface UpdateStudyLogInput {
|
||||
chosenAnswer?: number;
|
||||
result?: StudyResult;
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
export interface CreateFromProblemSetInput {
|
||||
problemSetId: number;
|
||||
totalTimeSpent: number;
|
||||
@@ -167,6 +174,7 @@ export class StudyLogsService {
|
||||
difficulty: problem.difficulty,
|
||||
baseCorrectRate: problem.baseCorrectRate,
|
||||
result: derivedResult,
|
||||
chosenAnswer,
|
||||
memo:
|
||||
problem.answerNumber === null
|
||||
? '정답 미등록 (자동 채점 불가)'
|
||||
@@ -269,6 +277,7 @@ export class StudyLogsService {
|
||||
id: log.id,
|
||||
studiedAt: log.studiedAt,
|
||||
result: log.result,
|
||||
chosenAnswer: log.chosenAnswer,
|
||||
memo: log.memo,
|
||||
timeSpent: log.timeSpent,
|
||||
difficulty: log.difficulty,
|
||||
@@ -284,6 +293,7 @@ export class StudyLogsService {
|
||||
choices: log.problem.choices,
|
||||
answerNumber: log.problem.answerNumber,
|
||||
imageUrl: log.problem.imageUrl,
|
||||
pageImageUrl: log.problem.pageImageUrl,
|
||||
problemSet: log.problem.problemSet,
|
||||
}
|
||||
: null,
|
||||
@@ -299,7 +309,7 @@ export class StudyLogsService {
|
||||
};
|
||||
}
|
||||
|
||||
async update(userId: number, id: number, data: { memo?: string }) {
|
||||
async update(userId: number, id: number, data: UpdateStudyLogInput) {
|
||||
const existing = await this.prisma.studyLog.findFirst({
|
||||
where: { id, userId },
|
||||
select: { id: true },
|
||||
@@ -309,10 +319,16 @@ export class StudyLogsService {
|
||||
return this.prisma.studyLog.update({
|
||||
where: { id },
|
||||
data: {
|
||||
memo: data.memo?.trim() ? data.memo : null,
|
||||
...(data.memo !== undefined && {
|
||||
memo: data.memo.trim() ? data.memo : null,
|
||||
}),
|
||||
...(data.result !== undefined && { result: data.result }),
|
||||
...(data.chosenAnswer !== undefined && { chosenAnswer: data.chosenAnswer }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
chosenAnswer: true,
|
||||
result: true,
|
||||
memo: true,
|
||||
},
|
||||
});
|
||||
@@ -365,6 +381,7 @@ export class StudyLogsService {
|
||||
difficulty: clamp01(input.difficulty),
|
||||
baseCorrectRate: input.baseCorrectRate ?? null,
|
||||
result: input.result,
|
||||
chosenAnswer: input.chosenAnswer ?? null,
|
||||
memo: input.memo ?? null,
|
||||
timeSpent: input.timeSpent ?? null,
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
481
frontend/src/app/solve/[studyLogId]/page.tsx
Normal file
481
frontend/src/app/solve/[studyLogId]/page.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { Button, Card } from '@/components/ui/primitives';
|
||||
import {
|
||||
getStudyLog,
|
||||
resolveUploadUrl,
|
||||
updateStudyLog,
|
||||
type StudyLogDetail,
|
||||
type StudyResult,
|
||||
} from '@/lib/api';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
export default function SolveStudyLogPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<SolveStudyLogBody />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function SolveStudyLogBody() {
|
||||
const params = useParams<{ studyLogId: string }>();
|
||||
const studyLogId = Number(params.studyLogId);
|
||||
|
||||
const [detail, setDetail] = useState<StudyLogDetail | null>(null);
|
||||
const [selectedAnswer, setSelectedAnswer] = useState<number | null>(null);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
if (!Number.isFinite(studyLogId)) {
|
||||
setError('잘못된 학습 기록 주소입니다.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await getStudyLog(studyLogId);
|
||||
if (cancelled) return;
|
||||
setDetail(response);
|
||||
setSelectedAnswer(response.chosenAnswer);
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setError('문제를 불러오지 못했어요.');
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [studyLogId]);
|
||||
|
||||
const problem = detail?.problem ?? null;
|
||||
const choices = useMemo(() => normalizeChoices(problem?.choices), [problem?.choices]);
|
||||
const problemImageUrl = resolveUploadUrl(problem?.imageUrl ?? problem?.pageImageUrl);
|
||||
const problemBodyText = normalizeOptionalText(problem?.bodyText);
|
||||
const result = useMemo(() => {
|
||||
if (!problem || selectedAnswer === null) return null;
|
||||
return computeResult(selectedAnswer, problem.answerNumber);
|
||||
}, [problem, selectedAnswer]);
|
||||
|
||||
const submitAnswer = async () => {
|
||||
if (!detail || !problem || selectedAnswer === null || saving) return;
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const nextResult = computeResult(selectedAnswer, problem.answerNumber);
|
||||
const response = await updateStudyLog(detail.id, {
|
||||
chosenAnswer: selectedAnswer,
|
||||
result: nextResult,
|
||||
});
|
||||
|
||||
setDetail((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
chosenAnswer: response.chosenAnswer,
|
||||
result: response.result,
|
||||
memo: response.memo,
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
setError('답안을 저장하지 못했어요.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <StateCard>문제를 준비하는 중...</StateCard>;
|
||||
}
|
||||
|
||||
if (error || !detail || !problem) {
|
||||
return (
|
||||
<StateCard>
|
||||
<Icon name="info" size={18} />
|
||||
{error ?? '학습 기록에 연결된 문제가 없습니다.'}
|
||||
</StateCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<HeaderCard>
|
||||
<Eyebrow>
|
||||
{problem.problemSet.year}학년도 {problem.problemSet.examType} · {problem.problemSet.subjectName}
|
||||
</Eyebrow>
|
||||
<Title>{problem.number}번 다시 풀기</Title>
|
||||
<MetaLine>
|
||||
{problem.problemSet.title}
|
||||
{detail.tag ? ` · ${detail.tag.name}` : ''}
|
||||
</MetaLine>
|
||||
</HeaderCard>
|
||||
|
||||
<Layout>
|
||||
<ProblemCard>
|
||||
{problemImageUrl ? (
|
||||
<ProblemImage
|
||||
src={problemImageUrl}
|
||||
alt={`${problem.problemSet.subjectName} ${problem.number}번`}
|
||||
/>
|
||||
) : null}
|
||||
{problemBodyText ? (
|
||||
<ProblemBody $muted={Boolean(problemImageUrl)}>{problemBodyText}</ProblemBody>
|
||||
) : !problemImageUrl ? (
|
||||
<ProblemBody>문제 본문이 저장되어 있지 않습니다.</ProblemBody>
|
||||
) : null}
|
||||
</ProblemCard>
|
||||
|
||||
<AnswerCard>
|
||||
<AnswerHeader>
|
||||
<div>
|
||||
<SectionTitle>답안 선택</SectionTitle>
|
||||
<SectionMeta>
|
||||
{choices.length > 0
|
||||
? '선택 후 제출하면 학습 기록의 chosenAnswer/result가 갱신됩니다.'
|
||||
: '선택지가 없는 문제라 답안 선택 없이 이미지만 다시 볼 수 있습니다.'}
|
||||
</SectionMeta>
|
||||
</div>
|
||||
<Link href={`/study-logs/${detail.id}`}>
|
||||
<BackLink>학습 기록 보기</BackLink>
|
||||
</Link>
|
||||
</AnswerHeader>
|
||||
|
||||
{choices.length > 0 ? (
|
||||
<ChoiceList>
|
||||
{choices.map((choice) => {
|
||||
const selected = selectedAnswer === choice.number;
|
||||
return (
|
||||
<ChoiceButton
|
||||
key={choice.number}
|
||||
type="button"
|
||||
$selected={selected}
|
||||
disabled={submitted}
|
||||
onClick={() => setSelectedAnswer(choice.number)}
|
||||
>
|
||||
<ChoiceNumber $selected={selected}>{choice.number}</ChoiceNumber>
|
||||
<ChoiceText>{choice.text}</ChoiceText>
|
||||
</ChoiceButton>
|
||||
);
|
||||
})}
|
||||
</ChoiceList>
|
||||
) : null}
|
||||
|
||||
{submitted && result ? (
|
||||
<ResultOverlay $result={result}>
|
||||
<ResultTitle>
|
||||
<Icon
|
||||
name={result === 'correct' ? 'check-circle' : result === 'incorrect' ? 'x' : 'triangle'}
|
||||
size={18}
|
||||
weight="fill"
|
||||
/>
|
||||
{resultLabel(result)}
|
||||
</ResultTitle>
|
||||
<ResultBody>
|
||||
{problem.answerNumber === null
|
||||
? '정답 번호가 없어 partial로 저장했습니다.'
|
||||
: `정답은 ${problem.answerNumber}번입니다.`}
|
||||
</ResultBody>
|
||||
<Link href={`/study-logs/${detail.id}`}>
|
||||
<Button as="span">학습 기록으로 돌아가기</Button>
|
||||
</Link>
|
||||
</ResultOverlay>
|
||||
) : (
|
||||
<SubmitRow>
|
||||
<SubmitSummary>
|
||||
{selectedAnswer === null
|
||||
? '답안을 선택해 주세요.'
|
||||
: `${selectedAnswer}번 선택`}
|
||||
</SubmitSummary>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void submitAnswer()}
|
||||
disabled={selectedAnswer === null || saving || choices.length === 0}
|
||||
>
|
||||
{saving ? '제출 중...' : '답안 제출'}
|
||||
</Button>
|
||||
</SubmitRow>
|
||||
)}
|
||||
</AnswerCard>
|
||||
</Layout>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeChoices(
|
||||
choices: Record<string, string> | null | undefined,
|
||||
): Array<{ number: number; text: string }> {
|
||||
if (!choices) return [];
|
||||
return Object.entries(choices)
|
||||
.map(([key, text]) => ({ number: Number(key), text }))
|
||||
.filter((choice) => Number.isFinite(choice.number))
|
||||
.sort((a, b) => a.number - b.number);
|
||||
}
|
||||
|
||||
function normalizeOptionalText(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function computeResult(
|
||||
chosenAnswer: number,
|
||||
answerNumber: number | null,
|
||||
): StudyResult {
|
||||
if (answerNumber === null) return 'partial';
|
||||
return chosenAnswer === answerNumber ? 'correct' : 'incorrect';
|
||||
}
|
||||
|
||||
function resultLabel(result: StudyResult): string {
|
||||
if (result === 'correct') return '정답입니다';
|
||||
if (result === 'incorrect') return '오답입니다';
|
||||
return '정답 정보가 없습니다';
|
||||
}
|
||||
|
||||
const Page = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
`;
|
||||
|
||||
const HeaderCard = styled(Card)`
|
||||
border-radius: 24px;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(99, 102, 241, 0.18), transparent 30%),
|
||||
rgba(21, 21, 28, 0.88);
|
||||
`;
|
||||
|
||||
const Eyebrow = styled.div`
|
||||
color: #a5b4fc;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
margin: 8px 0 0;
|
||||
color: ${theme.color.textBright};
|
||||
font-family: ${theme.font.display};
|
||||
font-size: 30px;
|
||||
line-height: 1.12;
|
||||
`;
|
||||
|
||||
const MetaLine = styled.p`
|
||||
margin: 10px 0 0;
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const Layout = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.95fr);
|
||||
gap: 20px;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.desktop}) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const ProblemCard = styled(Card)`
|
||||
border-radius: 24px;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
background: rgba(21, 21, 28, 0.88);
|
||||
`;
|
||||
|
||||
const AnswerCard = styled(ProblemCard)`
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const AnswerHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.mobile}) {
|
||||
flex-direction: column;
|
||||
}
|
||||
`;
|
||||
|
||||
const SectionTitle = styled.h2`
|
||||
margin: 0;
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 18px;
|
||||
`;
|
||||
|
||||
const SectionMeta = styled.p`
|
||||
margin: 8px 0 0;
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
const BackLink = styled.span`
|
||||
color: #c7d2fe;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const ProblemImage = styled.img`
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 18px;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.24);
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
`;
|
||||
|
||||
const ProblemBody = styled.div<{ $muted?: boolean }>`
|
||||
margin-top: 16px;
|
||||
white-space: pre-wrap;
|
||||
color: ${({ $muted }) => ($muted ? theme.color.textSub : theme.color.textMain)};
|
||||
line-height: 1.8;
|
||||
font-size: 15px;
|
||||
`;
|
||||
|
||||
const ChoiceList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const ChoiceButton = styled.button<{ $selected: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid
|
||||
${({ $selected }) =>
|
||||
$selected ? 'rgba(99, 102, 241, 0.48)' : theme.color.borderSoftAlpha};
|
||||
background: ${({ $selected }) =>
|
||||
$selected ? 'rgba(79, 70, 229, 0.14)' : 'rgba(255, 255, 255, 0.03)'};
|
||||
color: ${theme.color.textMain};
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(99, 102, 241, 0.42);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.82;
|
||||
}
|
||||
`;
|
||||
|
||||
const ChoiceNumber = styled.span<{ $selected: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
background: ${({ $selected }) =>
|
||||
$selected ? 'rgba(79, 70, 229, 0.45)' : theme.color.surface2};
|
||||
color: ${theme.color.textBright};
|
||||
font-weight: 700;
|
||||
box-shadow: ${({ $selected }) =>
|
||||
$selected ? '0 0 0 3px rgba(99, 102, 241, 0.22)' : 'none'};
|
||||
`;
|
||||
|
||||
const ChoiceText = styled.span`
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
const SubmitRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.mobile}) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
`;
|
||||
|
||||
const SubmitSummary = styled.div`
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const ResultOverlay = styled.div<{ $result: StudyResult }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
margin-top: 20px;
|
||||
border: 1px solid ${({ $result }) => {
|
||||
if ($result === 'correct') return 'rgba(16, 185, 129, 0.42)';
|
||||
if ($result === 'incorrect') return 'rgba(244, 63, 94, 0.4)';
|
||||
return 'rgba(250, 204, 21, 0.4)';
|
||||
}};
|
||||
background: ${({ $result }) => {
|
||||
if ($result === 'correct') return 'rgba(16, 185, 129, 0.12)';
|
||||
if ($result === 'incorrect') return 'rgba(244, 63, 94, 0.1)';
|
||||
return 'rgba(250, 204, 21, 0.1)';
|
||||
}};
|
||||
`;
|
||||
|
||||
const ResultTitle = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const ResultBody = styled.p`
|
||||
margin: 0;
|
||||
color: ${theme.color.textSub};
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
const StateCard = styled(Card)`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 180px;
|
||||
justify-content: center;
|
||||
border-radius: 24px;
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
@@ -8,8 +8,9 @@ import AppShell from '@/components/layout/AppShell';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { Button, Card, Textarea } from '@/components/ui/primitives';
|
||||
import {
|
||||
api,
|
||||
getStudyLog,
|
||||
resolveUploadUrl,
|
||||
updateStudyLog,
|
||||
type ReviewStatus,
|
||||
type StudyLogDetail,
|
||||
type StudyResult,
|
||||
@@ -47,10 +48,10 @@ function StudyLogDetailBody() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api.get<StudyLogDetail>(`/study-logs/${studyLogId}`);
|
||||
const response = await getStudyLog(studyLogId);
|
||||
if (cancelled) return;
|
||||
setDetail(response.data);
|
||||
setMemo(response.data.memo ?? '');
|
||||
setDetail(response);
|
||||
setMemo(response.memo ?? '');
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setError('학습 기록 상세를 불러오지 못했어요.');
|
||||
@@ -76,12 +77,18 @@ function StudyLogDetailBody() {
|
||||
if (!detail) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await api.patch<{ id: number; memo: string | null }>(
|
||||
`/study-logs/${detail.id}`,
|
||||
{ memo },
|
||||
const response = await updateStudyLog(detail.id, { memo });
|
||||
setDetail((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
memo: response.memo,
|
||||
result: response.result,
|
||||
chosenAnswer: response.chosenAnswer,
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
setDetail((prev) => (prev ? { ...prev, memo: response.data.memo } : prev));
|
||||
setMemo(response.data.memo ?? '');
|
||||
setMemo(response.memo ?? '');
|
||||
} catch {
|
||||
setError('메모 저장에 실패했어요.');
|
||||
} finally {
|
||||
@@ -125,7 +132,7 @@ function StudyLogDetailBody() {
|
||||
</MetaLine>
|
||||
</div>
|
||||
{problem && problemSet && (
|
||||
<Link href={`/study/exam/${problemSet.id}?problem=${problem.number}`}>
|
||||
<Link href={`/solve/${detail.id}`}>
|
||||
<ReplayButton as="span" $variant="secondary">
|
||||
다시 풀기
|
||||
<Icon name="arrow-right" size={16} />
|
||||
@@ -154,10 +161,29 @@ function StudyLogDetailBody() {
|
||||
<ChoiceList>
|
||||
{problemChoices.map((choice) => {
|
||||
const isCorrect = choice.number === problem?.answerNumber;
|
||||
const isChosen = choice.number === detail.chosenAnswer;
|
||||
const chosenState =
|
||||
isChosen && isCorrect
|
||||
? 'correct'
|
||||
: isChosen
|
||||
? 'incorrect'
|
||||
: isCorrect
|
||||
? 'answer'
|
||||
: 'default';
|
||||
return (
|
||||
<ChoiceItem key={choice.number} $correct={isCorrect}>
|
||||
<ChoiceNumber $correct={isCorrect}>{choice.number}</ChoiceNumber>
|
||||
<ChoiceItem key={choice.number} $state={chosenState}>
|
||||
<ChoiceNumber $state={chosenState}>{choice.number}</ChoiceNumber>
|
||||
<ChoiceText>{choice.text}</ChoiceText>
|
||||
{isChosen ? (
|
||||
<ChoiceBadge $tone={isCorrect ? 'correct' : 'incorrect'}>
|
||||
<Icon
|
||||
name={isCorrect ? 'check-circle' : 'x'}
|
||||
weight="fill"
|
||||
size={14}
|
||||
/>
|
||||
내 답
|
||||
</ChoiceBadge>
|
||||
) : null}
|
||||
{isCorrect ? (
|
||||
<ChoiceBadge>
|
||||
<Icon name="check-circle" weight="fill" size={14} />
|
||||
@@ -169,9 +195,6 @@ function StudyLogDetailBody() {
|
||||
})}
|
||||
</ChoiceList>
|
||||
)}
|
||||
<InlineNote>
|
||||
선택한 답안은 현재 DB에 저장되지 않아 과거 기록에서는 복원할 수 없습니다.
|
||||
</InlineNote>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard>
|
||||
@@ -429,19 +452,33 @@ const ChoiceList = styled.div`
|
||||
margin-top: 20px;
|
||||
`;
|
||||
|
||||
const ChoiceItem = styled.div<{ $correct: boolean }>`
|
||||
type ChoiceState = 'default' | 'answer' | 'correct' | 'incorrect';
|
||||
|
||||
const ChoiceItem = styled.div<{ $state: ChoiceState }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid
|
||||
${({ $correct }) => ($correct ? 'rgba(16, 185, 129, 0.4)' : theme.color.borderSoftAlpha)};
|
||||
background: ${({ $correct }) =>
|
||||
$correct ? 'rgba(16, 185, 129, 0.12)' : 'rgba(255, 255, 255, 0.02)'};
|
||||
border: 1px solid ${({ $state }) => {
|
||||
if ($state === 'correct' || $state === 'answer') return 'rgba(16, 185, 129, 0.4)';
|
||||
if ($state === 'incorrect') return 'rgba(244, 63, 94, 0.45)';
|
||||
return theme.color.borderSoftAlpha;
|
||||
}};
|
||||
background: ${({ $state }) => {
|
||||
if ($state === 'correct' || $state === 'answer') return 'rgba(16, 185, 129, 0.12)';
|
||||
if ($state === 'incorrect') return 'rgba(244, 63, 94, 0.1)';
|
||||
return 'rgba(255, 255, 255, 0.02)';
|
||||
}};
|
||||
box-shadow: ${({ $state }) => {
|
||||
if ($state === 'correct') return '0 0 0 2px rgba(16, 185, 129, 0.28) inset';
|
||||
if ($state === 'incorrect') return '0 0 0 2px rgba(244, 63, 94, 0.24) inset';
|
||||
if ($state === 'answer') return '0 0 0 2px rgba(16, 185, 129, 0.24) inset';
|
||||
return 'none';
|
||||
}};
|
||||
`;
|
||||
|
||||
const ChoiceNumber = styled.div<{ $correct: boolean }>`
|
||||
const ChoiceNumber = styled.div<{ $state: ChoiceState }>`
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: inline-flex;
|
||||
@@ -449,8 +486,22 @@ const ChoiceNumber = styled.div<{ $correct: boolean }>`
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
color: ${({ $correct }) => ($correct ? '#a7f3d0' : theme.color.textBright)};
|
||||
background: ${({ $correct }) => ($correct ? 'rgba(5, 150, 105, 0.38)' : theme.color.surface2)};
|
||||
color: ${({ $state }) => {
|
||||
if ($state === 'correct' || $state === 'answer') return '#a7f3d0';
|
||||
if ($state === 'incorrect') return '#fecdd3';
|
||||
return theme.color.textBright;
|
||||
}};
|
||||
background: ${({ $state }) => {
|
||||
if ($state === 'correct' || $state === 'answer') return 'rgba(5, 150, 105, 0.38)';
|
||||
if ($state === 'incorrect') return 'rgba(190, 24, 93, 0.36)';
|
||||
return theme.color.surface2;
|
||||
}};
|
||||
box-shadow: ${({ $state }) => {
|
||||
if ($state === 'correct') return '0 0 0 3px rgba(16, 185, 129, 0.22)';
|
||||
if ($state === 'incorrect') return '0 0 0 3px rgba(99, 102, 241, 0.34)';
|
||||
if ($state === 'answer') return '0 0 0 3px rgba(16, 185, 129, 0.2)';
|
||||
return 'none';
|
||||
}};
|
||||
`;
|
||||
|
||||
const ChoiceText = styled.div`
|
||||
@@ -459,22 +510,15 @@ const ChoiceText = styled.div`
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
const ChoiceBadge = styled.div`
|
||||
const ChoiceBadge = styled.div<{ $tone?: 'correct' | 'incorrect' }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #86efac;
|
||||
color: ${({ $tone }) => ($tone === 'incorrect' ? '#fda4af' : '#86efac')};
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const InlineNote = styled.p`
|
||||
margin: 16px 0 0;
|
||||
color: ${theme.color.textMute};
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
const MemoArea = styled(Textarea)`
|
||||
min-height: 140px;
|
||||
`;
|
||||
|
||||
@@ -96,7 +96,6 @@ export default function SideNav({ user, reviewCount }: SideNavProps) {
|
||||
|
||||
const MENU_ITEMS = [
|
||||
{ label: '프로필', href: '/profile', icon: 'user' as const },
|
||||
{ label: '설정', href: '/profile#settings', icon: 'gear' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -133,6 +133,7 @@ export interface StudyLog {
|
||||
difficulty: number;
|
||||
baseCorrectRate: number | null;
|
||||
result: StudyResult;
|
||||
chosenAnswer?: number | null;
|
||||
memo: string | null;
|
||||
studiedAt: string;
|
||||
timeSpent: number | null;
|
||||
@@ -153,6 +154,7 @@ export interface StudyLogDetail {
|
||||
id: number;
|
||||
studiedAt: string;
|
||||
result: StudyResult;
|
||||
chosenAnswer: number | null;
|
||||
memo: string | null;
|
||||
timeSpent: number | null;
|
||||
difficulty: number;
|
||||
@@ -304,3 +306,21 @@ export async function submitProblemSetStudy(payload: {
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getStudyLog(id: number) {
|
||||
const response = await api.get<StudyLogDetail>(`/study-logs/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function updateStudyLog(
|
||||
id: number,
|
||||
payload: Partial<Pick<StudyLogDetail, 'chosenAnswer' | 'result' | 'memo'>>,
|
||||
) {
|
||||
const response = await api.patch<{
|
||||
id: number;
|
||||
chosenAnswer: number | null;
|
||||
result: StudyResult;
|
||||
memo: string | null;
|
||||
}>(`/study-logs/${id}`, payload);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user