diff --git a/frontend/src/components/Main/Buttons/GoToCreateCompetitionLink.tsx b/frontend/src/components/Main/Buttons/GoToCreateCompetitionLink.tsx new file mode 100644 index 0000000..944b00a --- /dev/null +++ b/frontend/src/components/Main/Buttons/GoToCreateCompetitionLink.tsx @@ -0,0 +1,11 @@ +import { Link } from 'react-router-dom'; + +export default function GoToCreateCompetitionLink() { + // TODO: 로그인 여부에 따른 페이지 이동 설정 + + return ( + + + + ); +} diff --git a/frontend/src/components/Main/Buttons/JoinCompetitionButton.tsx b/frontend/src/components/Main/Buttons/JoinCompetitionButton.tsx new file mode 100644 index 0000000..b626298 --- /dev/null +++ b/frontend/src/components/Main/Buttons/JoinCompetitionButton.tsx @@ -0,0 +1,4 @@ +export default function JoinCompetitionButton() { + // TODO: 대회에 참여하는 로직 작성 / 참여하기 활성화 로직 작성 + return ; +} diff --git a/frontend/src/components/Main/Buttons/ViewDashboardButton.tsx b/frontend/src/components/Main/Buttons/ViewDashboardButton.tsx new file mode 100644 index 0000000..bb15ad4 --- /dev/null +++ b/frontend/src/components/Main/Buttons/ViewDashboardButton.tsx @@ -0,0 +1,10 @@ +import { Link } from 'react-router-dom'; + +export default function ViewDashboardButton(props: { id: number }) { + const dashboardLink = `/contest/dashboard/${props.id}`; + return ( + + + + ); +} diff --git a/frontend/src/components/Main/CompetitionList.tsx b/frontend/src/components/Main/CompetitionList.tsx new file mode 100644 index 0000000..b6d5f12 --- /dev/null +++ b/frontend/src/components/Main/CompetitionList.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; + +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; + name: string; + startsAt: string; + endsAt: string; + maxParticipants: number; +} + +const getCompetitionDetailURL = (competitionId: number) => `/contest/detail/${competitionId}`; + +function formatTimeRemaining(startsAt: string, endsAt: string): string { + const now = new Date(); + const startDate = new Date(startsAt); + const endDate = new Date(endsAt); + + if (endDate.getTime() < now.getTime()) { + return '종료'; + } else if (startDate.getTime() > now.getTime()) { + const timeDiff = startDate.getTime() - now.getTime(); + const { days, hours, minutes, seconds } = secToTime(timeDiff); + + return `시작까지 ${days}일 ${hours}:${minutes}:${seconds}`; + } else { + return '진행중'; + } +} + +export default function CompetitionList() { + const [competitions, setCompetitions] = useState([]); + + useEffect(() => { + // 실제 API 요청 대신 목업 데이터 사용 -> TODO: API배포가 완료되면 API처리하는 코드로 바꿔야함 + const mockData = generateMockData(); + setCompetitions(mockData); + }, []); + + // TODO: 대회 시작 전에 들어와서 대회가 시작된 뒤에 참여 버튼을 누르면 서버에서 거절하고 화면에 alert을 띄우고 새로고침 + return ( +
+ + + + + + + + + + + + + {competitions.map((competition) => ( + + + + + + + + + ))} + +
대회 이름시작 시간종료 시간상태참여대시보드
+ {competition.name} + {new Date(competition.startsAt).toLocaleString()}{new Date(competition.endsAt).toLocaleString()}{formatTimeRemaining(competition.startsAt, competition.endsAt)} + {competition.startsAt > new Date().toISOString() && } + + +
+
+ ); +} diff --git a/frontend/src/constants/index.ts b/frontend/src/constants/index.ts index 02c6d30..68e9368 100644 --- a/frontend/src/constants/index.ts +++ b/frontend/src/constants/index.ts @@ -1,3 +1,4 @@ export const SITE = { NAME: 'Alog With Me', + PAGE_DESCRIPTION: '천하제일코딩테스트', }; diff --git a/frontend/src/hooks/competition/useCompetition.ts b/frontend/src/hooks/competition/useCompetition.ts new file mode 100644 index 0000000..dc4ae47 --- /dev/null +++ b/frontend/src/hooks/competition/useCompetition.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; + +import axios from 'axios'; + +interface Competition { + id: number; + name: string; + detail: string; + maxParticipants: number; + startsAt: string; + endsAt: string; + createdAt: string; + updatedAt: string; +} + +const notFoundCompetition: Competition = { + id: 0, + name: 'Competition Not Found', + detail: 'Competition Not Found', + maxParticipants: 0, + startsAt: 'Competition Not Found', + endsAt: 'Competition Not Found', + createdAt: 'Competition Not Found', + updatedAt: 'Competition Not Found', +}; + +export const useCompetition = (competitionId: number) => { + const problems = [1, 2, 3]; // TODO: 대회에 해당하는 문제의 id를 유동적으로 채워넣을 수 있게 수정해야함 + const [competition, setCompetition] = useState(notFoundCompetition); + + useEffect(() => { + axios + .get(`http://101.101.208.240:3000/competitions/${competitionId}`) + .then((response) => { + setCompetition(response.data); + }) + .catch((error) => { + console.error('Error fetching competition data:', error); + }); + }, [competitionId]); + + return { + problems, + competition, + }; +}; diff --git a/frontend/src/hooks/problem/useProblem.ts b/frontend/src/hooks/problem/useProblem.ts new file mode 100644 index 0000000..49fd1f9 --- /dev/null +++ b/frontend/src/hooks/problem/useProblem.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react'; + +import axios from 'axios'; + +interface CompetitionProblem { + id: number; + title: string; + timeLimit: number; + memoryLimit: number; + content: string; + createdAt: string; + solutionCode: string; + testcases: string; +} + +const notFoundProblem: CompetitionProblem = { + id: 0, + title: 'Problem Not Found', + timeLimit: 0, + memoryLimit: 0, + content: 'The requested problem could not be found.', + solutionCode: '', + testcases: '', + createdAt: new Date().toISOString(), +}; + +export const useProblem = (problemId: number) => { + const [problem, setProblem] = useState(notFoundProblem); + + const fetchProblem = async (id: number) => { + try { + const response = await axios.get(`http://101.101.208.240:3000/competitions/problems/${id}`); + const fetchedProblem = response.data; + setProblem(fetchedProblem || notFoundProblem); + } catch (error) { + console.error('Error fetching problem data:', error); + } + }; + + useEffect(() => { + fetchProblem(problemId); + }, [problemId]); + + return { + problem, + }; +}; diff --git a/frontend/src/pages/ContestPage.tsx b/frontend/src/pages/ContestPage.tsx index 0346d9c..a2af98c 100644 --- a/frontend/src/pages/ContestPage.tsx +++ b/frontend/src/pages/ContestPage.tsx @@ -1,6 +1,7 @@ import { css } from '@style/css'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; import ContestBreadCrumb from '@/components/Contest/ContestBreadCrumb'; import Editor from '@/components/Editor/Editor'; @@ -9,25 +10,17 @@ import { SimulationInputList } from '@/components/Simulation/SimulationInputList import { SimulationResultList } from '@/components/Simulation/SimulationResultList'; import SubmissionResult from '@/components/SubmissionResult'; import { SITE } from '@/constants'; +import { useCompetition } from '@/hooks/competition/useCompetition'; +import { useProblem } from '@/hooks/problem/useProblem'; import { useSimulations } from '@/hooks/simulation'; -import mockData from '@/mockData.json'; - -const notFoundProblem = { - title: 'Problem Not Found', - timeLimit: 0, - memoryLimit: 0, - content: 'The requested problem could not be found.', - solutionCode: '', - simulations: [], - createdAt: new Date().toISOString(), -}; - -const INITIAL_PROBLEM_ID = 6; + const RUN_SIMULATION = '테스트 실행'; const CANCEL_SIMULATION = '실행 취소'; export default function ContestPage() { - const CONTEST_NAME = 'Test'; // api로 받을 정보 + const { id } = useParams<{ id: string }>(); + const [currentProblemIndex, setCurrentProblemIndex] = useState(0); + const { simulationInputs, simulationResults, @@ -36,10 +29,18 @@ export default function ContestPage() { changeInput, cancelSimulation, } = useSimulations(); - const [currentProblemId] = useState(INITIAL_PROBLEM_ID); - const targetProblem = - mockData.problems.find((problem) => problem.id === currentProblemId) || notFoundProblem; - const [code, setCode] = useState(targetProblem.solutionCode); + const competitionId: number = id ? parseInt(id, 10) : -1; + + const { problems, competition } = useCompetition(competitionId); + const currentProblemId = useMemo(() => { + return problems[currentProblemIndex]; + }, [problems, currentProblemIndex]); + + const { problem } = useProblem(currentProblemId); + + const [code, setCode] = useState(problem.solutionCode); + + const crumbs = [SITE.NAME, competition.name, problem.title]; const handleChangeCode = (newCode: string) => { setCode(newCode); @@ -57,18 +58,21 @@ export default function ContestPage() { changeInput(id, newParam); }; - const crumbs = [SITE.NAME, CONTEST_NAME, targetProblem.title]; + const handleNextProblem = () => { + setCurrentProblemIndex(currentProblemIndex + 1); + }; return (
+
- {targetProblem.title} + {problem.title}
- +
- + + {SITE.NAME} + {SITE.PAGE_DESCRIPTION} + + +
+ ); +} + +export default MainPage; + +const ProjectNameStyle = css({ + fontSize: '70px', +}); + +const style = css({ + display: 'grid', + placeItems: 'center', + height: '500px', +}); diff --git a/frontend/src/pages/ProblemPage.tsx b/frontend/src/pages/ProblemPage.tsx index a553f8e..f6d372b 100644 --- a/frontend/src/pages/ProblemPage.tsx +++ b/frontend/src/pages/ProblemPage.tsx @@ -1,31 +1,60 @@ import { css } from '@style/css'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; import ProblemViewer from '@/components/Problem/ProblemViewer'; -import mockData from '@/mockData.json'; - -const notFoundProblem = { - title: 'Problem Not Found', - timeLimit: 0, - memoryLimit: 0, - content: 'The requested problem could not be found.', - solutionCode: '', - testcases: [], - createdAt: new Date().toISOString(), -}; -const INITIAL_PROBLEM_ID = 6; +import axios from 'axios'; + +interface Problem { + id: number; + title: string; + timeLimit: number; + memoryLimit: number; + content: string; + createdAt: string; +} + +const PROBLEM_API_ENDPOINT = 'http://101.101.208.240:3000/problems/'; + +const fetchProblem = async ( + problemId: number, + setProblem: (problem: Problem | null) => void, + setLoading: (loading: boolean) => void, +) => { + try { + const response = await axios.get(`${PROBLEM_API_ENDPOINT}${problemId}`); + const data = response.data; + setProblem(data); + } catch (error) { + console.error('Error fetching problem:', (error as Error).message); + } finally { + setLoading(false); + } +}; function ProblemPage() { - const [currentProblemId] = useState(INITIAL_PROBLEM_ID); - const targetProblem = - mockData.problems.find((problem) => problem.id === currentProblemId) || notFoundProblem; + const { id } = useParams<{ id: string }>(); + const problemId = id ? parseInt(id, 10) : -1; + const [problem, setProblem] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchProblem(problemId, setProblem, setLoading); + }, [problemId]); + + if (loading) { + return

Loading...

; + } + if (!problem) { + return

Error loading problem data

; + } return (
- {targetProblem.title} - + {problem.title} +
); } diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 78a06ed..81a0405 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -2,6 +2,7 @@ import { createBrowserRouter } from 'react-router-dom'; import ContestPage from '@/pages/ContestPage'; import CreateCompetitionPage from '@/pages/CreateCompetitionPage'; +import MainPage from '@/pages/MainPage'; import ProblemPage from '@/pages/ProblemPage'; import App from './App'; @@ -13,10 +14,14 @@ const router = createBrowserRouter([ children: [ { index: true, + element: , + }, + { + path: '/contest/:id', element: , }, { - path: '/problem/:id', // TODO: api 연동 후 수정 + path: '/problem/:id', element: , }, { diff --git a/frontend/src/utils/secToTime.ts b/frontend/src/utils/secToTime.ts new file mode 100644 index 0000000..30241d7 --- /dev/null +++ b/frontend/src/utils/secToTime.ts @@ -0,0 +1,8 @@ +export default function secToTime(sec: number) { + const days = Math.floor(sec / (1000 * 60 * 60 * 24)); + const hours = Math.floor((sec % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((sec % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((sec % (1000 * 60)) / 1000); + + return { days, hours, minutes, seconds }; +}