Skip to content

Commit d376b94

Browse files
sumi-0011claudesumi
authored
refactor: Dialog 리팩토링 + 캐러셀 InventoryGrid 안정성 개선 (#375)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: sumi <sumi@sumiui-MacBookAir.local>
1 parent 348df15 commit d376b94

19 files changed

Lines changed: 534 additions & 167 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,5 @@ apps/admin-main/docs/superpowers
7979
.omc
8080

8181
docs/superpowers
82+
.superpowers
83+

apps/web/src/app/[locale]/game/quiz/_components/CreateOrSolve/SelectQuizType.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useTranslations } from 'next-intl';
44
import { wrap } from '@suspensive/react';
55
import { overlay } from 'overlay-kit';
66

7-
import { ConfirmDialog } from '@gitanimals/ui-tailwind';
7+
import { ConfirmDialogV2 } from '@gitanimals/ui-tailwind';
88
import { ROUTE } from '@/shared/config/route';
99
import { useRouter } from '@/shared/i18n/routing';
1010

@@ -27,7 +27,7 @@ const SelectQuizType = wrap
2727

2828
const handleSolveQuiz = () => {
2929
overlay.open(({ isOpen, close }) => (
30-
<ConfirmDialog
30+
<ConfirmDialogV2
3131
isOpen={isOpen}
3232
onClose={close}
3333
onConfirm={() => {
@@ -42,7 +42,7 @@ const SelectQuizType = wrap
4242

4343
const handleCheckLanguage = () => {
4444
overlay.open(({ isOpen, close }) => (
45-
<ConfirmDialog
45+
<ConfirmDialogV2
4646
isOpen={isOpen}
4747
onClose={close}
4848
onConfirm={() => {

apps/web/src/app/[locale]/game/quiz/create/_components/QuizCreateForm.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { toast } from 'sonner';
99

1010
import QuizField from '@/app/[locale]/game/quiz/create/_components/QuizField';
1111
import QuizTextArea from '@/app/[locale]/game/quiz/create/_components/QuizTextArea';
12-
import { ConfirmDialog } from '@gitanimals/ui-tailwind';
12+
import { ConfirmDialogV2 } from '@gitanimals/ui-tailwind';
1313
import Tabs from '@/components/Tabs/Tabs';
1414
import TabsList from '@/components/Tabs/TabsList';
1515
import TabsTrigger from '@/components/Tabs/TabsTrigger';
@@ -42,7 +42,7 @@ const QuizCreateForm = () => {
4242

4343
const handleCheckToggleLanguage = () => {
4444
overlay.open(({ isOpen, close }) => (
45-
<ConfirmDialog
45+
<ConfirmDialogV2
4646
title={t('toggle-language-title')}
4747
description={t('toggle-language-description')}
4848
isOpen={isOpen}

apps/web/src/app/[locale]/mypage/(github-custom)/FarmPersonaSelect.tsx

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ import { useState } from 'react';
44
import { useTranslations } from 'next-intl';
55
import type { Persona } from '@gitanimals/api';
66
import { userQueries } from '@gitanimals/react-query';
7-
import { cn } from '@gitanimals/ui-tailwind';
8-
import { Dialog, ScrollArea } from '@gitanimals/ui-tailwind';
7+
import { cn, DialogV2 } from '@gitanimals/ui-tailwind';
98
import { useQueryClient } from '@tanstack/react-query';
109
import { ExpandIcon } from 'lucide-react';
1110
import { toast } from 'sonner';
@@ -61,22 +60,24 @@ export function FarmPersonaSelect({ onImageRefresh }: { onImageRefresh: () => vo
6160
<ExpandIcon color="white" size={20} />
6261
</button>
6362
</section>
64-
<ScrollArea className="h-40">
65-
<SelectPersonaList {...personaListProps} />
66-
</ScrollArea>
67-
<Dialog open={isOpen} onOpenChange={setIsOpen}>
68-
<Dialog.Content size="large" className="overflow-hidden flex flex-col gap-4">
69-
<Dialog.Title>{t('farm-type-select-pet')}</Dialog.Title>
63+
<SelectPersonaList {...personaListProps}>
64+
<SelectPersonaList.InventoryGrid minRows={2} maxRows={3} />
65+
</SelectPersonaList>
66+
<DialogV2 open={isOpen} onOpenChange={() => setIsOpen(false)}>
67+
<DialogV2.Content size="lg" className="h-full">
68+
<DialogV2.CloseButton />
7069
<SelectPersonaList {...personaListProps}>
71-
<Dialog.TopSlot>
70+
<DialogV2.Header>
71+
<DialogV2.Title>{t('farm-type-select-pet')}</DialogV2.Title>
7272
<SelectPersonaList.Toolbar showSearch showVisibilityFilter />
73-
</Dialog.TopSlot>
74-
<Dialog.Body>
75-
<SelectPersonaList.Grid />
76-
</Dialog.Body>
73+
</DialogV2.Header>
74+
75+
<DialogV2.Body scroll={false} className="h-full flex-1">
76+
<SelectPersonaList.InventoryGrid minRows={2} mode="dialog" />
77+
</DialogV2.Body>
7778
</SelectPersonaList>
78-
</Dialog.Content>
79-
</Dialog>
79+
</DialogV2.Content>
80+
</DialogV2>
8081
</div>
8182
);
8283
}

apps/web/src/app/[locale]/mypage/(github-custom)/FarmType.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,7 @@ export function FarmType() {
3434
<div className="rounded-xl relative overflow-hidden w-fit">
3535
<GitanimalsFarm
3636
imageKey={String(imageKey)}
37-
className={cn(
38-
'w-[600px] aspect-[2/1]',
39-
'max-pc:w-[400px]',
40-
'max-tablet:w-[300px]',
41-
'max-mobile:w-full'
42-
)}
37+
className={cn('w-[600px] aspect-[2/1]', 'max-pc:w-[400px]', 'max-tablet:w-[300px]', 'max-mobile:w-full')}
4338
/>
4439
<button
4540
className={cn(

apps/web/src/app/[locale]/mypage/PersonaList.tsx

Lines changed: 198 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
'use client';
22

3-
import { createContext, useContext, useEffect, useMemo, useRef } from 'react';
3+
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
44
import { useTranslations } from 'next-intl';
55
import type { Persona } from '@gitanimals/api';
66
import { userQueries } from '@gitanimals/react-query';
77
import { cn, Skeleton } from '@gitanimals/ui-tailwind';
88
import { wrap } from '@suspensive/react';
99
import { useSuspenseQuery } from '@tanstack/react-query';
10+
import useEmblaCarousel from 'embla-carousel-react';
1011

11-
import { MemoizedBannerPersonaItem } from '@/entities/persona/ui/PersonaItem';
12-
import { PersonaListToolbar } from '@/entities/persona/ui/PersonaListToolbar';
12+
import { NextButton, PrevButton, usePrevNextButtons } from '@/components/EmblaCarousel/EmblaCarouselArrowButtons';
13+
import { DotButton, useDotButton } from '@/components/EmblaCarousel/EmblaCarouselDotButton';
1314
import type { PersonaFilterState } from '@/entities/persona/model/usePersonaListFilter';
1415
import { usePersonaListFilter } from '@/entities/persona/model/usePersonaListFilter';
16+
import { MemoizedBannerPersonaItem } from '@/entities/persona/ui/PersonaItem';
17+
import { PersonaListToolbar } from '@/entities/persona/ui/PersonaListToolbar';
1518
import { useClientUser } from '@/shared/utils/clientAuth';
1619

1720
// ─── Context ────────────────────────────────────────────────────────
@@ -44,9 +47,7 @@ const listStyle = cn(
4447
'max-mobile:grid-cols-[repeat(auto-fill,minmax(52px,auto))]',
4548
);
4649

47-
const emptyStyle = cn(
48-
'font-product text-glyph-14 text-white-50 text-center py-6',
49-
);
50+
const emptyStyle = cn('font-product text-glyph-14 text-white-50 text-center py-6');
5051

5152
// ─── Sub-components ─────────────────────────────────────────────────
5253

@@ -97,6 +98,196 @@ function Grid() {
9798
);
9899
}
99100

101+
// ─── InventoryGrid (Embla carousel + dynamic grid) ─────────────────
102+
103+
const MAX_DOTS = 5;
104+
const NAV_HEIGHT = 44; // arrows + dots bar
105+
/** 다이얼로그 크롬: 제목(50) + 검색(40) + 필터(40) + 패딩/갭(30) + nav(44) */
106+
const DIALOG_CHROME_HEIGHT = 204;
107+
const INLINE_CHROME_HEIGHT = NAV_HEIGHT;
108+
109+
function useInventoryGrid(
110+
totalItems: number,
111+
minItemSize: number,
112+
gap: number,
113+
minRows: number,
114+
maxRows: number,
115+
mode: 'inline' | 'dialog',
116+
) {
117+
const containerRef = useRef<HTMLDivElement>(null);
118+
const [cols, setCols] = useState(0);
119+
const [rows, setRows] = useState(0);
120+
const [gridHeight, setGridHeight] = useState(0);
121+
const ready = cols > 0 && rows > 0;
122+
123+
useEffect(() => {
124+
const el = containerRef.current;
125+
if (!el) return;
126+
127+
let rafId: number;
128+
const measure = () => {
129+
cancelAnimationFrame(rafId);
130+
rafId = requestAnimationFrame(() => {
131+
// cols: 컨테이너 너비 기반
132+
const width = el.offsetWidth;
133+
// reInit 중 컨테이너가 일시적으로 축소될 수 있으므로 비정상 폭은 무시
134+
if (width < minItemSize * 2) return;
135+
const nextCols = Math.max(Math.floor((width + gap) / (minItemSize + gap)), 1);
136+
137+
// rows: 뷰포트 높이에서 크롬 높이를 뺀 가용 영역 기반
138+
// 실제 아이템 높이를 측정하여 정확한 행 수 계산
139+
const firstItem = el.querySelector('[class*="grid"] > button');
140+
const measuredHeight = firstItem ? firstItem.getBoundingClientRect().height : 0;
141+
const itemHeight = measuredHeight > 0 ? measuredHeight : minItemSize;
142+
const rowHeight = itemHeight + gap;
143+
144+
const vh = window.innerHeight;
145+
const chrome = mode === 'dialog' ? DIALOG_CHROME_HEIGHT : INLINE_CHROME_HEIGHT;
146+
const dialogHeight = mode === 'dialog' ? vh * 0.9 : vh;
147+
const availableHeight = dialogHeight - chrome;
148+
const nextRows =
149+
availableHeight > 0 ? Math.min(Math.max(Math.floor(availableHeight / rowHeight), minRows), maxRows) : minRows;
150+
151+
// 그리드 명시적 높이 = 행 수 × 행 높이 - 마지막 gap
152+
const nextGridHeight = nextRows * rowHeight - gap;
153+
154+
setCols((prev) => (prev === nextCols ? prev : nextCols));
155+
setRows((prev) => (prev === nextRows ? prev : nextRows));
156+
setGridHeight((prev) => (prev === nextGridHeight ? prev : nextGridHeight));
157+
});
158+
};
159+
160+
measure();
161+
const observer = new ResizeObserver(measure);
162+
observer.observe(el);
163+
return () => {
164+
cancelAnimationFrame(rafId);
165+
observer.disconnect();
166+
};
167+
}, [gap, minItemSize, minRows, maxRows, mode]);
168+
169+
const itemsPerPage = cols * rows;
170+
171+
return { containerRef, cols, rows, itemsPerPage, gridHeight, ready };
172+
}
173+
174+
interface InventoryGridProps {
175+
minRows?: number;
176+
maxRows?: number;
177+
minItemSize?: number;
178+
gap?: number;
179+
mode?: 'inline' | 'dialog';
180+
}
181+
182+
function InventoryGrid({ minRows = 2, maxRows = 10, minItemSize = 64, gap = 4, mode = 'inline' }: InventoryGridProps) {
183+
const t = useTranslations('Mypage.Filter');
184+
const { filteredList, selectedIds, onSelectPersona, loadingPersona, isSpecialEffect } = useSelectPersonaListContext();
185+
186+
const { containerRef, cols, rows, itemsPerPage, gridHeight, ready } = useInventoryGrid(
187+
filteredList.length,
188+
minItemSize,
189+
gap,
190+
minRows,
191+
maxRows,
192+
mode,
193+
);
194+
195+
const [emblaRef, emblaApi] = useEmblaCarousel({
196+
align: 'start',
197+
slidesToScroll: 1,
198+
containScroll: 'trimSnaps',
199+
skipSnaps: false,
200+
watchDrag: true,
201+
});
202+
203+
const { selectedIndex, scrollSnaps, onDotButtonClick } = useDotButton(emblaApi);
204+
const { prevBtnDisabled, nextBtnDisabled, onPrevButtonClick, onNextButtonClick } = usePrevNextButtons(emblaApi);
205+
206+
// Recompute embla when grid dimensions or content change
207+
useEffect(() => {
208+
if (ready && emblaApi) {
209+
emblaApi.reInit();
210+
emblaApi.scrollTo(0, true);
211+
}
212+
}, [emblaApi, ready, cols, rows, itemsPerPage, filteredList.length]);
213+
214+
const pages = useMemo(() => {
215+
if (!ready) return [];
216+
const result: Persona[][] = [];
217+
for (let i = 0; i < filteredList.length; i += itemsPerPage) {
218+
result.push(filteredList.slice(i, i + itemsPerPage));
219+
}
220+
return result.length > 0 ? result : [[]];
221+
}, [filteredList, itemsPerPage, ready]);
222+
223+
if (filteredList.length === 0) {
224+
return <p className={emptyStyle}>{t('no-results')}</p>;
225+
}
226+
227+
return (
228+
<div ref={containerRef} className="overflow-hidden">
229+
{!ready ? null : (
230+
<>
231+
{/* Navigation: arrows left, dots right */}
232+
<div className="flex mb-2 justify-between items-center">
233+
<div className="flex gap-[10px]">
234+
<PrevButton onClick={onPrevButtonClick} disabled={prevBtnDisabled} className="w-6 h-6" />
235+
<NextButton onClick={onNextButtonClick} disabled={nextBtnDisabled} className="w-6 h-6" />
236+
</div>
237+
<div className="flex gap-1">
238+
{(() => {
239+
const total = scrollSnaps.length;
240+
const start = total <= MAX_DOTS ? 0 : Math.min(Math.max(selectedIndex - 2, 0), total - MAX_DOTS);
241+
const end = start + Math.min(total, MAX_DOTS);
242+
return scrollSnaps.slice(start, end).map((_, i) => {
243+
const index = start + i;
244+
return (
245+
<DotButton
246+
key={index}
247+
onClick={() => onDotButtonClick(index)}
248+
className={cn(index === selectedIndex && 'selected')}
249+
/>
250+
);
251+
});
252+
})()}
253+
</div>
254+
</div>
255+
256+
{/* Embla carousel - each slide is a grid page */}
257+
<div className="overflow-hidden w-full [&>div]:!min-w-0" ref={emblaRef}>
258+
<div className="flex">
259+
{pages.map((page, pageIdx) => (
260+
<div key={pageIdx} className="flex-[0_0_100%] min-w-0 overflow-hidden">
261+
<div
262+
className="grid overflow-hidden"
263+
style={{
264+
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
265+
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
266+
gap: `${gap}px`,
267+
height: `${gridHeight}px`,
268+
}}
269+
>
270+
{page.map((persona) => (
271+
<MemoizedBannerPersonaItem
272+
key={persona.id}
273+
persona={persona}
274+
isSelected={selectedIds.has(persona.id)}
275+
onClick={() => onSelectPersona(persona)}
276+
loading={loadingPersona?.includes(persona.id) ?? false}
277+
isSpecialEffect={isSpecialEffect}
278+
/>
279+
))}
280+
</div>
281+
</div>
282+
))}
283+
</div>
284+
</div>
285+
</>
286+
)}
287+
</div>
288+
);
289+
}
290+
100291
// ─── Root ───────────────────────────────────────────────────────────
101292

102293
interface Props {
@@ -187,4 +378,5 @@ const Root = wrap
187378
export const SelectPersonaList = Object.assign(Root, {
188379
Toolbar,
189380
Grid,
381+
InventoryGrid,
190382
});

apps/web/src/app/[locale]/mypage/my-pet/SelectedPetTable.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import Image from 'next/image';
55
import { useTranslations } from 'next-intl';
66
import { dropPet, type Persona } from '@gitanimals/api';
77
import { userQueries } from '@gitanimals/react-query';
8-
import { Button, Checkbox, ConfirmDialog, Label } from '@gitanimals/ui-tailwind';
8+
import { Button, Checkbox, ConfirmDialogV2, Label } from '@gitanimals/ui-tailwind';
99
import { snakeToTitleCase } from '@gitanimals/util-common';
1010
import { useMutation, useQueryClient } from '@tanstack/react-query';
1111
import { overlay } from 'overlay-kit';
@@ -161,7 +161,7 @@ function SellConfirmDialog({
161161
const [isDoNotShowAgain, setIsDoNotShowAgain] = useState(false);
162162

163163
return (
164-
<ConfirmDialog
164+
<ConfirmDialogV2
165165
isOpen={isOpen}
166166
onClose={onClose}
167167
onConfirm={() => onConfirm(isDoNotShowAgain)}
@@ -176,6 +176,6 @@ function SellConfirmDialog({
176176
{t('Shop.sell-confirm-checkbox')}
177177
</Label>
178178
</div>
179-
</ConfirmDialog>
179+
</ConfirmDialogV2>
180180
);
181181
}

0 commit comments

Comments
 (0)