diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..4e09b47 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,24 +1,27 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# environment +.env diff --git a/frontend/src/apis/competitions/index.ts b/frontend/src/apis/competitions/index.ts new file mode 100644 index 0000000..aaf6a3a --- /dev/null +++ b/frontend/src/apis/competitions/index.ts @@ -0,0 +1,29 @@ +import api from '@/utils/api'; + +import type { CompetitionForm, CompetitionInfo, CreateCompetitionResponse } from './types'; + +export * from './types'; + +export async function createCompetition( + competitionForm: CompetitionForm, +): Promise { + const { name, detail, maxParticipants, startsAt, endsAt, problems } = competitionForm; + + try { + const form = { + name, + detail, + maxParticipants, + startsAt, + endsAt, + problems, + }; + const { data } = await api.post('/competitions', form); + + return data; + } catch (err) { + console.error(err); + + return null; + } +} diff --git a/frontend/src/apis/competitions/types.ts b/frontend/src/apis/competitions/types.ts new file mode 100644 index 0000000..38fe5cb --- /dev/null +++ b/frontend/src/apis/competitions/types.ts @@ -0,0 +1,25 @@ +import type { ProblemId } from '../problems'; + +export type CompetitionId = number; + +export type CompetitionForm = { + name: string; + detail: string; + maxParticipants: number; + startsAt: string; + endsAt: string; + problems: ProblemId[]; +}; + +export type CompetitionInfo = { + id: CompetitionId; + name: string; + detail: string; + maxParticipants: number; + startsAt: string; + endsAt: string; + createdAt: string; + updatedAt: string; +}; + +export type CreateCompetitionResponse = CompetitionInfo; diff --git a/frontend/src/apis/problems/index.ts b/frontend/src/apis/problems/index.ts new file mode 100644 index 0000000..dba4ecb --- /dev/null +++ b/frontend/src/apis/problems/index.ts @@ -0,0 +1,17 @@ +import api from '@/utils/api'; + +import type { FetchProblemListResponse, ProblemInfo } from './types'; + +export * from './types'; + +export async function fetchProblemList(): Promise { + try { + const { data } = await api.get('/problems'); + + return data; + } catch (err) { + console.error(err); + + return []; + } +} diff --git a/frontend/src/apis/problems/types.ts b/frontend/src/apis/problems/types.ts new file mode 100644 index 0000000..7cd1a8c --- /dev/null +++ b/frontend/src/apis/problems/types.ts @@ -0,0 +1,8 @@ +export type ProblemId = number; + +export type ProblemInfo = { + id: ProblemId; + title: string; +}; + +export type FetchProblemListResponse = ProblemInfo[]; diff --git a/frontend/src/components/Common/Input.tsx b/frontend/src/components/Common/Input.tsx new file mode 100644 index 0000000..66aa6fa --- /dev/null +++ b/frontend/src/components/Common/Input.tsx @@ -0,0 +1,76 @@ +import { css, cx } from '@style/css'; + +import type { + ForwardedRef, + HTMLAttributes, + InputHTMLAttributes, + ReactElement, + ReactNode, + TextareaHTMLAttributes, +} from 'react'; +import { Children, cloneElement, forwardRef } from 'react'; + +interface Props extends HTMLAttributes { + label?: ReactNode; + children: ReactElement; +} + +export function Input({ id, label, children, ...props }: Props) { + const child = Children.only(children); + + return ( +
+ + {cloneElement(child, { + id, + ...child.props, + })} +
+ ); +} + +interface TextFieldProps extends Omit, 'type'> { + error?: boolean; +} + +Input.TextField = forwardRef( + ({ className, ...props }: TextFieldProps, ref: ForwardedRef) => { + return ; + }, +); + +const inputStyle = css({ + border: '1px solid black', + width: '20rem', +}); + +interface TextAreaProps extends TextareaHTMLAttributes {} + +Input.TextArea = forwardRef( + ({ className, ...props }: TextAreaProps, ref: ForwardedRef) => { + return ; + }, +); + +interface NumberFieldProps extends Omit, 'type'> {} + +Input.NumberField = forwardRef( + ({ className, ...props }: NumberFieldProps, ref: ForwardedRef) => { + return ; + }, +); + +interface DateTimeFieldProps extends Omit, 'type'> {} + +Input.DateTimeField = forwardRef( + ({ className, ...props }: DateTimeFieldProps, ref: ForwardedRef) => { + return ( + + ); + }, +); diff --git a/frontend/src/components/Common/index.ts b/frontend/src/components/Common/index.ts new file mode 100644 index 0000000..6322cf3 --- /dev/null +++ b/frontend/src/components/Common/index.ts @@ -0,0 +1 @@ +export { Input } from './Input'; diff --git a/frontend/src/components/Problem/SelectableProblemList.tsx b/frontend/src/components/Problem/SelectableProblemList.tsx new file mode 100644 index 0000000..a5087cb --- /dev/null +++ b/frontend/src/components/Problem/SelectableProblemList.tsx @@ -0,0 +1,48 @@ +import type { MouseEvent } from 'react'; + +import type { ProblemId, ProblemInfo } from '@/apis/problems'; + +interface ProblemListProps { + problemList: ProblemInfo[]; + pickedProblemIds: ProblemId[]; + onSelectProblem: (problemId: ProblemId) => void; +} + +const SelectableProblemList = ({ + problemList, + pickedProblemIds, + onSelectProblem, +}: ProblemListProps) => { + function handleSelectProblem(e: MouseEvent) { + const $target = e.target as HTMLElement; + if ($target.tagName !== 'BUTTON') return; + + const $li = $target.closest('li'); + if (!$li) return; + + const problemId = Number($li.dataset['problemId']); + onSelectProblem(problemId); + } + + return ( +
    + {problemList.map(({ id, title }) => ( +
  • + + {id}: {title} + + +
  • + ))} +
+ ); +}; + +export default SelectableProblemList; + +const SelectButton = ({ isPicked }: { isPicked: boolean }) => { + if (isPicked) { + return ; + } + return ; +}; diff --git a/frontend/src/hooks/competition/useCompetitionForm.ts b/frontend/src/hooks/competition/useCompetitionForm.ts new file mode 100644 index 0000000..8f2c181 --- /dev/null +++ b/frontend/src/hooks/competition/useCompetitionForm.ts @@ -0,0 +1,115 @@ +import { useState } from 'react'; + +import type { CompetitionForm } from '@/apis/competitions'; +import { createCompetition } from '@/apis/competitions'; +import type { ProblemId } from '@/apis/problems'; +import { formatDate, toLocalDate } from '@/utils/date'; + +type Valid = { + isValid: boolean; + message?: string; +}; + +const FIVE_MIN_BY_MS = 5 * 60 * 1000; +const VALIDATION_MESSAGE = { + needLongName: '이름은 1글자 이상이어야합니다', + needMoreParticipants: '최대 참여 인원은 1명 이상이어야 합니다', + tooEarlyStartTime: '대회 시작 시간은 현재보다 5분 늦은 시간부터 가능합니다', + tooEarlyEndTime: '대회 종료 시간은 대회 시작시간보다 늦게 끝나야합니다', + needMoreProblems: '대회 문제는 1개 이상이어야합니다', +}; + +export function useCompetitionForm(initialForm: Partial = {}) { + const [name, setName] = useState(initialForm.name ?? ''); + const [detail, setDetail] = useState(initialForm.detail ?? ''); + const [maxParticipants, setMaxParticipants] = useState(initialForm.maxParticipants ?? 1); + + const currentDate = toLocalDate(new Date()); + const currentDateStr = formatDate(currentDate, 'YYYY-MM-DDThh:mm'); + + const [startsAt, setStartsAt] = useState(initialForm.startsAt ?? currentDateStr); + const [endsAt, setEndsAt] = useState(initialForm.endsAt ?? currentDateStr); + const [problems, setProblems] = useState([]); + + function togglePickedProblem(problemId: ProblemId) { + if (problems.includes(problemId)) { + setProblems((ids) => ids.filter((id) => id !== problemId).sort()); + } else { + setProblems((ids) => [...ids, problemId].sort()); + } + } + + function getAllFormData(): CompetitionForm { + return { + name, + detail, + maxParticipants, + startsAt: new Date(startsAt).toISOString(), + endsAt: new Date(endsAt).toISOString(), + problems, + }; + } + + async function submitCompetition(formData: CompetitionForm) { + return await createCompetition(formData); + } + + function validateForm(formData: CompetitionForm): Valid { + const { name, maxParticipants, startsAt, endsAt, problems } = formData; + if (name.length <= 0) { + return { + isValid: false, + message: VALIDATION_MESSAGE.needLongName, + }; + } + + if (maxParticipants <= 0) { + return { + isValid: false, + message: VALIDATION_MESSAGE.needMoreParticipants, + }; + } + if (new Date(startsAt) <= new Date(Date.now() + FIVE_MIN_BY_MS)) { + return { + isValid: false, + message: VALIDATION_MESSAGE.tooEarlyStartTime, + }; + } + + if (new Date(endsAt) <= new Date(startsAt)) { + return { + isValid: false, + message: VALIDATION_MESSAGE.tooEarlyEndTime, + }; + } + + if (problems.length <= 0) { + return { + isValid: false, + message: VALIDATION_MESSAGE.needMoreProblems, + }; + } + + return { + isValid: true, + }; + } + + return { + name, + detail, + maxParticipants, + startsAt, + endsAt, + problems, + setName, + setDetail, + setMaxParticipants, + setStartsAt, + setEndsAt, + togglePickedProblem, + getAllFormData, + submitCompetition, + validateForm, + }; +} diff --git a/frontend/src/hooks/problem/useProblemList.ts b/frontend/src/hooks/problem/useProblemList.ts new file mode 100644 index 0000000..31de0f6 --- /dev/null +++ b/frontend/src/hooks/problem/useProblemList.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react'; + +import type { ProblemInfo } from '@/apis/problems'; +import { fetchProblemList } from '@/apis/problems'; + +export function useProblemList() { + const [problemList, setProblemList] = useState([]); + + async function updateProblemList() { + const problems = await fetchProblemList(); + + setProblemList(problems); + } + + useEffect(() => { + updateProblemList(); + }, []); + + return { + problemList, + }; +} diff --git a/frontend/src/pages/CreateCompetitionPage.tsx b/frontend/src/pages/CreateCompetitionPage.tsx new file mode 100644 index 0000000..3de0724 --- /dev/null +++ b/frontend/src/pages/CreateCompetitionPage.tsx @@ -0,0 +1,131 @@ +import { css } from '@style/css'; + +import type { ChangeEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import type { ProblemId } from '@/apis/problems'; +import { Input } from '@/components/Common'; +import SelectableProblemList from '@/components/Problem/SelectableProblemList'; +import { useCompetitionForm } from '@/hooks/competition/useCompetitionForm'; +import { useProblemList } from '@/hooks/problem/useProblemList'; +import { isNil } from '@/utils/type'; + +export default function CompetitionCreatePage() { + const navigate = useNavigate(); + + const form = useCompetitionForm(); + const { problemList } = useProblemList(); + + function handleChangeName(e: ChangeEvent) { + const newName = e.target.value; + form.setName(newName); + } + + function handleChangeDetail(e: ChangeEvent) { + const newDetail = e.target.value; + form.setDetail(newDetail); + } + + function handleChangeMaxParticipants(e: ChangeEvent) { + const newMaxParticipants = Number(e.target.value); + form.setMaxParticipants(newMaxParticipants); + } + + function handleChangeStartsAt(e: ChangeEvent) { + const newStartsAt = e.target.value; + form.setStartsAt(newStartsAt); + } + + function handleChangeEndsAt(e: ChangeEvent) { + const newEndsAt = e.target.value; + form.setEndsAt(newEndsAt); + } + + function handleSelectProblem(problemId: ProblemId) { + form.togglePickedProblem(problemId); + } + + async function handleSumbitCompetition() { + const formData = form.getAllFormData(); + const { isValid, message } = form.validateForm(formData); + + if (!isValid) { + if (!isNil(message)) { + alert(message); + } + return; + } + + const competition = await form.submitCompetition(formData); + if (isNil(competition)) { + alert('Oops... 대회 생성에 실패했습니다'); + return; + } + + const TO_DETAIL_PAGE = `/contest/detail/${competition.id}`; + navigate(TO_DETAIL_PAGE); + } + + return ( +
+

