-
Notifications
You must be signed in to change notification settings - Fork 16
feat(fe): create my-problem tab #3521
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
woo943
wants to merge
41
commits into
main
Choose a base branch
from
t2650-create-myproblem-tab
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
41 commits
Select commit
Hold shift + click to select a range
682078e
feat(fe): create problem tabs
24f8596
fix(fe): fix width
63e1245
feat(fe): remake tabs design
8c33722
fix(fe): remove unusables
ad06aa3
Merge branch 'main' into t2567-create-problem-tab
sONg20NOW 688af8d
fix(fe): change text color and leaf node
67877e1
fix(fe): change tab design
1dfa205
fix(fe): capitalize problem letters
a3a8983
Merge branch 'main' into t2567-create-problem-tab
sONg20NOW 8d5201d
fix(fe): change letters
be94113
Merge branch 't2567-create-problem-tab' of https://github.com/skkudin…
0c747b6
fix(fe): fix page design
3f6fe76
fix(fe): change table design
67f1827
fix(fe): korean patch
1619581
fix(fe): change table design
8f8da6c
fix(fe): change table design
1aea414
fix(fe): change table design
e019871
Update apps/frontend/app/(client)/(main)/problem/_components/ProblemD…
woo943 ec5dd0f
feat(fe): impl basic page design
36021e8
feat(fe): change card design
ce0835b
feat(fe): add icons
8cc971c
fix(fe): common change
7702ee7
feat(fe): infinite scroll
80c1675
feat(fe): impl empty page
04fc60d
fix(fe): add state button on published tab
3f6deaf
Merge branch 'main' into t2650-create-myproblem-tab
woo943 dd38462
Update apps/frontend/app/(client)/(main)/problem/_components/ProblemD…
woo943 060472a
Update apps/frontend/app/(client)/(main)/problem/_components/MyProble…
woo943 2439662
fix(fe): fix gemini code review
00620b7
Merge branch 't2650-create-myproblem-tab' of https://github.com/skkud…
1e40e6c
fix(fe): fix commented design
069aa5f
chore(fe): erase unused varibales
d16efbb
Merge remote-tracking branch 'origin/main' into t2650-create-myproble…
5284994
fix(fe): change search bar height
269b530
Merge branch 'main' into t2650-create-myproblem-tab
sONg20NOW a630201
fix(fe): change about comments
017ce60
fix(fe): change state design
aa9018a
chore(fe): Delete .codex
woo943 9546e6e
feat(fe): impl state filter
976783a
fix(fe): delete empty page
8dba293
Merge branch 'main' into t2650-create-myproblem-tab
sONg20NOW File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
95 changes: 95 additions & 0 deletions
95
apps/frontend/app/(client)/(main)/problem/_components/MyProblem.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <MyProblemDataTable data={data} search={search} /> | ||
| } | ||
|
|
||
| export function MyProblemFallback() { | ||
| return ( | ||
| <div className="flex w-full flex-col gap-6"> | ||
| <div className="flex items-center justify-between"> | ||
| <Skeleton className="h-10 w-40 rounded-xl" /> | ||
| <div className="flex gap-3"> | ||
| <Skeleton className="h-12 w-28 rounded-full" /> | ||
| <Skeleton className="h-12 w-72 rounded-full" /> | ||
| <Skeleton className="h-12 w-36 rounded-full" /> | ||
| </div> | ||
| </div> | ||
| <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4"> | ||
| {[...Array(8)].map((_, i) => ( | ||
| <div | ||
| key={i} | ||
| className="border-line overflow-hidden rounded-[24px] border" | ||
| > | ||
| <Skeleton className="h-40 w-full rounded-none" /> | ||
| <div className="space-y-4 p-5"> | ||
| <Skeleton className="h-7 w-20 rounded-md" /> | ||
| <Skeleton className="h-7 w-full rounded-md" /> | ||
| <Skeleton className="h-6 w-2/3 rounded-md" /> | ||
| <Skeleton className="h-5 w-1/2 rounded-md" /> | ||
| </div> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
184 changes: 184 additions & 0 deletions
184
apps/frontend/app/(client)/(main)/problem/_components/MyProblemDataTable.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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' | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. clock_gray 대신에 이번에 clock icon 써서 svgr 활용해서 구현해보세요! |
||
| 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 ( | ||
| <div className="flex w-full flex-col items-center"> | ||
| <div className="flex w-full items-center justify-between self-stretch"> | ||
| <div className="flex shrink-0 items-center justify-start"> | ||
| <p className="text-head3_sb_28 whitespace-nowrap">내가 만든 문제</p> | ||
| </div> | ||
| <div className="flex w-full flex-col gap-3 lg:flex-row lg:items-center lg:justify-end"> | ||
| <MyProblemStateFilter | ||
| selectedStates={selectedStates} | ||
| onSelectedStatesChange={setSelectedStates} | ||
| /> | ||
| <SearchBar className="w-60" sizeVariant="lg" /> | ||
| <Button | ||
| asChild | ||
| className="text-body1_m_16 bg-primary h-[46px] shrink-0 whitespace-nowrap rounded-full px-[22px] py-2.5" | ||
| > | ||
| <Link | ||
| href="/problem/create" | ||
| className="flex items-center justify-center gap-1.5 whitespace-nowrap" | ||
| > | ||
| <Image | ||
| src={PlusCircle} | ||
| alt="plus circle" | ||
| width={20} | ||
| height={20} | ||
| /> | ||
| 새 문제 생성 | ||
| </Link> | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| {filteredData.length ? ( | ||
| <div className="mb-30 mt-5 grid w-full grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4"> | ||
| {filteredData.map((problem) => { | ||
| const href = | ||
| `/problem/my-problem/${problem.id}${search ? `?search=${search}` : ''}` as Route | ||
| const badgeClassName = getStateBadgeClassName(problem.state) | ||
|
|
||
| return ( | ||
| <Link | ||
| key={problem.id} | ||
| href={href} | ||
| className="bg-background outline-color-cool-neutral-90 inline-flex w-full flex-col items-start gap-5 rounded-2xl p-5 outline outline-1 outline-offset-[-1px] transition-transform duration-200 hover:-translate-y-1" | ||
| > | ||
| <div className="flex w-full flex-col items-start gap-3"> | ||
| <div | ||
| className={cn( | ||
| 'inline-flex items-center justify-center gap-2.5 rounded px-2.5 py-1', | ||
| badgeClassName.container | ||
| )} | ||
| > | ||
| <span | ||
| className={cn( | ||
| 'text-caption1_m_13 text-center', | ||
| badgeClassName.text | ||
| )} | ||
| > | ||
| {problem.state} | ||
| </span> | ||
| </div> | ||
| <p className="text-title1_sb_20 line-clamp-1 self-stretch"> | ||
| {problem.title} | ||
| </p> | ||
| </div> | ||
| <div className="flex w-full flex-col items-start gap-2"> | ||
| <div className="inline-flex items-start justify-start gap-2 self-stretch"> | ||
| <div className="flex items-center justify-start gap-1"> | ||
| <Image | ||
| src={ClockGray} | ||
| alt="clock gray" | ||
| width={20} | ||
| height={20} | ||
| /> | ||
| <span className="text-caption3_r_13"> | ||
| {problem.timeLimit}ms | ||
| </span> | ||
| </div> | ||
| <div className="flex items-center justify-start gap-1"> | ||
| <Image src={Memory} alt="memory" width={20} height={20} /> | ||
| <span className="text-caption3_r_13"> | ||
| {problem.memoryLimit}MB | ||
| </span> | ||
| </div> | ||
| </div> | ||
| <div className="inline-flex items-center justify-start gap-1"> | ||
| <span className="text-caption3_r_13 text-color-neutral-90 justify-start text-right"> | ||
| Last Modified: | ||
| </span> | ||
| <span className="text-caption3_r_13 justify-start text-right text-gray-500"> | ||
| {problem.updatedAt} | ||
| </span> | ||
| </div> | ||
| </div> | ||
| </Link> | ||
| ) | ||
| })} | ||
| </div> | ||
| ) : ( | ||
| <MyProblemEmptyState /> | ||
| )} | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| function MyProblemEmptyState() { | ||
| return ( | ||
| <div className="border-line mb-30 mt-5 flex h-[266px] w-full items-center justify-center rounded-xl border bg-white py-20"> | ||
| <div className="flex flex-col items-center gap-[16px] text-center"> | ||
| <p className="text-sub1_sb_18 text-color-neutral-30"> | ||
| 내가 만든 문제가 | ||
| <br /> | ||
| 존재하지 않습니다. | ||
| </p> | ||
| <Button | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이거 일단 problem/create 페이지 완성되기 전까지는 disable로 막아주세요! |
||
| asChild | ||
| className="text-sub4_sb_14 bg-primary h-10 shrink-0 whitespace-nowrap rounded-lg px-3 py-2.5" | ||
| > | ||
| <Link href="/problem/create">새 문제 생성하기</Link> | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
110 changes: 110 additions & 0 deletions
110
apps/frontend/app/(client)/(main)/problem/_components/MyProblemStateFilter.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <Popover> | ||
| <PopoverTrigger asChild> | ||
| <Button | ||
| variant="outline" | ||
| className="border-line text-body1_m_16 h-[46px] min-w-28 justify-center rounded-full border px-5 py-[11px] text-black" | ||
| > | ||
| <IoFilter className="text-color-cool-neutral-30 mr-2 h-5 w-5" /> | ||
| State | ||
| {selectedValues.size > 0 && ( | ||
| <> | ||
| <Separator orientation="vertical" className="mx-2 h-4" /> | ||
| <div className="flex gap-1"> | ||
| {selectedValues.size === STATE_FILTER_OPTIONS.length ? ( | ||
| <Badge | ||
| variant="secondary" | ||
| className="rounded-xs px-1 font-normal" | ||
| > | ||
| All | ||
| </Badge> | ||
| ) : ( | ||
| STATE_FILTER_OPTIONS.filter((state) => | ||
| selectedValues.has(state) | ||
| ).map((state) => ( | ||
| <Badge | ||
| key={state} | ||
| variant="secondary" | ||
| className="rounded-xs px-1 font-normal" | ||
| > | ||
| {state} | ||
| </Badge> | ||
| )) | ||
| )} | ||
| </div> | ||
| </> | ||
| )} | ||
| </Button> | ||
| </PopoverTrigger> | ||
|
|
||
| <PopoverContent className="w-[112px] p-0" align="start"> | ||
| <Command> | ||
| <CommandList> | ||
| <CommandGroup> | ||
| {STATE_FILTER_OPTIONS.map((state) => ( | ||
| <CommandItem | ||
| key={state} | ||
| value={state} | ||
| className="gap-x-1" | ||
| onSelect={() => handleFilterSelect(state)} | ||
| > | ||
| <Checkbox checked={selectedValues.has(state)} /> | ||
| {state} | ||
| </CommandItem> | ||
| ))} | ||
| </CommandGroup> | ||
| </CommandList> | ||
| </Command> | ||
| </PopoverContent> | ||
| </Popover> | ||
| ) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.