diff --git a/.eslintrc.json b/.eslintrc.json index 2a706ec..616a8eb 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,6 +1,12 @@ { "parser": "@typescript-eslint/parser", - "extends": ["eslint:recommended", "next", "next/core-web-vitals", "prettier", "plugin:@typescript-eslint/recommended"], + "extends": [ + "eslint:recommended", + "next", + "next/core-web-vitals", + "prettier", + "plugin:@typescript-eslint/recommended" + ], "rules": { "prefer-const": "warn", "no-undef": "off", @@ -16,4 +22,4 @@ "@next/next/no-img-element": "off", "@typescript-eslint/no-empty-interface": "off" } -} \ No newline at end of file +} diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 237af8b..165d3bf 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -4,7 +4,6 @@ about: Provide guideline for a new PR title: '' labels: '' assignees: plzfday - --- ## What is this feature? @@ -13,6 +12,6 @@ assignees: plzfday ## Task Details -- [ ] +- [ ] ## Reference Materials (Optional) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c163ebc..579ad87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,4 +47,4 @@ jobs: run: yarn run lint - name: Run prettier - run: yarn run prettier + run: yarn run prettier:check diff --git a/.gitignore b/.gitignore index fd3dbb5..7c6929b 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + diff --git a/README.md b/README.md index f509b8e..5aac447 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # bibimbap-frontend + The frontend of Bibimbap This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). diff --git a/__tests__/pages/landing.test.tsx b/__tests__/pages/landing.test.tsx index 7d8a6e1..b897240 100644 --- a/__tests__/pages/landing.test.tsx +++ b/__tests__/pages/landing.test.tsx @@ -1,5 +1,6 @@ import '@testing-library/jest-dom'; import { render, screen } from '@testing-library/react'; + import Home from '@/app/page'; it('renders 6 change logs', () => { diff --git a/components.json b/components.json new file mode 100644 index 0000000..405a08c --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/package.json b/package.json index 47b8ad8..bffb4b1 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "start": "next start", "lint": "next lint", "prettier": "npx prettier --config ./.prettierrc --write **/*.{ts,tsx}", + "prettier:check": "npx prettier --config ./.prettierrc --check **/*.{ts,tsx}", "test": "jest", "test:watch": "jest --watch" }, diff --git a/public/duck.png b/public/duck.png new file mode 100644 index 0000000..b9cb728 Binary files /dev/null and b/public/duck.png differ diff --git a/public/logo.png b/public/logo.png index ffb6fbf..ddd6aa2 100644 Binary files a/public/logo.png and b/public/logo.png differ diff --git a/src/app/contests/page.tsx b/src/app/contests/page.tsx new file mode 100644 index 0000000..5dc783c --- /dev/null +++ b/src/app/contests/page.tsx @@ -0,0 +1,95 @@ +'use client'; + +import React from 'react'; +import { Plus, Trophy } from 'lucide-react'; + +import ContestTable, { Contest } from '@/components/ContestTable'; +import contestsData from '@/data/contestsData'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Button } from '@/components/ui/button'; +import ContestCards from '@/components/ContestCards'; +import Footer from '@/components/Footer'; + +export default function ContestsPage() { + const [contests, setContests] = React.useState(contestsData); + + const [favoriteOnly, setFavoriteOnly] = React.useState(false); + + const onClickFavorite = (id: Contest['id']) => { + setContests((prev) => + prev.map((c) => (c.id === id ? { ...c, isFavorite: !c.isFavorite } : c)), + ); + }; + + const filteredContests = React.useMemo( + () => (favoriteOnly ? contests.filter((p) => p.isFavorite) : contests), + [contests, favoriteOnly], + ); + + const onEnter = (id: Contest['id']) => { + // TODO: enter the contest/{id} detail edit page + console.log('enter', id); + }; + + const onDelete = (id: Contest['id']) => { + // TODO: delete the contest/{id} immediately + const ok = window.confirm('Are you sure you want to delete this?'); + if (!ok) return; + setContests((prev) => prev.filter((c) => c.id !== id)); + }; + + return ( +
+
+
+
+

+ + Contests +

+

+ {filteredContests.length} contests available +

