feat: 시험 시작 전 타이머 설정 화면 추가
- 문제집 진입 시 시작 화면에서 시험 시간을 설정할 수 있음 - 빠른 선택: 30분/60분/100분/120분 버튼 - 직접 입력: 1~300분 숫자 입력 - 시작 버튼 클릭 후 카운트다운 시작 (집중모드) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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};
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user