diff --git a/frontend/src/apis/competitionList/index.ts b/frontend/src/apis/competitionList/index.ts new file mode 100644 index 0000000..7850910 --- /dev/null +++ b/frontend/src/apis/competitionList/index.ts @@ -0,0 +1,13 @@ +import api from '@/utils/api'; + +import { Competition } from './types'; + +export const fetchCompetitionList = async (): Promise => { + try { + const response = await api.get('/competitions'); + return response.data; + } catch (error) { + console.error('Error fetching competitions:', (error as Error).message); + throw error; + } +}; diff --git a/frontend/src/apis/competitionList/types.ts b/frontend/src/apis/competitionList/types.ts new file mode 100644 index 0000000..69f1ea7 --- /dev/null +++ b/frontend/src/apis/competitionList/types.ts @@ -0,0 +1,7 @@ +export type Competition = { + id: number; + name: string; + startsAt: string; + endsAt: string; + maxParticipants: number; +}; diff --git a/frontend/src/apis/joinCompetition/index.ts b/frontend/src/apis/joinCompetition/index.ts new file mode 100644 index 0000000..7a766ff --- /dev/null +++ b/frontend/src/apis/joinCompetition/index.ts @@ -0,0 +1,44 @@ +import api from '@/utils/api'; + +import type { CompetitionApiData } from './types'; +import axios from 'axios'; + +const STATUS = { + Forbidden: 403, + BadRequest: 400, +} as const; + +export async function joinCompetition(data: CompetitionApiData) { + const { id, token } = data; + + try { + await api.post( + `/competitions/${id}/participations`, + {}, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + return '대회에 성공적으로 참여했습니다.'; + } catch (error: unknown) { + if (!axios.isAxiosError(error)) { + return 'Unexpected error occurred'; + } + + if (!error.response) { + return 'Network error occurred'; + } + + switch (error.response.status) { + case STATUS.Forbidden: + return '대회 참여에 실패했습니다. 서버에서 거절되었습니다.'; + case STATUS.BadRequest: + return '이미 참여한 대회입니다.'; + default: + return `HTTP Error ${error.response.status}`; + } + } +} diff --git a/frontend/src/apis/joinCompetition/types.ts b/frontend/src/apis/joinCompetition/types.ts new file mode 100644 index 0000000..441522a --- /dev/null +++ b/frontend/src/apis/joinCompetition/types.ts @@ -0,0 +1,4 @@ +export type CompetitionApiData = { + id: number; + token: string | null; +}; diff --git a/frontend/src/components/Common/Modal/Modal.tsx b/frontend/src/components/Common/Modal/Modal.tsx new file mode 100644 index 0000000..d3937be --- /dev/null +++ b/frontend/src/components/Common/Modal/Modal.tsx @@ -0,0 +1,74 @@ +import { css, cx } from '@style/css'; + +import type { HTMLAttributes, MouseEvent } from 'react'; +import { useContext, useEffect, useRef } from 'react'; +import ReactDOM from 'react-dom'; + +import { ModalContext } from './ModalContext'; +import { ModalProvider } from './ModalProvider'; + +export interface Props extends HTMLAttributes { + onBackdropPressed?: () => void; +} + +export function Modal({ onBackdropPressed, children, ...props }: Props) { + const modal = useContext(ModalContext); + const $dialog = useRef(null); + + const handleClickBackdrop = (e: MouseEvent) => { + const $target = e.target as HTMLDialogElement; + + if ($target.nodeName !== 'DIALOG') return; + + if (onBackdropPressed instanceof Function) { + onBackdropPressed(); + } + }; + + useEffect(() => { + if (modal.isOpen) { + $dialog.current?.showModal(); + } else { + $dialog.current?.close(); + } + }, [modal.isOpen]); + + return ReactDOM.createPortal( + +
{children}
+
, + document.body, + ); +} + +Modal.Context = ModalContext; +Modal.Provider = ModalProvider; + +const style = css({ + borderRadius: '0.5rem', +}); + +const dialogStyle = css({ + position: 'fixed', + left: '50%', + top: '50%', + transform: 'translate(-50%,-50%)', + width: '500px', + height: '400px', + _backdrop: { + background: 'rgba(00,00,00,0.5)', + backdropFilter: 'blur(1rem)', + }, +}); + +const contentStyle = css({ + width: '100%', + height: '100%', +}); diff --git a/frontend/src/components/Common/Modal/ModalContext.ts b/frontend/src/components/Common/Modal/ModalContext.ts new file mode 100644 index 0000000..aacb2fd --- /dev/null +++ b/frontend/src/components/Common/Modal/ModalContext.ts @@ -0,0 +1,13 @@ +import { createContext } from 'react'; + +interface ModalContextProps { + isOpen: boolean; + close: () => void; + open: () => void; +} + +export const ModalContext = createContext({ + isOpen: false, + close: () => {}, + open: () => {}, +}); diff --git a/frontend/src/components/Common/Modal/ModalProvider.tsx b/frontend/src/components/Common/Modal/ModalProvider.tsx new file mode 100644 index 0000000..7938c1c --- /dev/null +++ b/frontend/src/components/Common/Modal/ModalProvider.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react'; +import { useState } from 'react'; + +import { ModalContext } from './ModalContext'; + +export function ModalProvider({ children }: { children: ReactNode }) { + const [isOpen, setIsOpen] = useState(false); + const close = () => { + setIsOpen(false); + }; + const open = () => { + setIsOpen(true); + }; + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/Common/Modal/index.ts b/frontend/src/components/Common/Modal/index.ts new file mode 100644 index 0000000..ef6a639 --- /dev/null +++ b/frontend/src/components/Common/Modal/index.ts @@ -0,0 +1,2 @@ +export { Modal } from './Modal'; +export type { Props as ModalProps } from './Modal'; diff --git a/frontend/src/components/Common/index.ts b/frontend/src/components/Common/index.ts index 6322cf3..7aa1ae9 100644 --- a/frontend/src/components/Common/index.ts +++ b/frontend/src/components/Common/index.ts @@ -1 +1,2 @@ export { Input } from './Input'; +export * from './Modal'; diff --git a/frontend/src/components/Contest/CompetitionHeader.tsx b/frontend/src/components/Contest/CompetitionHeader.tsx new file mode 100644 index 0000000..4a4e72f --- /dev/null +++ b/frontend/src/components/Contest/CompetitionHeader.tsx @@ -0,0 +1,27 @@ +import { css } from '@style/css'; + +import ViewDashboardButton from '../Main/Buttons/ViewDashboardButton'; +import ContestBreadCrumb from './ContestBreadCrumb'; + +interface Props { + crumbs: string[]; + id: number; +} + +export default function CompetitionHeader(props: Props) { + return ( +
+ + +
+ ); +} + +const headerStyle = css({ + backgroundColor: 'gray', + color: 'black', + width: '850px', + height: '50px', + display: 'flex', + justifyContent: 'space-between', +}); diff --git a/frontend/src/components/Contest/ContestBreadCrumb.tsx b/frontend/src/components/Contest/ContestBreadCrumb.tsx index fb086e2..9fd8338 100644 --- a/frontend/src/components/Contest/ContestBreadCrumb.tsx +++ b/frontend/src/components/Contest/ContestBreadCrumb.tsx @@ -8,27 +8,16 @@ export default function ContestBreadCrumb(props: Props) { const { crumbs } = props; return ( -
-
- {crumbs.map((crumb, index) => ( - - {crumb} - - ))} -
-
+
+ {crumbs.map((crumb, index) => ( + + {crumb} + + ))} +
); } -const titleContainerStyle = css({ - backgroundColor: 'gray', - color: 'black', - padding: '10px', - width: '850px', - height: '50px', - display: 'flex', -}); - const crumbStyle = css({ marginRight: '1rem', _after: { @@ -41,3 +30,7 @@ const crumbStyle = css({ }, }, }); + +const titleContainerStyle = css({ + padding: '10px', +}); diff --git a/frontend/src/components/Contest/ContestProblemSelector.tsx b/frontend/src/components/Contest/ContestProblemSelector.tsx new file mode 100644 index 0000000..4bd10e6 --- /dev/null +++ b/frontend/src/components/Contest/ContestProblemSelector.tsx @@ -0,0 +1,31 @@ +import { css } from '@style/css'; + +interface AsideProps { + problemIds: number[]; + onChangeProblemIndex: (index: number) => void; +} + +export default function ContestProblemSelector(props: AsideProps) { + function handleChangeProblemIndex(index: number) { + props.onChangeProblemIndex(index); + } + + return ( + + ); +} + +const selectProblemStyle = css({ + color: 'black', +}); diff --git a/frontend/src/components/Main/Buttons/GoToCreateCompetitionLink.tsx b/frontend/src/components/Main/Buttons/GoToCreateCompetitionLink.tsx index 944b00a..462d21f 100644 --- a/frontend/src/components/Main/Buttons/GoToCreateCompetitionLink.tsx +++ b/frontend/src/components/Main/Buttons/GoToCreateCompetitionLink.tsx @@ -1,11 +1,19 @@ -import { Link } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; + +import useAuth from '@/hooks/login/useAuth'; export default function GoToCreateCompetitionLink() { - // TODO: 로그인 여부에 따른 페이지 이동 설정 + const { isLoggedin } = useAuth(); + const navigate = useNavigate(); + + const handleNavigate = () => { + if (!isLoggedin) { + alert('로그인이 필요합니다.'); + navigate('/login'); + } else { + navigate('/contest/create'); + } + }; - return ( - - - - ); + return ; } diff --git a/frontend/src/components/Main/Buttons/JoinCompetitionButton.tsx b/frontend/src/components/Main/Buttons/JoinCompetitionButton.tsx index b626298..02b3b5b 100644 --- a/frontend/src/components/Main/Buttons/JoinCompetitionButton.tsx +++ b/frontend/src/components/Main/Buttons/JoinCompetitionButton.tsx @@ -1,4 +1,33 @@ -export default function JoinCompetitionButton() { - // TODO: 대회에 참여하는 로직 작성 / 참여하기 활성화 로직 작성 - return ; +import { useNavigate } from 'react-router-dom'; + +import { joinCompetition } from '@/apis/joinCompetition'; +import type { CompetitionApiData } from '@/apis/joinCompetition/types'; +import useAuth from '@/hooks/login/useAuth'; + +const TOKEN_KEY = 'accessToken'; + +export default function JoinCompetitionButton(props: { id: number }) { + const { isLoggedin } = useAuth(); + const navigate = useNavigate(); + + const queryParams = new URLSearchParams(location.search); + const token = queryParams.get(TOKEN_KEY) || localStorage.getItem(TOKEN_KEY); + + const handleJoinClick = async () => { + if (!isLoggedin) { + alert('로그인이 필요합니다.'); + navigate('/login'); + return; + } + + const result = await joinCompetition(competitionData); + alert(result); + window.location.reload(); + }; + const competitionData: CompetitionApiData = { + id: props.id, + token: token, + }; + + return ; } diff --git a/frontend/src/components/Main/CompetitionList.tsx b/frontend/src/components/Main/CompetitionList.tsx index 4b3332f..953bc38 100644 --- a/frontend/src/components/Main/CompetitionList.tsx +++ b/frontend/src/components/Main/CompetitionList.tsx @@ -1,64 +1,10 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; +import { fetchCompetitionList } from '@/apis/competitionList'; import JoinCompetitionButton from '@/components/Main/Buttons/JoinCompetitionButton'; import ViewDashboardButton from '@/components/Main/Buttons/ViewDashboardButton'; import secToTime from '@/utils/secToTime'; -const generateMockData = () => { - // API배포가 완료되면 삭제 에정 - return [ - { - id: 1, - name: '테스트 대회 이름', - detail: '테스트 대회 설명', - maxParticipants: 70, - startsAt: '2023-11-14T08:35:24.358Z', - endsAt: '2023-11-20T12:13:04.005Z', - createdAt: '2023-11-14T08:35:24.358Z', - updatedAt: '2023-11-21T02:28:43.955Z', - }, - { - id: 2, - name: 'ICPC 서울', - detail: '이거슨 아이씨피씨입니다', - maxParticipants: 1000, - startsAt: '2023-11-21T07:10:44.456Z', - endsAt: '2023-11-21T10:10:44.456Z', - createdAt: '2023-11-21T07:50:58.686Z', - updatedAt: '2023-11-21T07:50:58.686Z', - }, - { - id: 3, - name: '천하제일코딩대회', - detail: '^오^', - maxParticipants: 10, - startsAt: '2023-11-21T07:10:44.456Z', - endsAt: '2023-11-21T10:10:44.456Z', - createdAt: '2023-11-21T07:57:07.563Z', - updatedAt: '2023-11-21T07:57:07.563Z', - }, - { - id: 4, - name: 'fe테스트대회', - detail: '가나다라마바사', - maxParticipants: 3, - startsAt: '2023-11-22T01:20:00.000Z', - endsAt: '2023-11-23T01:20:00.000Z', - createdAt: '2023-11-22T10:22:03.723Z', - updatedAt: '2023-11-22T10:22:03.723Z', - }, - { - id: 5, - name: '가나다라', - detail: '마바사아자차카타파하', - maxParticipants: 3, - startsAt: '2023-11-23T03:00:00.000Z', - endsAt: '2023-11-23T04:00:00.000Z', - createdAt: '2023-11-22T12:00:46.942Z', - updatedAt: '2023-11-22T12:00:46.942Z', - }, - ]; -}; interface Competition { id: number; @@ -90,13 +36,19 @@ function formatTimeRemaining(startsAt: string, endsAt: string): string { export default function CompetitionList() { const [competitions, setCompetitions] = useState([]); + const fetchCompetitions = async () => { + try { + const competitionData = await fetchCompetitionList(); + setCompetitions(competitionData); + } catch (error) { + console.error('Error fetching competitions:', (error as Error).message); + } + }; + useEffect(() => { - // 실제 API 요청 대신 목업 데이터 사용 -> TODO: API배포가 완료되면 API처리하는 코드로 바꿔야함 - const mockData = generateMockData(); - setCompetitions(mockData); + fetchCompetitions(); }, []); - // TODO: 대회 시작 전에 들어와서 대회가 시작된 뒤에 참여 버튼을 누르면 서버에서 거절하고 화면에 alert을 띄우고 새로고침 return (
@@ -120,7 +72,9 @@ export default function CompetitionList() {
{new Date(competition.endsAt).toLocaleString()} {formatTimeRemaining(competition.startsAt, competition.endsAt)} - {competition.startsAt > new Date().toISOString() && } + {competition.startsAt > new Date().toISOString() && ( + + )} diff --git a/frontend/src/components/Simulation/SimulationInputModal.tsx b/frontend/src/components/Simulation/SimulationInputModal.tsx new file mode 100644 index 0000000..0a1a22a --- /dev/null +++ b/frontend/src/components/Simulation/SimulationInputModal.tsx @@ -0,0 +1,46 @@ +import { useContext, useState } from 'react'; + +import type { SimulationInput } from '@/hooks/simulation'; +import { deepCopy } from '@/utils/copy'; + +import { Modal, type ModalProps } from '../Common'; +import { SimulationInputList } from './SimulationInputList'; + +interface Props extends ModalProps { + simulationInputs: SimulationInput[]; + onSave: (inputs: SimulationInput[]) => void; +} + +export function SimulationInputModal({ simulationInputs, onSave, ...props }: Props) { + const modal = useContext(Modal.Context); + const [inputs, setInputs] = useState(deepCopy(simulationInputs)); + + const handleCloseModal = () => { + setInputs(simulationInputs); + modal.close(); + }; + + const handleSave = () => { + onSave(deepCopy(inputs)); + modal.close(); + }; + + const handleChangeInput = (targetId: number, newParam: string) => { + const changedSimulation = inputs.find(({ id }) => id === targetId); + if (changedSimulation) { + changedSimulation.input = newParam; + } + setInputs([...inputs]); + }; + + return ( + + + + + + ); +} diff --git a/frontend/src/hooks/simulation/index.ts b/frontend/src/hooks/simulation/index.ts index 8b89797..52b61a5 100644 --- a/frontend/src/hooks/simulation/index.ts +++ b/frontend/src/hooks/simulation/index.ts @@ -1,2 +1,2 @@ -export * from './useSimulations'; +export * from './useSimulation'; export * from './types'; diff --git a/frontend/src/hooks/simulation/useSimulation.ts b/frontend/src/hooks/simulation/useSimulation.ts new file mode 100644 index 0000000..06915bd --- /dev/null +++ b/frontend/src/hooks/simulation/useSimulation.ts @@ -0,0 +1,94 @@ +import { useEffect, useMemo, useState } from 'react'; + +import evaluator from '@/modules/evaluator'; + +import type { SimulationInput, SimulationResult } from './types'; + +export const useSimulation = () => { + const [inputs, setInputs] = useState([ + { id: 1, input: '' }, + { id: 2, input: '' }, + { id: 3, input: '' }, + { id: 4, input: '' }, + { id: 5, input: '' }, + ]); + const [results, setResults] = useState([ + { id: 1, isDone: true, input: '', output: '' }, + { id: 2, isDone: true, input: '', output: '' }, + { id: 3, isDone: true, input: '', output: '' }, + { id: 4, isDone: true, input: '', output: '' }, + { id: 5, isDone: true, input: '', output: '' }, + ]); + const isRunning = useMemo(() => { + return results.some((result) => !result.isDone); + }, [results]); + + useEffect(() => { + return evaluator.subscribe(({ result: output, error, task }) => { + if (!task) return; + + setResults((results) => { + return results.map((result) => { + if (result.id !== task.clientId) return result; + + if (error) { + return { + ...result, + isDone: true, + output: `${error.name}: ${error.message} \n${error.stack}`, + }; + } + return { + ...result, + isDone: true, + output, + }; + }); + }); + }); + }, []); + + function run(code: string) { + const tasks = inputs.map(({ id, input }) => evaluator.createEvalMessage(id, code, input)); + + const isRequestSuccess = evaluator.evaluate(tasks); + + if (!isRequestSuccess) { + return; + } + + setResults((results) => { + return results + .map((result, index) => ({ + ...result, + input: inputs[index].input, + })) + .map(toEvaluatingState); + }); + } + + function changeInputs(inputs: SimulationInput[]) { + setInputs([...inputs]); + } + + function cancel() { + evaluator.cancelEvaluation(); + } + + return { + inputs, + results, + isRunning, + run, + cancel, + changeInputs, + }; +}; + +const toEvaluatingState = (simulation: SimulationResult) => { + return { + ...simulation, + output: '계산중...', + isDone: false, + }; +}; diff --git a/frontend/src/hooks/simulation/useSimulations.ts b/frontend/src/hooks/simulation/useSimulations.ts deleted file mode 100644 index 60d6124..0000000 --- a/frontend/src/hooks/simulation/useSimulations.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; - -import evaluator from '@/modules/evaluator'; - -import type { SimulationInput, SimulationResult } from './types'; - -export const useSimulations = () => { - const [simulationInputs, setSimulationInputs] = useState([ - { id: 1, input: '' }, - { id: 2, input: '' }, - { id: 3, input: '' }, - { id: 4, input: '' }, - { id: 5, input: '' }, - ]); - const [simulationResults, setSimulationResults] = useState([ - { id: 1, isDone: true, input: '', output: '' }, - { id: 2, isDone: true, input: '', output: '' }, - { id: 3, isDone: true, input: '', output: '' }, - { id: 4, isDone: true, input: '', output: '' }, - { id: 5, isDone: true, input: '', output: '' }, - ]); - const isSimulating = useMemo(() => { - return simulationResults.some((result) => !result.isDone); - }, [simulationResults]); - - useEffect(() => { - return evaluator.subscribe(({ result, error, task }) => { - if (!task) return; - - setSimulationResults((simulations) => { - return simulations.map((simul) => { - if (simul.id !== task.clientId) return simul; - - if (error) { - return { - ...simul, - isDone: true, - output: `${error.name}: ${error.message} \n${error.stack}`, - }; - } - return { - ...simul, - isDone: true, - output: result, - }; - }); - }); - }); - }, []); - - function runSimulation(code: string) { - const tasks = simulationInputs.map(({ id, input }) => - evaluator.createEvalMessage(id, code, input), - ); - - const isRequestSuccess = evaluator.evaluate(tasks); - - if (!isRequestSuccess) { - return; - } - - setSimulationResults((simulResults) => { - return simulResults - .map((simul, index) => ({ - ...simul, - input: simulationInputs[index].input, - })) - .map(toEvaluatingState); - }); - } - - function changeInput(targetId: number, newParam: string) { - const changedSimulation = simulationInputs.find(({ id }) => id === targetId); - if (changedSimulation) { - changedSimulation.input = newParam; - } - setSimulationInputs([...simulationInputs]); - } - - function cancelSimulation() { - evaluator.cancelEvaluation(); - } - - return { - simulationInputs, - simulationResults, - isSimulating, - runSimulation, - cancelSimulation, - changeInput, - }; -}; - -const toEvaluatingState = (simulation: SimulationResult) => { - return { - ...simulation, - output: '계산중...', - isDone: false, - }; -}; diff --git a/frontend/src/index.css b/frontend/src/index.css index b2b6068..6171fff 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,71 +1,75 @@ -@layer reset, base, tokens, recipes, utilities; - -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-text-size-adjust: 100%; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} +@layer reset, base, tokens, recipes, utilities; + +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +html:has(dialog[open]) { + overflow: hidden; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f008af4..d67a5ef 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,12 +5,15 @@ import ReactDOM from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; import AuthProvider from './components/Auth/AuthProvider'; +import { Modal } from './components/Common'; import router from './router'; ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , ); diff --git a/frontend/src/mockData.json b/frontend/src/mockData.json deleted file mode 100644 index 356ca9a..0000000 --- a/frontend/src/mockData.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "problems": [ - { - "id": 6, - "title": "Markdown Example", - "timeLimit": 5, - "memoryLimit": 10, - "content": "# Markdown Example\n\nThis is a Markdown example that includes various elements.\n\n## Text\n\n- Text can include **bold** or *italic* formatting.\n\n## Links\n\nYou can create [hyperlinks](https://www.example.com) in Markdown.\n\n## Lists\n\n- Unordered List Item 1\n- Unordered List Item 2\n - Nested List Item\n\n1. Ordered List Item 1\n2. Ordered List Item 2\n 1. Nested Ordered List Item\n\n## Images\n\n![React Logo](https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/React-icon.svg/1280px-React-icon.svg.png)\n\n## Blockquotes\n\n> This is a blockquote.\n\n## Horizontal Rule\n\n---\n\n## Code\n\nInline code: `const variable = 'Hello, Markdown!';`\n\nCode block:\n\n```javascript\nfunction solution() {\n console.log('Hello, Markdown!');\n}\n```\n\n## Table\n\n| Header 1 | Header 2 |\n|----------|----------|\n| Row 1 | Cell 1 |\n| Row 2 | Cell 2 |", - "solutionCode": "function solution() {\n console.log('Hello, Markdown!');\n}", - "testcases": "Temporary", - "createdAt": "2023-11-14T08:35:24.358Z" - } - ] -} diff --git a/frontend/src/pages/ContestPage.tsx b/frontend/src/pages/ContestPage.tsx index 538c05a..fe55a8f 100644 --- a/frontend/src/pages/ContestPage.tsx +++ b/frontend/src/pages/ContestPage.tsx @@ -1,12 +1,14 @@ import { css } from '@style/css'; -import { useEffect, useMemo, useState } from 'react'; +import { useContext, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; -import ContestBreadCrumb from '@/components/Contest/ContestBreadCrumb'; +import { ModalContext } from '@/components/Common/Modal/ModalContext'; +import CompetitionHeader from '@/components/Contest/CompetitionHeader'; +import ContestProblemSelector from '@/components/Contest/ContestProblemSelector'; import Editor from '@/components/Editor/Editor'; import ProblemViewer from '@/components/Problem/ProblemViewer'; -import { SimulationInputList } from '@/components/Simulation/SimulationInputList'; +import { SimulationInputModal } from '@/components/Simulation/SimulationInputModal'; import { SimulationResultList } from '@/components/Simulation/SimulationResultList'; import { SubmissionResult } from '@/components/Submission'; import Timer from '@/components/Timer'; @@ -15,7 +17,7 @@ import type { SubmissionForm } from '@/hooks/competition'; import { useCompetition } from '@/hooks/competition'; import { useCompetitionProblem } from '@/hooks/problem'; import { useCompetitionProblemList } from '@/hooks/problem/useCompetitionProblemList'; -import { useSimulations } from '@/hooks/simulation'; +import { SimulationInput, useSimulation } from '@/hooks/simulation'; import { isNil } from '@/utils/type'; const RUN_SIMULATION = '테스트 실행'; @@ -25,15 +27,9 @@ export default function ContestPage() { const { id } = useParams<{ id: string }>(); const competitionId: number = id ? parseInt(id, 10) : -1; const [currentProblemIndex, setCurrentProblemIndex] = useState(0); + const modal = useContext(ModalContext); - const { - simulationInputs, - simulationResults, - isSimulating, - runSimulation, - changeInput, - cancelSimulation, - } = useSimulations(); + const simulation = useSimulation(); const { socket, competition, submitSolution, isConnected } = useCompetition(competitionId); const { problemList } = useCompetitionProblemList(competitionId); @@ -60,19 +56,15 @@ export default function ContestPage() { }; const handleSimulate = () => { - runSimulation(code); + simulation.run(code); }; const handleSimulationCancel = () => { - cancelSimulation(); + simulation.cancel(); }; - const handleChangeInput = (id: number, newParam: string) => { - changeInput(id, newParam); - }; - - const handleNextProblem = () => { - setCurrentProblemIndex(currentProblemIndex + 1); + const handleSaveSimulationInputs = (simulationInputs: SimulationInput[]) => { + simulation.changeInputs(simulationInputs); }; function handleSubmitSolution() { @@ -90,26 +82,33 @@ export default function ContestPage() { submitSolution(form); } + const { endsAt } = competition; + function handleOpenModal() { + modal.open(); + } + + const problems = problemList.map((problem) => problem.id); + + return (
- - -
+ +
{problem.title}
+
- - - {isSimulating ? ( + + {simulation.isRunning ? ( @@ -122,8 +121,17 @@ export default function ContestPage() {
- + +
+
); } diff --git a/frontend/src/utils/copy/__tests__/copy.spec.ts b/frontend/src/utils/copy/__tests__/copy.spec.ts new file mode 100644 index 0000000..babd088 --- /dev/null +++ b/frontend/src/utils/copy/__tests__/copy.spec.ts @@ -0,0 +1,22 @@ +import { deepCopy } from '../index'; +import { describe, expect, it } from 'vitest'; + +describe('deepCopy', () => { + it('deepCopy는 객체를 깊은 복사한다.', () => { + const obj = { + inner: { + a: 1, + }, + }; + expect(deepCopy(obj)).not.equal(obj); + expect(deepCopy(obj).inner).not.equal(obj.inner); + }); + + it('deepCopy는 배열을 깊은 복사한다.', () => { + const obj = { inner: { a: 1 } }; + const arr = [obj]; + expect(deepCopy(arr)).not.equal(arr); + expect(deepCopy(arr)[0]).not.equal(arr[0]); + expect(deepCopy(arr)[0].inner).not.equal(obj.inner); + }); +}); diff --git a/frontend/src/utils/copy/index.ts b/frontend/src/utils/copy/index.ts new file mode 100644 index 0000000..0450899 --- /dev/null +++ b/frontend/src/utils/copy/index.ts @@ -0,0 +1,3 @@ +export function deepCopy(value: T): T { + return structuredClone(value); +}