feat(ia): /exams route + focus toggle + mobile logout access — 7G.1

This commit is contained in:
reloop
2026-04-12 07:07:37 +09:00
parent 76c5026cd3
commit 47a70f226b
12 changed files with 623 additions and 486 deletions

View File

@@ -0,0 +1 @@
1775934117

View File

View 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};
`;

View File

@@ -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);
`;

View File

@@ -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`

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
`;

View File

@@ -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: '복습',

View File

@@ -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;