diff --git a/frontend/src/components/Common/Loading.tsx b/frontend/src/components/Common/Loading.tsx new file mode 100644 index 0000000..cb74d11 --- /dev/null +++ b/frontend/src/components/Common/Loading.tsx @@ -0,0 +1,35 @@ +interface Props { + size: string; + color: string; +} + +export default function Loading({ size, color }: Props) { + return ( + + + + + + ); +} diff --git a/frontend/src/components/Submission/Connecting.tsx b/frontend/src/components/Submission/Connecting.tsx new file mode 100644 index 0000000..3ba2fdc --- /dev/null +++ b/frontend/src/components/Submission/Connecting.tsx @@ -0,0 +1,22 @@ +import { css } from '@style/css'; + +import Loading from '@/components/Common/Loading'; + +interface Props { + isConnected: boolean; +} + +export default function Connecting(props: Props) { + if (!props.isConnected) return null; + + return ( +
+ 연결 중... + +
+ ); +} +const rowStyle = css({ + display: 'flex', + gap: '0.5rem', +}); diff --git a/frontend/src/components/Submission/SubmissionResult.tsx b/frontend/src/components/Submission/SubmissionResult.tsx index a6d9fe6..2d6341e 100644 --- a/frontend/src/components/Submission/SubmissionResult.tsx +++ b/frontend/src/components/Submission/SubmissionResult.tsx @@ -2,6 +2,7 @@ import { css } from '@style/css'; import { useEffect, useState } from 'react'; +import Connecting from '@/components/Submission/Connecting'; import { range } from '@/utils/array'; import type { Socket } from '@/utils/socket'; @@ -10,6 +11,7 @@ import { type Message, type ScoreResult, SUBMIT_STATE, type SubmitState } from ' interface Props { socket: Socket; + isConnected: boolean; } type SubmitResult = { @@ -18,7 +20,7 @@ type SubmitResult = { score?: ScoreResult; }; -export function SubmissionResult({ socket }: Props) { +export function SubmissionResult({ socket, isConnected }: Props) { const [scoreResults, setScoreResults] = useState([]); const [submissionMessage, setSubmissionMessage] = useState(''); @@ -71,6 +73,7 @@ export function SubmissionResult({ socket }: Props) { return ( <>
+

{submissionMessage}

