diff --git a/apps/frontend/app/(client)/(main)/problem/_components/MyProblem.tsx b/apps/frontend/app/(client)/(main)/problem/_components/MyProblem.tsx new file mode 100644 index 0000000000..bc0d48529e --- /dev/null +++ b/apps/frontend/app/(client)/(main)/problem/_components/MyProblem.tsx @@ -0,0 +1,95 @@ +'use client' + +import { Skeleton } from '@/components/shadcn/skeleton' +import type { Problem } from '@/types/type' +import { useSearchParams } from 'next/navigation' +import { MyProblemDataTable } from './MyProblemDataTable' + +export interface MyProblemCardItem extends Problem { + state: 'Draft' | 'Ready' | 'Published' + timeLimit: number + memoryLimit: number + updatedAt: string +} + +interface MyProblemProps { + forceEmpty?: boolean +} + +const MOCK_MY_PROBLEMS: MyProblemCardItem[] = Array.from( + { length: 48 }, + (_, index) => { + const id = 3000 + index + 1 + const difficulty = `Level${(index % 5) + 1}` as Problem['difficulty'] + const submissionCount = 24 + index * 7 + const acceptedRate = ((index % 9) + 2) / 12 + const states: MyProblemCardItem['state'][] = ['Published', 'Draft', 'Ready'] + + return { + id, + title: `글자 수 상관 없음. 단, 한 줄로만 노출되는 Mock My Problem ${index + 1}`, + difficulty, + submissionCount, + acceptedRate, + tags: [], + languages: ['C', 'Cpp', 'Java', 'Python3'], + hasPassed: null, + state: states[index % states.length], + timeLimit: 1000 + (index % 4) * 500, + memoryLimit: 128 + (index % 3) * 64, + updatedAt: `2024-01-${String((index % 24) + 1).padStart(2, '0')} 19:00` + } + } +) + +export function MyProblem({ forceEmpty = false }: MyProblemProps) { + const searchParams = useSearchParams() + const search = searchParams.get('search') ?? '' + const normalizedSearch = search.trim().toLowerCase() + + const filteredProblems = MOCK_MY_PROBLEMS.filter((problem) => { + if (!normalizedSearch) { + return true + } + + return ( + problem.title.toLowerCase().includes(normalizedSearch) || + String(problem.id).includes(normalizedSearch) + ) + }) + + const data = forceEmpty ? [] : filteredProblems + + return +} + +export function MyProblemFallback() { + return ( +
+
+ +
+ + + +
+
+
+ {[...Array(8)].map((_, i) => ( +
+ +
+ + + + +
+
+ ))} +
+
+ ) +} diff --git a/apps/frontend/app/(client)/(main)/problem/_components/MyProblemDataTable.tsx b/apps/frontend/app/(client)/(main)/problem/_components/MyProblemDataTable.tsx new file mode 100644 index 0000000000..a928c51d1b --- /dev/null +++ b/apps/frontend/app/(client)/(main)/problem/_components/MyProblemDataTable.tsx @@ -0,0 +1,184 @@ +'use client' + +import { SearchBar } from '@/components/SearchBar' +import { Button } from '@/components/shadcn/button' +import { cn } from '@/libs/utils' +import ClockGray from '@/public/icons/clock_gray.svg' +import Memory from '@/public/icons/memory.svg' +import PlusCircle from '@/public/icons/plus-circle-blue.svg' +import type { Route } from 'next' +import Image from 'next/image' +import Link from 'next/link' +import { useState } from 'react' +import type { MyProblemCardItem } from './MyProblem' +import { + MyProblemStateFilter, + STATE_FILTER_OPTIONS +} from './MyProblemStateFilter' + +interface MyProblemDataTableProps { + data: MyProblemCardItem[] + search: string +} + +const stateBadgeClassName = { + Draft: { + container: 'border border-line bg-color-neutral-99', + text: 'text-color-neutral-70' + }, + Ready: { + container: 'border border-color-green-50 bg-white', + text: 'text-color-green-40' + }, + Published: { + container: 'bg-[#EDF4FF]', + text: 'text-primary' + } +} as const + +function getStateBadgeClassName(state: string) { + if (state === 'Draft' || state === 'DRAFT') { + return stateBadgeClassName.Draft + } + + if (state === 'Ready' || state === 'READY') { + return stateBadgeClassName.Ready + } + + return stateBadgeClassName.Published +} + +export function MyProblemDataTable({ data, search }: MyProblemDataTableProps) { + const [selectedStates, setSelectedStates] = useState< + MyProblemCardItem['state'][] + >([]) + + const filteredData = + selectedStates.length === 0 || + selectedStates.length === STATE_FILTER_OPTIONS.length + ? data + : data.filter((problem) => selectedStates.includes(problem.state)) + + return ( +
+
+
+

내가 만든 문제

+
+
+ + + +
+
+ {filteredData.length ? ( +
+ {filteredData.map((problem) => { + const href = + `/problem/my-problem/${problem.id}${search ? `?search=${search}` : ''}` as Route + const badgeClassName = getStateBadgeClassName(problem.state) + + return ( + +
+
+ + {problem.state} + +
+

+ {problem.title} +

+
+
+
+
+ clock gray + + {problem.timeLimit}ms + +
+
+ memory + + {problem.memoryLimit}MB + +
+
+
+ + Last Modified: + + + {problem.updatedAt} + +
+
+ + ) + })} +
+ ) : ( + + )} +
+ ) +} + +function MyProblemEmptyState() { + return ( +
+
+

+ 내가 만든 문제가 +
+ 존재하지 않습니다. +

+ +
+
+ ) +} diff --git a/apps/frontend/app/(client)/(main)/problem/_components/MyProblemStateFilter.tsx b/apps/frontend/app/(client)/(main)/problem/_components/MyProblemStateFilter.tsx new file mode 100644 index 0000000000..9623f1868b --- /dev/null +++ b/apps/frontend/app/(client)/(main)/problem/_components/MyProblemStateFilter.tsx @@ -0,0 +1,110 @@ +'use client' + +import { Badge } from '@/components/shadcn/badge' +import { Button } from '@/components/shadcn/button' +import { Checkbox } from '@/components/shadcn/checkbox' +import { + Command, + CommandGroup, + CommandItem, + CommandList +} from '@/components/shadcn/command' +import { + Popover, + PopoverContent, + PopoverTrigger +} from '@/components/shadcn/popover' +import { Separator } from '@/components/shadcn/separator' +import { IoFilter } from 'react-icons/io5' +import type { MyProblemCardItem } from './MyProblem' + +export const STATE_FILTER_OPTIONS: MyProblemCardItem['state'][] = [ + 'Published', + 'Ready', + 'Draft' +] + +interface MyProblemStateFilterProps { + selectedStates: MyProblemCardItem['state'][] + onSelectedStatesChange: (states: MyProblemCardItem['state'][]) => void +} + +export function MyProblemStateFilter({ + selectedStates, + onSelectedStatesChange +}: MyProblemStateFilterProps) { + const selectedValues = new Set(selectedStates) + + const handleFilterSelect = (value: MyProblemCardItem['state']) => { + const nextSelectedValues = new Set(selectedValues) + + if (nextSelectedValues.has(value)) { + nextSelectedValues.delete(value) + } else { + nextSelectedValues.add(value) + } + + onSelectedStatesChange(Array.from(nextSelectedValues)) + } + + return ( + + + + + + + + + + {STATE_FILTER_OPTIONS.map((state) => ( + handleFilterSelect(state)} + > + + {state} + + ))} + + + + + + ) +} diff --git a/apps/frontend/app/(client)/(main)/problem/_components/ProblemDataTable.tsx b/apps/frontend/app/(client)/(main)/problem/_components/ProblemDataTable.tsx index a66683a66e..a21b1b91a9 100644 --- a/apps/frontend/app/(client)/(main)/problem/_components/ProblemDataTable.tsx +++ b/apps/frontend/app/(client)/(main)/problem/_components/ProblemDataTable.tsx @@ -1,6 +1,7 @@ 'use client' import { SearchBar } from '@/components/SearchBar' +import { Button } from '@/components/shadcn/button' import { Table, TableBody, @@ -18,7 +19,8 @@ import { } from '@tanstack/react-table' import type { Route } from 'next' import Link from 'next/link' -import { usePathname, useRouter } from 'next/navigation' +import { usePathname } from 'next/navigation' +import { IoFilter } from 'react-icons/io5' interface Item { id: number @@ -28,8 +30,6 @@ interface ProblemDataTableProps { columns: ColumnDef[] data: TData[] total: number - itemsPerPage: number - currentPage: number headerStyle: { [key: string]: string } @@ -42,8 +42,6 @@ export function ProblemDataTable({ columns, data, total, - itemsPerPage, - currentPage, headerStyle, search, linked = false, @@ -54,13 +52,9 @@ export function ProblemDataTable({ columns, getCoreRowModel: getCoreRowModel() }) - const router = useRouter() const currentPath = usePathname() - const startIndex = (currentPage - 1) * itemsPerPage - const paginatedItems = table - .getRowModel() - .rows.slice(startIndex, startIndex + itemsPerPage) + const paginatedItems = table.getRowModel().rows return (
@@ -70,7 +64,14 @@ export function ProblemDataTable({

{total}

- + +
@@ -105,13 +106,11 @@ export function ProblemDataTable({ const href = `${currentPath}/${row.original.id}${search ? `?search=${search}` : ''}` as Route - const handleClick = linked - ? () => { - router.push(href) - } - : (e: React.MouseEvent) => { + const handleClick = !linked + ? (e: React.MouseEvent) => { e.currentTarget.classList.toggle('expanded') } + : undefined return ( -
- - - +
+
+
+ + +
+
+ + +
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ {[...Array(10)].map((_, i) => ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ))}
- {[...Array(5)].map((_, i) => ( - - ))} - +
+ +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ +
+
) } diff --git a/apps/frontend/app/(client)/(main)/problem/my-problem/page.tsx b/apps/frontend/app/(client)/(main)/problem/my-problem/page.tsx index 0dc18360fa..681908aa60 100644 --- a/apps/frontend/app/(client)/(main)/problem/my-problem/page.tsx +++ b/apps/frontend/app/(client)/(main)/problem/my-problem/page.tsx @@ -1,7 +1,14 @@ +import { FetchErrorFallback } from '@/components/FetchErrorFallback' +import { TanstackQueryErrorBoundary } from '@/components/TanstackQueryErrorBoundary' +import { Suspense } from 'react' +import { MyProblem, MyProblemFallback } from '../_components/MyProblem' + export default function MyProblemsPage() { return ( -
- My problems page is under construction. -
+ + }> + + + ) } diff --git a/apps/frontend/public/icons/clock_gray.svg b/apps/frontend/public/icons/clock_gray.svg new file mode 100644 index 0000000000..40a9163768 --- /dev/null +++ b/apps/frontend/public/icons/clock_gray.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/public/icons/memory.svg b/apps/frontend/public/icons/memory.svg new file mode 100644 index 0000000000..207053af24 --- /dev/null +++ b/apps/frontend/public/icons/memory.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/public/icons/plus-circle-blue.svg b/apps/frontend/public/icons/plus-circle-blue.svg new file mode 100644 index 0000000000..5f3899e391 --- /dev/null +++ b/apps/frontend/public/icons/plus-circle-blue.svg @@ -0,0 +1,3 @@ + + +