feat: 시험 시작 전 타이머 설정 화면 추가

- 문제집 진입 시 시작 화면에서 시험 시간을 설정할 수 있음
- 빠른 선택: 30분/60분/100분/120분 버튼
- 직접 입력: 1~300분 숫자 입력
- 시작 버튼 클릭 후 카운트다운 시작 (집중모드)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
reloop
2026-04-19 16:56:31 +09:00
parent 5db1520e67
commit 132a7e397a

View File

@@ -68,6 +68,8 @@ export default function ExamPage() {
const [audioDuration, setAudioDuration] = useState(0);
const [audioPlaying, setAudioPlaying] = useState(false);
const [assignmentNotice, setAssignmentNotice] = useState<{ id: number; className: string } | null>(null);
const [examStarted, setExamStarted] = useState(false);
const [customMinutes, setCustomMinutes] = useState(100);
const examStartedAtRef = useRef<number>(0);
const problemEnteredAtRef = useRef<number>(0);
@@ -130,9 +132,9 @@ export default function ExamPage() {
setAnswers({});
setFlagged({});
setVisited(data.problems[initialIndex] ? { [data.problems[initialIndex].id]: true } : {});
setRemainingSeconds(getExamDurationSeconds(data.subjectName));
examStartedAtRef.current = Date.now();
problemEnteredAtRef.current = Date.now();
setRemainingSeconds(0);
setExamStarted(false);
setCustomMinutes(Math.round(getExamDurationSeconds(data.subjectName) / 60));
problemTimeMsRef.current = {};
submittedRef.current = false;
setShowLeaveConfirm(false);
@@ -152,6 +154,15 @@ export default function ExamPage() {
}
};
const startExam = (minutes: number) => {
setRemainingSeconds(minutes * 60);
examStartedAtRef.current = Date.now();
problemEnteredAtRef.current = Date.now();
problemTimeMsRef.current = {};
submittedRef.current = false;
setExamStarted(true);
};
useEffect(() => {
if (!hasToken()) {
router.replace('/login');
@@ -290,20 +301,20 @@ export default function ExamPage() {
}, [audioTracks, currentProblem, hasAudioTracks]);
useEffect(() => {
if (!problemSet || submitting || submittedRef.current) return;
if (!problemSet || !examStarted || submitting || submittedRef.current) return;
const timer = window.setInterval(() => {
setRemainingSeconds((prev) => Math.max(0, prev - 1));
}, 1000);
return () => window.clearInterval(timer);
}, [problemSet, submitting]);
}, [problemSet, examStarted, submitting]);
useEffect(() => {
if (!problemSet || submitting || submittedRef.current) return;
if (!problemSet || !examStarted || submitting || submittedRef.current) return;
if (remainingSeconds !== 0) return;
void finishExam(true);
}, [problemSet, remainingSeconds, submitting]);
}, [problemSet, examStarted, remainingSeconds, submitting]);
useEffect(() => {
if (!menuOpen) return;
@@ -540,6 +551,52 @@ export default function ExamPage() {
return focusMode ? errorState : <AppShell>{errorState}</AppShell>;
}
if (!examStarted) {
const startScreen = (
<StateScreen $embedded={!focusMode}>
<StartCard>
<StartHeader>
<Icon name="clock" size={32} color={theme.color.brandIndigo} />
<StartTitle>{problemSet.title}</StartTitle>
<StartMeta>{problemSet.problems.length}</StartMeta>
</StartHeader>
<TimerSetup>
<TimerLabel> </TimerLabel>
<TimerInputRow>
<TimerQuickBtn type="button" $active={customMinutes === 30} onClick={() => setCustomMinutes(30)}>30</TimerQuickBtn>
<TimerQuickBtn type="button" $active={customMinutes === 60} onClick={() => setCustomMinutes(60)}>60</TimerQuickBtn>
<TimerQuickBtn type="button" $active={customMinutes === 100} onClick={() => setCustomMinutes(100)}>100</TimerQuickBtn>
<TimerQuickBtn type="button" $active={customMinutes === 120} onClick={() => setCustomMinutes(120)}>120</TimerQuickBtn>
</TimerInputRow>
<TimerCustomRow>
<TimerInput
type="number"
min={1}
max={300}
value={customMinutes}
onChange={(e) => setCustomMinutes(Math.max(1, Math.min(300, Number(e.target.value) || 1)))}
/>
<TimerUnit></TimerUnit>
</TimerCustomRow>
</TimerSetup>
<StartActions>
<StartBackBtn type="button" onClick={() => router.push('/exams')}>
<Icon name="arrow-left" size={16} />
</StartBackBtn>
<StartBtn type="button" onClick={() => startExam(customMinutes)}>
<Icon name="caret-right" size={20} weight="fill" />
</StartBtn>
</StartActions>
</StartCard>
</StateScreen>
);
return focusMode ? startScreen : <AppShell>{startScreen}</AppShell>;
}
const timerDanger = remainingSeconds <= 5 * 60;
const answeredCurrent = answers[currentProblem.id] !== undefined && answers[currentProblem.id] !== null;
@@ -1823,3 +1880,161 @@ const RetryButton = styled.button`
color: white;
font-weight: 700;
`;
const StartCard = styled.div`
${Surface};
width: min(480px, 100%);
display: flex;
flex-direction: column;
gap: 28px;
padding: 36px 32px;
text-align: center;
`;
const StartHeader = styled.div`
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
`;
const StartTitle = styled.h1`
margin: 0;
font-size: 22px;
font-weight: 800;
color: ${theme.color.textBright};
`;
const StartMeta = styled.span`
font-size: 14px;
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
`;
const TimerSetup = styled.div`
display: flex;
flex-direction: column;
gap: 14px;
`;
const TimerLabel = styled.span`
font-size: 13px;
font-weight: 700;
color: ${theme.color.textSub};
text-transform: uppercase;
letter-spacing: 0.06em;
`;
const TimerInputRow = styled.div`
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
`;
const TimerQuickBtn = styled.button<{ $active: boolean }>`
min-width: 72px;
min-height: 42px;
padding: 0 16px;
border-radius: 12px;
border: 1px solid
${({ $active }) =>
$active ? 'rgba(129, 140, 248, 0.6)' : theme.color.borderSoftAlpha};
background: ${({ $active }) =>
$active ? 'rgba(79, 70, 229, 0.2)' : 'rgba(255, 255, 255, 0.03)'};
color: ${({ $active }) =>
$active ? theme.color.textBright : theme.color.textSub};
font-weight: 700;
font-size: 14px;
transition: all 0.12s ease;
&:hover {
border-color: rgba(129, 140, 248, 0.4);
color: ${theme.color.textBright};
}
`;
const TimerCustomRow = styled.div`
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
`;
const TimerInput = styled.input`
width: 80px;
height: 44px;
padding: 0 12px;
border-radius: 12px;
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255, 255, 255, 0.04);
color: ${theme.color.textBright};
font-size: 18px;
font-weight: 700;
font-family: ${theme.font.mono};
text-align: center;
outline: none;
&:focus {
border-color: ${theme.color.brandIndigo};
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
-moz-appearance: textfield;
`;
const TimerUnit = styled.span`
font-size: 16px;
font-weight: 600;
color: ${theme.color.textSub};
`;
const StartActions = styled.div`
display: flex;
gap: 12px;
justify-content: center;
`;
const StartBackBtn = styled.button`
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 48px;
padding: 0 20px;
border-radius: 14px;
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255, 255, 255, 0.03);
color: ${theme.color.textSub};
font-weight: 700;
transition: all 0.12s ease;
&:hover {
color: ${theme.color.textBright};
border-color: ${theme.color.borderBrightAlpha};
}
`;
const StartBtn = styled.button`
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 48px;
padding: 0 28px;
border-radius: 14px;
background: ${theme.color.brandGradient};
color: white;
font-size: 16px;
font-weight: 800;
box-shadow: ${theme.shadow.glowIndigo};
transition: all 0.15s ease;
&:hover {
transform: translateY(-1px);
box-shadow: ${theme.shadow.glowIndigoStrong};
}
`;