{scoreResults.map(({ score, submitState, testcaseId }) => ( diff --git a/frontend/src/components/Timer/index.tsx b/frontend/src/components/Timer/index.tsx new file mode 100644 index 0000000..ff15dfe --- /dev/null +++ b/frontend/src/components/Timer/index.tsx @@ -0,0 +1,60 @@ +import { css } from '@style/css'; + +import Loading from '@/components/Common/Loading'; +import useTimer from '@/hooks/timer/useTimer'; +import { formatMilliSecond } from '@/utils/date'; +import type { Socket } from '@/utils/socket'; + +interface Props { + socket: Socket; + endsAt: Date; + isConnected?: boolean; +} + +export default function Timer(props: Props) { + let { socket, endsAt, isConnected } = props; + // api 연결이 X endsAt 대신 임시로 만들어놓은 것. + endsAt = new Date('2023-11-29T13:10:10.000Z'); + const { remainMiliSeconds } = useTimer({ socket, endsAt }); + + if (isConnected && remainMiliSeconds !== -1) { + // 연결도 되어있고, 서버 시간도 도착해서 count down을 시작할 수 있을 때 + return ( +
+
+ {formatMilliSecond(remainMiliSeconds, 'hh:mm:ss')} +
+
+ ); + } + + return ( +
+
+
+ 연결 중... + +
+
+
+ ); +} + +const wrapperStyle = css({ + display: 'flex', + alignItems: 'center', +}); + +const disconnectedStyle = css({ + color: 'darkred', +}); + +const loadingBoxStyle = css({ + display: 'flex', + gap: '1rem', +}); + +const timeTextStyle = css({ + color: 'lightgray', + fontWeight: 'bold', +}); diff --git a/frontend/src/hooks/competition/useCompetition.ts b/frontend/src/hooks/competition/useCompetition.ts index dd19dd9..a4204b8 100644 --- a/frontend/src/hooks/competition/useCompetition.ts +++ b/frontend/src/hooks/competition/useCompetition.ts @@ -25,6 +25,7 @@ const notFoundCompetition: CompetitionInfo = { export const useCompetition = (competitionId: number) => { const [competition, setCompetition] = useState(notFoundCompetition); + const [isConnected, setIsConnected] = useState(false); const socket = useRef( createSocketInstance('/competitions', { @@ -38,10 +39,12 @@ export const useCompetition = (competitionId: number) => { const handleConnect = () => { console.log('connected!'); + setIsConnected(true); }; const handleDisconnect = () => { console.log('disconnected!'); + setIsConnected(false); }; useEffect(() => { @@ -77,5 +80,6 @@ export const useCompetition = (competitionId: number) => { socket, competition, submitSolution, + isConnected, }; }; diff --git a/frontend/src/hooks/timer/useTimer.ts b/frontend/src/hooks/timer/useTimer.ts new file mode 100644 index 0000000..fba4d63 --- /dev/null +++ b/frontend/src/hooks/timer/useTimer.ts @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { Socket } from '@/utils/socket'; + +interface UseTimer { + socket: Socket; + endsAt: Date; + onTimeoutHandler?: () => void; +} + +export default function useTimer({ socket, endsAt, onTimeoutHandler }: UseTimer) { + const timerIntervalId = useRef(null); + const endTime = useMemo(() => endsAt.getTime(), [endsAt]); + const [remainMiliSeconds, setRemainMiliSeconds] = useState(-1); + useEffect(() => { + console.log('타이머 실행'); + // 웹 소켓 대신 사용. + mockWebSocket(); + if (!socket.hasListeners('ping')) { + socket.on('ping', handlePingMessage); + } + }, [socket]); + + const handlePingMessage = useCallback((time: Date | string) => { + if (timerIntervalId.current) clearInterval(timerIntervalId.current); + + time = typeof time === 'string' ? new Date(time) : time; + const remainMiliSec = endTime - time.getTime(); + setRemainMiliSeconds(remainMiliSec); + timerIntervalId.current = setInterval(() => { + console.log('1초마다 실행'); + setRemainMiliSeconds((prev) => prev - 1000); + }, 1000); + }, []); + + // 웹 소켓 대신 사용. + // 웹 소켓 연결 후 삭제 예정 + const mockWebSocket = useCallback(() => { + const delayFactor = 2000; + setInterval(() => { + console.log('ping 5초( + 네트워크 지연) 마다 실행'); + const serverTime = new Date(); + handlePingMessage(serverTime); + }, 5000 + Math.random() * delayFactor); + }, []); + + useEffect(() => { + // TODO time 0이면 대시보드로 이동하는 로직 + // 해당 PR에서 해결할 문제는 아니라 PASS + if (Math.floor(remainMiliSeconds / 1000) <= 0) { + if (typeof onTimeoutHandler === 'function') onTimeoutHandler(); + // 나가는 로직 + } + }, [remainMiliSeconds]); + return { remainMiliSeconds }; +} diff --git a/frontend/src/pages/ContestPage.tsx b/frontend/src/pages/ContestPage.tsx index 5352823..fe55a8f 100644 --- a/frontend/src/pages/ContestPage.tsx +++ b/frontend/src/pages/ContestPage.tsx @@ -11,6 +11,7 @@ import ProblemViewer from '@/components/Problem/ProblemViewer'; import { SimulationInputModal } from '@/components/Simulation/SimulationInputModal'; import { SimulationResultList } from '@/components/Simulation/SimulationResultList'; import { SubmissionResult } from '@/components/Submission'; +import Timer from '@/components/Timer'; import { SITE } from '@/constants'; import type { SubmissionForm } from '@/hooks/competition'; import { useCompetition } from '@/hooks/competition'; @@ -30,7 +31,7 @@ export default function ContestPage() { const simulation = useSimulation(); - const { socket, competition, submitSolution } = useCompetition(competitionId); + const { socket, competition, submitSolution, isConnected } = useCompetition(competitionId); const { problemList } = useCompetitionProblemList(competitionId); const currentProblem = useMemo(() => { @@ -81,17 +82,22 @@ export default function ContestPage() { submitSolution(form); } + + const { endsAt } = competition; + function handleOpenModal() { modal.open(); } - + const problems = problemList.map((problem) => problem.id); + return (
{problem.title} +
- + @@ -154,3 +160,8 @@ const problemTitleStyle = css({ const execButtonStyle = css({ color: 'black', }); + +const rowStyle = css({ + display: 'flex', + justifyContent: 'space-between', +}); diff --git a/frontend/src/utils/date/index.ts b/frontend/src/utils/date/index.ts index 367eb7a..96ed403 100644 --- a/frontend/src/utils/date/index.ts +++ b/frontend/src/utils/date/index.ts @@ -1,5 +1,8 @@ const ONE_SEC_BY_MS = 1_000; const ONE_MIN_BY_MS = 60 * ONE_SEC_BY_MS; +const ONE_MIN_BY_SEC = 60; +const ONE_HOUR_BY_MIN = 60; +const ONE_HOUR_BY_SEC = ONE_HOUR_BY_MIN * ONE_MIN_BY_SEC; export function toLocalDate(date: Date) { const localTimeOffset = date.getTimezoneOffset() * ONE_MIN_BY_MS; @@ -15,3 +18,16 @@ export const formatDate = (date: Date, form: string) => { return ''; }; + +export const formatMilliSecond = (ms: number, form: string) => { + const sec = Math.floor(ms / ONE_SEC_BY_MS); + + if (form === 'hh:mm:ss') { + // 시간(초)을 'hh:mm:ss' 형식으로 변환 + const hours = Math.floor(sec / ONE_HOUR_BY_SEC); + const minutes = Math.floor((sec % ONE_HOUR_BY_SEC) / ONE_MIN_BY_SEC); + const seconds = sec % ONE_MIN_BY_SEC; + return [hours, minutes, seconds].map((time) => String(time).padStart(2, '0')).join(':'); + } + return ''; +};