feat(ia): /exams route + focus toggle + mobile logout access — 7G.1
This commit is contained in:
1
frontend/src/.claude/sessions/.last_inbox_check
Normal file
1
frontend/src/.claude/sessions/.last_inbox_check
Normal file
@@ -0,0 +1 @@
|
||||
1775934117
|
||||
0
frontend/src/.claude/state/session-events.lock
Normal file
0
frontend/src/.claude/state/session-events.lock
Normal file
0
frontend/src/.claude/state/session.events.jsonl
Normal file
0
frontend/src/.claude/state/session.events.jsonl
Normal file
0
frontend/src/.claude/state/session.json
Normal file
0
frontend/src/.claude/state/session.json
Normal file
319
frontend/src/app/exams/page.tsx
Normal file
319
frontend/src/app/exams/page.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { Badge, Button, Card, Label, PageHeader, Select } from '@/components/ui/primitives';
|
||||
import { api, type ProblemSetSummary } from '@/lib/api';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
const YEAR_FILTERS = [2026, 2025] as const;
|
||||
const SUBJECT_FILTERS = ['전체', '국어', '수학', '영어', '한국사', '생활과 윤리'] as const;
|
||||
|
||||
type YearFilter = number | 'all';
|
||||
type SubjectFilter = (typeof SUBJECT_FILTERS)[number];
|
||||
|
||||
export default function ExamsPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<ExamsBody />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ExamsBody() {
|
||||
const router = useRouter();
|
||||
const [problemSets, setProblemSets] = useState<ProblemSetSummary[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedYear, setSelectedYear] = useState<YearFilter>('all');
|
||||
const [selectedSubject, setSelectedSubject] = useState<SubjectFilter>('전체');
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
const loadProblemSets = useCallback(() => {
|
||||
let active = true;
|
||||
|
||||
setProblemSets(null);
|
||||
setError(null);
|
||||
|
||||
api
|
||||
.get<ProblemSetSummary[]>('/problem-sets', {
|
||||
params: {
|
||||
year: selectedYear === 'all' ? undefined : selectedYear,
|
||||
subjectName: selectedSubject === '전체' ? undefined : selectedSubject,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
setProblemSets(response.data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return;
|
||||
setError('문제집 목록을 불러오지 못했어. 다시 시도해줘.');
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [selectedSubject, selectedYear]);
|
||||
|
||||
useEffect(() => loadProblemSets(), [loadProblemSets, reloadKey]);
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
<HeaderCard>
|
||||
<PageHeader
|
||||
eyebrow="Exams"
|
||||
title="모의고사 문제집"
|
||||
subtitle="기출 문제집을 선택해 실전처럼 풀어봐"
|
||||
/>
|
||||
</HeaderCard>
|
||||
|
||||
<FilterCard>
|
||||
<FilterRow>
|
||||
<FilterField>
|
||||
<Label>연도</Label>
|
||||
<FieldSelect
|
||||
value={selectedYear}
|
||||
onChange={(event) =>
|
||||
setSelectedYear(event.target.value === 'all' ? 'all' : Number(event.target.value))
|
||||
}
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
{YEAR_FILTERS.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
</FilterField>
|
||||
|
||||
<FilterField>
|
||||
<Label>과목</Label>
|
||||
<FieldSelect
|
||||
value={selectedSubject}
|
||||
onChange={(event) => setSelectedSubject(event.target.value as SubjectFilter)}
|
||||
>
|
||||
{SUBJECT_FILTERS.map((subject) => (
|
||||
<option key={subject} value={subject}>
|
||||
{subject}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
</FilterField>
|
||||
</FilterRow>
|
||||
</FilterCard>
|
||||
|
||||
{error ? (
|
||||
<StateCard role="alert">
|
||||
<StateIcon>
|
||||
<Icon name="info" size={24} />
|
||||
</StateIcon>
|
||||
<StateTitle>불러오기에 실패했어</StateTitle>
|
||||
<StateText>{error}</StateText>
|
||||
<Button type="button" $variant="secondary" onClick={() => setReloadKey((prev) => prev + 1)}>
|
||||
다시 시도
|
||||
</Button>
|
||||
</StateCard>
|
||||
) : problemSets === null ? (
|
||||
<StateCard>
|
||||
<StateIcon>
|
||||
<Icon name="clock" size={24} />
|
||||
</StateIcon>
|
||||
<StateTitle>문제집을 준비하는 중</StateTitle>
|
||||
<StateText>필터에 맞는 평가원 문제집을 불러오고 있어.</StateText>
|
||||
</StateCard>
|
||||
) : problemSets.length === 0 ? (
|
||||
<StateCard>
|
||||
<StateIcon>
|
||||
<Icon name="book-open-text" size={24} />
|
||||
</StateIcon>
|
||||
<StateTitle>조건에 맞는 문제집이 아직 없어</StateTitle>
|
||||
<StateText>연도나 과목 필터를 바꿔서 다시 찾아봐.</StateText>
|
||||
</StateCard>
|
||||
) : (
|
||||
<Grid>
|
||||
{problemSets.map((problemSet) => {
|
||||
const isAutoGradeUnavailable =
|
||||
(problemSet.problems?.length ?? 0) > 0 &&
|
||||
problemSet.problems?.every((problem) => problem.needsReview);
|
||||
|
||||
return (
|
||||
<ExamCard key={problemSet.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{problemSet.title}</CardTitle>
|
||||
{isAutoGradeUnavailable ? (
|
||||
<MutedBadge $variant="default">정답 자동 채점 미지원</MutedBadge>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
|
||||
<CardMeta>
|
||||
{problemSet.year} · {problemSet.subjectName} ·{' '}
|
||||
{(problemSet._count?.problems ?? 0).toLocaleString()}문항
|
||||
</CardMeta>
|
||||
|
||||
<CardFooter>
|
||||
<InfoLine>
|
||||
<Icon name="clock" size={16} />
|
||||
실전 타이머 포함
|
||||
</InfoLine>
|
||||
<Button
|
||||
type="button"
|
||||
$variant="secondary"
|
||||
onClick={() => router.push(`/study/exam/${problemSet.id}`)}
|
||||
>
|
||||
시작
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</ExamCard>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
)}
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.lg};
|
||||
`;
|
||||
|
||||
const HeaderCard = styled(Card)`
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(79, 70, 229, 0.16), transparent 30%),
|
||||
${theme.color.surfaceDeep};
|
||||
border-color: ${theme.color.borderSoftAlpha};
|
||||
`;
|
||||
|
||||
const FilterCard = styled(Card)`
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: ${theme.color.borderSoftAlpha};
|
||||
`;
|
||||
|
||||
const FilterRow = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 240px));
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const FilterField = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const FieldSelect = styled(Select)`
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: ${theme.color.borderSoftAlpha};
|
||||
`;
|
||||
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const ExamCard = styled(Card)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 220px;
|
||||
justify-content: space-between;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(79, 70, 229, 0.12), transparent 30%),
|
||||
rgba(255, 255, 255, 0.02);
|
||||
border-color: ${theme.color.borderSoftAlpha};
|
||||
`;
|
||||
|
||||
const CardHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.mobile}) {
|
||||
flex-direction: column;
|
||||
}
|
||||
`;
|
||||
|
||||
const CardTitle = styled.h2`
|
||||
font-size: 20px;
|
||||
line-height: 1.45;
|
||||
color: ${theme.color.textBright};
|
||||
`;
|
||||
|
||||
const MutedBadge = styled(Badge)`
|
||||
color: ${theme.color.textSub};
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: ${theme.color.borderSoftAlpha};
|
||||
`;
|
||||
|
||||
const CardMeta = styled.p`
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
const CardFooter = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: auto;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.mobile}) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
`;
|
||||
|
||||
const InfoLine = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const StateCard = styled(Card)`
|
||||
min-height: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: ${theme.color.borderSoftAlpha};
|
||||
`;
|
||||
|
||||
const StateIcon = styled.div`
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 16px;
|
||||
background: rgba(79, 70, 229, 0.12);
|
||||
color: ${theme.color.textBright};
|
||||
`;
|
||||
|
||||
const StateTitle = styled.h2`
|
||||
font-size: 20px;
|
||||
color: ${theme.color.textBright};
|
||||
`;
|
||||
|
||||
const StateText = styled.p`
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
@@ -637,6 +637,13 @@ function ProfileBody() {
|
||||
</Card>
|
||||
</SecondaryColumn>
|
||||
</MainGrid>
|
||||
|
||||
<MobileLogoutBar>
|
||||
<MobileLogoutButton type="button" onClick={handleLogout}>
|
||||
<Icon name="sign-out" size={18} color="#fca5a5" />
|
||||
로그아웃
|
||||
</MobileLogoutButton>
|
||||
</MobileLogoutBar>
|
||||
</PageWrap>
|
||||
);
|
||||
}
|
||||
@@ -1499,3 +1506,30 @@ const LoadingState = styled.div`
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
const MobileLogoutBar = styled.div`
|
||||
display: none;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
display: block;
|
||||
position: sticky;
|
||||
bottom: calc(84px + env(safe-area-inset-bottom));
|
||||
z-index: 2;
|
||||
}
|
||||
`;
|
||||
|
||||
const MobileLogoutButton = styled.button`
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.18);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: #fca5a5;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
backdrop-filter: blur(18px);
|
||||
`;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import styled, { css } from 'styled-components';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { ConfirmDialog } from '@/components/ui/Modal';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
} from '@/components/exam/shared';
|
||||
|
||||
type AnswerMap = Record<number, number | null>;
|
||||
const FOCUS_MODE_STORAGE_KEY = 'reloop-exam-focus-mode';
|
||||
|
||||
export default function ExamPage() {
|
||||
const params = useParams<{ problemSetId: string }>();
|
||||
@@ -35,6 +37,7 @@ export default function ExamPage() {
|
||||
const [showLeaveConfirm, setShowLeaveConfirm] = useState(false);
|
||||
const [showSubmitConfirm, setShowSubmitConfirm] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [focusMode, setFocusMode] = useState(true);
|
||||
|
||||
const examStartedAtRef = useRef<number>(0);
|
||||
const problemEnteredAtRef = useRef<number>(0);
|
||||
@@ -90,6 +93,18 @@ export default function ExamPage() {
|
||||
void loadProblemSet();
|
||||
}, [problemSetId, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const stored = window.localStorage.getItem(FOCUS_MODE_STORAGE_KEY);
|
||||
if (stored === null) return;
|
||||
setFocusMode(stored === 'true');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(FOCUS_MODE_STORAGE_KEY, String(focusMode));
|
||||
}, [focusMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!problemSet || submitting || submittedRef.current) return;
|
||||
|
||||
@@ -226,19 +241,20 @@ export default function ExamPage() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<StateScreen>
|
||||
const loadingState = (
|
||||
<StateScreen $embedded={!focusMode}>
|
||||
<StateCard>
|
||||
<Icon name="clock" size={28} />
|
||||
문제집을 준비하는 중...
|
||||
</StateCard>
|
||||
</StateScreen>
|
||||
);
|
||||
return focusMode ? loadingState : <AppShell>{loadingState}</AppShell>;
|
||||
}
|
||||
|
||||
if (error || !problemSet || !currentProblem) {
|
||||
return (
|
||||
<StateScreen>
|
||||
const errorState = (
|
||||
<StateScreen $embedded={!focusMode}>
|
||||
<StateCard>
|
||||
<Icon name="info" size={28} />
|
||||
{error ?? '문제집을 찾지 못했어.'}
|
||||
@@ -248,11 +264,12 @@ export default function ExamPage() {
|
||||
</StateCard>
|
||||
</StateScreen>
|
||||
);
|
||||
return focusMode ? errorState : <AppShell>{errorState}</AppShell>;
|
||||
}
|
||||
|
||||
const timerDanger = remainingSeconds <= 5 * 60;
|
||||
|
||||
return (
|
||||
const examContent = (
|
||||
<Page>
|
||||
<TopBar>
|
||||
<BackButton type="button" onClick={() => setShowLeaveConfirm(true)}>
|
||||
@@ -262,10 +279,20 @@ export default function ExamPage() {
|
||||
|
||||
<TopTitle>{problemSet.title}</TopTitle>
|
||||
|
||||
<Timer $danger={timerDanger}>
|
||||
<Icon name="clock" size={18} />
|
||||
{formatClock(remainingSeconds)}
|
||||
</Timer>
|
||||
<TopBarActions>
|
||||
<FocusToggleButton
|
||||
type="button"
|
||||
$active={focusMode}
|
||||
onClick={() => setFocusMode((prev) => !prev)}
|
||||
>
|
||||
<Icon name="target" size={16} weight={focusMode ? 'fill' : 'regular'} />
|
||||
집중 모드: {focusMode ? 'ON' : 'OFF'}
|
||||
</FocusToggleButton>
|
||||
<Timer $danger={timerDanger}>
|
||||
<Icon name="clock" size={18} />
|
||||
{formatClock(remainingSeconds)}
|
||||
</Timer>
|
||||
</TopBarActions>
|
||||
</TopBar>
|
||||
|
||||
<Content>
|
||||
@@ -429,7 +456,7 @@ export default function ExamPage() {
|
||||
cancelLabel="계속 풀기"
|
||||
tone="danger"
|
||||
onCancel={() => setShowLeaveConfirm(false)}
|
||||
onConfirm={() => router.push('/study')}
|
||||
onConfirm={() => router.push('/exams')}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -443,6 +470,8 @@ export default function ExamPage() {
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
|
||||
return focusMode ? examContent : <AppShell>{examContent}</AppShell>;
|
||||
}
|
||||
|
||||
function toWholeSeconds(ms: number): number {
|
||||
@@ -456,6 +485,8 @@ const Page = styled.div`
|
||||
radial-gradient(circle at top left, rgba(79, 70, 229, 0.2), transparent 24%),
|
||||
linear-gradient(180deg, #0d1018 0%, #131724 100%);
|
||||
color: ${theme.color.textBright};
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const TopBar = styled.header`
|
||||
@@ -463,7 +494,7 @@ const TopBar = styled.header`
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr) 180px;
|
||||
grid-template-columns: 180px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 72px;
|
||||
@@ -473,7 +504,7 @@ const TopBar = styled.header`
|
||||
background: rgba(15, 15, 20, 0.88);
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
padding: 14px 16px;
|
||||
}
|
||||
`;
|
||||
@@ -489,6 +520,23 @@ const BackButton = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
const TopBarActions = styled.div`
|
||||
justify-self: end;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
@media (max-width: ${theme.breakpoint.mobile}) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
`;
|
||||
|
||||
const TopTitle = styled.h1`
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
@@ -508,7 +556,6 @@ const TopTitle = styled.h1`
|
||||
`;
|
||||
|
||||
const Timer = styled.div<{ $danger: boolean }>`
|
||||
justify-self: end;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -521,6 +568,22 @@ const Timer = styled.div<{ $danger: boolean }>`
|
||||
color: ${({ $danger }) => ($danger ? theme.color.danger : theme.color.textBright)};
|
||||
`;
|
||||
|
||||
const FocusToggleButton = styled.button<{ $active: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
border-radius: ${theme.radius.pill};
|
||||
border: 1px solid
|
||||
${({ $active }) =>
|
||||
$active ? 'rgba(129, 140, 248, 0.44)' : theme.color.borderSoftAlpha};
|
||||
background: ${({ $active }) =>
|
||||
$active ? 'rgba(79, 70, 229, 0.18)' : 'transparent'};
|
||||
color: ${({ $active }) => ($active ? theme.color.textBright : theme.color.textSub)};
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
@@ -903,12 +966,12 @@ const DrawerClose = styled.button`
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
const StateScreen = styled.div`
|
||||
min-height: 100vh;
|
||||
const StateScreen = styled.div<{ $embedded: boolean }>`
|
||||
min-height: ${({ $embedded }) => ($embedded ? '60vh' : '100vh')};
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: ${theme.color.bgDeep};
|
||||
background: ${({ $embedded }) => ($embedded ? 'transparent' : theme.color.bgDeep)};
|
||||
`;
|
||||
|
||||
const StateCard = styled.div`
|
||||
|
||||
@@ -7,7 +7,6 @@ import AppShell from '@/components/layout/AppShell';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ErrorText,
|
||||
@@ -20,20 +19,9 @@ import {
|
||||
Stack,
|
||||
Textarea,
|
||||
} from '@/components/ui/primitives';
|
||||
import {
|
||||
api,
|
||||
getProblemSets,
|
||||
type ProblemSetSummary,
|
||||
type StudyResult,
|
||||
type Subject,
|
||||
} from '@/lib/api';
|
||||
import { api, type StudyResult, type Subject } from '@/lib/api';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
type Mode = 'manual' | 'problemset';
|
||||
|
||||
const SUBJECT_FILTERS = ['국어', '영어', '한국사', '생활과 윤리'] as const;
|
||||
const YEAR_FILTERS = [2026, 2025] as const;
|
||||
|
||||
export default function StudyPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
@@ -47,8 +35,6 @@ function StudyBody() {
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [subjects, setSubjects] = useState<Subject[] | null>(null);
|
||||
const [mode, setMode] = useState<Mode>('manual');
|
||||
|
||||
const [subjectId, setSubjectId] = useState<number | ''>('');
|
||||
const [tagId, setTagId] = useState<number | ''>('');
|
||||
const [title, setTitle] = useState('');
|
||||
@@ -57,12 +43,6 @@ function StudyBody() {
|
||||
const [result, setResult] = useState<StudyResult>('correct');
|
||||
const [memo, setMemo] = useState('');
|
||||
const [timeSpent, setTimeSpent] = useState('');
|
||||
|
||||
const [problemSets, setProblemSets] = useState<ProblemSetSummary[] | null>(null);
|
||||
const [problemSetError, setProblemSetError] = useState<string | null>(null);
|
||||
const [selectedYear, setSelectedYear] = useState<number | 'all'>('all');
|
||||
const [selectedSubject, setSelectedSubject] = useState<string | 'all'>('all');
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
@@ -81,18 +61,6 @@ function StudyBody() {
|
||||
});
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'problemset') return;
|
||||
|
||||
setProblemSetError(null);
|
||||
getProblemSets({
|
||||
year: selectedYear === 'all' ? undefined : selectedYear,
|
||||
subjectName: selectedSubject === 'all' ? undefined : selectedSubject,
|
||||
})
|
||||
.then((data) => setProblemSets(data))
|
||||
.catch(() => setProblemSetError('문제집 목록을 불러오지 못했어. 다시 시도해줘.'));
|
||||
}, [mode, selectedYear, selectedSubject]);
|
||||
|
||||
useEffect(() => {
|
||||
setTagId('');
|
||||
}, [subjectId]);
|
||||
@@ -156,7 +124,7 @@ function StudyBody() {
|
||||
<PageHeader
|
||||
eyebrow="Study"
|
||||
title="새 학습 기록"
|
||||
subtitle="직접 기록하거나, 실제 평가원 문제집 모드로 바로 시험을 시작할 수 있어."
|
||||
subtitle="푼 문제를 직접 기록하고, 복습 큐에 바로 연결해."
|
||||
right={
|
||||
<HeaderBadge>
|
||||
<Icon name="clock-counter-clockwise" size={16} />
|
||||
@@ -166,311 +134,173 @@ function StudyBody() {
|
||||
/>
|
||||
</HeaderCard>
|
||||
|
||||
<ModeTabs role="tablist" aria-label="학습 기록 방식">
|
||||
<TabBtn
|
||||
type="button"
|
||||
$active={mode === 'manual'}
|
||||
onClick={() => setMode('manual')}
|
||||
>
|
||||
<Icon name="pencil-simple" size={16} weight={mode === 'manual' ? 'fill' : 'regular'} />
|
||||
직접 입력
|
||||
</TabBtn>
|
||||
<TabBtn
|
||||
type="button"
|
||||
$active={mode === 'problemset'}
|
||||
onClick={() => setMode('problemset')}
|
||||
>
|
||||
<Icon name="book-open" size={16} weight={mode === 'problemset' ? 'fill' : 'regular'} />
|
||||
문제집에서 풀기
|
||||
</TabBtn>
|
||||
</ModeTabs>
|
||||
|
||||
{mode === 'manual' ? (
|
||||
<form onSubmit={submit}>
|
||||
<StudyCard>
|
||||
<Stack $gap={theme.space.md}>
|
||||
<SectionTitle>기본 정보</SectionTitle>
|
||||
<Row>
|
||||
<Field>
|
||||
<Label>과목</Label>
|
||||
<FieldFrame>
|
||||
<FieldSelect
|
||||
value={subjectId}
|
||||
onChange={(event) => setSubjectId(Number(event.target.value))}
|
||||
>
|
||||
{subjects.map((subject) => (
|
||||
<option key={subject.id} value={subject.id}>
|
||||
{subject.name}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
</FieldFrame>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label>태그 (선택)</Label>
|
||||
<FieldFrame>
|
||||
<FieldSelect
|
||||
value={tagId}
|
||||
onChange={(event) =>
|
||||
setTagId(event.target.value === '' ? '' : Number(event.target.value))
|
||||
}
|
||||
>
|
||||
<option value="">— 없음 —</option>
|
||||
{currentSubject?.tags?.map((tag) => (
|
||||
<option key={tag.id} value={tag.id}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
</FieldFrame>
|
||||
</Field>
|
||||
</Row>
|
||||
|
||||
<div>
|
||||
<Label>문제 제목</Label>
|
||||
<FieldFrame>
|
||||
<FieldInput
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder="예) 2026 6월 국어 18번"
|
||||
required
|
||||
/>
|
||||
</FieldFrame>
|
||||
</div>
|
||||
|
||||
<SectionTitle>풀이 결과</SectionTitle>
|
||||
<ResultGrid>
|
||||
<ResultChoice
|
||||
type="button"
|
||||
$active={result === 'correct'}
|
||||
$tone="success"
|
||||
onClick={() => setResult('correct')}
|
||||
>
|
||||
<ResultMark>
|
||||
<Icon name="check" weight="bold" size={18} />
|
||||
</ResultMark>
|
||||
<ResultLabel>맞음</ResultLabel>
|
||||
</ResultChoice>
|
||||
<ResultChoice
|
||||
type="button"
|
||||
$active={result === 'partial'}
|
||||
$tone="warning"
|
||||
onClick={() => setResult('partial')}
|
||||
>
|
||||
<ResultMark>
|
||||
<Icon name="triangle" weight="fill" size={18} />
|
||||
</ResultMark>
|
||||
<ResultLabel>부분</ResultLabel>
|
||||
</ResultChoice>
|
||||
<ResultChoice
|
||||
type="button"
|
||||
$active={result === 'incorrect'}
|
||||
$tone="danger"
|
||||
onClick={() => setResult('incorrect')}
|
||||
>
|
||||
<ResultMark>
|
||||
<Icon name="x" weight="bold" size={18} />
|
||||
</ResultMark>
|
||||
<ResultLabel>틀림</ResultLabel>
|
||||
</ResultChoice>
|
||||
</ResultGrid>
|
||||
|
||||
<div>
|
||||
<SliderHeader>
|
||||
<Label>체감 난이도</Label>
|
||||
<SliderValue>{Math.round(difficulty * 100)}%</SliderValue>
|
||||
</SliderHeader>
|
||||
<RangeInput
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={difficulty}
|
||||
onChange={(event) => setDifficulty(Number(event.target.value))}
|
||||
/>
|
||||
<HelpText>0%에 가까울수록 쉬움, 100%에 가까울수록 어려움.</HelpText>
|
||||
</div>
|
||||
|
||||
<Row>
|
||||
<Field>
|
||||
<Label>기준 정답률 (선택)</Label>
|
||||
<FieldFrame>
|
||||
<FieldInput
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={baseCorrectRate}
|
||||
onChange={(event) => setBaseCorrectRate(event.target.value)}
|
||||
placeholder="예) 45"
|
||||
/>
|
||||
</FieldFrame>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label>풀이 시간 (초, 선택)</Label>
|
||||
<FieldFrame>
|
||||
<FieldInput
|
||||
type="number"
|
||||
min={0}
|
||||
value={timeSpent}
|
||||
onChange={(event) => setTimeSpent(event.target.value)}
|
||||
placeholder="예) 180"
|
||||
/>
|
||||
</FieldFrame>
|
||||
</Field>
|
||||
</Row>
|
||||
|
||||
<div>
|
||||
<Label>메모 (선택)</Label>
|
||||
<FieldFrame>
|
||||
<FieldTextarea
|
||||
value={memo}
|
||||
onChange={(event) => setMemo(event.target.value)}
|
||||
placeholder="어디서 막혔는지, 무엇을 배웠는지 짧게 남겨봐."
|
||||
/>
|
||||
</FieldFrame>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<ErrorBox role="alert" aria-live="polite">
|
||||
<Icon name="info" size={16} weight="bold" />
|
||||
<ErrorText as="span">{err}</ErrorText>
|
||||
</ErrorBox>
|
||||
)}
|
||||
|
||||
<ButtonRow>
|
||||
<Button
|
||||
type="button"
|
||||
$variant="ghost"
|
||||
disabled={submitting}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<PrimarySubmit type="submit" $size="lg" $block disabled={submitting}>
|
||||
{submitting ? '저장 중...' : '기록하고 복습 예약'}
|
||||
</PrimarySubmit>
|
||||
</ButtonRow>
|
||||
</Stack>
|
||||
</StudyCard>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={submit}>
|
||||
<StudyCard>
|
||||
<Stack $gap={theme.space.lg}>
|
||||
<div>
|
||||
<SectionTitle>문제집 선택</SectionTitle>
|
||||
<HelpText>
|
||||
실전처럼 시간을 재면서 풀고, 제출하면 각 문항이 자동 채점되어 복습 큐에
|
||||
들어가.
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<FilterRow>
|
||||
<FilterField>
|
||||
<Label>연도</Label>
|
||||
<FieldFrame>
|
||||
<FieldSelect
|
||||
value={selectedYear}
|
||||
onChange={(event) =>
|
||||
setSelectedYear(
|
||||
event.target.value === 'all' ? 'all' : Number(event.target.value),
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
{YEAR_FILTERS.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
</FieldFrame>
|
||||
</FilterField>
|
||||
|
||||
<FilterField>
|
||||
<Stack $gap={theme.space.md}>
|
||||
<SectionTitle>기본 정보</SectionTitle>
|
||||
<Row>
|
||||
<Field>
|
||||
<Label>과목</Label>
|
||||
<FieldFrame>
|
||||
<FieldSelect
|
||||
value={selectedSubject}
|
||||
onChange={(event) => setSelectedSubject(event.target.value)}
|
||||
value={subjectId}
|
||||
onChange={(event) => setSubjectId(Number(event.target.value))}
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
{SUBJECT_FILTERS.map((subject) => (
|
||||
<option key={subject} value={subject}>
|
||||
{subject}
|
||||
{subjects.map((subject) => (
|
||||
<option key={subject.id} value={subject.id}>
|
||||
{subject.name}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
</FieldFrame>
|
||||
</FilterField>
|
||||
</FilterRow>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label>태그 (선택)</Label>
|
||||
<FieldFrame>
|
||||
<FieldSelect
|
||||
value={tagId}
|
||||
onChange={(event) =>
|
||||
setTagId(event.target.value === '' ? '' : Number(event.target.value))
|
||||
}
|
||||
>
|
||||
<option value="">— 없음 —</option>
|
||||
{currentSubject?.tags?.map((tag) => (
|
||||
<option key={tag.id} value={tag.id}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
</FieldFrame>
|
||||
</Field>
|
||||
</Row>
|
||||
|
||||
{problemSetError && (
|
||||
<ErrorBox role="alert">
|
||||
<div>
|
||||
<Label>문제 제목</Label>
|
||||
<FieldFrame>
|
||||
<FieldInput
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder="예) 2026 6월 국어 18번"
|
||||
required
|
||||
/>
|
||||
</FieldFrame>
|
||||
</div>
|
||||
|
||||
<SectionTitle>풀이 결과</SectionTitle>
|
||||
<ResultGrid>
|
||||
<ResultChoice
|
||||
type="button"
|
||||
$active={result === 'correct'}
|
||||
$tone="success"
|
||||
onClick={() => setResult('correct')}
|
||||
>
|
||||
<ResultMark>
|
||||
<Icon name="check" weight="bold" size={18} />
|
||||
</ResultMark>
|
||||
<ResultLabel>맞음</ResultLabel>
|
||||
</ResultChoice>
|
||||
<ResultChoice
|
||||
type="button"
|
||||
$active={result === 'partial'}
|
||||
$tone="warning"
|
||||
onClick={() => setResult('partial')}
|
||||
>
|
||||
<ResultMark>
|
||||
<Icon name="triangle" weight="fill" size={18} />
|
||||
</ResultMark>
|
||||
<ResultLabel>부분</ResultLabel>
|
||||
</ResultChoice>
|
||||
<ResultChoice
|
||||
type="button"
|
||||
$active={result === 'incorrect'}
|
||||
$tone="danger"
|
||||
onClick={() => setResult('incorrect')}
|
||||
>
|
||||
<ResultMark>
|
||||
<Icon name="x" weight="bold" size={18} />
|
||||
</ResultMark>
|
||||
<ResultLabel>틀림</ResultLabel>
|
||||
</ResultChoice>
|
||||
</ResultGrid>
|
||||
|
||||
<div>
|
||||
<SliderHeader>
|
||||
<Label>체감 난이도</Label>
|
||||
<SliderValue>{Math.round(difficulty * 100)}%</SliderValue>
|
||||
</SliderHeader>
|
||||
<RangeInput
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={difficulty}
|
||||
onChange={(event) => setDifficulty(Number(event.target.value))}
|
||||
/>
|
||||
<HelpText>0%에 가까울수록 쉬움, 100%에 가까울수록 어려움.</HelpText>
|
||||
</div>
|
||||
|
||||
<Row>
|
||||
<Field>
|
||||
<Label>기준 정답률 (선택)</Label>
|
||||
<FieldFrame>
|
||||
<FieldInput
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={baseCorrectRate}
|
||||
onChange={(event) => setBaseCorrectRate(event.target.value)}
|
||||
placeholder="예) 45"
|
||||
/>
|
||||
</FieldFrame>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label>풀이 시간 (초, 선택)</Label>
|
||||
<FieldFrame>
|
||||
<FieldInput
|
||||
type="number"
|
||||
min={0}
|
||||
value={timeSpent}
|
||||
onChange={(event) => setTimeSpent(event.target.value)}
|
||||
placeholder="예) 180"
|
||||
/>
|
||||
</FieldFrame>
|
||||
</Field>
|
||||
</Row>
|
||||
|
||||
<div>
|
||||
<Label>메모 (선택)</Label>
|
||||
<FieldFrame>
|
||||
<FieldTextarea
|
||||
value={memo}
|
||||
onChange={(event) => setMemo(event.target.value)}
|
||||
placeholder="어디서 막혔는지, 무엇을 배웠는지 짧게 남겨봐."
|
||||
/>
|
||||
</FieldFrame>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<ErrorBox role="alert" aria-live="polite">
|
||||
<Icon name="info" size={16} weight="bold" />
|
||||
<ErrorText as="span">{problemSetError}</ErrorText>
|
||||
<ErrorText as="span">{err}</ErrorText>
|
||||
</ErrorBox>
|
||||
)}
|
||||
|
||||
{problemSets === null ? (
|
||||
<CatalogLoading>문제집 목록을 불러오는 중...</CatalogLoading>
|
||||
) : problemSets.length === 0 ? (
|
||||
<CatalogEmpty>
|
||||
<Icon name="book-open-text" size={28} />
|
||||
조건에 맞는 문제집이 아직 없어.
|
||||
</CatalogEmpty>
|
||||
) : (
|
||||
<ProblemSetGrid>
|
||||
{problemSets.map((problemSet) => {
|
||||
const reviewFlags = problemSet.problems ?? [];
|
||||
const isAutoGradeUnavailable =
|
||||
reviewFlags.length > 0 &&
|
||||
reviewFlags.every((problem) => problem.needsReview);
|
||||
|
||||
return (
|
||||
<ProblemSetCard key={problemSet.id}>
|
||||
<CardTop>
|
||||
<CardTags>
|
||||
<MetaChip>{problemSet.year}</MetaChip>
|
||||
<MetaChip>{problemSet.subjectName}</MetaChip>
|
||||
{isAutoGradeUnavailable && (
|
||||
<Badge $variant="warning">정답 자동 채점 미지원</Badge>
|
||||
)}
|
||||
</CardTags>
|
||||
<ProblemCount>
|
||||
{(problemSet._count?.problems ?? 0).toLocaleString()}문항
|
||||
</ProblemCount>
|
||||
</CardTop>
|
||||
|
||||
<ProblemSetTitle>{problemSet.title}</ProblemSetTitle>
|
||||
|
||||
<ProblemSetMeta>
|
||||
<MetaLine>
|
||||
<Icon name="clock" size={16} />
|
||||
실전 타이머 포함
|
||||
</MetaLine>
|
||||
<MetaLine>
|
||||
<Icon name="check-square" size={16} />
|
||||
제출 후 자동 채점 및 학습 기록 생성
|
||||
</MetaLine>
|
||||
</ProblemSetMeta>
|
||||
|
||||
<ButtonRow>
|
||||
<Button
|
||||
type="button"
|
||||
$variant="secondary"
|
||||
onClick={() => router.push(`/study/exam/${problemSet.id}`)}
|
||||
>
|
||||
시작
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
</ProblemSetCard>
|
||||
);
|
||||
})}
|
||||
</ProblemSetGrid>
|
||||
)}
|
||||
<ButtonRow>
|
||||
<Button
|
||||
type="button"
|
||||
$variant="ghost"
|
||||
disabled={submitting}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<PrimarySubmit type="submit" $size="lg" $block disabled={submitting}>
|
||||
{submitting ? '저장 중...' : '기록하고 복습 예약'}
|
||||
</PrimarySubmit>
|
||||
</ButtonRow>
|
||||
</Stack>
|
||||
</StudyCard>
|
||||
)}
|
||||
</form>
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
@@ -501,43 +331,8 @@ const HeaderBadge = styled.span`
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const ModeTabs = styled.div`
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const TabBtn = styled.button<{ $active: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
padding: 0 18px;
|
||||
border-radius: ${theme.radius.pill};
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
background: ${theme.color.surfaceDeep};
|
||||
color: ${theme.color.textSub};
|
||||
font-weight: 700;
|
||||
transition: 0.15s ease;
|
||||
|
||||
${({ $active }) =>
|
||||
$active &&
|
||||
css`
|
||||
background: rgba(79, 70, 229, 0.2);
|
||||
border-color: rgba(129, 140, 248, 0.42);
|
||||
color: ${theme.color.textBright};
|
||||
box-shadow: ${theme.shadow.glowIndigo};
|
||||
`}
|
||||
`;
|
||||
|
||||
const StudyCard = styled(Card)`
|
||||
background: ${theme.color.surfaceDeep};
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: ${theme.color.borderSoftAlpha};
|
||||
`;
|
||||
|
||||
@@ -554,19 +349,7 @@ const Row = styled.div`
|
||||
const Field = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
`;
|
||||
|
||||
const FilterRow = styled(Row)`
|
||||
align-items: end;
|
||||
`;
|
||||
|
||||
const FilterField = styled(Field)`
|
||||
max-width: 240px;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
max-width: none;
|
||||
}
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const FieldFrame = styled.div`
|
||||
@@ -684,100 +467,6 @@ const PrimarySubmit = styled(Button)`
|
||||
border: none;
|
||||
`;
|
||||
|
||||
const ProblemSetGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: ${theme.space.md};
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const ProblemSetCard = styled(Card)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(124, 58, 237, 0.12), transparent 34%),
|
||||
rgba(255, 255, 255, 0.02);
|
||||
border-color: ${theme.color.borderSoftAlpha};
|
||||
`;
|
||||
|
||||
const CardTop = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.mobile}) {
|
||||
flex-direction: column;
|
||||
}
|
||||
`;
|
||||
|
||||
const CardTags = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const MetaChip = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: ${theme.radius.pill};
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const ProblemCount = styled.span`
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 13px;
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
|
||||
const ProblemSetTitle = styled.h2`
|
||||
font-size: 20px;
|
||||
line-height: 1.45;
|
||||
color: ${theme.color.textBright};
|
||||
`;
|
||||
|
||||
const ProblemSetMeta = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
const MetaLine = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const CatalogLoading = styled.div`
|
||||
min-height: 240px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: ${theme.color.textSub};
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
|
||||
const CatalogEmpty = styled.div`
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
border-radius: ${theme.radius.lg};
|
||||
border: 1px dashed ${theme.color.borderSoftAlpha};
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
const Empty = styled.div`
|
||||
min-height: 60vh;
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -148,6 +149,10 @@ export default function AuthCard({ initialTab }: AuthCardProps) {
|
||||
<GlowTop />
|
||||
<GlowBottom />
|
||||
<Shell>
|
||||
<HomeLink href="/">
|
||||
<Icon name="arrow-left" size={16} />
|
||||
홈으로
|
||||
</HomeLink>
|
||||
<BrandBlock>
|
||||
<LogoMark>
|
||||
<LogoGlow />
|
||||
@@ -301,6 +306,21 @@ const Shell = styled.div`
|
||||
max-width: 480px;
|
||||
`;
|
||||
|
||||
const HomeLink = styled(Link)`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
color: ${theme.color.textMute};
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${theme.color.textBright};
|
||||
}
|
||||
`;
|
||||
|
||||
const BrandBlock = styled.header`
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
|
||||
@@ -13,6 +13,7 @@ const TABS = [
|
||||
{ href: '/study', label: '학습', icon: 'pencil-simple' as const },
|
||||
{ href: '/subjects', label: '과목', icon: 'folders' as const },
|
||||
{ href: '/stats', label: '통계', icon: 'trend-up' as const },
|
||||
{ href: '/profile', label: '프로필', icon: 'user' as const },
|
||||
];
|
||||
|
||||
export default function BottomNav() {
|
||||
@@ -58,13 +59,13 @@ const Nav = styled.nav`
|
||||
|
||||
const Tab = styled(Link)<{ $active: boolean }>`
|
||||
flex: 1;
|
||||
min-height: 72px;
|
||||
min-height: 68px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 8px;
|
||||
padding: 8px 4px;
|
||||
color: ${({ $active }) => ($active ? theme.color.textBright : theme.color.textMute)};
|
||||
transition: color 0.15s ease;
|
||||
border-top: 2px solid
|
||||
@@ -80,7 +81,8 @@ const IconWrap = styled.span`
|
||||
`;
|
||||
|
||||
const Label = styled.span`
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
`;
|
||||
|
||||
@@ -28,6 +28,13 @@ const NAV_ITEMS: Array<{
|
||||
icon: 'book-open',
|
||||
match: (pathname) => pathname.startsWith('/study/history'),
|
||||
},
|
||||
{
|
||||
href: '/exams',
|
||||
label: '문제집',
|
||||
icon: 'books',
|
||||
match: (pathname) =>
|
||||
pathname.startsWith('/exams') || pathname.startsWith('/study/exam'),
|
||||
},
|
||||
{
|
||||
href: '/review',
|
||||
label: '복습',
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
MagnifyingGlass, DownloadSimple, CalendarBlank, CalendarCheck, BellRinging, Bell, Sparkle,
|
||||
Minus, DotsThree, Translate, BookOpenText, SkipForward, Info, SignOut, Camera, Crown,
|
||||
Export, Gear, ArrowRight, type IconProps as PhIconProps, type IconWeight,
|
||||
Scales, Feather, Clock, Flag, CheckSquare, Circle, ArrowLeft,
|
||||
Scales, Feather, Clock, Flag, CheckSquare, Circle, ArrowLeft, User, Books,
|
||||
} from '@phosphor-icons/react';
|
||||
|
||||
// name → component map. 새 아이콘 추가 시 이 맵에만 등록.
|
||||
@@ -80,6 +80,8 @@ const ICON_MAP = {
|
||||
'flag': Flag,
|
||||
'check-square': CheckSquare,
|
||||
'circle': Circle,
|
||||
'user': User,
|
||||
'books': Books,
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof ICON_MAP;
|
||||
|
||||
Reference in New Issue
Block a user