diff --git a/.gitignore b/.gitignore index 657d339e..e4791f7e 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,7 @@ apps/web/public/sw.js *storybook.log .playwright-mcp + +# Local Claude Code instructions +apps/*/CLAUDE.local.md +CLAUDE.local.md diff --git a/.serena/project.yml b/.serena/project.yml index 94d3e642..2a795b8e 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,10 +1,3 @@ -# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby) -# * For C, use cpp -# * For JavaScript, use typescript -# Special requirements: -# * csharp: Requires the presence of a .sln file in the project folder. -language: typescript - # whether to use the project's gitignore file to ignore files # Added on 2025-04-07 ignore_all_files_in_gitignore: true @@ -64,5 +57,95 @@ excluded_tools: [] # initial prompt for the project. It will always be given to the LLM upon activating the project # (contrary to the memories, which are loaded on demand). initial_prompt: "" - +# the name by which the project can be referenced within Serena project_name: "git-animal-client" + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# list of regex patterns which, when matched, mark a memory entry as readโ€‘only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: utf-8 + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# php_phpactor powershell python python_jedi r +# rego ruby ruby_solargraph rust scala +# swift terraform toml typescript typescript_vts +# vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- typescript + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} diff --git a/apps/web/messages/en_US.json b/apps/web/messages/en_US.json index 77feeac6..a79d49a1 100644 --- a/apps/web/messages/en_US.json +++ b/apps/web/messages/en_US.json @@ -141,7 +141,28 @@ "please-choose-pet": "Select a pet to merge. The selected pet will be consumed and disappear." }, "laboratory": "Laboratory", - "maximum-pet-count-error": "You can have a maximum of 30 pets." + "maximum-pet-count-error": "You can have a maximum of 30 pets.", + "Filter": { + "search-placeholder": "Search pet name", + "grade": "Grade", + "tier": "Tier", + "visibility": "Status", + "sort": "Sort", + "filter-all": "All", + "filter-grade-collaborator": "Collab", + "filter-grade-default": "Default", + "filter-grade-evolution": "Evolution", + "filter-visible": "Visible", + "filter-hidden": "Hidden", + "evolvable-only": "Evolvable", + "sort-grade": "By Grade", + "sort-level-desc": "Level (High)", + "sort-level-asc": "Level (Low)", + "sort-tier": "By Rarity", + "sort-name": "By Name", + "reset": "Reset filters", + "no-results": "No pets match the criteria." + } }, "Event": { "event-end": "Event has ended.", @@ -245,6 +266,8 @@ "Error": { "global-error-message": "Something went wrong ๐Ÿ˜ญ", "want-to-report-error": "The gitanimals development team is currently tracking this error.\nIf you have a moment, we would greatly appreciate it if you could report the circumstances that led to this error.", - "report-error-button": "Report Error" + "report-error-button": "Report Error", + "ranking-error-title": "Unable to load ranking information", + "ranking-error-description": "Please try again later" } } diff --git a/apps/web/messages/ko_KR.json b/apps/web/messages/ko_KR.json index 39daacb9..710a12a7 100644 --- a/apps/web/messages/ko_KR.json +++ b/apps/web/messages/ko_KR.json @@ -142,7 +142,28 @@ "please-choose-pet": "ํ•ฉ์น˜๊ธฐ์— ์‚ฌ์šฉํ•  ํŽซ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”. ํ•ฉ์นœ ํŽซ์€ ์‚ฌ๋ผ์ง‘๋‹ˆ๋‹ค." }, "laboratory": "์‹คํ—˜์‹ค", - "maximum-pet-count-error": "ํŽซ์€ ์ตœ๋Œ€ 30๋งˆ๋ฆฌ๊นŒ์ง€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค." + "maximum-pet-count-error": "ํŽซ์€ ์ตœ๋Œ€ 30๋งˆ๋ฆฌ๊นŒ์ง€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.", + "Filter": { + "search-placeholder": "ํŽซ ์ด๋ฆ„ ๊ฒ€์ƒ‰", + "grade": "๋“ฑ๊ธ‰", + "tier": "ํ‹ฐ์–ด", + "visibility": "์ƒํƒœ", + "sort": "์ •๋ ฌ", + "filter-all": "์ „์ฒด", + "filter-grade-collaborator": "์ฝœ๋ผ๋ณด", + "filter-grade-default": "๊ธฐ๋ณธ", + "filter-grade-evolution": "์ง„ํ™”", + "filter-visible": "ํ‘œ์‹œ์ค‘", + "filter-hidden": "์ˆจ๊น€", + "evolvable-only": "์ง„ํ™” ๊ฐ€๋Šฅ", + "sort-grade": "๋“ฑ๊ธ‰์ˆœ", + "sort-level-desc": "๋ ˆ๋ฒจ ๋†’์€์ˆœ", + "sort-level-asc": "๋ ˆ๋ฒจ ๋‚ฎ์€์ˆœ", + "sort-tier": "ํฌ๊ท€๋„์ˆœ", + "sort-name": "์ด๋ฆ„์ˆœ", + "reset": "ํ•„ํ„ฐ ์ดˆ๊ธฐํ™”", + "no-results": "์กฐ๊ฑด์— ๋งž๋Š” ํŽซ์ด ์—†์Šต๋‹ˆ๋‹ค." + } }, "Event": { "event-end": "์ด๋ฒคํŠธ๊ฐ€ ์ข…๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", @@ -246,6 +267,8 @@ "Error": { "global-error-message": "๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ์–ด์š” ๐Ÿ˜ญ", "want-to-report-error": "gitanimals ๊ฐœ๋ฐœํŒ€์€ ํ˜„์žฌ ์ด ์˜ค๋ฅ˜๋ฅผ ์ถ”์ ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.\n์‹œ๊ฐ„์ด ๋˜์‹ ๋‹ค๋ฉด ์ด ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•œ ์ƒํ™ฉ์„ ๋ณด๊ณ ํ•ด์ฃผ์‹œ๋ฉด ๊ฐ์‚ฌํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.", - "report-error-button": "์˜ค๋ฅ˜ ๋ณด๊ณ ํ•˜๊ธฐ" + "report-error-button": "์˜ค๋ฅ˜ ๋ณด๊ณ ํ•˜๊ธฐ", + "ranking-error-title": "๋žญํ‚น ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค", + "ranking-error-description": "์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”" } } diff --git a/apps/web/src/app/[locale]/guild/_components/SelectPersonaList.tsx b/apps/web/src/app/[locale]/guild/_components/SelectPersonaList.tsx index 65064e7f..96b9de55 100644 --- a/apps/web/src/app/[locale]/guild/_components/SelectPersonaList.tsx +++ b/apps/web/src/app/[locale]/guild/_components/SelectPersonaList.tsx @@ -1,17 +1,18 @@ 'use client'; -import { memo } from 'react'; +import { useTranslations } from 'next-intl'; import { css, cx } from '_panda/css'; import type { Persona } from '@gitanimals/api'; import { userQueries } from '@gitanimals/react-query'; -import { LevelBanner } from '@gitanimals/ui-panda'; import { BannerSkeletonList } from '@gitanimals/ui-panda/src/components/Banner/Banner'; import { wrap } from '@suspensive/react'; import { useSuspenseQuery } from '@tanstack/react-query'; +import { MemoizedLevelPersonaItem } from '@/components/PersonaItem'; +import { PersonaListToolbar } from '@/components/PersonaListToolbar'; +import { usePersonaListFilter } from '@/hooks/persona/usePersonaListFilter'; import { customScrollStyle } from '@/styles/scrollStyle'; import { useClientUser } from '@/utils/clientAuth'; -import { getPersonaImage } from '@/utils/image'; const flexOverflowStyle = cx( css({ @@ -47,20 +48,37 @@ export const SelectPersonaList = wrap .on(function SelectPersonaList({ selectPersona, onSelectPersona }: SelectPersonaListProps) { const { name } = useClientUser(); const { data } = useSuspenseQuery(userQueries.allPersonasOptions(name)); + const t = useTranslations('Mypage.Filter'); + + const { filteredList, filterState, updateFilter, resetFilter, isFiltering, counts } = usePersonaListFilter( + data.personas, + ); - // TODO: ์ •๋ ฌ return (
-
- {data.personas.map((persona) => ( - onSelectPersona(persona)} - /> - ))} -
+ + {filteredList.length === 0 ? ( +

{t('no-results')}

+ ) : ( +
+ {filteredList.map((persona) => ( + onSelectPersona(persona)} + size="small" + /> + ))} +
+ )}
); }); @@ -74,25 +92,9 @@ const sectionStyle = css({ gap: '16px', }); -interface PersonaItemProps { - persona: Persona; - isSelected: boolean; - onClick: () => void; -} - -function PersonaItem({ persona, isSelected, onClick }: PersonaItemProps) { - return ( - - ); -} - -const MemoizedPersonaItem = memo(PersonaItem, (prev, next) => { - return prev.isSelected === next.isSelected && prev.persona.level === next.persona.level; +const emptyStyle = css({ + textStyle: 'glyph14.regular', + color: 'white.white_50', + textAlign: 'center', + padding: '24px 0', }); diff --git a/apps/web/src/app/[locale]/laboratory/multi-merge/PersonaItem.tsx b/apps/web/src/app/[locale]/laboratory/multi-merge/PersonaItem.tsx index 62e961d0..5893f50d 100644 --- a/apps/web/src/app/[locale]/laboratory/multi-merge/PersonaItem.tsx +++ b/apps/web/src/app/[locale]/laboratory/multi-merge/PersonaItem.tsx @@ -1,75 +1,4 @@ -import type { ComponentProps } from 'react'; -import { memo } from 'react'; -import { css, cx } from '_panda/css'; -import type { Persona } from '@gitanimals/api'; -import { Banner, LevelBanner } from '@gitanimals/ui-panda'; - -import { getPersonaImage } from '@/utils/image'; - -interface PersonaItemProps { - persona: Persona; - isSelected: boolean; - onClick: () => void; - size?: ComponentProps['size']; - className?: string; -} - -function PersonaItem({ persona, isSelected, onClick, size = 'full', className }: PersonaItemProps) { - return ( - - ); -} - -function PersonaBannerItem({ persona, isSelected, onClick, size = 'full', className }: PersonaItemProps) { - return ( - - ); -} - -export const MemoizedPersonaItem = memo(PersonaItem, (prev, next) => { - return ( - prev.isSelected === next.isSelected && - prev.persona.id === next.persona.id && - prev.persona.level === next.persona.level && - prev.onClick === next.onClick - ); -}); - -export const MemoizedPersonaBannerItem = memo(PersonaBannerItem, (prev, next) => { - return prev.isSelected === next.isSelected && prev.persona.level === next.persona.level; -}); +export { + MemoizedLevelPersonaItem as MemoizedPersonaItem, + MemoizedBannerPersonaItem as MemoizedPersonaBannerItem, +} from '@/components/PersonaItem'; diff --git a/apps/web/src/app/[locale]/landing/AvailablePetSection/AvailablePetSection.tsx b/apps/web/src/app/[locale]/landing/AvailablePetSection/AvailablePetSection.tsx index c24d9ea3..a9dec97e 100644 --- a/apps/web/src/app/[locale]/landing/AvailablePetSection/AvailablePetSection.tsx +++ b/apps/web/src/app/[locale]/landing/AvailablePetSection/AvailablePetSection.tsx @@ -4,6 +4,7 @@ import React, { Suspense } from 'react'; import Image from 'next/image'; import { AnchorButton } from '@gitanimals/ui-panda'; +import { Responsive } from '@/components/Responsive'; import { useGetTotalProductCount } from '@/hooks/query/auction/useGetTotalProductCount'; import { useGetTotalIdentityUserCount } from '@/hooks/query/identity/useGetTotalIdentityUserCount'; import { useGetTotalPersonaCount } from '@/hooks/query/render/useGetTotalPersonaCount'; @@ -54,12 +55,15 @@ function AvailablePetSection() {
- + Show More Pets - - - Show More Pets - +
diff --git a/apps/web/src/app/[locale]/landing/ChoosePetSection/ChoosePetSection.tsx b/apps/web/src/app/[locale]/landing/ChoosePetSection/ChoosePetSection.tsx index e8f9e755..8b5ad719 100644 --- a/apps/web/src/app/[locale]/landing/ChoosePetSection/ChoosePetSection.tsx +++ b/apps/web/src/app/[locale]/landing/ChoosePetSection/ChoosePetSection.tsx @@ -2,6 +2,7 @@ import { Button } from '@gitanimals/ui-panda'; import { getServerAuth } from '@/auth'; import { LoginButton } from '@/components/AuthButton'; +import { Responsive } from '@/components/Responsive'; import { Link } from '@/i18n/routing'; import * as styles from './ChoosePetSection.style'; @@ -22,12 +23,9 @@ async function ChoosePetSection() { ) : ( - - + )} diff --git a/apps/web/src/app/[locale]/landing/Footer/Footer.style.ts b/apps/web/src/app/[locale]/landing/Footer/Footer.style.ts index 21c044f8..ee89e4e7 100644 --- a/apps/web/src/app/[locale]/landing/Footer/Footer.style.ts +++ b/apps/web/src/app/[locale]/landing/Footer/Footer.style.ts @@ -5,7 +5,7 @@ export const footer = css({ display: 'flex', flexDir: 'column', gap: '120px', - // bg: 'black.black', + bg: 'black.black', width: '100%', color: 'white.white', padding: '120px 0', diff --git a/apps/web/src/app/[locale]/landing/MainSection/MainSection.style.ts b/apps/web/src/app/[locale]/landing/MainSection/MainSection.style.ts index 280602f8..bae2f571 100644 --- a/apps/web/src/app/[locale]/landing/MainSection/MainSection.style.ts +++ b/apps/web/src/app/[locale]/landing/MainSection/MainSection.style.ts @@ -14,20 +14,6 @@ export const section = flex({ _mobile: { padding: '80px 12px', }, - - '& .mobile': { - display: 'none', - _mobile: { - display: 'block', - }, - }, - - '& .desktop': { - display: 'block', - _mobile: { - display: 'none', - }, - }, }); export const heading = css({ diff --git a/apps/web/src/app/[locale]/landing/MainSection/MainSection.tsx b/apps/web/src/app/[locale]/landing/MainSection/MainSection.tsx index 605c1d07..0b6fea4f 100644 --- a/apps/web/src/app/[locale]/landing/MainSection/MainSection.tsx +++ b/apps/web/src/app/[locale]/landing/MainSection/MainSection.tsx @@ -2,6 +2,7 @@ import { Button } from '@gitanimals/ui-panda'; import { getServerAuth } from '@/auth'; import { LoginButton } from '@/components/AuthButton'; +import { Responsive } from '@/components/Responsive'; import { Link } from '@/i18n/routing'; import * as styles from './MainSection.style'; @@ -17,17 +18,13 @@ async function MainSection() {

You can acquire and grow pets through GitHub activities. Choose from over 50 different pets and raise them.

- {/* TODO: button ๋ฐ˜์‘ํ˜• ์ฒ˜๋ฆฌ */} {!session ? ( ) : ( - - + )}
diff --git a/apps/web/src/app/[locale]/landing/MainSection/MainSlider.tsx b/apps/web/src/app/[locale]/landing/MainSection/MainSlider.tsx index f0a68480..d8b7e0e5 100644 --- a/apps/web/src/app/[locale]/landing/MainSection/MainSlider.tsx +++ b/apps/web/src/app/[locale]/landing/MainSection/MainSlider.tsx @@ -1,10 +1,9 @@ 'use client'; -import { useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import Image from 'next/image'; import { css, cx } from '_panda/css'; -import type { ChangedEvent, FlickingOptions, FlickingProps } from '@egjs/react-flicking'; -import Flicking from '@egjs/react-flicking'; +import useEmblaCarousel from 'embla-carousel-react'; import * as styles from './MainSlider.style'; import SliderItem from './SliderItem'; @@ -26,47 +25,26 @@ const MODE_ITEM_LIST = [ ]; function MainSlider() { - const flicking = useRef(null); + const [emblaRef, emblaApi] = useEmblaCarousel({ align: 'start', containScroll: 'trimSnaps' }); const [currentPanelIndex, setCurrentPanelIndex] = useState(0); const isFirstPanel = currentPanelIndex === 0; const isLastPanel = currentPanelIndex === MODE_ITEM_LIST.length - 1; - const onPanelIndexChange = (index: number) => { - if (!flicking.current) return; - if (flicking.current.animating) return; - flicking.current?.moveTo(index); - }; - - const moveToNextPanel = async () => { - if (!flicking.current) return; - if (isLastPanel) return; - if (flicking.current.animating) return; - - try { - flicking.current.next(); - } catch (error) {} - }; - - const moveToPrevPanel = async () => { - if (!flicking.current) return; - if (isFirstPanel) return; - if (flicking.current.animating) return; - - try { - flicking.current.prev(); - } catch (error) {} - }; - - const onPanelChanged = (e: ChangedEvent) => { - setCurrentPanelIndex(e.index); - }; - - // TODO: arrow plugin ์œผ๋กœ ๋ณ€๊ฒฝ - const sliderOptions: Partial = { - panelsPerView: 1, - onChanged: onPanelChanged, - }; + useEffect(() => { + if (!emblaApi) return; + + const onSelect = () => setCurrentPanelIndex(emblaApi.selectedScrollSnap()); + onSelect(); + emblaApi.on('select', onSelect); + return () => { + emblaApi.off('select', onSelect); + }; + }, [emblaApi]); + + const onPanelIndexChange = (index: number) => emblaApi?.scrollTo(index); + const moveToNextPanel = () => emblaApi?.scrollNext(); + const moveToPrevPanel = () => emblaApi?.scrollPrev(); return (
@@ -85,13 +63,15 @@ function MainSlider() {
- - {MODE_ITEM_LIST.map((item) => ( -
- -
- ))} -
+
+
+ {MODE_ITEM_LIST.map((item) => ( +
+ +
+ ))} +
+
); @@ -170,3 +150,7 @@ const nextArrowStyle = cx( }, }), ); + +const emblaViewportStyle = css({ overflow: 'hidden', width: '100%' }); +const emblaContainerStyle = css({ display: 'flex' }); +const emblaSlideStyle = css({ flex: '0 0 100%', minWidth: 0 }); diff --git a/apps/web/src/app/[locale]/landing/RankingSection/MobileRankingTable.tsx b/apps/web/src/app/[locale]/landing/RankingSection/MobileRankingTable.tsx index d1d9521d..e8112d83 100644 --- a/apps/web/src/app/[locale]/landing/RankingSection/MobileRankingTable.tsx +++ b/apps/web/src/app/[locale]/landing/RankingSection/MobileRankingTable.tsx @@ -1,143 +1,179 @@ -import { useRouter } from 'next/navigation'; -import { useSearchParams } from 'next/navigation'; +'use client'; + +import { useRef, useState } from 'react'; import { useSession } from 'next-auth/react'; import { css, cx } from '_panda/css'; -import Flicking from '@egjs/react-flicking'; import type { RankType } from '@gitanimals/api'; -import { getNewUrl } from '@gitanimals/util-common'; +import { rankQueries } from '@gitanimals/react-query'; +import { Skeleton } from '@gitanimals/ui-panda'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { RankingLink } from './RankingLink'; -import '@egjs/react-flicking/dist/flicking.css'; -import '@egjs/flicking-plugins/dist/pagination.css'; +const RANKS_PER_PAGE = 5; +const SWIPE_THRESHOLD = 50; + +interface MobileRankingTableProps { + initialRanks: RankType[]; + initialPage: number; + totalPage: number; + type: 'WEEKLY_USER_CONTRIBUTIONS' | 'WEEKLY_GUILD_CONTRIBUTIONS'; +} -export function MobileRankingTable({ ranks, page, totalPage }: { page: number; ranks: RankType[]; totalPage: number }) { - const router = useRouter(); +export function MobileRankingTable({ initialRanks, initialPage, totalPage, type }: MobileRankingTableProps) { const { data: session } = useSession(); - const searchParams = useSearchParams(); const currentUsername = session?.user?.name; + const [page, setPage] = useState(initialPage); + const touchStartX = useRef(0); - const getRankingPageUrl = (params: Record) => { - const oldParams = Object.fromEntries(searchParams.entries()); - return getNewUrl({ baseUrl: '/', newParams: params, oldParams }); + const goToPage = (next: number) => { + setPage(next); }; - const handleMoveEnd = (e: any) => { - const direction = e.direction; - const newPage = direction === 'NEXT' ? page - 1 : page + 1; - - if (newPage < 0) return; - if (newPage > totalPage) return; - - const newUrl = getRankingPageUrl({ page: newPage }); - router.push(newUrl); + const rankStart = page * RANKS_PER_PAGE + 4; + + const { data: ranks, isPlaceholderData } = useQuery({ + ...rankQueries.getRanksOptions({ rank: rankStart, size: RANKS_PER_PAGE, type }), + initialData: page === initialPage ? initialRanks : undefined, + placeholderData: keepPreviousData, + }); + + // Prefetch adjacent pages + useQuery({ + ...rankQueries.getRanksOptions({ rank: (page - 1) * RANKS_PER_PAGE + 4, size: RANKS_PER_PAGE, type }), + enabled: page > 0, + }); + useQuery({ + ...rankQueries.getRanksOptions({ rank: (page + 1) * RANKS_PER_PAGE + 4, size: RANKS_PER_PAGE, type }), + enabled: page < totalPage, + }); + + const handleTouchStart = (e: React.TouchEvent) => { + touchStartX.current = e.touches[0].clientX; }; - // ํŽ˜์ด์ง€ ๋ฐ์ดํ„ฐ๋ฅผ 10๊ฐœ์”ฉ ๊ทธ๋ฃนํ™” - const groupedRanks = ranks.reduce((acc: RankType[][], rank, index) => { - const groupIndex = Math.floor(index / 10); - if (!acc[groupIndex]) { - acc[groupIndex] = []; + const handleTouchEnd = (e: React.TouchEvent) => { + const diff = touchStartX.current - e.changedTouches[0].clientX; + if (Math.abs(diff) < SWIPE_THRESHOLD) return; + + if (diff > 0 && page < totalPage) { + goToPage(page + 1); + } else if (diff < 0 && page > 0) { + goToPage(page - 1); } - acc[groupIndex].push(rank); - return acc; - }, []); + }; return ( -
- - {groupedRanks.map((group, index) => ( -
- - - - - - - - - - - {group.map((item) => ( - - - - - - - ))} - -
RankPetNameContribution
{item.rank} - - {item.name} - - - {item.name} - {item.contributions}
-
- ))} -
+
+
+ +
- {[0, 1, 2].map((group, index) => { - const isActive = - (page === 0 && index === 0) || - (page !== 0 && page !== totalPage && index === 1) || - (page === totalPage && index === 2); - return
; - })} + + + {page + 1} / {totalPage + 1} + +
); } -const flickingStyle = css({ - width: '100%', - height: 'auto', - margin: '0 auto', - position: 'relative', - overflow: 'hidden', +function RankingTableView({ + ranks, + currentUsername, +}: { + ranks: RankType[] | undefined; + currentUsername: string | null | undefined; +}) { + return ( + + + + + + + + + + + {ranks + ? ranks.map((item) => ( + + + + + + + )) + : Array.from({ length: RANKS_PER_PAGE }).map((_, i) => ( + + + + + + + ))} + +
RankPetNameContribution
{item.rank} + + {item.name} + + + {item.name} + {item.contributions}
+ + + + + + + +
+ ); +} + +const containerStyle = css({ width: '100%', overflow: 'hidden' }); + +const tableWrapperStyle = css({ + transition: 'opacity 0.15s ease', }); -const slideStyle = css({ - width: '100%', - height: 'auto', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', +const fetchingStyle = css({ + opacity: 0.5, }); const paginationStyle = css({ - marginTop: '20px', - position: 'relative', - height: '30px', + marginTop: '16px', display: 'flex', alignItems: 'center', justifyContent: 'center', - gap: 2, }); -const paginationBulletStyle = css({ - width: '8px', - height: '8px', - borderRadius: '50%', - backgroundColor: 'white.white_50', - cursor: 'pointer', - - '&.active': { - backgroundColor: 'white.white_100', - }, +const paginationTextStyle = css({ + textStyle: 'glyph14.regular', + color: 'white.white_50', }); -const rankingListStyle = css({ - width: '100%', +const arrowButtonStyle = css({ + width: '32px', + height: '32px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + border: 'none', + background: 'none', + color: 'white.white_75', + fontSize: '20px', + cursor: 'pointer', + _disabled: { + opacity: 0.25, + cursor: 'default', + }, }); const tableStyle = css({ @@ -148,7 +184,7 @@ const tableStyle = css({ }); const trBaseStyle = css({ - borderRadius: '8px', + borderRadius: '6px', '& img': { borderRadius: '50%', @@ -157,43 +193,23 @@ const trBaseStyle = css({ '& td, & th': { border: 'none', - padding: '0 16px', + padding: '0 8px', }, '& td:first-child, & th:first-child': { - paddingLeft: '40px', - borderRadius: '8px 0 0 8px', - width: '120px', + paddingLeft: '16px', + borderRadius: '6px 0 0 6px', + width: '54px', }, '& td:last-child, & th:last-child': { - paddingRight: '40px', - borderRadius: '0 8px 8px 0', + paddingRight: '16px', + borderRadius: '0 6px 6px 0', textAlign: 'right', }, '& td:nth-child(2), & th:nth-child(2)': { textAlign: 'center', - width: '72px', - padding: '0 8px', - }, - - _mobile: { - borderRadius: '6px', - '& td, & th': { - border: 'none', - padding: '0 8px', - }, - - '& td:first-child, & th:first-child': { - paddingLeft: '16px', - width: '54px', - }, - '& td:last-child, & th:last-child': { - paddingRight: '16px', - }, - '& td:nth-child(2), & th:nth-child(2)': { - width: '28px', - paddingLeft: '0px', - }, + width: '28px', + paddingLeft: '0px', }, }); @@ -213,22 +229,15 @@ const trStyle = cx( textStyle: 'glyph18.regular', color: 'white.white_100', backgroundColor: 'white.white_10', - height: '60px', + height: '48px', + fontSize: 'glyph15.regular', '& td:first-child': { - fontSize: '20px', + fontSize: '16px', lineHeight: '28px', fontFamily: 'token(fonts.dnf)', color: 'white.white_50', }, - _mobile: { - height: '48px', - fontSize: 'glyph15.regular', - - '& td:first-child': { - fontSize: '16px', - }, - }, }), ); diff --git a/apps/web/src/app/[locale]/landing/RankingSection/RankingSection.tsx b/apps/web/src/app/[locale]/landing/RankingSection/RankingSection.tsx index bbb174b2..474d40ac 100644 --- a/apps/web/src/app/[locale]/landing/RankingSection/RankingSection.tsx +++ b/apps/web/src/app/[locale]/landing/RankingSection/RankingSection.tsx @@ -10,6 +10,7 @@ import { Link } from '@/i18n/routing'; import GameConsole from './GameConsole/GameConsole'; import { MobileGameConsole } from './MobileGameConsole/MobileGameConsole'; +import { calcTotalPage, RANKS_PER_PAGE, RANKS_TOP_3 } from './constants'; import { MobileRankingTable } from './MobileRankingTable'; import { RankingTable } from './RankingTable'; import { TopPodium } from './TopPodium'; @@ -28,8 +29,8 @@ export default function RankingSection({ const queries = useQueries({ queries: [ - rankQueries.getRanksOptions({ rank: 1, size: 3, type }), - rankQueries.getRanksOptions({ rank: startRankNumber, size: 5, type }), + rankQueries.getRanksOptions({ rank: 1, size: RANKS_TOP_3, type }), + rankQueries.getRanksOptions({ rank: startRankNumber, size: RANKS_PER_PAGE, type }), rankQueries.getTotalRankOptions({ type }), ], }); @@ -58,7 +59,12 @@ export default function RankingSection({
- +
} @@ -66,12 +72,6 @@ export default function RankingSection({
); } - -const calcTotalPage = (totalCount: number) => { - if (totalCount <= 3) return 0; - return Math.ceil((totalCount - 3) / 5) - 1; -}; - const titleStyle = css({ textStyle: 'glyph82.bold', color: 'white.white_100', diff --git a/apps/web/src/app/[locale]/landing/RankingSection/RankingServerSide.tsx b/apps/web/src/app/[locale]/landing/RankingSection/RankingServerSide.tsx index 99a0ddd2..401d9bb2 100644 --- a/apps/web/src/app/[locale]/landing/RankingSection/RankingServerSide.tsx +++ b/apps/web/src/app/[locale]/landing/RankingSection/RankingServerSide.tsx @@ -4,18 +4,16 @@ import { rankQueries } from '@gitanimals/react-query'; import { getDehydratedQueries, Hydrate } from '@/lib/react-query/queryClient'; +import { RANKS_PER_PAGE, RANKS_TOP_3 } from './constants'; import RankingSection from './RankingSection'; -const TOTAL_VIEW_RANKS = 8 as const; -const RANKS_TOP_3 = 3 as const; -const RANKS_PER_PAGE = TOTAL_VIEW_RANKS - RANKS_TOP_3; - export async function RankingServerSide({ - searchParams, + searchParams: searchParamsPromise, }: { - searchParams: { [key: string]: string | string[] | undefined }; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { try { + const searchParams = await searchParamsPromise; const type = searchParams.ranking ?? 'people'; const session = await getServerSession(); diff --git a/apps/web/src/app/[locale]/landing/RankingSection/constants.ts b/apps/web/src/app/[locale]/landing/RankingSection/constants.ts new file mode 100644 index 00000000..ac965b75 --- /dev/null +++ b/apps/web/src/app/[locale]/landing/RankingSection/constants.ts @@ -0,0 +1,10 @@ +export const RANKS_TOP_3 = 3; +export const RANKS_PER_PAGE = 5; +export const RANK_OFFSET = RANKS_TOP_3 + 1; + +export const getRankStart = (page: number) => page * RANKS_PER_PAGE + RANK_OFFSET; + +export const calcTotalPage = (totalCount: number) => { + if (totalCount <= RANKS_TOP_3) return 0; + return Math.ceil((totalCount - RANKS_TOP_3) / RANKS_PER_PAGE) - 1; +}; diff --git a/apps/web/src/app/[locale]/landing/index.ts b/apps/web/src/app/[locale]/landing/index.ts index eb6effb1..5f44c7cf 100644 --- a/apps/web/src/app/[locale]/landing/index.ts +++ b/apps/web/src/app/[locale]/landing/index.ts @@ -1,5 +1,8 @@ import { AvailablePetSection } from './AvailablePetSection'; +import { ChoosePetSection } from './ChoosePetSection'; +import { Footer } from './Footer'; import { HavePetWaySection } from './HavePetWaySection'; import { MainSection } from './MainSection'; +import { RankingServerSide } from './RankingSection/RankingServerSide'; -export { AvailablePetSection, HavePetWaySection, MainSection }; +export { AvailablePetSection, ChoosePetSection, Footer, HavePetWaySection, MainSection, RankingServerSide }; diff --git a/apps/web/src/app/[locale]/mypage/(github-custom)/FarmBackgroundSelect.tsx b/apps/web/src/app/[locale]/mypage/(github-custom)/FarmBackgroundSelect.tsx index 4f699f7a..0365f468 100644 --- a/apps/web/src/app/[locale]/mypage/(github-custom)/FarmBackgroundSelect.tsx +++ b/apps/web/src/app/[locale]/mypage/(github-custom)/FarmBackgroundSelect.tsx @@ -16,9 +16,9 @@ export const FarmBackgroundSelect = wrap fallback: <>, }) .on(function FarmBackgroundSelect({ - onChangeStatus, + onImageRefresh, }: { - onChangeStatus: (status: 'loading' | 'success' | 'error') => void; + onImageRefresh: () => void; }) { const session = useClientSession(); const { name } = useClientUser(); @@ -29,16 +29,10 @@ export const FarmBackgroundSelect = wrap data: { backgrounds }, } = useSuspenseQuery(renderUserQueries.getMyBackground(name)); const { mutate: changeMyBackground } = useChangeMyBackgroundByToken(session.data?.user.accessToken ?? '', { - onMutate: () => { - onChangeStatus('loading'); + onSettled: () => { + onImageRefresh(); }, - onSuccess: () => { - onChangeStatus('success'); - }, - onError: () => { - onChangeStatus('error'); - }, - }); // TODO: ์ถ”ํ›„ ์ˆ˜์ • + }); const handleChangeBackground = (background: RenderBackground) => { changeMyBackground({ type: background.type }); diff --git a/apps/web/src/app/[locale]/mypage/(github-custom)/FarmPersonaSelect.tsx b/apps/web/src/app/[locale]/mypage/(github-custom)/FarmPersonaSelect.tsx index 91909d19..3125dea1 100644 --- a/apps/web/src/app/[locale]/mypage/(github-custom)/FarmPersonaSelect.tsx +++ b/apps/web/src/app/[locale]/mypage/(github-custom)/FarmPersonaSelect.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useTranslations } from 'next-intl'; -import { css, cx } from '_panda/css'; +import { css } from '_panda/css'; import type { Persona } from '@gitanimals/api'; import { userQueries } from '@gitanimals/react-query'; import { Dialog, ScrollArea } from '@gitanimals/ui-panda'; @@ -9,67 +9,41 @@ import { ExpandIcon } from 'lucide-react'; import { toast } from 'sonner'; import { useChangePersonaVisible } from '@/apis/persona/useChangePersonaVisible'; -import { customScrollHorizontalStyle } from '@/styles/scrollStyle'; import { SelectPersonaList } from '../PersonaList'; -export function FarmPersonaSelect({ - onChangeStatus, -}: { - onChangeStatus: (status: 'loading' | 'success' | 'error') => void; -}) { +export function FarmPersonaSelect({ onImageRefresh }: { onImageRefresh: () => void }) { const queryClient = useQueryClient(); const t = useTranslations('Mypage'); const tError = useTranslations('Error'); - const [selectPersona, setSelectPersona] = useState([]); const [loadingPersona, setLoadingPersona] = useState([]); const [isOpen, setIsOpen] = useState(false); const { mutate } = useChangePersonaVisible({ throwOnError: false, - onMutate: () => { - onChangeStatus('loading'); - }, - onSuccess: (res) => { - if (res.visible) { - setSelectPersona((prev) => Array.from(new Set([...prev, res.id]))); - } else { - setSelectPersona((prev) => prev.filter((id) => id !== res.id)); - } - onChangeStatus('success'); - }, onError: (error) => { - const isMaximumPetCountError = error.response?.status === 400; - - onChangeStatus('error'); - - if (isMaximumPetCountError) { - // ์ตœ๋Œ€ ํŽซ ๊ฐœ์ˆ˜ ์ดˆ๊ณผ ์—๋Ÿฌ + if (error.response?.status === 400) { toast.error(t('maximum-pet-count-error')); } else { - // ๊ธฐํƒ€ ์—๋Ÿฌ toast.error(tError('global-error-message')); } }, - onSettled: async (res, error, variables) => { + onSettled: async (_, __, variables) => { await queryClient.invalidateQueries({ queryKey: userQueries.allPersonasKey() }); setLoadingPersona((prev) => prev.filter((id) => id !== variables.personaId)); + onImageRefresh(); }, }); const onSelectPersona = (persona: Persona) => { setLoadingPersona((prev) => [...prev, persona.id]); - - const isVisible = selectPersona.includes(persona.id); - - // ๋ณด์ด๋Š” ์ƒํƒœ๋ผ๋ฉด ์ˆจ๊น€์œผ๋กœ, ์ˆจ๊น€ ์ƒํƒœ๋ผ๋ฉด ๋ณด์ด๋Š” ์ƒํƒœ๋กœ ๋ณ€๊ฒฝ ์š”์ฒญ - mutate({ personaId: persona.id, visible: !isVisible }); + mutate({ personaId: persona.id, visible: !persona.visible }); }; - const initSelectPersonas = (list: Persona[]) => { - const visiblePersonaIds = list.filter((persona) => persona.visible).map((persona) => persona.id); - setSelectPersona(visiblePersonaIds); + const personaListProps = { + loadingPersona, + onSelectPersona, }; return ( @@ -81,47 +55,25 @@ export function FarmPersonaSelect({ - + - + {t('farm-type-select-pet')} -
- -
+ + + + + + + +
); } -const flexOverflowStyle = cx( - css({ - display: 'flex', - overflowY: 'auto', - overflowX: 'hidden', - width: '100%', - gap: '4px', - height: '100%', - minHeight: '0', - flexWrap: 'wrap', - justifyContent: 'center', - maxHeight: 'calc(100%)', - marginTop: '24px', - }), - customScrollHorizontalStyle, -); - const selectPetContainerStyle = css({ position: 'relative', display: 'flex', diff --git a/apps/web/src/app/[locale]/mypage/(github-custom)/FarmType.tsx b/apps/web/src/app/[locale]/mypage/(github-custom)/FarmType.tsx index c717e3df..e03e2af8 100644 --- a/apps/web/src/app/[locale]/mypage/(github-custom)/FarmType.tsx +++ b/apps/web/src/app/[locale]/mypage/(github-custom)/FarmType.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useReducer } from 'react'; import { useTranslations } from 'next-intl'; import { css } from '_panda/css'; import { ClipboardIcon } from 'lucide-react'; @@ -18,7 +18,7 @@ export function FarmType() { const { name } = useClientUser(); - const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); + const [imageKey, refreshImage] = useReducer((x: number) => x + 1, 0); const onLinkCopy = async () => { try { @@ -33,7 +33,7 @@ export function FarmType() {
- +
- + ); } diff --git a/apps/web/src/app/[locale]/mypage/(github-custom)/LinePersonaSelect.tsx b/apps/web/src/app/[locale]/mypage/(github-custom)/LinePersonaSelect.tsx index 0fcbbc73..363a108a 100644 --- a/apps/web/src/app/[locale]/mypage/(github-custom)/LinePersonaSelect.tsx +++ b/apps/web/src/app/[locale]/mypage/(github-custom)/LinePersonaSelect.tsx @@ -2,13 +2,11 @@ import React, { useState } from 'react'; import { useTranslations } from 'next-intl'; -import { css, cx } from '_panda/css'; -import { flex } from '_panda/patterns'; +import { css } from '_panda/css'; + import { Dialog, ScrollArea } from '@gitanimals/ui-panda'; import { ExpandIcon } from 'lucide-react'; -import { customScrollStyle } from '@/styles/scrollStyle'; - import { SelectPersonaList } from '../PersonaList'; interface Props { @@ -36,53 +34,25 @@ export const LinePersonaSelect = ({ selectPersona, onChangePersona }: Props) => /> setIsExtend(false)}> - + {t('line-type-select-pet')} -
- onChangePersona(persona.id)} - /> -
+ onChangePersona(persona.id)} + > + + + + + + +
); }; -const flexOverflowStyle = cx( - css({ - display: 'flex', - overflowY: 'auto', - overflowX: 'hidden', - width: '100%', - gap: '4px', - height: '100%', - minHeight: '0', - flexWrap: 'wrap', - justifyContent: 'center', - maxHeight: 'calc(100%)', - marginTop: '24px', - }), - customScrollStyle, -); - -const listStyle = cx( - flex({ - gap: '4px', - w: '100%', - h: '100%', - minH: '0', - overflowX: 'auto', - overflowY: 'hidden', - display: 'grid', - gridTemplateRows: 'repeat(2, 1fr)', - gridAutoColumns: 'max-content', - gridAutoFlow: 'column', - }), - customScrollStyle, -); - const selectPetContainerStyle = css({ position: 'relative', display: 'flex', diff --git a/apps/web/src/app/[locale]/mypage/PersonaList.tsx b/apps/web/src/app/[locale]/mypage/PersonaList.tsx index c776769f..0439adc5 100644 --- a/apps/web/src/app/[locale]/mypage/PersonaList.tsx +++ b/apps/web/src/app/[locale]/mypage/PersonaList.tsx @@ -1,16 +1,44 @@ 'use client'; -import React, { memo, useEffect, useMemo, useRef } from 'react'; +import { createContext, useContext, useEffect, useMemo, useRef } from 'react'; +import { useTranslations } from 'next-intl'; import { css, cx } from '_panda/css'; import type { Persona } from '@gitanimals/api'; import { userQueries } from '@gitanimals/react-query'; -import { Banner } from '@gitanimals/ui-panda'; -import { BannerSkeleton } from '@gitanimals/ui-panda/src/components/Banner/Banner'; +import { BannerSkeleton } from '@gitanimals/ui-panda'; import { wrap } from '@suspensive/react'; import { useSuspenseQuery } from '@tanstack/react-query'; +import { MemoizedBannerPersonaItem } from '@/components/PersonaItem'; +import { PersonaListToolbar } from '@/components/PersonaListToolbar'; +import type { PersonaFilterState } from '@/hooks/persona/usePersonaListFilter'; +import { usePersonaListFilter } from '@/hooks/persona/usePersonaListFilter'; import { useClientUser } from '@/utils/clientAuth'; -import { getPersonaImage } from '@/utils/image'; + +// โ”€โ”€โ”€ Context โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface SelectPersonaListContextValue { + filterState: PersonaFilterState; + updateFilter: (partial: Partial) => void; + resetFilter: () => void; + counts: { filtered: number; total: number }; + isFiltering: boolean; + filteredList: Persona[]; + selectedIds: Set; + onSelectPersona: (persona: Persona) => void; + loadingPersona?: string[]; + isSpecialEffect?: boolean; +} + +const SelectPersonaListContext = createContext(null); + +function useSelectPersonaListContext() { + const ctx = useContext(SelectPersonaListContext); + if (!ctx) throw new Error('SelectPersonaList compound components must be used within '); + return ctx; +} + +// โ”€โ”€โ”€ Styles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const listStyle = css({ gap: '4px', @@ -21,18 +49,76 @@ const listStyle = css({ }, }); +const emptyStyle = css({ + textStyle: 'glyph14.regular', + color: 'white.white_50', + textAlign: 'center', + padding: '24px 0', +}); + +// โ”€โ”€โ”€ Sub-components โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface ToolbarProps { + showSearch?: boolean; + showVisibilityFilter?: boolean; + showEvolvableFilter?: boolean; +} + +function Toolbar({ showSearch, showVisibilityFilter, showEvolvableFilter }: ToolbarProps) { + const { filterState, updateFilter, resetFilter, counts, isFiltering } = useSelectPersonaListContext(); + + return ( + + ); +} + +function Grid() { + const t = useTranslations('Mypage.Filter'); + const { filteredList, selectedIds, onSelectPersona, loadingPersona, isSpecialEffect } = + useSelectPersonaListContext(); + + if (filteredList.length === 0) { + return

{t('no-results')}

; + } + + return ( +
+ {filteredList.map((persona) => ( + onSelectPersona(persona)} + loading={loadingPersona?.includes(persona.id) ?? false} + isSpecialEffect={isSpecialEffect} + /> + ))} +
+ ); +} + +// โ”€โ”€โ”€ Root โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + interface Props { - selectPersona: string[]; + selectPersona?: string[]; onSelectPersona: (persona: Persona) => void; initSelectPersonas?: (list: Persona[]) => void; loadingPersona?: string[]; - isSpecialEffect?: boolean; + children?: React.ReactNode; } -export const SelectPersonaList = wrap +const Root = wrap .ErrorBoundary({ - // TODO: ๊ณตํ†ต ์—๋Ÿฌ ์ปดํฌ๋„ŒํŠธ๋กœ ๋Œ€์ฒด fallback:
error
, }) .Suspense({ @@ -44,90 +130,61 @@ export const SelectPersonaList = wrap ), }) - .on(function SelectPersonaList({ selectPersona, isSpecialEffect, onSelectPersona, initSelectPersonas, loadingPersona, + children, }: Props) { const { name } = useClientUser(); const { data } = useSuspenseQuery(userQueries.allPersonasOptions(name)); const hasInitialized = useRef(false); - // ์ดˆ๊ธฐ ์„ ํƒ ๋กœ์ง, ์™ธ๋ถ€์—์„œ ์ดˆ๊ธฐํ™” ํ•จ์ˆ˜ ์ „๋‹ฌ + const { filteredList, filterState, updateFilter, resetFilter, isFiltering, counts } = usePersonaListFilter( + data.personas, + ); + useEffect(() => { if (initSelectPersonas && !hasInitialized.current && data.personas.length > 0) { hasInitialized.current = true; initSelectPersonas(data.personas); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data]); - - const gradeSortedList = useMemo(() => { - // COLLABORATOR, EVOLUTION, DEFAULT ์ˆœ์œผ๋กœ ์ •๋ ฌ - return data.personas.sort((a, b) => { - if (a.grade === 'COLLABORATOR') return -1; - if (a.grade === 'EVOLUTION') return 1; - return 0; - }); - }, [data]); - - const viewList = useMemo(() => { - const viewListSorted = gradeSortedList.sort((a, b) => { - if (a.visible && !b.visible) return -1; - if (!a.visible && b.visible) return 1; - return parseInt(b.level) - parseInt(a.level); - }); - - return viewListSorted; - }, [gradeSortedList]); + }, []); - return ( -
- {viewList.map((persona) => ( - onSelectPersona(persona)} - isLoading={loadingPersona?.includes(persona.id) ?? false} - isSpecialEffect={isSpecialEffect} - /> - ))} -
+ const selectedIds = useMemo( + () => new Set(selectPersona ?? data.personas.filter((p) => p.visible).map((p) => p.id)), + [selectPersona, data], ); - }); -interface PersonaItemProps { - persona: Persona; - isSelected: boolean; - onClick: () => void; - isLoading: boolean; + const contextValue: SelectPersonaListContextValue = useMemo( + () => ({ + filterState, + updateFilter, + resetFilter, + counts, + isFiltering, + filteredList, + selectedIds, + onSelectPersona, + loadingPersona, + isSpecialEffect, + }), + [filterState, updateFilter, resetFilter, counts, isFiltering, filteredList, selectedIds, onSelectPersona, loadingPersona, isSpecialEffect], + ); - isSpecialEffect?: boolean; -} + return ( + + {children ?? } + + ); + }); -function PersonaItem({ persona, isSelected, onClick, isSpecialEffect, isLoading }: PersonaItemProps) { - return ( - - ); -} +// โ”€โ”€โ”€ Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -const MemoizedPersonaItem = memo(PersonaItem); +export const SelectPersonaList = Object.assign(Root, { + Toolbar, + Grid, +}); diff --git a/apps/web/src/app/[locale]/mypage/my-pet/SelectedPetTable.tsx b/apps/web/src/app/[locale]/mypage/my-pet/SelectedPetTable.tsx index 1f195654..54dbacc0 100644 --- a/apps/web/src/app/[locale]/mypage/my-pet/SelectedPetTable.tsx +++ b/apps/web/src/app/[locale]/mypage/my-pet/SelectedPetTable.tsx @@ -47,7 +47,6 @@ export function SelectedPetTable({ currentPersona, reset }: SelectedPetTableProp }); const isEvolutionAble = currentPersona?.isEvolutionable; - console.debug('c', currentPersona); const onSellClick = async () => { if (!currentPersona) return; @@ -231,8 +230,6 @@ function SellConfirmDialog({ setIsLoading(true); await onConfirm(isDoNotShowAgain); setIsLoading(false); - - onClose(); }; return ( diff --git a/apps/web/src/app/[locale]/mypage/my-pet/_components/SelectPersonaList.tsx b/apps/web/src/app/[locale]/mypage/my-pet/_components/SelectPersonaList.tsx index fd795afe..9092e319 100644 --- a/apps/web/src/app/[locale]/mypage/my-pet/_components/SelectPersonaList.tsx +++ b/apps/web/src/app/[locale]/mypage/my-pet/_components/SelectPersonaList.tsx @@ -1,16 +1,16 @@ -import { memo } from 'react'; import { useTranslations } from 'next-intl'; import { css, cx } from '_panda/css'; import type { Persona } from '@gitanimals/api'; import { userQueries } from '@gitanimals/react-query'; -import { LevelBanner } from '@gitanimals/ui-panda'; import { BannerSkeletonList } from '@gitanimals/ui-panda/src/components/Banner/Banner'; import { wrap } from '@suspensive/react'; import { useSuspenseQuery } from '@tanstack/react-query'; +import { MemoizedLevelPersonaItem } from '@/components/PersonaItem'; +import { PersonaListToolbar } from '@/components/PersonaListToolbar'; +import { usePersonaListFilter } from '@/hooks/persona/usePersonaListFilter'; import { customScrollStyle } from '@/styles/scrollStyle'; import { useClientUser } from '@/utils/clientAuth'; -import { getPersonaImage } from '@/utils/image'; interface SelectPersonaListProps { selectPersona: string[]; @@ -25,23 +25,40 @@ export const SelectPersonaList = wrap const { name } = useClientUser(); const { data } = useSuspenseQuery(userQueries.allPersonasOptions(name)); const t = useTranslations('Mypage.Merge'); + const tFilter = useTranslations('Mypage.Filter'); + + const { filteredList, filterState, updateFilter, resetFilter, isFiltering, counts } = usePersonaListFilter( + data.personas, + ); - // TODO: ์ •๋ ฌ return (

{t('please-choose-pet')}

-
- {data.personas.map((persona) => ( - onSelectPersona(persona)} - /> - ))} -
+ + {filteredList.length === 0 ? ( +

{tFilter('no-results')}

+ ) : ( +
+ {filteredList.map((persona) => ( + onSelectPersona(persona)} + size="small" + /> + ))} +
+ )}
); }); @@ -77,25 +94,9 @@ const flexOverflowStyle = cx( customScrollStyle, ); -interface PersonaItemProps { - persona: Persona; - isSelected: boolean; - onClick: () => void; -} - -function PersonaItem({ persona, isSelected, onClick }: PersonaItemProps) { - return ( - - ); -} - -const MemoizedPersonaItem = memo(PersonaItem, (prev, next) => { - return prev.isSelected === next.isSelected && prev.persona.level === next.persona.level; +const emptyStyle = css({ + textStyle: 'glyph14.regular', + color: 'white.white_50', + textAlign: 'center', + padding: '24px 0', }); diff --git a/apps/web/src/app/[locale]/mypage/my-pet/page.tsx b/apps/web/src/app/[locale]/mypage/my-pet/page.tsx index 171f1a98..2aad438a 100644 --- a/apps/web/src/app/[locale]/mypage/my-pet/page.tsx +++ b/apps/web/src/app/[locale]/mypage/my-pet/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useCallback, useState } from 'react'; +import { useCallback, useState } from 'react'; import { useTranslations } from 'next-intl'; import { css } from '_panda/css'; import { flex } from '_panda/patterns'; @@ -15,14 +15,9 @@ function MypageMyPets() { const t = useTranslations('Mypage'); const [selectPersona, setSelectPersona] = useState(null); - const initSelectPersonas = useCallback( - (list: Persona[]) => { - if (!selectPersona && list.length > 0) { - setSelectPersona(list[0]); - } - }, - [selectPersona], - ); + const initSelectPersonas = useCallback((list: Persona[]) => { + setSelectPersona((prev) => (prev ? prev : list[0] ?? null)); + }, []); return (
@@ -36,7 +31,10 @@ function MypageMyPets() { onSelectPersona={(persona) => setSelectPersona(persona)} initSelectPersonas={initSelectPersonas} isSpecialEffect - /> + > + + + diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index f62548ac..9c57ed13 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -1,15 +1,18 @@ import type { Metadata } from 'next'; import { getTranslations } from 'next-intl/server'; -import { css } from '_panda/css'; import { ErrorBoundary } from '@suspensive/react'; import { ErrorSection } from '@/components/Error/ErrorSection'; import GNB from '@/components/GNB/GNB'; -import { ChoosePetSection } from './landing/ChoosePetSection'; -import { Footer } from './landing/Footer'; -import { RankingServerSide } from './landing/RankingSection/RankingServerSide'; -import { AvailablePetSection, HavePetWaySection, MainSection } from './landing'; +import { + AvailablePetSection, + ChoosePetSection, + Footer, + HavePetWaySection, + MainSection, + RankingServerSide, +} from './landing'; import '@egjs/react-flicking/dist/flicking.css'; import '@egjs/react-flicking/dist/flicking-inline.css'; @@ -22,22 +25,28 @@ export async function generateMetadata(): Promise { }; } -export default function HomePage({ searchParams }: { searchParams: { [key: string]: string | string[] | undefined } }) { +export default async function HomePage({ + searchParams, +}: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const t = await getTranslations('Error'); + return ( -
+ <> - - } - > - - - - - -
-
-
-
+
+ + } + > + + + + + +
+