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 0000000..b0d850c Binary files /dev/null and b/frontend/public/algo.ico differ 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 +}