From 1ed88c8380d59bac03f560a6e49cbff92b575e28 Mon Sep 17 00:00:00 2001 From: dev2820 <40891497+dev2820@users.noreply.github.com> Date: Wed, 13 Dec 2023 11:07:32 +0900 Subject: [PATCH] Fe dev -> main merge (#277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Chip 컴포넌트 추가 * refactor: Text 컴포넌트에 alias 추가 * refactor: Icon 컴포넌트 생성 * feat: 딕셔너리 체크하는 유틸 함수 추가 * refactor: fetch 로직 apis로 옮김 * refactor: useUserCode 함수를 인자로 받아서 쓸 수 있도록 변경 * fix: 상태 값 변경 * refactor: 타입 가드 추가 * refactor: useUser 훅 사용하도록 변경 * chore: 필요없는 주석 삭제 * refactor: Error 네이밍 변경 * refactor: type 가드 추가 * feat: gap 추가 * refactor: 공통되는 style 분리 * refactor: 유틸함수 통합 getOrigin => getTarget * refactor: 함수 return문 수정 * refactor: 유틸함수 사용하도록 수정, 스코어가 테이블 뒤에 출력되도록 수정 * feat: dashboardPage 레이아웃 반영- 아톰 컴포넌트 사용 * reafactor: eslint활성화 * chore: if else 문 if 문으로 변경해서 가독성 올림 * refactor: 필요 없는 주석 삭제 * refactor: 불명확한 함수 제거, types 디렉토리 생성 * refactor: 대시보드 보기 버튼 텍스트 수정 * refactor: 스타일 중복 제거, 레이아웃 적용 * refactor: 테스트용 코드 제거 * refactor: 안내문구 수정 * refactor: 필요없는 분기 제거 * feat: 대회 종료 후 5분 이내에는 대시보드 페이지에서 로딩 페이지를 출력 * feat: surface.light 추가 * refactor: type title에 대한 오타 수정 * refactor: 중복 제거, common컴포넌트 사용, pandacss 변수 사용하도록 수정 * refactor: PageLayout을 사용하도록 코드 수정 * dashboardTable에서 socket과 api별로 처리를 하도록 분리 * refactor: 모달창이 dashboardTable에게 props로 데이터를 주도록 수정 * refactor: 데이터를 props로 받도록 수정 * feat: 시간에 따라 socket 혹은 api로 데이터를 받도록 코드 작성 * refactor: consolelog코드 삭제 * chore: 메인 페이지 디자인 수정 * chore: 대회 생성 버튼 디자인 수정 * chore: 페이지 설명 상수 변경 * feat: CheckCircle 컴포넌트 생성 * chore: Header 디자인 수정 * chore: 대회 정보 리스트 디자인 변경 * chore: 필요없는 주석 삭제 * feat: alert에 success와 danger에 light추가 * refactor: 레이아웃 컴포넌트 활용 및 스타일 적용 * refactor: 깃허브 URL을 환경변수로 분리 * refactor: CheckCircle => 공통 디자인으로 변경 * refactor: VStack 컴포넌트 폴더로 이동 * update: VStack에 placeItem center를 default로 추가 * update: CompetitionHeader 요소 중앙 배치 및 gap 설정 * update: BreadCrumb 모양 업데이트 * update: 대회 화면에 로고 추가 * fix: 에디터가 화면을 벗어나는 문제 해결 * update: 문제 해더 레이아웃 변경 * fix: react의 unique props key 문제 해결" * refactor: HStack 폴더로 이동 * update: 문제 선택 aside 레이아웃 조정 * update: footer 레이아웃 개선 * update: 에디터 크기를 전체 50% 사용하도록 수정 * refactor: ProblemSolveContainer 분리 * refactor: ProblemSolveContainer에 실제 대회 정보 주입 * refactor: 불필요한 라인 제거 * refactor: Stack 컴포넌트에서 중앙정렬 로직 제거 * refactor: 일부 100% -> full 로 스타일 토큰 이름 변경 * update: 에디터 사이즈 조절을 자유롭게 할 수 있게 확장 * update: 시뮬레이션 결과 디자인 적용 * update: 실행중인 경우 시뮬레이션에 스피너 추가 * update: 에디터, 결과화면 비율 50:50으로 수정 * fix: 변경된 코드 저장이 동작하지 않는 버그 수정 * refactor: 린트 제거 * refactor: 미사용 코드 제거 * chore: 공통 컴포넌트 적용, 대회 리스트 페이지 css 수정 * fix: ts-ignore 빌드시에 에러 발생해서AuthContext 타입 지정 * refactor: api요청과 socket요청 훅 결합 * refactor: 필요 없는 컴포넌트 삭제 * refactor: DashboardTable에 필요한 props를 넘겨주도록 수정 * refactor: DashboardModal에 필요한 props를 넘겨주도록 수정 * chore: merge * refactor: common의 Loading컴포넌트 활용 * refactor: useParticipantDashboard의 api로직 분류 * refactor: flex 속성=> table로 변경 * refactor: 대회 만드는 버튼에 Link 태그 추가 * chore: 컴포넌트 이름 변경 * refactor: html 태그, css 변경 * fix: 대회 상세보기 페이지가 상태에 따라 출력되지 않던 버그 수정 * fix: 배경색이 잘리던 버그 수정 * refactor: Header 컴포넌트 css 수정 * chore: 대회 테이블 css 수정 * refactor: Chip이 의도대로 출력되지 않던 오류 수정 * chore: chip text 크기 지정 * refactor: 대시보드 페이지에서 데이터를 읽어오도록 코드 수정 * chore: 대회 테이블 디자인 수정 * fix: 화면 확대시 배경화면이 잘리는 오류 수정 * refactor: useAuth커스텀 훅 사용 * refactor: 명시적인 선언 사용 * refactor: css코드 변수 사용하도록 수정 * refactor: 중복 제거 * chore: 메인 페이지 디자인 수정 * fix: 머지 후 빌드 안 되던 것 수정 * fix: key 값 빠진 부분 추가 * fix: Logo path 바뀐거 적용 * fix: Header 디자인 수정 * fix": merge 과정에서 빠진 code 추가 및 로그인 버튼 디자인 수정 * refactor: PageLayout컴포넌트 사용하도록 수정 * refactor: 헤더 요소명 변경 및 css요소 이름 변경 * refactor: DashboardTable의 text요소가 중앙 정렬이 되지 않도록 수정 * refactor: 대시보드 테이블 width 수정 * refactor: DashboardPage의 상태를 나타내는 부분 컴포넌트화 * fix: import 경로 수정 * refactor: 문제가 9개일 경우 텍스트가 뭉개지는 오류 수정 * refactor: Header의 로고 혹은 텍스트를 클릭하면 메인 페이지로 이동 * refactor: MainPage에서 대회가 시작 전일 경우 시작 전 상태를 표시 * refactor: 대회 화면에서 로고 혹은 Algo With Me 누르면 홈으로 이동 * refactor: 필요없는 코드 삭제 * refactor: 틀린 문제시 '-'빨간배경, 미제출시 '-'배경없음 * refactor: css 요소 이름 변경 * refactor: 모달 사이즈 고정 * refactor: 페이지 확대시 dashboardTable이 동적으로 바뀌도록 수정 * refactor: 페이지 새로고침 코드 제거 * feat: 제출 시 결과 화면 스위칭 * update: 문제 탭 변경시 제출 결과 초기화 * update: 실패한 케이스는 x버튼 나오게 함 * feat: 서버에서 내려받은 데이터로 testcase 변경 * feat: 테스트 결과창에 예상값 추가 * feat: 실제 서버 시간으로 동기화 * fix: type 에러 수정 * refactor: type 수정 * refactor: window.location.reload 대신 navigate사용, 버튼 컴포넌트 위치 변경 * refactor: navigate 인자값 수정 * refactor: navigate 인자값 경로로 수정 * refactor: 동일한 경로로의 navigate코드 삭제 * refactor: 탭 인덱스 상수화 * update: 테스트케이스가 기대값과 일치하면 체크,아니면 x표시를 함 * update: 탭 이동시에도 테스트케이스 임시 저장 * feat: 대회 페이지 내에서 검증 실패가 되면 메인 페이지로 이동 * chore: alert 창 추가 * chore: 대시보드 집계 후 보여지기 때문에 main 페이지로 * fix: build 에러 수정 * fix: 대회 생성 버튼 클릭시 동작 안하는 버그 대회 생성 버튼 안의 텍스트만 클릭됨 텍스트를 링크로 감싸고 있어서 생긴 문제 버튼 전체를 링크로 감싸도록해서 해결 Co-authored-by: youseock Co-authored-by: 이우찬 * update: 디테일 페이지 레이아웃 변경 & 시간정보 추가 * refactor: Card 컴포넌트 Common으로 분리 * fix: 문제 상세보기 페이지 안나오는 버그 수정 * fix: alog with me 문구 algo with me로 수정 * feat: 대회 상세 페이지에서 참여 가능 인원, 총 참여자 표시 * fix: 칩 border 색상 변경 * feat: Console.log 추가 * refactor: 주석 제거 * update: Pretendard 폰트 적용 * feat: 대회 시작 시간 및 종료 시간에 따라 대회 상세정보 보기 페이지가 넘어가도록 작성 * refactor: Modal이 클래스를 외부에서 주입받을 수 있게 함 * refactor: Icon 컴포넌트에 minus 아이콘 추가 * update: 모달 디자인 개선 * update: 테스트케이스가 많으면 스크롤 생성 * fix: 대회 페이지용 레이아웃 생성 및 적용 * update: 메인 페이지 하단에 빈 공간 삽입 * refactor: navigate 인자 0으로 수정 * refactor: 테스트용 console 추가 * chore: netlify redirect 옵션 추가 * refactor: navigate 인자 replace true 추가 * refactor: navigate path 수정 * refactor: navigate 인자 0으로 수정 * refactor: 필요 없는 코드 삭제 * fix: websocket 관련 버그 제거 * chore: 필요없는 로직 삭제 * chore: isNil 유틸함수 사용 * fix: 메인페이지의 대시보드 링크가 대회페이지로 이동하는 버그 수정 * update: contest -> competition으로 경로 수정 * fix: 대시보드 링크로 이동하지 않는 버그 수정 * update: 대회 목록 아이템 하단에 border 추가 * update: 메인페이지 테이블 날짜 형식 yyyy. mm. dd. hh:mm 형식으로 통일 * fix: 대회 참여를 신청하지 않은 사람은 대회에 입장할 수 없게 막음 * update: 대회 내의 대시보드 모달에 borderRadius 추가 * update: 서비스 안내 문구 변경 * update: 대시보드 패딩, 마이랭크 하이라이트 * refactor: 스트릭트 모드 제한 해제 * fix: 대회 입장 가능 여부 확인에 토큰을 사용하도록 함 * feat: 유저의 대시보드 row는 하이라이트 * update: 대회가 종료되면 대회 상세 페이지로 이동 * feat: 채점 중에는 다시 제출이 불가능하도록 막음 * refactor: strict mode 켬 * fix: Syntax에러를 만나면 터지는 문제 해결 * fix:일부 푼 문제 가 대시보드에서 보이지 않는 버그 수정 * fix: 모달 밖으로 테이블이 삐져나오는 버그 수정 * feat: 코드 초기화 버튼 추가 * update: aside 문제 구분 잘 안가는 문제 해결 * update: 로그인페이지 레이아웃 개선 * update: 메인페이지, header 버튼 크기 살짝 줄임 * update: ProblemPage에 문제 중앙 배치 * update: 대회 완료시 집계중 화면에 header 추가 * fix: Loading 참조 에러 수정 * refactor: 필요 없는 코드 삭제 * refactor: endsAt에 따라 대시보드 화면이 넘어가도록 수정 * feat: 대시보드 로딩 화면에 남은 시간이 출력되도록 수정 * refactor: 로딩 페이지 ui개선 및 로딩 페이지 정상출력되도록 수정 * refactor: hook 이름 변경 * refactor: 숫자 상수화 * chore: 파피콘 추가, meta 정보 추가 * fix: meta 태그 수정 * refactor: 렌더링 조건문 수정 및 버퍼타임 추가 * refactor: Plus 아이콘 추가 * update: 대회 생성하기 페이지 레이아웃 개 * refactor: 디버깅코드 제거 * update: 대회 생성 조건 강화 - 대회 기간이 5분보다 짧으면 대회를 생성하지 않음 - 대회 시작 시간이 현재 시간 + 5분과 같으면 생성할 수 있음 * refactor: Form 태그 적용 * update: stdOut -> stdout으로 변경 * feat: 실행 결과 시간과 메모리 출력 * update: 제출 결과 확인 로직에 problemResult 적용 * refactor: strict mode 해제 * refactor: 불필요한 문자열 처리 제거 * fix: alignItems가 flexStart를 default로 하며 생기는 버그 수정 * fix: Loading Page의 시간 변경 -> 남은 시간 출력되도록 수정 --------- Co-authored-by: youseock Co-authored-by: 이우찬 <132538081+dmdmdkdkr@users.noreply.github.com> Co-authored-by: youseock <78193416+mahwin@users.noreply.github.com> Co-authored-by: youseock Co-authored-by: 이우찬 --- frontend/_redirects | 1 + frontend/index.html | 3 +- frontend/netlify.toml | 4 + frontend/public/algo.ico | Bin 0 -> 9662 bytes frontend/public/icons.svg | 5 + frontend/src/apis/joinCompetition/index.ts | 18 ++ frontend/src/apis/joinCompetition/types.ts | 5 + frontend/src/components/Common/Button.tsx | 40 ++++- frontend/src/components/Common/Icon.tsx | 2 + frontend/src/components/Common/Input.tsx | 25 ++- frontend/src/components/Common/Loading.tsx | 2 +- .../Common/Socket/SocketProvider.tsx | 10 +- .../src/components/Common/VStack/VStack.tsx | 38 ++++- frontend/src/components/Common/index.ts | 1 + .../Competition/CompetitionHeader.tsx | 2 +- .../Buttons/EnterCompetitionButton.tsx | 35 +++- .../Buttons/JoinCompetitionButton.tsx | 1 + .../CompetitionDetailContent.tsx | 11 +- .../components/Dashboard/DashboardLoading.tsx | 38 ++++- .../components/Dashboard/DashboardModal.tsx | 27 +-- .../components/Dashboard/DashboardTable.tsx | 119 ++++++++----- frontend/src/components/Header/index.tsx | 11 +- frontend/src/components/Layout/PageLayout.tsx | 2 +- .../Buttons/GoToCreateCompetitionLink.tsx | 8 +- .../Main/Buttons/ViewDashboardButton.tsx | 2 +- .../src/components/Main/CompetitionTable.tsx | 22 +-- .../Problem/ProblemSolveContainer.tsx | 50 ++++-- .../Problem/SelectableProblemList.tsx | 74 ++++---- .../Problem/SelectedProblemList.tsx | 54 ++++++ frontend/src/components/SocketTimer/index.tsx | 2 +- .../src/components/Submission/Connecting.tsx | 2 +- frontend/src/components/Submission/Score.tsx | 5 +- frontend/src/components/Submission/types.ts | 4 +- frontend/src/constants/index.ts | 4 +- .../hooks/competition/useCompetitionForm.ts | 16 +- frontend/src/hooks/competitionDetail/index.ts | 1 + frontend/src/hooks/competitionDetail/types.ts | 0 .../useCompetitionRerenderState.ts | 25 +++ frontend/src/hooks/dashboard/index.ts | 2 + .../dashboard/useDashboardRenderState.ts | 31 ++++ .../dashboard/useRemainingTimeCounter.ts | 34 ++++ frontend/src/hooks/editor/useUserCode.ts | 1 - frontend/src/index.css | 6 +- frontend/src/modules/evaluator/quickjs.ts | 16 +- frontend/src/pages/CompetitionPage.tsx | 2 +- frontend/src/pages/CreateCompetitionPage.tsx | 161 +++++++++++------- frontend/src/pages/DashboardPage.tsx | 10 +- frontend/src/pages/LoginPage.tsx | 42 ++++- frontend/src/pages/ProblemPage.tsx | 22 ++- frontend/src/router.tsx | 8 +- frontend/src/utils/date/index.ts | 10 +- .../src/utils/unit/__tests__/byteToKb.spec.ts | 11 ++ frontend/src/utils/unit/index.ts | 5 + frontend/styled-system/tokens/index.css | 85 +++++---- 54 files changed, 801 insertions(+), 314 deletions(-) create mode 100644 frontend/_redirects create mode 100644 frontend/netlify.toml create mode 100644 frontend/public/algo.ico create mode 100644 frontend/src/components/Problem/SelectedProblemList.tsx create mode 100644 frontend/src/hooks/competitionDetail/index.ts create mode 100644 frontend/src/hooks/competitionDetail/types.ts create mode 100644 frontend/src/hooks/competitionDetail/useCompetitionRerenderState.ts create mode 100644 frontend/src/hooks/dashboard/useDashboardRenderState.ts create mode 100644 frontend/src/hooks/dashboard/useRemainingTimeCounter.ts create mode 100644 frontend/src/utils/unit/__tests__/byteToKb.spec.ts create mode 100644 frontend/src/utils/unit/index.ts diff --git a/frontend/_redirects b/frontend/_redirects new file mode 100644 index 0000000..bbb3e7a --- /dev/null +++ b/frontend/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/frontend/index.html b/frontend/index.html index 652cb6f..5afebf0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,8 +2,9 @@ - + + Algo With Me diff --git a/frontend/netlify.toml b/frontend/netlify.toml new file mode 100644 index 0000000..b87b8d3 --- /dev/null +++ b/frontend/netlify.toml @@ -0,0 +1,4 @@ +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 diff --git a/frontend/public/algo.ico b/frontend/public/algo.ico new file mode 100644 index 0000000000000000000000000000000000000000..b0d850cb0c2242f2ef0e7b3ca4267a4de3576871 GIT binary patch literal 9662 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 literal 0 HcmV?d00001 diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg index 7427aef..f741ebb 100644 --- a/frontend/public/icons.svg +++ b/frontend/public/icons.svg @@ -29,4 +29,9 @@ + + + + + diff --git a/frontend/src/apis/joinCompetition/index.ts b/frontend/src/apis/joinCompetition/index.ts index 7a766ff..ad31fed 100644 --- a/frontend/src/apis/joinCompetition/index.ts +++ b/frontend/src/apis/joinCompetition/index.ts @@ -1,3 +1,4 @@ +import { CompetitionId } from '@/apis/competitions'; import api from '@/utils/api'; import type { CompetitionApiData } from './types'; @@ -42,3 +43,20 @@ export async function joinCompetition(data: CompetitionApiData) { } } } + +export async function fetchIsJoinableCompetition( + competitionId: CompetitionId, + token: string, +): Promise { + try { + const { data } = await api.get(`/competitions/validation/${competitionId}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + return data.isJoinable; + } catch (err) { + return false; + } +} diff --git a/frontend/src/apis/joinCompetition/types.ts b/frontend/src/apis/joinCompetition/types.ts index 441522a..8329e50 100644 --- a/frontend/src/apis/joinCompetition/types.ts +++ b/frontend/src/apis/joinCompetition/types.ts @@ -2,3 +2,8 @@ export type CompetitionApiData = { id: number; token: string | null; }; + +export type FetchIsCompetitionJoinableResponse = { + isJoinable: boolean; + message: string; +}; diff --git a/frontend/src/components/Common/Button.tsx b/frontend/src/components/Common/Button.tsx index 1974983..c459bec 100644 --- a/frontend/src/components/Common/Button.tsx +++ b/frontend/src/components/Common/Button.tsx @@ -1,15 +1,17 @@ import { cva, cx } from '@style/css'; -import type { HTMLAttributes, MouseEvent } from 'react'; +import type { HTMLAttributes, MouseEvent, ReactElement } from 'react'; import { isNil } from '@/utils/type'; -type Theme = 'brand' | 'danger' | 'success' | 'warning' | 'none'; +type Theme = 'brand' | 'danger' | 'success' | 'warning' | 'transparent' | 'none'; interface Props extends HTMLAttributes { theme?: Theme; selected?: boolean; disabled?: boolean; + leading?: ReactElement; + type?: 'button' | 'submit' | 'reset'; } export function Button({ @@ -17,8 +19,10 @@ export function Button({ children, theme = 'none', onClick, + leading, selected = false, disabled = false, + type = 'button', ...props }: Props) { const handleClick = (evt: MouseEvent) => { @@ -28,13 +32,20 @@ export function Button({ onClick(evt); }; + const hasLeading = !isNil(leading); + return ( ); @@ -43,6 +54,7 @@ export function Button({ const style = cva({ base: { display: 'flex', + gap: '0.25rem', alignItems: 'center', justifyContent: 'center', borderRadius: '0.5rem', @@ -58,6 +70,11 @@ const style = cva({ cursor: 'not-allowed', }, }, + hasLeading: { + true: { + paddingLeft: '0.5rem', + }, + }, }, }); @@ -85,10 +102,14 @@ const themeStyle = cva({ warning: { background: 'alert.warning', }, + transparent: { + background: 'transparent', + }, }, selected: { true: { - filter: 'brightness(1.2)', + outline: '2px solid', + outlineColor: 'brand', }, }, disabled: { @@ -100,4 +121,13 @@ const themeStyle = cva({ }, }, }, + compoundVariants: [ + { + selected: true, + theme: 'transparent', + css: { + background: 'surface', + }, + }, + ], }); diff --git a/frontend/src/components/Common/Icon.tsx b/frontend/src/components/Common/Icon.tsx index b571c87..4c90566 100644 --- a/frontend/src/components/Common/Icon.tsx +++ b/frontend/src/components/Common/Icon.tsx @@ -12,6 +12,7 @@ const ICON_NAMES = [ 'spinner', 'cancel', 'minus', + 'plus', ] as const; type IconName = (typeof ICON_NAMES)[number]; @@ -56,6 +57,7 @@ Icon.CancelRound = ({ ...props }: Omit) => ( Icon.Spinner = ({ ...props }: Omit) => ; Icon.Cancel = ({ ...props }: Omit) => ; Icon.Minus = ({ ...props }: Omit) => ; +Icon.Plus = ({ ...props }: Omit) => ; const style = css({ display: 'inline-block', diff --git a/frontend/src/components/Common/Input.tsx b/frontend/src/components/Common/Input.tsx index 80dc196..80135cd 100644 --- a/frontend/src/components/Common/Input.tsx +++ b/frontend/src/components/Common/Input.tsx @@ -10,25 +10,46 @@ import type { } from 'react'; import { Children, cloneElement, forwardRef } from 'react'; +import { Text } from '@/components/Common'; + interface Props extends HTMLAttributes { label?: ReactNode; + comment?: string; children: ReactElement; } -export function Input({ id, label, children, ...props }: Props) { +export function Input({ id, label, comment, children, ...props }: Props) { const child = Children.only(children); return (
- + {cloneElement(child, { id, ...child.props, })} + + {comment} +
); } +const labelStyle = css({ + display: 'block', + marginLeft: '0.25rem', + marginBottom: '0.5rem', +}); + +const commentStyle = css({ + display: 'block', + marginTop: '0.25rem', + marginLeft: '0.25rem', + color: 'text.light', +}); + interface TextFieldProps extends Omit, 'type'> { error?: boolean; } diff --git a/frontend/src/components/Common/Loading.tsx b/frontend/src/components/Common/Loading.tsx index a0b3639..1f2109d 100644 --- a/frontend/src/components/Common/Loading.tsx +++ b/frontend/src/components/Common/Loading.tsx @@ -3,7 +3,7 @@ interface Props { color: string; } -export default function Loading({ size, color }: Props) { +export function Loading({ size, color }: Props) { return ( { + if (isNil(socket.current)) return; + return () => { + disconnect(`/${namespace}`); + }; + }, [socket]); + return ( { as?: React.ElementType; + alignItems?: 'flexStart' | 'flexEnd' | 'baseline' | 'stretch' | 'center'; } -export function VStack({ children, className, as = 'div', ...props }: Props) { +export function VStack({ + children, + className, + as = 'div', + alignItems = 'flexStart', + ...props +}: Props) { const As = as; return ( - + {children} ); @@ -19,3 +26,28 @@ export function VStack({ children, className, as = 'div', ...props }: Props) { const rowListStyle = css({ display: 'flex', }); + +const alignItemStyle = cva({ + defaultVariants: { + alignItems: 'flexStart', + }, + variants: { + alignItems: { + flexStart: { + alignItems: 'flex-start', + }, + flexEnd: { + alignItems: 'flex-end', + }, + baseline: { + alignItems: 'baseline', + }, + stretch: { + alignItems: 'stretch', + }, + center: { + alignItems: 'center', + }, + }, + }, +}); diff --git a/frontend/src/components/Common/index.ts b/frontend/src/components/Common/index.ts index bcd6649..9a644f7 100644 --- a/frontend/src/components/Common/index.ts +++ b/frontend/src/components/Common/index.ts @@ -11,3 +11,4 @@ export * from './Icon'; export * from './BreadCrumb'; export * from './Logo'; export * from './Card'; +export * from './Loading'; diff --git a/frontend/src/components/Competition/CompetitionHeader.tsx b/frontend/src/components/Competition/CompetitionHeader.tsx index 64167d6..ccd495c 100644 --- a/frontend/src/components/Competition/CompetitionHeader.tsx +++ b/frontend/src/components/Competition/CompetitionHeader.tsx @@ -6,7 +6,7 @@ interface Props extends VStackProps {} export default function CompetitionHeader({ className, children, ...props }: Props) { return ( - + {children} ); diff --git a/frontend/src/components/CompetitionDetail/Buttons/EnterCompetitionButton.tsx b/frontend/src/components/CompetitionDetail/Buttons/EnterCompetitionButton.tsx index 9176e17..74cd319 100644 --- a/frontend/src/components/CompetitionDetail/Buttons/EnterCompetitionButton.tsx +++ b/frontend/src/components/CompetitionDetail/Buttons/EnterCompetitionButton.tsx @@ -1,5 +1,6 @@ import { useNavigate } from 'react-router-dom'; +import { fetchIsJoinableCompetition } from '@/apis/joinCompetition'; import { Button } from '@/components/Common'; import useAuth from '@/hooks/login/useAuth'; @@ -10,25 +11,43 @@ interface Props { } export default function EnterCompetitionButton({ id, startsAt, endsAt }: Props) { - const competitionLink = `/contest/${id}`; + const competitionLink = `/competition/${id}`; const { isLoggedin } = useAuth(); const navigate = useNavigate(); - const handleNavigate = () => { + const handleNavigate = async () => { const currentTime = new Date(); if (!isLoggedin) { alert('로그인이 필요합니다.'); navigate('/login'); - } else if (currentTime < startsAt) { + + return; + } + + if (currentTime < startsAt) { alert('아직 대회가 시작되지 않았습니다. 다시 시도해주세요'); - window.location.reload(); - } else if (currentTime >= endsAt) { + navigate(0); + + return; + } + + if (currentTime >= endsAt) { alert('해당 대회는 종료되었습니다.'); - window.location.reload(); - } else { - navigate(competitionLink); + navigate(0); + + return; } + + const accessToken = localStorage.getItem('accessToken') ?? ''; + + const isJoinable = await fetchIsJoinableCompetition(id, accessToken); + if (!isJoinable) { + alert('대회에 참여할 수 없습니다.\n다음부터는 늦지 않게 대회 신청을 해주세요 :)'); + return; + } + + navigate(competitionLink); }; return ( diff --git a/frontend/src/components/CompetitionDetail/Buttons/JoinCompetitionButton.tsx b/frontend/src/components/CompetitionDetail/Buttons/JoinCompetitionButton.tsx index 399aa65..fada0fb 100644 --- a/frontend/src/components/CompetitionDetail/Buttons/JoinCompetitionButton.tsx +++ b/frontend/src/components/CompetitionDetail/Buttons/JoinCompetitionButton.tsx @@ -23,6 +23,7 @@ export default function JoinCompetitionButton(props: { id: number }) { const result = await joinCompetition(competitionData); alert(result); + navigate(0); }; const competitionData: CompetitionApiData = { id: props.id, diff --git a/frontend/src/components/CompetitionDetail/CompetitionDetailContent.tsx b/frontend/src/components/CompetitionDetail/CompetitionDetailContent.tsx index 9ffd1d6..e9be109 100644 --- a/frontend/src/components/CompetitionDetail/CompetitionDetailContent.tsx +++ b/frontend/src/components/CompetitionDetail/CompetitionDetailContent.tsx @@ -4,6 +4,7 @@ import { CompetitionInfo } from '@/apis/competitions'; import AfterCompetition from '@/components/CompetitionDetail/AfterCompetition'; import BeforeCompetition from '@/components/CompetitionDetail/BeforeCompetition'; import DuringCompetition from '@/components/CompetitionDetail/DuringCompetition'; +import { useCompetitionRerender } from '@/hooks/competitionDetail'; interface Props extends HTMLAttributes { competitionId: number; @@ -20,17 +21,19 @@ export function CompetitionDetailContent({ const startsAt = new Date(competition.startsAt || ''); const endsAt = new Date(competition.endsAt || ''); - if (currentDate < startsAt) { + const { shouldRerenderDuring, shouldRerenderAfter } = useCompetitionRerender(startsAt, endsAt); + + if ((shouldRerenderAfter && shouldRerenderDuring) || currentDate >= endsAt) { return ( - + ); } - if (currentDate < endsAt) { + if (shouldRerenderDuring || currentDate >= startsAt) { return ( ); } - return ; + return ; } diff --git a/frontend/src/components/Dashboard/DashboardLoading.tsx b/frontend/src/components/Dashboard/DashboardLoading.tsx index 943d5c6..01ce50f 100644 --- a/frontend/src/components/Dashboard/DashboardLoading.tsx +++ b/frontend/src/components/Dashboard/DashboardLoading.tsx @@ -1,17 +1,33 @@ import { css } from '@style/css'; -import { Text } from '../Common'; -import Loading from '../Common/Loading'; +import { HStack, Loading, Text } from '@/components/Common'; +import { useRemainingTimeCounter } from '@/hooks/dashboard'; + +import Header from '../Header'; import { PageLayout } from '../Layout/PageLayout'; -export default function DashboardLoading() { +interface Props { + bufferTimeAfterCompetitionEnd: Date; +} + +export default function DashboardLoading({ bufferTimeAfterCompetitionEnd }: Props) { + const remainingTime = useRemainingTimeCounter(new Date(bufferTimeAfterCompetitionEnd)); + return ( - - - 대회 종료 후 5분 뒤에 집계가 완료됩니다 - - - + <> +
+ + + + 대회 종료 후 5분 뒤에 집계가 완료됩니다 + + + 남은 시간: {remainingTime} + + + + + ); } @@ -23,6 +39,10 @@ const pageStyle = css({ justifyContent: 'center', }); +const textContainerStyle = css({ + alignItems: 'center', +}); + const textStyle = css({ marginBottom: '20px', }); diff --git a/frontend/src/components/Dashboard/DashboardModal.tsx b/frontend/src/components/Dashboard/DashboardModal.tsx index 166860c..4754150 100644 --- a/frontend/src/components/Dashboard/DashboardModal.tsx +++ b/frontend/src/components/Dashboard/DashboardModal.tsx @@ -1,6 +1,6 @@ import { css } from '@style/css'; -import { Button, Text, VStack } from '../Common'; +import { Button, HStack, Text, VStack } from '../Common'; import { buttonContainerStyle } from '../CompetitionDetail/styles/styles'; import DashboardTable from './DashboardTable'; @@ -18,19 +18,21 @@ export default function DashboardModal({ isOpen, onClose, competitionId, competi return (
-
e.stopPropagation()}> -
+ e.stopPropagation()}> +

{competitionName} +

+
+
- -
+
); } @@ -48,15 +50,20 @@ const modalOverlayStyle = css({ }); const modalContentStyle = css({ - padding: '32px', + padding: '1rem 1.5rem', position: 'relative', - width: '1264px', - height: '920px', + width: 'calc(100% - 4rem)', + minWidth: '900px', + height: 'calc(100% - 4rem)', + minHeight: '680px', + borderRadius: '0.5rem', background: 'background', + gap: '2rem', }); -const competitionNameStyle = css({ - marginBottom: '32px', +const tableStyle = css({ + overflow: 'auto', + flexGrow: 1, }); const buttonStyle = css({ diff --git a/frontend/src/components/Dashboard/DashboardTable.tsx b/frontend/src/components/Dashboard/DashboardTable.tsx index ef534ca..12ab911 100644 --- a/frontend/src/components/Dashboard/DashboardTable.tsx +++ b/frontend/src/components/Dashboard/DashboardTable.tsx @@ -1,4 +1,4 @@ -import { css } from '@style/css'; +import { css, cva } from '@style/css'; import { SystemStyleObject } from '@style/types'; import { useParticipantDashboard } from '@/hooks/dashboard'; @@ -54,7 +54,7 @@ export default function DashboardTable({ useWebsocket, competitionId }: Props) { {!isNil(myRank) && ( - + {myRank.rank} @@ -65,25 +65,21 @@ export default function DashboardTable({ useWebsocket, competitionId }: Props) { {myRank.email} - {problemCount.map((problemId) => ( - - - {myRank.problemDict[Number(problemId)] === -1 || - isNil(myRank.problemDict[Number(problemId)]) - ? '-' - : myRank.problemDict[Number(problemId)]} - - - ))} + {Object.keys(myRank.problemDict).map((problemId) => { + const solvedValue = myRank.problemDict[Number(problemId)]; + const solvedState = toSolvedState(solvedValue); + + const style = problemCellStyle({ + solvedState, + }); + return ( + + + {toStateText(solvedState, solvedValue)} + + + ); + })} {myRank.score} @@ -103,25 +99,21 @@ export default function DashboardTable({ useWebsocket, competitionId }: Props) { {rank.email} - {problemCount.map((problemId) => ( - - - {rank.problemDict[Number(problemId)] === -1 || - isNil(rank.problemDict[Number(problemId)]) - ? '-' - : rank.problemDict[Number(problemId)]} - - - ))} + {Object.keys(rank.problemDict).map((problemId) => { + const solvedValue = rank.problemDict[Number(problemId)]; + const solvedState = toSolvedState(solvedValue); + + const style = problemCellStyle({ + solvedState, + }); + return ( + + + {toStateText(solvedState, solvedValue)} + + + ); + })} {rank.score} @@ -134,9 +126,28 @@ export default function DashboardTable({ useWebsocket, competitionId }: Props) { ); } +type ProblemValue = null | number; +type ProblemState = 'none' | 'wrong' | 'success'; +function toSolvedState(problemValue: ProblemValue): ProblemState { + if (isNil(problemValue)) { + return 'none'; + } + if (problemValue < 0) { + return 'wrong'; + } + return 'success'; +} +function toStateText(problemState: ProblemState, originValue: ProblemValue) { + if (problemState === 'none') return '-'; + if (problemState === 'wrong') return '-'; + + return originValue; +} + const tableStyle = css({ width: '100%', margin: '0 auto', + borderCollapse: 'collapse', }); const defaultCellStyle: SystemStyleObject = { @@ -184,10 +195,30 @@ const centeredCellStyle = css(defaultCellStyle, { textAlign: 'center', }); -const wrongProblemCellStyle = css(defaultCellStyle, { - background: 'rgba(226, 54, 54, 0.70)', +const problemCellStyle = cva({ + base: { + height: '64px', + padding: '12px', + border: '1px solid', + borderColor: 'border', + background: 'surface', + }, + variants: { + solvedState: { + wrong: { + background: 'rgba(226, 54, 54, 0.70)', + }, + success: { + background: 'rgba(130, 221, 85, 0.70)', + }, + none: { + background: 'transparent', + }, + }, + }, }); -const correctProblemCellStyle = css(defaultCellStyle, { - background: 'rgba(130, 221, 85, 0.70)', +const highlightRowStyle = css({ + border: '2px solid', + borderColor: 'brand', }); diff --git a/frontend/src/components/Header/index.tsx b/frontend/src/components/Header/index.tsx index 3b3cacf..90da3a0 100644 --- a/frontend/src/components/Header/index.tsx +++ b/frontend/src/components/Header/index.tsx @@ -45,7 +45,7 @@ export default function Header() { const headerWrapperStyle = css({ width: '100%', - height: '64px', + height: '4rem', display: 'flex', justifyContent: 'center', alignItems: 'center', @@ -53,11 +53,12 @@ const headerWrapperStyle = css({ }); const headerStyle = css({ + paddingY: '0.5rem', height: '40px', width: '100%', maxWidth: '1200px', alignItems: 'center', - gap: '16px', + gap: '1rem', }); const textStyle = css({ @@ -65,11 +66,5 @@ const textStyle = css({ }); const buttonStyle = css({ - display: 'flex', width: '120px', - padding: '12px 20px', - justifyContent: 'center', - alignItems: 'center', - gap: '10px', - flexShrink: 0, }); diff --git a/frontend/src/components/Layout/PageLayout.tsx b/frontend/src/components/Layout/PageLayout.tsx index 30071b9..c0ee01b 100644 --- a/frontend/src/components/Layout/PageLayout.tsx +++ b/frontend/src/components/Layout/PageLayout.tsx @@ -13,8 +13,8 @@ export function PageLayout({ children, className, ...props }: Props) { } const style = css({ + background: 'transparent', width: '100%', color: 'text', - background: 'background', paddingBottom: '300px', }); diff --git a/frontend/src/components/Main/Buttons/GoToCreateCompetitionLink.tsx b/frontend/src/components/Main/Buttons/GoToCreateCompetitionLink.tsx index 6152d6a..0a55b9f 100644 --- a/frontend/src/components/Main/Buttons/GoToCreateCompetitionLink.tsx +++ b/frontend/src/components/Main/Buttons/GoToCreateCompetitionLink.tsx @@ -13,7 +13,7 @@ export default function GoToCreateCompetitionLink() { }; return ( - + + - @@ -238,10 +258,6 @@ const tabStyle = cva({ }, }); -const submissionButtonStyle = css({ - paddingX: '2rem', -}); - const simulationModalStyle = css({ width: '1000px', }); diff --git a/frontend/src/components/Problem/SelectableProblemList.tsx b/frontend/src/components/Problem/SelectableProblemList.tsx index a5087cb..7fa2f0c 100644 --- a/frontend/src/components/Problem/SelectableProblemList.tsx +++ b/frontend/src/components/Problem/SelectableProblemList.tsx @@ -1,48 +1,54 @@ -import type { MouseEvent } from 'react'; +import { css } from '@style/css'; import type { ProblemId, ProblemInfo } from '@/apis/problems'; +import { Icon } from '../Common'; + 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); +export function SelectableProblemList({ problemList, onSelectProblem }: ProblemListProps) { + function handleSelectProblem(id: ProblemId) { + onSelectProblem(id); } return ( -
    - {problemList.map(({ id, title }) => ( -
  • - - {id}: {title} - - -
  • - ))} -
+ + + + + + + + + {problemList.map(({ id, title }) => ( + + + + + ))} + +
문제 이름문제 추가
{title} + handleSelectProblem(id)} /> +
); -}; +} -export default SelectableProblemList; +const tableStyle = css({ + width: '320px', + padding: '24px 16px', + tableLayout: 'fixed', +}); -const SelectButton = ({ isPicked }: { isPicked: boolean }) => { - if (isPicked) { - return ; - } - return ; -}; +const dividingStyle = css({ + borderBottom: '1px solid', + borderColor: 'border', +}); diff --git a/frontend/src/components/Problem/SelectedProblemList.tsx b/frontend/src/components/Problem/SelectedProblemList.tsx new file mode 100644 index 0000000..3e5f62e --- /dev/null +++ b/frontend/src/components/Problem/SelectedProblemList.tsx @@ -0,0 +1,54 @@ +import { css } from '@style/css'; + +import type { ProblemId, ProblemInfo } from '@/apis/problems'; + +import { Icon } from '../Common'; + +interface SelectedProblemListProps { + problemList: ProblemInfo[]; + onCancelProblem: (problemId: ProblemId) => void; +} + +export function SelectedProblemList({ problemList, onCancelProblem }: SelectedProblemListProps) { + function handleCancelProblem(id: ProblemId) { + onCancelProblem(id); + } + + return ( + + + + + + + + + {problemList.map(({ id, title }) => ( + + + + + ))} + +
문제 이름문제 삭제
{title} + handleCancelProblem(id)} /> +
+ ); +} + +const tableStyle = css({ + width: '320px', + padding: '1.5rem 1rem', + tableLayout: 'fixed', +}); + +const dividingStyle = css({ + borderBottom: '1px solid', + borderColor: 'border', +}); diff --git a/frontend/src/components/SocketTimer/index.tsx b/frontend/src/components/SocketTimer/index.tsx index e4c3a4f..475fef5 100644 --- a/frontend/src/components/SocketTimer/index.tsx +++ b/frontend/src/components/SocketTimer/index.tsx @@ -2,7 +2,7 @@ import { css } from '@style/css'; import { useContext, useEffect } from 'react'; -import Loading from '@/components/Common/Loading'; +import { Loading } from '@/components/Common'; import useSocketTimer from '@/hooks/timer/useSocketTimer'; import { formatMilliSecond } from '@/utils/date'; diff --git a/frontend/src/components/Submission/Connecting.tsx b/frontend/src/components/Submission/Connecting.tsx index 5a314fe..87a9ddb 100644 --- a/frontend/src/components/Submission/Connecting.tsx +++ b/frontend/src/components/Submission/Connecting.tsx @@ -1,6 +1,6 @@ import { css } from '@style/css'; -import Loading from '@/components/Common/Loading'; +import { Loading } from '@/components/Common'; interface Props { isConnected: boolean; diff --git a/frontend/src/components/Submission/Score.tsx b/frontend/src/components/Submission/Score.tsx index b3f2be4..7c1ee84 100644 --- a/frontend/src/components/Submission/Score.tsx +++ b/frontend/src/components/Submission/Score.tsx @@ -1,6 +1,7 @@ import { css, cva } from '@style/css'; import { Icon, Text, VStack } from '@/components/Common'; +import { byteToKB } from '@/utils/unit'; import type { ScoreResult, SubmitState } from './types'; import { SUBMIT_STATE } from './types'; @@ -26,7 +27,7 @@ export default function Score({ testcaseId, score, submitState }: Props) { } const isSuccess = score?.result === '정답입니다'; - + const { result = '', memoryUsage = 0, timeUsage = 0 } = score ?? {}; return ( {isSuccess ? : } @@ -35,7 +36,7 @@ export default function Score({ testcaseId, score, submitState }: Props) { size="lg" className={resultTextStyle({ status: isSuccess ? 'success' : 'failed' })} > - {score?.result ?? ''} ({score?.stdOut ?? ''}) + {result} ({`${byteToKB(memoryUsage)}KB, ${(timeUsage / 1000).toFixed(2)}s`}) ); diff --git a/frontend/src/components/Submission/types.ts b/frontend/src/components/Submission/types.ts index 8a5f1db..69acd84 100644 --- a/frontend/src/components/Submission/types.ts +++ b/frontend/src/components/Submission/types.ts @@ -15,8 +15,10 @@ export type ScoreStart = { export type ScoreResult = { problemId: ProblemId; + testcaseId: number; + timeUsage: number; + memoryUsage: number; result: string; - stdOut: string; }; export type SubmitResult = { diff --git a/frontend/src/constants/index.ts b/frontend/src/constants/index.ts index d41819a..256ec04 100644 --- a/frontend/src/constants/index.ts +++ b/frontend/src/constants/index.ts @@ -1,8 +1,8 @@ export const SITE = { NAME: 'Algo With Me', - PAGE_DESCRIPTION: '쉽고 빠르게 만드는 코딩 대회', + PAGE_DESCRIPTION: '누구나 만들고 참여할 수 있는 알고리즘 대회', }; export const ROUTE = { - DASHBOARD: '/contest/dashboard', + DASHBOARD: '/competition/dashboard', }; diff --git a/frontend/src/hooks/competition/useCompetitionForm.ts b/frontend/src/hooks/competition/useCompetitionForm.ts index 1fdfa49..67d0103 100644 --- a/frontend/src/hooks/competition/useCompetitionForm.ts +++ b/frontend/src/hooks/competition/useCompetitionForm.ts @@ -14,9 +14,10 @@ const FIVE_MIN_BY_MS = 5 * 60 * 1000; const VALIDATION_MESSAGE = { needLongName: '이름은 1글자 이상이어야합니다', needMoreParticipants: '최대 참여 인원은 1명 이상이어야 합니다', - tooEarlyStartTime: '대회 시작 시간은 현재보다 5분 늦은 시간부터 가능합니다', + tooEarlyStartTime: '대회 시작 시간은 현재보다 5분 이상 늦은 시간부터 가능합니다', tooEarlyEndTime: '대회 종료 시간은 대회 시작시간보다 늦게 끝나야합니다', needMoreProblems: '대회 문제는 1개 이상이어야합니다', + tooShortEndTime: '대회는 최소 5분 이상 진행되어야 합니다.', }; export function useCompetitionForm(initialForm: Partial = {}) { @@ -33,9 +34,9 @@ export function useCompetitionForm(initialForm: Partial = {}) { function togglePickedProblem(problemId: ProblemId) { if (problemIds.includes(problemId)) { - setProblemIds((ids) => ids.filter((id) => id !== problemId).sort()); + setProblemIds((ids) => ids.filter((id) => id !== problemId)); } else { - setProblemIds((ids) => [...ids, problemId].sort()); + setProblemIds((ids) => [...ids, problemId]); } } @@ -69,7 +70,7 @@ export function useCompetitionForm(initialForm: Partial = {}) { message: VALIDATION_MESSAGE.needMoreParticipants, }; } - if (new Date(startsAt) <= new Date(Date.now() + FIVE_MIN_BY_MS)) { + if (new Date(startsAt) < new Date(Date.now() + FIVE_MIN_BY_MS)) { return { isValid: false, message: VALIDATION_MESSAGE.tooEarlyStartTime, @@ -83,6 +84,13 @@ export function useCompetitionForm(initialForm: Partial = {}) { }; } + if (new Date(endsAt).getTime() - new Date(startsAt).getTime() < FIVE_MIN_BY_MS) { + return { + isValid: false, + message: VALIDATION_MESSAGE.tooShortEndTime, + }; + } + if (problemIds.length <= 0) { return { isValid: false, diff --git a/frontend/src/hooks/competitionDetail/index.ts b/frontend/src/hooks/competitionDetail/index.ts new file mode 100644 index 0000000..885d479 --- /dev/null +++ b/frontend/src/hooks/competitionDetail/index.ts @@ -0,0 +1 @@ +export * from './useCompetitionRerenderState'; diff --git a/frontend/src/hooks/competitionDetail/types.ts b/frontend/src/hooks/competitionDetail/types.ts new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/hooks/competitionDetail/useCompetitionRerenderState.ts b/frontend/src/hooks/competitionDetail/useCompetitionRerenderState.ts new file mode 100644 index 0000000..2cbfe36 --- /dev/null +++ b/frontend/src/hooks/competitionDetail/useCompetitionRerenderState.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react'; + +const TIME_INTERVAL = 1000; + +export function useCompetitionRerender(startsAt: Date, endsAt: Date) { + const [shouldRerenderDuring, setShouldRerenderDuring] = useState(false); + const [shouldRerenderAfter, setShouldRerenderAfter] = useState(false); + + useEffect(() => { + const intervalId = setInterval(() => { + const currentDate = new Date(); + if (currentDate >= startsAt && !shouldRerenderDuring) { + setShouldRerenderDuring(true); + } + + if (currentDate >= endsAt && !shouldRerenderAfter) { + setShouldRerenderAfter(true); + } + }, TIME_INTERVAL); + + return () => clearInterval(intervalId); + }, [startsAt, endsAt, shouldRerenderDuring, shouldRerenderAfter]); + + return { shouldRerenderDuring, shouldRerenderAfter }; +} diff --git a/frontend/src/hooks/dashboard/index.ts b/frontend/src/hooks/dashboard/index.ts index 6239f74..cffe93a 100644 --- a/frontend/src/hooks/dashboard/index.ts +++ b/frontend/src/hooks/dashboard/index.ts @@ -1,2 +1,4 @@ export * from './types'; export * from './useParticipantDashboard'; +export * from './useDashboardRenderState'; +export * from './useRemainingTimeCounter'; diff --git a/frontend/src/hooks/dashboard/useDashboardRenderState.ts b/frontend/src/hooks/dashboard/useDashboardRenderState.ts new file mode 100644 index 0000000..c7558a9 --- /dev/null +++ b/frontend/src/hooks/dashboard/useDashboardRenderState.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react'; + +const TIME_INTERVAL = 1000; +const ADDITIONAL_BUFFER_TIME = 3 * 1000; + +export function useDashboardRerenderState(endsAt: Date, bufferTimeAfterCompetitionEnd: Date) { + const [shouldRenderLoading, setShouldRenderLoading] = useState(false); + + useEffect(() => { + const intervalId = setInterval(() => { + const currentDate = new Date(); + + if ( + currentDate >= endsAt && + currentDate < new Date(bufferTimeAfterCompetitionEnd.getTime() + ADDITIONAL_BUFFER_TIME) + ) { + setShouldRenderLoading(true); + } + + if ( + currentDate >= new Date(bufferTimeAfterCompetitionEnd.getTime() + ADDITIONAL_BUFFER_TIME) + ) { + setShouldRenderLoading(false); + } + }, TIME_INTERVAL); + + return () => clearInterval(intervalId); + }, [endsAt, shouldRenderLoading, bufferTimeAfterCompetitionEnd]); + + return shouldRenderLoading; +} diff --git a/frontend/src/hooks/dashboard/useRemainingTimeCounter.ts b/frontend/src/hooks/dashboard/useRemainingTimeCounter.ts new file mode 100644 index 0000000..8b76b83 --- /dev/null +++ b/frontend/src/hooks/dashboard/useRemainingTimeCounter.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; + +const TIME_INTERVAL = 1000; + +export function useRemainingTimeCounter(endsAt: Date) { + const [remainingTime, setRemainingTime] = useState(''); + + useEffect(() => { + const endsAtDate = new Date(endsAt); + + const calculateRemainingTime = () => { + const currentTime = new Date(); + const timeDifference = endsAtDate.getTime() - currentTime.getTime(); + + if (timeDifference > 0) { + const minutes = Math.floor((timeDifference % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((timeDifference % (1000 * 60)) / 1000); + + return `${minutes}분 ${seconds}초`; + } + + return ''; + }; + + const intervalId = setInterval(() => { + const newRemainingTime = calculateRemainingTime(); + setRemainingTime(newRemainingTime); + }, TIME_INTERVAL); + + return () => clearInterval(intervalId); + }, [endsAt]); + + return remainingTime; +} diff --git a/frontend/src/hooks/editor/useUserCode.ts b/frontend/src/hooks/editor/useUserCode.ts index cb2f5a4..81926d7 100644 --- a/frontend/src/hooks/editor/useUserCode.ts +++ b/frontend/src/hooks/editor/useUserCode.ts @@ -67,7 +67,6 @@ export function useUserCode({ return; } - if (code === problem.solutionCode) return; origin[competitionKey][currentProblemIndex] = code; save(localStorageKey, origin); diff --git a/frontend/src/index.css b/frontend/src/index.css index def3e22..75eb1c9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -14,9 +14,6 @@ font-weight: 400; color-scheme: light dark; - color: #213547; - background-color: #ffffff; - font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; @@ -42,6 +39,9 @@ h1 { } #root { + width: 100%; + height: 100%; min-width: 100vw; min-height: 100vh; + background-color: #263238; } diff --git a/frontend/src/modules/evaluator/quickjs.ts b/frontend/src/modules/evaluator/quickjs.ts index 7639171..4586a42 100644 --- a/frontend/src/modules/evaluator/quickjs.ts +++ b/frontend/src/modules/evaluator/quickjs.ts @@ -14,6 +14,19 @@ export async function evaluate(code: string, params: string) { try { return evalCode(vm, code, params, logs); + } catch (err) { + const error = err as Error; + console.log(err); + return { + time: 0, + result: undefined, + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + logs: logs, + }; } finally { vm.dispose(); runtime.dispose(); @@ -58,8 +71,7 @@ const evalCode = (vm: QuickJSContext, code: string, params: string, logs: string }; const toRunableScript = (code: string, params: string) => { - return ` - ${code}\n + return `${code}\n (()=>{ try { diff --git a/frontend/src/pages/CompetitionPage.tsx b/frontend/src/pages/CompetitionPage.tsx index 5e834e2..6334504 100644 --- a/frontend/src/pages/CompetitionPage.tsx +++ b/frontend/src/pages/CompetitionPage.tsx @@ -46,7 +46,7 @@ export default function CompetitionPage() { const problemIds = problemList.map((problem) => problem.id); function handleTimeout() { - navigate('/'); + navigate(`/competition/detail/${competitionId}`); } const { competition } = useCompetition(competitionId); diff --git a/frontend/src/pages/CreateCompetitionPage.tsx b/frontend/src/pages/CreateCompetitionPage.tsx index 0ea6f0a..a22d8c2 100644 --- a/frontend/src/pages/CreateCompetitionPage.tsx +++ b/frontend/src/pages/CreateCompetitionPage.tsx @@ -1,11 +1,14 @@ import { css } from '@style/css'; -import type { ChangeEvent } from 'react'; +import type { ChangeEvent, FormEvent } 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 type { ProblemId, ProblemInfo } from '@/apis/problems'; +import { Button, HStack, Input, VStack } from '@/components/Common'; +import Header from '@/components/Header'; +import { PageLayout } from '@/components/Layout'; +import { SelectableProblemList } from '@/components/Problem/SelectableProblemList'; +import { SelectedProblemList } from '@/components/Problem/SelectedProblemList'; import { useCompetitionForm } from '@/hooks/competition/useCompetitionForm'; import { useProblemList } from '@/hooks/problem/useProblemList'; import { isNil } from '@/utils/type'; @@ -16,6 +19,13 @@ export default function CompetitionCreatePage() { const form = useCompetitionForm(); const { problemList } = useProblemList(); + const unpickedProblems = problemList.filter((problem) => { + return !form.problemIds.includes(problem.id); + }); + const pickedProblems = form.problemIds.map((problemId) => { + return problemList.find((problem) => problem.id === problemId); + }) as ProblemInfo[]; + function handleChangeName(e: ChangeEvent) { const newName = e.target.value; form.setName(newName); @@ -41,11 +51,13 @@ export default function CompetitionCreatePage() { form.setEndsAt(newEndsAt); } - function handleSelectProblem(problemId: ProblemId) { + function handleToggleSelectedProblem(problemId: ProblemId) { form.togglePickedProblem(problemId); } - async function handleSumbitCompetition() { + async function handleSumbitCompetition(e: FormEvent) { + e.preventDefault(); + const formData = form.getAllFormData(); const { isValid, message } = form.validateForm(formData); @@ -62,70 +74,89 @@ export default function CompetitionCreatePage() { return; } - const TO_DETAIL_PAGE = `/contest/detail/${competition.id}`; + const TO_DETAIL_PAGE = `/competition/detail/${competition.id}`; navigate(TO_DETAIL_PAGE); } return ( -
-

대회 생성 하기

-
- - - - - - - - - - - - - - - - -
선택된 문제: {[...form.problemIds].join(',')}
-
- -
+ <> +
+ + +

대회 생성하기

+ + + + + + + + + + + + + + + + + + + + + + +
+
+ ); } -const fieldSetStyle = css({ - display: 'flex', - flexDirection: 'column', +const contentStyle = css({ + margin: '100px auto 0 auto', + maxWidth: '900px', + gap: '3rem', + alignItems: 'flex-start', }); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index ca99d3d..9c72cd6 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -9,6 +9,7 @@ import DashboardTable from '@/components/Dashboard/DashboardTable'; import Header from '@/components/Header'; import { PageLayout } from '@/components/Layout/PageLayout'; import { useCompetition } from '@/hooks/competition'; +import { useDashboardRerenderState } from '@/hooks/dashboard'; export default function DashboardPage() { const { id } = useParams<{ id: string }>(); @@ -32,8 +33,13 @@ export default function DashboardPage() { const useWebSocket = currentTime < bufferTimeAfterCompetitionEnd; - if (currentTime < bufferTimeAfterCompetitionEnd && currentTime >= new Date(endsAt)) { - return ; + const shouldRenderLoading = useDashboardRerenderState( + new Date(endsAt), + bufferTimeAfterCompetitionEnd, + ); + + if (shouldRenderLoading) { + return ; } return ( diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 994f3c5..02b1104 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,9 +1,11 @@ -import Login from '@/components/Login'; +import { css } from '@style/css'; + +import { Button, HStack, Logo } from '@/components/Common'; +import { PageLayout } from '@/components/Layout'; const GITHUB_AUTH_URL = import.meta.env.VITE_GITHUB_AUTH_URL; export default function LoginPage() { - // 넘겨주는 함수는 handle, 함수를 넘길 때의 프로펄티 네임은 on const handleLogin = () => { try { window.location.href = GITHUB_AUTH_URL; @@ -13,5 +15,39 @@ export default function LoginPage() { } }; - return ; + return ( + + + +
Algo With Me
+ +
+
+ ); } + +const style = css({ position: 'relative' }); + +const loginWrapperStyle = css({ + position: 'absolute', + left: '50%', + transform: 'translateX(-50%)', + top: '180px', + width: '900px', + margin: '0 auto', + height: '100%', + alignItems: 'center', +}); + +const loginHeaderStyle = css({ + fontSize: '3rem', + fontWeight: 'bold', + textAlign: 'center', + padding: '1rem', +}); + +const loginButtonStyle = css({ + width: '300px', +}); diff --git a/frontend/src/pages/ProblemPage.tsx b/frontend/src/pages/ProblemPage.tsx index b8af958..06e5ded 100644 --- a/frontend/src/pages/ProblemPage.tsx +++ b/frontend/src/pages/ProblemPage.tsx @@ -4,6 +4,8 @@ import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { fetchProblemDetail, type Problem, ProblemId } from '@/apis/problems'; +import { HStack } from '@/components/Common'; +import Header from '@/components/Header'; import { PageLayout } from '@/components/Layout/PageLayout'; import ProblemViewer from '@/components/Problem/ProblemViewer'; import type { Nil } from '@/utils/type'; @@ -37,18 +39,24 @@ function ProblemPage() { return

Error loading problem data

; } return ( - - {problem.title} - - + <> +
+ + + {problem.title} + + + + ); } export default ProblemPage; -const style = css({ - backgroundColor: '#1e1e1e', - color: '#ffffff', +const contentStyle = css({ + width: '100%', + maxWidth: '1200px', + margin: '0 auto', }); const problemTitleStyle = css({ diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 0d590f3..3f7e5a6 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -20,7 +20,7 @@ const router = createBrowserRouter([ element: , }, { - path: '/contest/:id', + path: '/competition/:id', element: , }, { @@ -28,16 +28,16 @@ const router = createBrowserRouter([ element: , }, { - path: '/contest/create', + path: '/competition/create', element: , }, { path: '/login', element: }, { - path: '/contest/detail/:id', + path: '/competition/detail/:id', element: , }, { - path: '/contest/dashboard/:id', + path: '/competition/dashboard/:id', element: , }, ], diff --git a/frontend/src/utils/date/index.ts b/frontend/src/utils/date/index.ts index 7c877e8..55f6a5b 100644 --- a/frontend/src/utils/date/index.ts +++ b/frontend/src/utils/date/index.ts @@ -19,11 +19,11 @@ export const formatDate = (date: Date, form: string) => { if (form === 'YYYY. MM. DD. hh:mm') { return date.toLocaleString('ko-KR', { year: 'numeric', - month: 'numeric', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - hour12: false, + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: true, }); } diff --git a/frontend/src/utils/unit/__tests__/byteToKb.spec.ts b/frontend/src/utils/unit/__tests__/byteToKb.spec.ts new file mode 100644 index 0000000..39488b9 --- /dev/null +++ b/frontend/src/utils/unit/__tests__/byteToKb.spec.ts @@ -0,0 +1,11 @@ +import { byteToKB } from '../index'; +import { describe, expect, it } from 'vitest'; + +describe('byteToKB', () => { + it('byte를 KB로 변환한다.', () => { + expect(byteToKB(0)).toBe(0); + expect(byteToKB(1024)).toBe(1); + expect(byteToKB(2048)).toBe(2); + expect(byteToKB(1024 * 1024)).toBe(1024); + }); +}); diff --git a/frontend/src/utils/unit/index.ts b/frontend/src/utils/unit/index.ts new file mode 100644 index 0000000..1fdce38 --- /dev/null +++ b/frontend/src/utils/unit/index.ts @@ -0,0 +1,5 @@ +const KB_BY_BYTES = 1024; + +export function byteToKB(byte: number) { + return Math.floor(byte / KB_BY_BYTES); +} diff --git a/frontend/styled-system/tokens/index.css b/frontend/styled-system/tokens/index.css index 9a81e1b..105d80e 100644 --- a/frontend/styled-system/tokens/index.css +++ b/frontend/styled-system/tokens/index.css @@ -1,45 +1,44 @@ @layer tokens { - :where(:root, :host) { - --animations-spin: spin 1s linear infinite; - --breakpoints-sm: 640px; - --breakpoints-md: 768px; - --breakpoints-lg: 1024px; - --breakpoints-xl: 1280px; - --breakpoints-2xl: 1536px; - --sizes-breakpoint-sm: 640px; - --sizes-breakpoint-md: 768px; - --sizes-breakpoint-lg: 1024px; - --sizes-breakpoint-xl: 1280px; - --sizes-breakpoint-2xl: 1536px; - --colors-background: #263238; - --colors-alert-success: #82DD55; - --colors-alert-success-dark: #355A23; - --colors-alert-warning: #EDB95E; - --colors-alert-warning-dark: #8E6F3A; - --colors-alert-danger: #E23636; - --colors-alert-danger-dark: #751919; - --colors-alert-info: #C8CDD0; - --colors-alert-info-dark: #444749; - --colors-brand: #FFA800; - --colors-brand-alt: #FFBB36; - --colors-surface: #37474F; - --colors-surface-alt: #455A64; - --colors-surface-light: #D9D9D9; - --colors-text: #F5F5F5; - --colors-text-light: #FAFAFA; - --colors-border: #455A64; - --font-sizes-display-lg: 57px; - --font-sizes-display-md: 45px; - --font-sizes-display-sm: 36px; - --font-sizes-title-lg: 22px; - --font-sizes-title-md: 16px; - --font-sizes-title-sm: 14px; - --font-sizes-body-lg: 16px; - --font-sizes-body-md: 14px; - --font-sizes-body-sm: 12px; - --font-sizes-label-lg: 14px; - --font-sizes-label-md: 12px; - --font-sizes-label-sm: 11px -} + :where(:root, :host) { + --animations-spin: spin 1s linear infinite; + --breakpoints-sm: 640px; + --breakpoints-md: 768px; + --breakpoints-lg: 1024px; + --breakpoints-xl: 1280px; + --breakpoints-2xl: 1536px; + --sizes-breakpoint-sm: 640px; + --sizes-breakpoint-md: 768px; + --sizes-breakpoint-lg: 1024px; + --sizes-breakpoint-xl: 1280px; + --sizes-breakpoint-2xl: 1536px; + --colors-background: #263238; + --colors-alert-success: #82dd55; + --colors-alert-success-dark: #355a23; + --colors-alert-warning: #edb95e; + --colors-alert-warning-dark: #8e6f3a; + --colors-alert-danger: #e23636; + --colors-alert-danger-dark: #751919; + --colors-alert-info: #c8cdd0; + --colors-alert-info-dark: #444749; + --colors-brand: #ffa800; + --colors-brand-alt: #ffbb36; + --colors-surface: #37474f; + --colors-surface-alt: #455a64; + --colors-surface-light: #d9d9d9; + --colors-text: #f5f5f5; + --colors-text-light: #fafafa99; + --colors-border: #455a64; + --font-sizes-display-lg: 57px; + --font-sizes-display-md: 45px; + --font-sizes-display-sm: 36px; + --font-sizes-title-lg: 22px; + --font-sizes-title-md: 16px; + --font-sizes-title-sm: 14px; + --font-sizes-body-lg: 16px; + --font-sizes-body-md: 14px; + --font-sizes-body-sm: 12px; + --font-sizes-label-lg: 14px; + --font-sizes-label-md: 12px; + --font-sizes-label-sm: 11px; } - \ No newline at end of file +}