+
+
+
+ setFavoriteOnly(Boolean(v))} + aria-controls='contests-table' + /> + +
+ {/* TODO: get user-info, and add filter function and onClick state */} +
+ + +
+ +
+
+ + + +
+
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index b5c61c9..7d47d41 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,3 +1,63 @@ +@import url('https://fonts.googleapis.com/css2?family=Baloo+2:wght@600&display=swap'); + +@layer base { + :root { + --font-logo: 'Baloo 2', sans-serif; + } +} + @tailwind base; @tailwind components; @tailwind utilities; + +@layer base { + :root { + --font-logo: 'Baloo 2', sans-serif; + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + /* --primary: 36 100% 50%; */ + --primary: 45 100% 60%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + /* --ring: 36 100% 50%; */ + --ring: 45 100% 60%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 45 100% 60%; + --primary-foreground: 222.2 47.4% 11.2%; + /* --primary: 36 100% 50%; */ + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + /* --ring: 36 100% 50%; */ + --ring: 45 100% 60%; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fd534e0..874cc08 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,11 +5,12 @@ import { Container, ThemeProvider } from '@mui/material'; import theme from '../theme'; import './globals.css'; -import Navigation from '@/components/Navigation'; +// import Navigation from '@/components/Navigation'; +import Header from '@/components/Header'; export const metadata: Metadata = { - title: 'Bibimbap', - description: 'A delicious Korean dish', + title: 'coduck', + description: 'coduck~', }; export default function RootLayout({ @@ -22,8 +23,9 @@ export default function RootLayout({ - - + + {/* */} +
{children} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index ebbba0e..42376f9 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,80 +1,92 @@ -import { - Avatar, - Box, - Button, - Checkbox, - FormControlLabel, - Grid, - TextField, - Typography, -} from '@mui/material'; -import { LockOutlined } from '@mui/icons-material'; +'use client'; + +import Link from 'next/link'; +import { User } from 'lucide-react'; import LoginSubmitBox from './SubmitBox'; -import Copyright from '@/components/Copyright'; -import CustomLink from '@/components/CustomLink'; +import Footer from '@/components/Footer'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; export default function Login() { return ( - <> - - - - - - Login - - - - - } - label='Remember me' - /> - - - - - Forgot password? - - - - - {"Don't have an account? Sign Up"} - - - - - - - +
+
+ + + + + Login + + + + +
+ + +
+
+
+ +
+ +
+
+
+ + +
+
+
+ + +
+ + Forgot password? + +
+ Don't have an account?{' '} + + Sign Up + +
+
+
+
+
+
+
+
); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 9651e9b..f9707f5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,55 +1,126 @@ -import { Paper, Stack, Typography } from '@mui/material'; +import { ChevronRight } from 'lucide-react'; -export default function Home() { - const serviceSummary = { - numUsers: 55202, - numProblems: 372278, - numInvokers: 0, - }; - const changeLogs = [ - '27 Jun 2016 - Polygon API has been released. You can view documentation here.', - '7 Dec 2011 - Now you may download testlib and polygon documentation from Polygon installation.', - '17 Jul 2010 - Supported contests and PDF-statements.', - '3 Nov 2009 - Released new version. Tags and problem filters supported. Also many other smaller fixes done.', - '8 Mar 2009 - Use public issue tracker to post bug or feature request.', - '8 Mar 2009 - Beta version has been deployed.', - ]; +import Footer from '@/components/Footer'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +export default function Home() { return ( - - - - The mission of Polygon is to provide platform for creation of programming - contest problems. Polygon supports the whole development cycle: - -
    -
  • problem statement writing
  • -
  • test data preparing (generators supported)
  • -
  • model solutions (including correct and wittingly incorrect)
  • -
  • judging
  • -
  • automatic validation
  • -
-
- - - - Registered users: {serviceSummary.numUsers} - - - Problems total: {serviceSummary.numProblems} - - - Invokers waiting: {serviceSummary.numInvokers} - - - - {changeLogs.map((log, index) => ( - - {log} - - ))} - - -
+
+
+
+
+

+ Problem Preparation Platform +

+

+ Prepare, Manage and Validate programming problems with{' '} + our comprehensive + platform +

+ +
+ + + Polygon Platform + + +

+ The mission of Polygon is to provide a platform for creation of + programming contest problems. Polygon supports the whole development + cycle: +

+
    + {[ + 'Problem statement writing', + 'Test data preparing (generators supported)', + 'Model solutions (including correct and wittingly incorrect)', + 'Judging', + 'Automatic validation', + ].map((item, index) => ( +
  • + + {item} +
  • + ))} +
+
+
+ +
+ + + Platform Statistics + + +
+
+

55,202

+

Registered Users

+
+
+

372,278

+

Total Problems

+
+
+

0

+

Invokers Waiting

+
+
+
+
+ + + + Latest Updates + + +
+ {[ + { + date: '27 Jun 2016', + content: + 'Polygon API has been released. You can view documentation here.', + }, + { + date: '7 Dec 2011', + content: + 'Now you may download testlib and polygon documentation from Polygon installation.', + }, + { + date: '17 Jul 2010', + content: 'Supported contests and PDF-statements.', + }, + { + date: '3 Nov 2009', + content: + 'Released new version. Tags and problem filters supported. Also many other smaller fixes done.', + }, + { + date: '8 Mar 2009', + content: + 'Use public issue tracker to post bug or feature request.', + }, + { + date: '8 Mar 2009', + content: 'Beta version has been deployed.', + }, + ].map((update, index) => ( +
+ + {update.date} + +

{update.content}

+
+ ))} +
+
+
+
+
+
+
+
+
); } diff --git a/src/app/problems/[qid]/general-info/util.ts b/src/app/problems/[qid]/general-info/util.ts index eba2880..82502fd 100644 --- a/src/app/problems/[qid]/general-info/util.ts +++ b/src/app/problems/[qid]/general-info/util.ts @@ -1,14 +1,17 @@ import { Dispatch, MouseEvent, SetStateAction } from 'react'; -export const removeChip = (event: MouseEvent, setHook: Dispatch>) => { - if (event.currentTarget.tagName === 'PATH') { - console.info('Path'); - return; - } - - const contest = event.currentTarget.parentNode?.firstElementChild?.textContent; - if (!contest) { - return; - } - setHook((contests) => contests.filter((c) => c !== contest)); -}; \ No newline at end of file +export const removeChip = ( + event: MouseEvent, + setHook: Dispatch>, +) => { + if (event.currentTarget.tagName === 'PATH') { + console.info('Path'); + return; + } + + const contest = event.currentTarget.parentNode?.firstElementChild?.textContent; + if (!contest) { + return; + } + setHook((contests) => contests.filter((c) => c !== contest)); +}; diff --git a/src/app/problems/page.tsx b/src/app/problems/page.tsx new file mode 100644 index 0000000..cff2650 --- /dev/null +++ b/src/app/problems/page.tsx @@ -0,0 +1,93 @@ +'use client'; + +import React from 'react'; +import { FileCode2, Plus } from 'lucide-react'; + +import ProblemTable, { Problem } from '@/components/ProblemTable'; +import problemsData from '@/data/problemsData'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Button } from '@/components/ui/button'; +import Footer from '@/components/Footer'; + +export default function ProblemsPage() { + const [problems, setProblems] = React.useState(problemsData); + + const [favoriteOnly, setFavoriteOnly] = React.useState(false); + + const onClickFavorite = (id: Problem['id']) => { + setProblems((prev) => + prev.map((c) => (c.id === id ? { ...c, isFavorite: !c.isFavorite } : c)), + ); + }; + + const filteredProblems = React.useMemo( + () => (favoriteOnly ? problems.filter((p) => p.isFavorite) : problems), + [problems, favoriteOnly], + ); + + const onEnter = (id: Problem['id']) => { + // TODO: enter the problem/{id} detail edit page + console.log('enter', id); + }; + + const onDelete = (id: Problem['id']) => { + // TODO: delete the problem/{id} immediately + const ok = window.confirm('Are you sure you want to delete this?'); + if (!ok) return; + setProblems((prev) => prev.filter((c) => c.id !== id)); + }; + + return ( +
+
+
+
+

+ + Problems +

+

+ {filteredProblems.length} problems available +

+
+
+
+ setFavoriteOnly(Boolean(v))} + aria-controls='problems-table' + /> + +
+ {/* TODO: get user-info, and add filter function and onClick state */} +
+ + +
+ +
+
+ + +
+
+
+ ); +} diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index 809c64e..0833a61 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -1,120 +1,109 @@ -import { - Avatar, - Box, - Button, - Checkbox, - Grid, - TextField, - Typography, - FormControlLabel, -} from '@mui/material'; -import { LockOutlined } from '@mui/icons-material'; +'use client'; + +import Link from 'next/link'; +import { UserPlus } from 'lucide-react'; import RegisterSubmitBox from './SubmitBox'; -import Copyright from '@/components/Copyright'; -import CustomLink from '@/components/CustomLink'; +import Footer from '@/components/Footer'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; import TACModal from '@/components/TACModal'; -export default function SignUp() { +export default function Register() { return ( - <> - - - - - - Register - - - - - - - - - - } - label='I agree to the terms and conditions.' - /> - - - {/* } - label='I want to receive inspiration, marketing promotions and updates via email.' - /> */} - - - - - Already have an account? Login - - - - - - - +
+
+ + + + Register + + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ + +
+ Already have an account?{' '} + + Login + +
+
+
+
+
+
+
); } diff --git a/src/components/ContestCards.tsx b/src/components/ContestCards.tsx new file mode 100644 index 0000000..f3259b8 --- /dev/null +++ b/src/components/ContestCards.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { Trophy, FileCode2, Users } from 'lucide-react'; + +export type ContestStats = { + id: string | number; + problems: number; + participants: number; +}; + +type Props = { + contests: ContestStats[]; +}; + +function StatCard({ + icon, + value, + label, + gradient, +}: { + icon: React.ReactNode; + value: number; + label: string; + gradient: string; +}) { + return ( +
+
+
+ {icon} +
+
+

{value}

+

{label}

+
+
+
+ ); +} + +export default function StatsCards({ contests }: Props) { + const totalContests = contests.length; + const totalProblems = contests.reduce((sum, c) => sum + (c.problems ?? 0), 0); + const totalParticipants = contests.reduce((sum, c) => sum + (c.participants ?? 0), 0); + + return ( +
+ } + value={totalContests} + label='Total Contests' + gradient='bg-gradient-to-br from-green-500 to-emerald-600' + /> + } + value={totalProblems} + label='Total Problems' + gradient='bg-gradient-to-br from-purple-500 to-pink-600' + /> + } + value={totalParticipants} + label='Total Participants' + gradient='bg-gradient-to-br from-blue-500 to-cyan-600' + /> +
+ ); +} diff --git a/src/components/ContestTable.tsx b/src/components/ContestTable.tsx new file mode 100644 index 0000000..2718f06 --- /dev/null +++ b/src/components/ContestTable.tsx @@ -0,0 +1,136 @@ +'use client'; + +import React from 'react'; + +import { ListingTable, Column } from '@/components/ListingTable'; +import { + FavoriteCell, + DateCell, + ActionsCell, + ContestMeta, + sortById, + sortByDate, +} from '@/components/TableCells'; + +export type Contest = { + id: number; + name: string; + description?: string | null; + owner: string; + modificationTime?: string | Date; + creationTime: string | Date; + status: 'active' | 'upcoming' | 'archived' | string; + participants: number; + problems: number; + isFavorite: boolean; +}; + +type Props = { + contests: Contest[]; + onEnter?: (id: Contest['id']) => void; + onDelete?: (id: Contest['id']) => void; + onClickFavorite?: (id: Contest['id']) => void; + className?: string; +}; + +export default function ContestsTable({ + contests, + onEnter, + onDelete, + onClickFavorite, + className, +}: Props) { + const columns = React.useMemo[]>( + () => [ + { + id: 'favorite', + header: '', + cell: (r) => FavoriteCell(r, onClickFavorite), + width: 56, + }, + { + id: 'id', + header: 'Id', + accessor: 'id', + sortable: true, + sortAccessor: sortById, + width: 120, + }, + { + id: 'name', + header: 'Contest', + cell: (r) => ( +
+
+ {r.name} +
+ {r.description && ( +
+ {r.description} +
+ )} + +
+ ), + sortable: true, + sortAccessor: (r) => r.name, + }, + { + id: 'owner', + header: 'Owner', + accessor: (r) => {r.owner}, + sortable: true, + sortAccessor: (r) => r.owner, + width: 180, + }, + { + id: 'modificationTime', + header: 'Modification', + cell: (r) => DateCell(r.modificationTime), + sortable: true, + sortAccessor: (r) => sortByDate(r.modificationTime), + width: 160, + }, + { + id: 'creationTime', + header: 'Creation', + cell: (r) => DateCell(r.creationTime), + sortable: true, + sortAccessor: (r) => sortByDate(r.creationTime), + width: 160, + }, + { + id: 'actions', + header: 'Actions', + align: 'center', + cell: (r) => ActionsCell(r, onEnter, onDelete), + width: 164, + }, + ], + [onClickFavorite, onEnter, onDelete], + ); + + return ( + String(r.id)} + initialSortKey='id' + initialSortOrder='asc' + empty='No contests' + /> + ); +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000..0e74def --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,11 @@ +export default function Footer() { + return ( +
+
+

+ © {new Date().getFullYear()} Coduck. All rights reserved. +

+
+
+ ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..c3051fd --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,132 @@ +'use client'; + +import Image from 'next/image'; +import Link from 'next/link'; +import { Menu, Search } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; + +export default function Header() { + return ( +
+
+
+
+ {/* Mobile Menu Toggle */} +
+ + + + + +
+

Menu

+
+ + +
+ +
+
+
+ + + +
+
+
+
+
+ + {/* Logo + Title */} + + Coduck Logo + + coduck + + +
+ + {/* Search bar */} +
+ + +
+ + {/* Navigation */} + + + {/* Login, Register, Help */} +
+ + + +
+
+
+
+ ); +} diff --git a/src/components/ListingTable.tsx b/src/components/ListingTable.tsx new file mode 100644 index 0000000..0518d0c --- /dev/null +++ b/src/components/ListingTable.tsx @@ -0,0 +1,213 @@ +'use client'; + +import * as React from 'react'; +import { ArrowUp } from 'lucide-react'; + +export type Column = { + id: string; + header: React.ReactNode; + accessor?: keyof T | ((row: T) => React.ReactNode); + sortAccessor?: (row: T) => string | number | Date | boolean; + cell?: (row: T) => React.ReactNode; + sortable?: boolean; + width?: number | string; + align?: 'left' | 'center' | 'right'; +}; + +export type SortState> = { + key: string | null; + order: 'asc' | 'desc'; + accessors: Record string | number | Date | boolean>; +}; + +type ListingTableProps = { + data: T[]; + columns: Column[]; + rowKey?: keyof T | ((row: T) => string | number); + empty?: React.ReactNode; + initialSortKey?: string; + initialSortOrder?: 'asc' | 'desc'; + className?: string; +}; + +function getPrimitive(val: unknown): string | number { + if (val instanceof Date) return val.getTime(); + if (typeof val === 'number') return val; + if (typeof val === 'boolean') return val ? 1 : 0; + return String(val ?? ''); +} + +export function ListingTable>({ + data, + columns, + rowKey, + empty = 'No data', + initialSortKey, + initialSortOrder = 'asc', + className = '', +}: ListingTableProps) { + const sortAccessors = React.useMemo(() => { + const map: Record string | number | Date | boolean> = {}; + for (const c of columns) { + if (!c.sortable) continue; + + if (c.sortAccessor) { + map[c.id] = c.sortAccessor; + continue; + } + + if (typeof c.accessor === 'function') { + map[c.id] = (row) => { + const out = (c.accessor as (row: T) => React.ReactNode)(row); + return typeof out === 'string' || typeof out === 'number' ? out : String(out); + }; + } else if (typeof c.accessor === 'string') { + const key = c.accessor; + map[c.id] = (row) => row[key] as string | number | Date | boolean; + } + } + return map; + }, [columns]); + + const [sort, setSort] = React.useState>({ + key: initialSortKey ?? null, + order: initialSortOrder, + accessors: sortAccessors, + }); + + React.useEffect(() => { + setSort((prev) => ({ ...prev, accessors: sortAccessors })); + }, [sortAccessors]); + + const handleSort = (colId: string) => { + if (!columns.find((c) => c.id === colId)?.sortable) return; + setSort((prev) => { + if (prev.key === colId) { + return { ...prev, order: prev.order === 'asc' ? 'desc' : 'asc' }; + } + return { ...prev, key: colId, order: 'asc' }; + }); + }; + + const getRowKey = React.useCallback( + (row: T, index: number) => { + if (!rowKey) return String(index); + if (typeof rowKey === 'function') return String(rowKey(row)); + const val = row[rowKey]; + return String(val ?? index); + }, + [rowKey], + ); + + const sorted = React.useMemo(() => { + if (!sort.key) return data; + const acc = sort.accessors[sort.key]; + if (!acc) return data; + + const arr = [...data]; + arr.sort((a, b) => { + const A = acc(a); + const B = acc(b); + const aP = getPrimitive(A); + const bP = getPrimitive(B); + + if (typeof aP === 'number' && typeof bP === 'number') { + return sort.order === 'asc' ? aP - bP : bP - aP; + } + const cmp = String(aP).localeCompare(String(bP), undefined, { numeric: true }); + return sort.order === 'asc' ? cmp : -cmp; + }); + return arr; + }, [data, sort]); + + return ( +
+
+ + + + {columns.map((col) => { + const isSorted = sort.key === col.id; + const ariaSort: 'none' | 'ascending' | 'descending' = + col.sortable && isSorted + ? sort.order === 'asc' + ? 'ascending' + : 'descending' + : 'none'; + return ( + + ); + })} + + + + + {sorted.length === 0 ? ( + + + + ) : ( + sorted.map((row, i) => ( + + {columns.map((col) => { + let content: React.ReactNode = null; + if (col.cell) content = col.cell(row); + else if (typeof col.accessor === 'function') + content = col.accessor(row); + else if (typeof col.accessor === 'string') + content = row[col.accessor] as React.ReactNode; + return ( + + ); + })} + + )) + )} + +
+ {col.sortable ? ( + + ) : ( + + {col.header} + + )} +
+ {empty} +
+ {content} +
+
+
+ ); +} diff --git a/src/components/ProblemTable.tsx b/src/components/ProblemTable.tsx new file mode 100644 index 0000000..0cb14c1 --- /dev/null +++ b/src/components/ProblemTable.tsx @@ -0,0 +1,156 @@ +'use client'; + +import React from 'react'; + +import { ListingTable, Column } from '@/components/ListingTable'; +import { + FavoriteCell, + DateCell, + ActionsCell, + sortById, + sortByDate, +} from '@/components/TableCells'; + +export type Problem = { + id: number; + name: string; + subtitle: string | null; + owner: string; + info: { + language: string; + tests: number; + timeLimit: string; + memoryLimit: string; + files: string; + }; + revision: string; + modified: string; // TODO: Date type will need to be handled + status: string; + isFavorite: boolean; +}; + +type Props = { + problems: Problem[]; + onEnter?: (id: Problem['id']) => void; + onDelete?: (id: Problem['id']) => void; + onClickFavorite?: (id: Problem['id']) => void; + className?: string; +}; + +export default function ProblemTable({ + problems, + onEnter, + onDelete, + onClickFavorite, + className, +}: Props) { + const columns = React.useMemo[]>( + () => [ + { + id: 'favorite', + header: '', + cell: (r) => FavoriteCell(r, onClickFavorite), + width: 56, + }, + { + id: 'id', + header: 'Id', + accessor: 'id', + sortable: true, + sortAccessor: sortById, + width: 120, + }, + { + id: 'name', + header: 'Name', + cell: (r) => ( +
+
+ {r.name} +
+ {r.subtitle && ( +
+ {r.subtitle} +
+ )} +
+ ), + sortable: true, + sortAccessor: (r) => r.name, + }, + { + id: 'owner', + header: 'Owner', + accessor: (r) => {r.owner}, + sortable: true, + sortAccessor: (r) => r.owner, + width: 180, + }, + { + id: 'info', + header: 'Info', + accessor: (r) => ( +
+
+ Lang: {r.info.language} +
+
+ Tests: {r.info.tests} +
+
+ Limit: {r.info.timeLimit} /{' '} + {r.info.memoryLimit} +
+
+ Files: {r.info.files} +
+
+ ), + width: 280, + }, + { + id: 'modified', + header: 'Modification', + cell: (r) => DateCell(r.modified), + sortable: true, + sortAccessor: (r) => sortByDate(r.modified), + width: 160, + }, + { + id: 'revision', + header: 'Rev.', + accessor: (r) => ( + + {r.revision} + + ), + width: 160, + }, + { + id: 'actions', + header: 'Actions', + align: 'center', + cell: (r) => ActionsCell(r, onEnter, onDelete), + width: 160, + }, + ], + [onClickFavorite, onEnter, onDelete], + ); + + return ( + String(r.id)} + initialSortKey='id' + initialSortOrder='asc' + empty='No problems' + /> + ); +} diff --git a/src/components/TACModal.tsx b/src/components/TACModal.tsx index d41d094..7bfdcd8 100644 --- a/src/components/TACModal.tsx +++ b/src/components/TACModal.tsx @@ -1,39 +1,31 @@ 'use client'; -import { Box, Button, Modal, Typography } from '@mui/material'; + import { useState } from 'react'; -const style = { - position: 'absolute', - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', - width: { xs: '80%', sm: '60%' }, - bgcolor: 'background.paper', - border: '2px solid #000', - boxShadow: 24, - p: 4, -} as const; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; export default function TACModal() { const [open, setOpen] = useState(false); const handleOpen = () => setOpen(true); - const handleClose = () => setOpen(false); return (
- - - - - Terms and Conditions - - -
    + + + + + Terms and Conditions + +
    +
    1. You can use Polygon to develop problems only.
    2. You should use your real name and all information about you should be @@ -41,37 +33,37 @@ export default function TACModal() {
    3. You should not submit files containing malicious code: -
        -
      • trojan horses;
      • -
      • rootkits;
      • -
      • backdoors;
      • -
      • viruses.
      • +
          +
        • trojan horses
        • +
        • rootkits
        • +
        • backdoors
        • +
        • viruses
      • - Your code not allowed to: -
          -
        • access the network;
        • + Your code is not allowed to: +
            +
          • access the network
          • work with any files except those explicitly specified in the problem - statement; + statement
          • -
          • attack system security;
          • -
          • execute other programs and create new processes;
          • -
          • change file system permissions;
          • -
          • work with subdirectories;
          • +
          • attack system security
          • +
          • execute other programs and create new processes
          • +
          • change file system permissions
          • +
          • work with subdirectories
          • - create or manipulate any GUI items (windows, dialog boxes, etc); + create or manipulate any GUI items (windows, dialog boxes, etc)
          • -
          • work with external devices (sound, printer, etc);
          • -
          • work with OS registry;
          • -
          • do anything else that can stir Polygon functioning.
          • +
          • work with external devices (sound, printer, etc)
          • +
          • work with OS registry
          • +
          • do anything else that can stir Polygon functioning
    - - - +
    +
    +
); } diff --git a/src/components/TableCells.tsx b/src/components/TableCells.tsx new file mode 100644 index 0000000..e3724a0 --- /dev/null +++ b/src/components/TableCells.tsx @@ -0,0 +1,109 @@ +'use client'; + +import React from 'react'; +import { Star, Edit, Trash2, Users, FileCode2 } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { formatDateTime, toDate } from '@/lib/datetime'; + +export function FavoriteCell( + row: T, + onClick?: (id: T['id']) => void, +) { + return ( + + ); +} + +export function DateCell(input: Date | string | undefined) { + const { date, time } = formatDateTime(input); + return ( +
+
{date}
+ {time &&
{time}
} +
+ ); +} + +export function ActionsCell( + row: T, + onEnter?: (id: T['id']) => void, + onDelete?: (id: T['id']) => void, +) { + return ( +
+ + +
+ ); +} + +export const statusClass: Record = { + active: 'bg-green-100 text-green-800', + upcoming: 'bg-blue-100 text-blue-800', + archived: 'bg-gray-100 text-gray-800', +}; + +export function ContestMeta({ + status, + participants, + problems, +}: { + status: string; + participants: number; + problems: number; +}) { + return ( +
+ + {status} + +
+ + {participants} participants +
+
+ + {problems} problems +
+
+ ); +} + +export const sortById = (r: T) => + typeof r.id === 'number' ? r.id : String(r.id); +export const sortByDate = (v: unknown) => toDate(v) ?? new Date(0); diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..6d6bfce --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..a688726 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: + 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ( + + ); + }, +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..db649c9 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = 'CardFooter'; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..4709fad --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +'use client'; + +import * as React from 'react'; +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { Check } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..d739a3f --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,116 @@ +'use client'; + +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..389bf52 --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = 'Input'; + +export { Input }; diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..7114fb0 --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,26 @@ +'use client'; + +import * as React from 'react'; +import * as LabelPrimitive from '@radix-ui/react-label'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const labelVariants = cva( + 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', +); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx new file mode 100644 index 0000000..dcada35 --- /dev/null +++ b/src/components/ui/sheet.tsx @@ -0,0 +1,131 @@ +'use client'; + +import * as React from 'react'; +import * as SheetPrimitive from '@radix-ui/react-dialog'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { X } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Sheet = SheetPrimitive.Root; + +const SheetTrigger = SheetPrimitive.Trigger; + +const SheetClose = SheetPrimitive.Close; + +const SheetPortal = SheetPrimitive.Portal; + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; + +const sheetVariants = cva( + 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out', + { + variants: { + side: { + top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', + bottom: + 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', + left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', + right: + 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm', + }, + }, + defaultVariants: { + side: 'right', + }, + }, +); + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = 'right', className, children, ...props }, ref) => ( + + + + + + Close + + {children} + + +)); +SheetContent.displayName = SheetPrimitive.Content.displayName; + +const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +SheetHeader.displayName = 'SheetHeader'; + +const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +SheetFooter.displayName = 'SheetFooter'; + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetTitle.displayName = SheetPrimitive.Title.displayName; + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetDescription.displayName = SheetPrimitive.Description.displayName; + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/src/data/contestsData.ts b/src/data/contestsData.ts new file mode 100644 index 0000000..e88db5f --- /dev/null +++ b/src/data/contestsData.ts @@ -0,0 +1,40 @@ +const contests = [ + { + id: 10001, + name: 'Winter Programming Contest 2025', + description: 'problems (20): description', + owner: 'owner1', + modificationTime: '2023-02-07 18:48:20', + creationTime: '2022-11-12 21:08:48', + status: 'active', + participants: 101, + problems: 20, + isFavorite: false, + }, + { + id: 20002, + name: 'Contest of Champions', + description: 'problems (17): dp, graph', + owner: 'user2', + modificationTime: '2024-02-05 14:30:15', + creationTime: '2023-12-01 09:15:30', + status: 'upcoming', + participants: 8, + problems: 17, + isFavorite: true, + }, + { + id: 40000, + name: 'cat cat Contest', + description: 'problems (8): why people love cats', + owner: 'm3nt1r', + modificationTime: '2025-01-28 16:22:45', + creationTime: '2023-10-15 11:45:20', + status: 'completed', + participants: 22, + problems: 8, + isFavorite: false, + }, +]; + +export default contests; diff --git a/src/data/problemsData.ts b/src/data/problemsData.ts new file mode 100644 index 0000000..6913a84 --- /dev/null +++ b/src/data/problemsData.ts @@ -0,0 +1,110 @@ +const problems = [ + { + id: 100001, + name: 'problem-a', + subtitle: null, + owner: 'user1', + info: { + language: 'korean', + tests: 127, + timeLimit: '5 s', + memoryLimit: '1024 MB', + files: 'solution.cpp (39/5), std::nomp.cpp, val.cpp', + }, + revision: '0', + modified: '2021-03-18 08:41:26', + status: 'start', + isFavorite: false, + }, + { + id: 100002, + name: 'problem-b', + subtitle: 'b 문제', + owner: 'user2', + info: { + language: 'korean', + tests: 127, + timeLimit: '5 s', + memoryLimit: '1024 MB', + files: 'solution.cpp (39/5), std::nomp.cpp, val.cpp', + }, + revision: '74 / 74', + modified: '2023-02-10 10:13:46', + status: 'continue', + progress: 74, + isFavorite: true, + }, + { + id: 100003, + name: 'problem-c', + subtitle: 'c 문제', + owner: 'user2', + info: { + language: 'korean', + tests: 180, + timeLimit: '1 s', + memoryLimit: '256 MB', + files: 'solution.cpp (25/8), std::wcmp.cpp, validator.cpp, interactor.cpp', + }, + revision: '37 / 37*', + modified: '2022-02-07 18:18:20', + status: 'continue', + progress: 37, + isFavorite: false, + }, + { + id: 100004, + name: 'problem-d', + subtitle: 'ddd자로 시작하는 말', + owner: 'user3', + info: { + language: 'korean', + tests: 142, + timeLimit: '15 s', + memoryLimit: '1024 MB', + files: 'solution.cpp (52/11), std::wcmp.cpp, val.cpp', + }, + revision: '58 / 56*', + modified: '2022-02-06 23:21:28', + status: 'continue', + progress: 58, + isFavorite: false, + }, + { + id: 100005, + name: 'problem-e', + subtitle: 'elephant를 냉장고에 넣는 방법은?', + owner: 'user4', + info: { + language: 'korean', + tests: 114, + timeLimit: '1 s', + memoryLimit: '1024 MB', + files: 'solution.cpp (39/3), std::wcmp.cpp, validator.cpp', + }, + revision: '70 / 69', + modified: '2025-02-06 23:19:17', + status: 'continue', + progress: 70, + isFavorite: true, + }, + { + id: 100006, + name: 'problem-f', + subtitle: '끝없이 F를 피하기 위해 수영해야 하는 XX', + owner: 'user5', + info: { + language: 'korean', + tests: 48, + timeLimit: '1 s', + memoryLimit: '1024 MB', + files: 'answer.cpp (18/4), lazy_checker.cpp, val.cpp', + }, + revision: '33 / 31', + modified: '2025-02-05 11:21:43', + status: 'start', + isFavorite: true, + }, +]; + +export default problems; diff --git a/src/lib/datetime.ts b/src/lib/datetime.ts new file mode 100644 index 0000000..4463e7f --- /dev/null +++ b/src/lib/datetime.ts @@ -0,0 +1,25 @@ +export function toDate(input: unknown): Date | null { + if (!input) return null; + if (input instanceof Date) return isNaN(input.getTime()) ? null : input; + if (typeof input === 'string') { + // "2025-02-07 18:48:20" → Date + const iso = input.replace(' ', 'T'); + const t = Date.parse(iso); + return Number.isNaN(t) ? null : new Date(t); + } + return null; +} + +export function formatDateTime(input: unknown) { + const d = toDate(input); + if (!d) return { date: '-', time: undefined as string | undefined }; + + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + const HH = String(d.getHours()).padStart(2, '0'); + const MM = String(d.getMinutes()).padStart(2, '0'); + const SS = String(d.getSeconds()).padStart(2, '0'); + + return { date: `${yyyy}-${mm}-${dd}`, time: `${HH}:${MM}:${SS}` }; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..2819a83 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/theme.ts b/src/theme.ts index c02a801..9a5ee7c 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -1,4 +1,5 @@ 'use client'; + import { Roboto } from 'next/font/google'; import { createTheme } from '@mui/material/styles'; @@ -9,6 +10,24 @@ const roboto = Roboto({ }); const theme = createTheme({ + palette: { + primary: { + main: '#ffcc33', + contrastText: '#000000', + }, + secondary: { + main: '#fff1c2', // #fffde7 + contrastText: '#000000', + }, + info: { + main: '#fff7cc', + contrastText: '#000000', + }, + background: { + default: '#ffffff', + paper: '#f9f9f9', + }, + }, typography: { fontFamily: roboto.style.fontFamily, }, diff --git a/tailwind.config.ts b/tailwind.config.ts index 13d21fa..fa69bf1 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,20 +1,101 @@ import type { Config } from 'tailwindcss'; const config: Config = { + darkMode: ['class'], content: [ './src/pages/**/*.{js,ts,jsx,tsx,mdx}', './src/components/**/*.{js,ts,jsx,tsx,mdx}', './src/app/**/*.{js,ts,jsx,tsx,mdx}', ], + prefix: '', theme: { + container: { + center: true, + padding: '2rem', + screens: { + sm: '640px', + md: '768px', + lg: '1024px', + xl: '1280px', + '2xl': '1536px', + }, + }, extend: { - backgroundImage: { - 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', - 'gradient-conic': - 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', + fontFamily: { + logo: 'var(--font-logo)', + }, + colors: { + duckbrown: '#5a3d0e', + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + chart: { + '1': 'hsl(var(--chart-1))', + '2': 'hsl(var(--chart-2))', + '3': 'hsl(var(--chart-3))', + '4': 'hsl(var(--chart-4))', + '5': 'hsl(var(--chart-5))', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + keyframes: { + 'accordion-down': { + from: { + height: '0', + }, + to: { + height: 'var(--radix-accordion-content-height)', + }, + }, + 'accordion-up': { + from: { + height: 'var(--radix-accordion-content-height)', + }, + to: { + height: '0', + }, + }, + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', }, }, }, - plugins: [], + plugins: [require('tailwindcss-animate')], }; export default config;