|
1 | 1 | 'use client'; |
2 | 2 |
|
3 | | -import { createContext, useContext, useEffect, useMemo, useRef } from 'react'; |
| 3 | +import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; |
4 | 4 | import { useTranslations } from 'next-intl'; |
5 | 5 | import type { Persona } from '@gitanimals/api'; |
6 | 6 | import { userQueries } from '@gitanimals/react-query'; |
7 | 7 | import { cn, Skeleton } from '@gitanimals/ui-tailwind'; |
8 | 8 | import { wrap } from '@suspensive/react'; |
9 | 9 | import { useSuspenseQuery } from '@tanstack/react-query'; |
| 10 | +import useEmblaCarousel from 'embla-carousel-react'; |
10 | 11 |
|
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'; |
13 | 14 | import type { PersonaFilterState } from '@/entities/persona/model/usePersonaListFilter'; |
14 | 15 | import { usePersonaListFilter } from '@/entities/persona/model/usePersonaListFilter'; |
| 16 | +import { MemoizedBannerPersonaItem } from '@/entities/persona/ui/PersonaItem'; |
| 17 | +import { PersonaListToolbar } from '@/entities/persona/ui/PersonaListToolbar'; |
15 | 18 | import { useClientUser } from '@/shared/utils/clientAuth'; |
16 | 19 |
|
17 | 20 | // ─── Context ──────────────────────────────────────────────────────── |
@@ -44,9 +47,7 @@ const listStyle = cn( |
44 | 47 | 'max-mobile:grid-cols-[repeat(auto-fill,minmax(52px,auto))]', |
45 | 48 | ); |
46 | 49 |
|
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'); |
50 | 51 |
|
51 | 52 | // ─── Sub-components ───────────────────────────────────────────────── |
52 | 53 |
|
@@ -97,6 +98,196 @@ function Grid() { |
97 | 98 | ); |
98 | 99 | } |
99 | 100 |
|
| 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 | + |
100 | 291 | // ─── Root ─────────────────────────────────────────────────────────── |
101 | 292 |
|
102 | 293 | interface Props { |
@@ -187,4 +378,5 @@ const Root = wrap |
187 | 378 | export const SelectPersonaList = Object.assign(Root, { |
188 | 379 | Toolbar, |
189 | 380 | Grid, |
| 381 | + InventoryGrid, |
190 | 382 | }); |
0 commit comments