대회 생성 하기

+
+ + + + + + + + + + + + + + + + +
선택된 문제: {[...form.problems].join(',')}
+
+ +
+ ); +} + +const fieldSetStyle = css({ + display: 'flex', + flexDirection: 'column', +}); diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index a5ca3ac..81a0405 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -1,6 +1,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'; @@ -23,6 +24,10 @@ const router = createBrowserRouter([ path: '/problem/:id', element: , }, + { + path: '/contest/create', + element: , + }, ], }, ]); diff --git a/frontend/src/utils/api/index.ts b/frontend/src/utils/api/index.ts new file mode 100644 index 0000000..a08c68e --- /dev/null +++ b/frontend/src/utils/api/index.ts @@ -0,0 +1,7 @@ +import axios from 'axios'; + +const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL, +}); + +export default api; diff --git a/frontend/src/utils/date/__tests__/formatDate.spec.ts b/frontend/src/utils/date/__tests__/formatDate.spec.ts new file mode 100644 index 0000000..ee5c781 --- /dev/null +++ b/frontend/src/utils/date/__tests__/formatDate.spec.ts @@ -0,0 +1,18 @@ +import { formatDate } from '../index'; +import { describe, expect, it } from 'vitest'; + +describe('formatDate', () => { + it('YYYY-MM-DDThh:mm 형식의 문자열을 반환한다.', () => { + const now = new Date(2000, 1, 1, 13, 1, 1); + const dateStr = formatDate(now, 'YYYY-MM-DDThh:mm'); + + expect(dateStr).toBe('2000-02-01T04:01'); + }); + + it('일치하는 형식이 없으면 빈 문자열을 반환한다.', () => { + const now = new Date(2000, 1, 1, 13, 1, 1); + const dateStr = formatDate(now, '??'); + + expect(dateStr).toBe(''); + }); +}); diff --git a/frontend/src/utils/date/__tests__/toLocalDate.spec.ts b/frontend/src/utils/date/__tests__/toLocalDate.spec.ts new file mode 100644 index 0000000..d5b8f10 --- /dev/null +++ b/frontend/src/utils/date/__tests__/toLocalDate.spec.ts @@ -0,0 +1,19 @@ +import { toLocalDate } from '../index'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('toLocalDate', () => { + beforeEach(() => { + const spyFn = vi.spyOn(Date.prototype, 'getTimezoneOffset'); + spyFn.mockReturnValue(-9 * 60); // kr 시간 차이만큼 timezoneOffset 설정 + }); + afterEach(() => { + vi.clearAllMocks(); + }); + + it('입력받은 Date를 현재 지역 기준 Date로 변환한다.', () => { + const date = new Date(2000, 1, 1, 13, 1, 1); // 2000년 2월 1일 13시 1분 1초 + const localDate = toLocalDate(date); + // ISOString이 9시간 차이나는 것이 아니라 설정한 시간으로 나오게 됨 + expect(localDate.toISOString()).toBe('2000-02-01T13:01:01.000Z'); + }); +}); diff --git a/frontend/src/utils/date/index.ts b/frontend/src/utils/date/index.ts new file mode 100644 index 0000000..367eb7a --- /dev/null +++ b/frontend/src/utils/date/index.ts @@ -0,0 +1,17 @@ +const ONE_SEC_BY_MS = 1_000; +const ONE_MIN_BY_MS = 60 * ONE_SEC_BY_MS; + +export function toLocalDate(date: Date) { + const localTimeOffset = date.getTimezoneOffset() * ONE_MIN_BY_MS; + const localDate = new Date(date.getTime() - localTimeOffset); + + return localDate; +} + +export const formatDate = (date: Date, form: string) => { + if (form === 'YYYY-MM-DDThh:mm') { + return date.toISOString().slice(0, 'YYYY-MM-DDThh:mm'.length); + } + + return ''; +}; diff --git a/frontend/src/utils/type/__tests__/isNil.spec.ts b/frontend/src/utils/type/__tests__/isNil.spec.ts new file mode 100644 index 0000000..7bbf615 --- /dev/null +++ b/frontend/src/utils/type/__tests__/isNil.spec.ts @@ -0,0 +1,16 @@ +import { isNil } from '../index'; +import { describe, expect, it } from 'vitest'; + +describe('isNil', () => { + it('null 또는 undefined라면 true를 반환한다.', () => { + expect(isNil(null)).toBe(true); + expect(isNil(undefined)).toBe(true); + }); + + it.each([[''], ['a'], [+0], [-0], [0], [1], [Number(0)], [String('')], [true], [false], [{}]])( + 'isNil(%s) -> false', + (input) => { + expect(isNil(input)).toBe(false); + }, + ); +}); diff --git a/frontend/src/utils/type/index.ts b/frontend/src/utils/type/index.ts new file mode 100644 index 0000000..7102b44 --- /dev/null +++ b/frontend/src/utils/type/index.ts @@ -0,0 +1,8 @@ +type Nil = undefined | null; + +export const isNil = (type: unknown): type is Nil => { + if (Object.is(type, null)) return true; + if (Object.is(type, undefined)) return true; + + return false; +};