From f1c396d77037590d2bd12e0d786e30ee4ae6d4a4 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Sat, 4 Apr 2026 10:48:59 +0900 Subject: [PATCH] feat(explore): search/filter integration, editorial preview, scroll perf (#61) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Search & Filter - Inline search bar with Meilisearch API integration - Autocomplete suggestions (popular/recent searches) - Server-side sort (relevant/recent/popular/solution_count) and context filter - Client-side artist multi-select badges from search facets - Search infinite scroll pagination via useInfiniteQuery - /search page redirects to /explore?q= ## Explore UI - Replace dropdown filters with compact filter bar - Remove ExploreFilterBar, ExploreFilterSheet, hierarchicalFilterStore, mockFilterData - FilterChip component with active/inactive variants and counts - Search result highlight with artist name overlay on cards ## Detail Modal - Explore-preview variant for ImageDetailContent - EditorialPreviewHeader with description and "전체 보기" navigation - Compact MagazineItemsSection for preview mode - DecodeShowcase adapter with TDD ## Performance - IntersectionObserver replaces ScrollTrigger in compact mode - Throttled activeIndex updates with rAF - Deferred ScrollTrigger init for modal animation - ActiveSpotStore (Zustand) for zero-render spot highlighting ## Other - Fix ASCII logo right-shift clipping - Supabase fallback for posts/search API routes - Fix explore_posts view type with as-any cast Co-Authored-By: Claude Opus 4.6 (1M context) --- .planning/STATE.md | 3 +- .../260403-kp5-PLAN.md | 15 + .../260403-kp5-SUMMARY.md | 20 + ...04-03-explore-search-enhancement-design.md | 132 +++++++ packages/shared/data/mockFilterData.ts | 296 -------------- packages/shared/index.ts | 21 - .../shared/stores/hierarchicalFilterStore.ts | 371 ------------------ .../web/app/@modal/(.)posts/[id]/page.tsx | 13 +- packages/web/app/api/v1/posts/route.ts | 112 +++++- packages/web/app/api/v1/search/route.ts | 138 +++++++ packages/web/app/explore/ExploreClient.tsx | 326 ++++++++++++--- packages/web/app/explore/page.tsx | 14 +- packages/web/app/search/SearchPageClient.tsx | 139 ------- packages/web/app/search/page.tsx | 52 +-- packages/web/lib/components/ASCIIText.tsx | 12 +- packages/web/lib/components/DecodedLogo.tsx | 19 +- packages/web/lib/components/ThiingsGrid.tsx | 1 + .../detail/EditorialPreviewHeader.tsx | 60 +++ .../components/detail/ImageDetailContent.tsx | 207 +++++----- .../components/detail/ImageDetailModal.tsx | 5 +- .../adapters/toDecodeShowcaseData.test.ts | 126 ++++++ .../detail/adapters/toDecodeShowcaseData.ts | 50 +++ .../detail/magazine/MagazineItemsSection.tsx | 224 ++++++----- packages/web/lib/components/detail/types.ts | 11 +- .../components/explore/ExploreCardCell.tsx | 43 +- .../components/explore/ExploreFilterBar.tsx | 191 --------- .../components/explore/ExploreFilterSheet.tsx | 178 --------- .../web/lib/components/explore/FilterChip.tsx | 42 +- packages/web/lib/hooks/useExploreData.ts | 202 ++++++++++ packages/web/lib/hooks/useImages.ts | 63 ++- packages/web/lib/stores/activeSpotStore.ts | 11 + packages/web/next-env.d.ts | 2 +- packages/web/vitest.config.ts | 2 + 33 files changed, 1573 insertions(+), 1528 deletions(-) create mode 100644 .planning/quick/260403-kp5-fix-header-ascii-logo-shifting-right-and/260403-kp5-PLAN.md create mode 100644 .planning/quick/260403-kp5-fix-header-ascii-logo-shifting-right-and/260403-kp5-SUMMARY.md create mode 100644 docs/superpowers/specs/2026-04-03-explore-search-enhancement-design.md delete mode 100644 packages/shared/data/mockFilterData.ts delete mode 100644 packages/shared/stores/hierarchicalFilterStore.ts create mode 100644 packages/web/app/api/v1/search/route.ts delete mode 100644 packages/web/app/search/SearchPageClient.tsx create mode 100644 packages/web/lib/components/detail/EditorialPreviewHeader.tsx create mode 100644 packages/web/lib/components/detail/adapters/toDecodeShowcaseData.test.ts create mode 100644 packages/web/lib/components/detail/adapters/toDecodeShowcaseData.ts delete mode 100644 packages/web/lib/components/explore/ExploreFilterBar.tsx delete mode 100644 packages/web/lib/components/explore/ExploreFilterSheet.tsx create mode 100644 packages/web/lib/hooks/useExploreData.ts create mode 100644 packages/web/lib/stores/activeSpotStore.ts diff --git a/.planning/STATE.md b/.planning/STATE.md index 4d036359..71fe00c2 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -56,10 +56,11 @@ See: .planning/PROJECT.md | 260402-pqz | post 상세 페이지 뱃지 삭제 | 2026-04-02 | ba54f977 | [260402-pqz-post](./quick/260402-pqz-post/) | | 260402-qix | explore 페이지 필터 제거 | 2026-04-02 | - | [260402-qix-explore](./quick/260402-qix-explore/) | | 260402-qhu | post 상세페이지 및 오른쪽 모달 패널 컴포넌트 크기 조정 | 2026-04-02 | 890374ce | [260402-qhu-post](./quick/260402-qhu-post/) | +| 260403-kp5 | 헤더 ASCII 로고 오른쪽 밀림/잘림 수정 | 2026-04-03 | - | [260403-kp5](./quick/260403-kp5-fix-header-ascii-logo-shifting-right-and/) | --- -Last activity: 2026-04-02 - Completed quick task 260402-qhu: post 상세페이지 및 오른쪽 모달 패널 컴포넌트 크기 조정 +Last activity: 2026-04-03 - Completed quick task 260403-kp5: 헤더 ASCII 로고 오른쪽 밀림/잘림 수정 _Created: 2026-02-05_ _Reset: 2026-04-02 (harness optimization — GSD lightweight mode)_ diff --git a/.planning/quick/260403-kp5-fix-header-ascii-logo-shifting-right-and/260403-kp5-PLAN.md b/.planning/quick/260403-kp5-fix-header-ascii-logo-shifting-right-and/260403-kp5-PLAN.md new file mode 100644 index 00000000..6c88c68b --- /dev/null +++ b/.planning/quick/260403-kp5-fix-header-ascii-logo-shifting-right-and/260403-kp5-PLAN.md @@ -0,0 +1,15 @@ +# Quick Task 260403-kp5: Fix header ASCII logo shifting right and getting clipped + +## Task +Fix the DecodedLogo/ASCIIText `
` element positioning that causes the ASCII logo to shift right and get clipped intermittently.
+
+## Root Cause
+The `
` element was centered with `left: 50%; transform: translate(-50%, -50%)`. At very small font sizes (e.g., `asciiFontSize: 3`), Canvas 2D `measureText` and CSS font rendering produce different character widths. This makes the `
` wider than expected, and centering shifts it right.
+
+## Tasks
+
+### Task 1: Fix pre element positioning in DecodedLogo.tsx and ASCIIText.tsx
+- **files**: `packages/web/lib/components/DecodedLogo.tsx`, `packages/web/lib/components/ASCIIText.tsx`
+- **action**: Change `
` positioning from centered (`left: 50%; transform: translate(-50%, -50%)`) to top-left aligned (`left: 0; top: 0; overflow: hidden; width: 100%; height: 100%`). Apply to both JS inline styles in `reset()` and CSS rules.
+- **verify**: `bunx tsc --noEmit` passes
+- **done**: Pre element no longer shifts right regardless of font size or rendering differences
diff --git a/.planning/quick/260403-kp5-fix-header-ascii-logo-shifting-right-and/260403-kp5-SUMMARY.md b/.planning/quick/260403-kp5-fix-header-ascii-logo-shifting-right-and/260403-kp5-SUMMARY.md
new file mode 100644
index 00000000..30444b9e
--- /dev/null
+++ b/.planning/quick/260403-kp5-fix-header-ascii-logo-shifting-right-and/260403-kp5-SUMMARY.md
@@ -0,0 +1,20 @@
+# Quick Task 260403-kp5: Summary
+
+## What Changed
+
+### DecodedLogo.tsx
+- `AsciiFilter.reset()`: Changed `
` inline styles from `left: 50%; top: 50%; transform: translate(-50%, -50%)` to `left: 0; top: 0; transform: none; overflow: hidden; width: 100%; height: 100%`
+- CSS `.decoded-logo-container pre`: Same positioning change, plus `text-align: left` instead of `center`
+
+### ASCIIText.tsx
+- `AsciiFilter.reset()`: Same inline style fix as DecodedLogo
+- CSS `.ascii-text-container pre`: Added `overflow: hidden; width: 100%; height: 100%`
+
+## Root Cause
+At small `asciiFontSize` values (e.g., 3px in the Header), Canvas 2D `measureText("A").width` can differ from CSS rendered character width. This made the `
` element wider than its container. Combined with `left: 50%; translate(-50%)` centering, the content shifted right and overflowed.
+
+## Fix Approach
+Anchor `
` to top-left (`left: 0; top: 0`) with `width: 100%; height: 100%; overflow: hidden`. The ASCII grid naturally fills the container, so centering was unnecessary and caused the drift.
+
+## Verification
+- TypeScript check: passed
diff --git a/docs/superpowers/specs/2026-04-03-explore-search-enhancement-design.md b/docs/superpowers/specs/2026-04-03-explore-search-enhancement-design.md
new file mode 100644
index 00000000..f95bc38b
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-03-explore-search-enhancement-design.md
@@ -0,0 +1,132 @@
+# Explore Search Enhancement Design Spec
+
+## Overview
+
+Explore 페이지의 검색을 "그리드 내 필터" 역할로 확정하고, 자동완성·facet 뱃지·하이라이트·/search 제거를 통해 검색 경험을 개선한다.
+
+## 결정사항
+
+| 항목 | 결정 |
+|------|------|
+| Search 역할 | Explore 내 필터 (별도 페이지 없음) |
+| 결과 표시 | 그리드 교체 (현재 방식 유지) |
+| 초기 상태 | 뱃지 없이 깨끗, 검색 후에만 facet 뱃지 |
+| /search 페이지 | 제거, `/explore?q=` 로 redirect |
+
+---
+
+## 1. 자동완성 드롭다운
+
+### 데이터 소스
+- `GET /api/v1/search/popular` — 인기 검색어 목록
+- `GET /api/v1/search/recent` — 최근 검색어 (로그인 사용자)
+- 입력 중: 인기 검색어 중 입력값과 매칭되는 항목만 필터링
+
+### UI
+- 검색 바 포커스 + 1자 이상 입력 시 드롭다운 오픈
+- 각 항목: 텍스트 + Search 아이콘 (우측)
+- 항목 클릭 → 해당 검색어로 즉시 검색 (setQuery + setDebouncedQuery)
+- 키보드: ↑↓ 이동, Enter 선택, Escape 닫기
+- 검색 바 바깥 클릭 → 닫힘
+
+### 구현
+- 기존 `packages/web/lib/components/search/SearchSuggestions.tsx` 재사용
+- Orval generated hooks: `useSearchPopular`, `useSearchRecent` (이미 존재)
+- `ExploreClient`에서 검색 바 `
` 안에 `SearchSuggestions` 조건부 렌더링 +- `onSelect` 콜백으로 검색어 설정 + 드롭다운 닫기 + +### 파일 +- Modify: `packages/web/app/explore/ExploreClient.tsx` +- Reuse: `packages/web/lib/components/search/SearchSuggestions.tsx` + +--- + +## 2. Facet 뱃지 UX 개선 + +### 현재 동작 (유지) +- 검색어 없을 때: 뱃지 숨김 +- 검색 결과 로드 후: context, media_type facet 뱃지 표시 +- 클릭으로 토글 필터, 활성 뱃지는 primary + X 아이콘 + +### 개선사항 + +| 개선 | 상세 | +|------|------| +| 최소 필터 의미 | facet 값이 1개뿐인 카테고리는 뱃지 생성 안 함 | +| 정렬 | count 높은 순으로 정렬 | +| 최대 표시 | 카테고리당 5개, 나머지는 "+N" 뱃지 (클릭 시 전체 펼침) | + +### 파일 +- Modify: `packages/web/app/explore/ExploreClient.tsx` (뱃지 렌더링 로직) + +--- + +## 3. 검색 결과 하이라이트 + +### 데이터 +- `SearchResultItem.highlight`: `{[key: string]: string} | null` +- Meilisearch가 매칭 부분을 `` 태그로 감싸서 반환 +- 예: `{ "artist_name": "Lisa" }` + +### UI +- 검색 모드일 때 `ExploreCardCell`에 아티스트명 오버레이 표시 +- highlight가 있으면 `` 태그를 `` 스타일로 렌더링 +- highlight가 없으면 일반 텍스트로 아티스트명 표시 + +### 구현 +- `PostGridItem`에 `highlight` 필드 추가 (optional) +- `mapSearchResultToGridItem`에서 highlight 전달 +- `ExploreCardCell`에서 highlight 있으면 하단 오버레이에 아티스트명 표시 +- `dangerouslySetInnerHTML` 대신 `` 파싱하여 안전하게 렌더링 + +### 파일 +- Modify: `packages/web/lib/hooks/useExploreData.ts` (highlight 매핑) +- Modify: `packages/web/lib/hooks/useImages.ts` (PostGridItem 타입) +- Modify: `packages/web/lib/components/explore/ExploreCardCell.tsx` (오버레이) + +--- + +## 4. /search 페이지 제거 + +### 라우팅 +- `packages/web/app/search/page.tsx` → `/explore?q={검색어}` redirect +- Next.js `redirect()` 함수 사용 (서버 컴포넌트에서) + +### URL 동기화 +- `ExploreClient` mount 시 URL의 `q` param 읽어서 `useSearchStore` 초기화 +- 검색어 변경 시 `router.replace(/explore?q=...)` 로 URL 업데이트 (optional, 공유용) + +### 삭제 대상 +- `packages/web/app/search/SearchPageClient.tsx` — 삭제 +- `packages/web/app/search/page.tsx` — redirect로 교체 +- `packages/web/app/search/layout.tsx` — 삭제 (있는 경우) + +### 네비게이션 +- 네비바의 검색 아이콘/버튼 → `/explore` 이동으로 변경 +- 또는 검색 아이콘 클릭 시 explore 페이지의 검색 바에 포커스 + +### 파일 +- Delete: `packages/web/app/search/SearchPageClient.tsx` +- Modify: `packages/web/app/search/page.tsx` (redirect) +- Modify: 네비게이션 컴포넌트 (검색 링크 변경) + +--- + +## 구현 순서 + +1. `/search` 페이지 제거 + redirect (독립적, 가장 간단) +2. 자동완성 드롭다운 (검색 바에 SearchSuggestions 연결) +3. Facet 뱃지 개선 (정렬, 최대 개수, 최소 필터) +4. 검색 결과 하이라이트 (PostGridItem 타입 변경 → 카드 오버레이) + +## 검증 + +- `bunx tsc --noEmit` 타입 에러 없음 +- `bunx eslint --fix` lint 통과 +- 수동 테스트: + - `/search?q=lisa` → `/explore?q=lisa` redirect 확인 + - `/explore` 검색 바 입력 → 자동완성 드롭다운 표시 + - 자동완성 항목 클릭 → 검색 실행 + 결과 그리드 + - Facet 뱃지: count순 정렬, 5개 초과 시 "+N" 표시 + - 카드에 아티스트명 오버레이 + 하이라이트 표시 + - 뱃지 토글 → 결과 필터링 diff --git a/packages/shared/data/mockFilterData.ts b/packages/shared/data/mockFilterData.ts deleted file mode 100644 index 8964114b..00000000 --- a/packages/shared/data/mockFilterData.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * Mock data for Hierarchical Filter development - * Will be replaced with Supabase queries when backend is ready - */ - -import type { - CategoryOption, - MediaOption, - CastOption, - ContextOption, - CategoryType, - ContextType, -} from "../types/filter"; - -// ============================================ -// Level 1: Categories -// ============================================ -export const MOCK_CATEGORIES: CategoryOption[] = [ - { id: "K-POP", label: "K-POP", labelKo: "케이팝", postCount: 450 }, - { id: "K-Drama", label: "K-Drama", labelKo: "드라마", postCount: 120 }, - { id: "K-Variety", label: "K-Variety", labelKo: "예능", postCount: 85 }, - { id: "K-Movie", label: "K-Movie", labelKo: "영화", postCount: 45 }, - { id: "K-Fashion", label: "K-Fashion", labelKo: "패션", postCount: 30 }, -]; - -// ============================================ -// Level 2: Media by Category -// ============================================ -export const MOCK_MEDIA: Record = { - "K-POP": [ - { - id: "newjeans", - name: "NewJeans", - nameKo: "뉴진스", - type: "group", - category: "K-POP", - imageUrl: null, - postCount: 150, - }, - { - id: "blackpink", - name: "BLACKPINK", - nameKo: "블랙핑크", - type: "group", - category: "K-POP", - imageUrl: null, - postCount: 120, - }, - { - id: "ive", - name: "IVE", - nameKo: "아이브", - type: "group", - category: "K-POP", - imageUrl: null, - postCount: 95, - }, - { - id: "aespa", - name: "aespa", - nameKo: "에스파", - type: "group", - category: "K-POP", - imageUrl: null, - postCount: 85, - }, - { - id: "lesserafim", - name: "LE SSERAFIM", - nameKo: "르세라핌", - type: "group", - category: "K-POP", - imageUrl: null, - postCount: 75, - }, - { - id: "illit", - name: "ILLIT", - nameKo: "아일릿", - type: "group", - category: "K-POP", - imageUrl: null, - postCount: 45, - }, - ], - "K-Drama": [ - { - id: "lovely-runner", - name: "Lovely Runner", - nameKo: "선재 업고 튀어", - type: "drama", - category: "K-Drama", - imageUrl: null, - postCount: 45, - }, - { - id: "queen-of-tears", - name: "Queen of Tears", - nameKo: "눈물의 여왕", - type: "drama", - category: "K-Drama", - imageUrl: null, - postCount: 38, - }, - { - id: "business-proposal", - name: "Business Proposal", - nameKo: "사내맞선", - type: "drama", - category: "K-Drama", - imageUrl: null, - postCount: 25, - }, - ], - "K-Variety": [ - { - id: "running-man", - name: "Running Man", - nameKo: "런닝맨", - type: "show", - category: "K-Variety", - imageUrl: null, - postCount: 35, - }, - { - id: "knowing-bros", - name: "Knowing Bros", - nameKo: "아는 형님", - type: "show", - category: "K-Variety", - imageUrl: null, - postCount: 28, - }, - { - id: "hangout-with-yoo", - name: "Hangout with Yoo", - nameKo: "놀면 뭐하니", - type: "show", - category: "K-Variety", - imageUrl: null, - postCount: 22, - }, - ], - "K-Movie": [ - { - id: "exhuma", - name: "Exhuma", - nameKo: "파묘", - type: "movie", - category: "K-Movie", - imageUrl: null, - postCount: 20, - }, - { - id: "concrete-utopia", - name: "Concrete Utopia", - nameKo: "콘크리트 유토피아", - type: "movie", - category: "K-Movie", - imageUrl: null, - postCount: 15, - }, - ], - "K-Fashion": [ - { - id: "vogue-korea", - name: "Vogue Korea", - nameKo: "보그 코리아", - type: "show", - category: "K-Fashion", - imageUrl: null, - postCount: 18, - }, - { - id: "elle-korea", - name: "Elle Korea", - nameKo: "엘르 코리아", - type: "show", - category: "K-Fashion", - imageUrl: null, - postCount: 12, - }, - ], -}; - -// ============================================ -// Level 3: Cast by Media -// ============================================ -export const MOCK_CAST: Record = { - newjeans: [ - { id: "minji", name: "Minji", nameKo: "민지", profileImageUrl: null, postCount: 45 }, - { id: "hanni", name: "Hanni", nameKo: "하니", profileImageUrl: null, postCount: 42 }, - { id: "danielle", name: "Danielle", nameKo: "다니엘", profileImageUrl: null, postCount: 38 }, - { id: "haerin", name: "Haerin", nameKo: "해린", profileImageUrl: null, postCount: 35 }, - { id: "hyein", name: "Hyein", nameKo: "혜인", profileImageUrl: null, postCount: 30 }, - ], - blackpink: [ - { id: "jisoo", name: "Jisoo", nameKo: "지수", profileImageUrl: null, postCount: 35 }, - { id: "jennie", name: "Jennie", nameKo: "제니", profileImageUrl: null, postCount: 40 }, - { id: "rose", name: "Rosé", nameKo: "로제", profileImageUrl: null, postCount: 32 }, - { id: "lisa", name: "Lisa", nameKo: "리사", profileImageUrl: null, postCount: 38 }, - ], - ive: [ - { id: "yujin", name: "Yujin", nameKo: "유진", profileImageUrl: null, postCount: 25 }, - { id: "gaeul", name: "Gaeul", nameKo: "가을", profileImageUrl: null, postCount: 18 }, - { id: "rei", name: "Rei", nameKo: "레이", profileImageUrl: null, postCount: 20 }, - { id: "wonyoung", name: "Wonyoung", nameKo: "원영", profileImageUrl: null, postCount: 35 }, - { id: "liz", name: "Liz", nameKo: "리즈", profileImageUrl: null, postCount: 15 }, - { id: "leeseo", name: "Leeseo", nameKo: "이서", profileImageUrl: null, postCount: 12 }, - ], - aespa: [ - { id: "karina", name: "Karina", nameKo: "카리나", profileImageUrl: null, postCount: 30 }, - { id: "giselle", name: "Giselle", nameKo: "지젤", profileImageUrl: null, postCount: 22 }, - { id: "winter", name: "Winter", nameKo: "윈터", profileImageUrl: null, postCount: 25 }, - { id: "ningning", name: "Ningning", nameKo: "닝닝", profileImageUrl: null, postCount: 18 }, - ], - lesserafim: [ - { id: "sakura", name: "Sakura", nameKo: "사쿠라", profileImageUrl: null, postCount: 22 }, - { id: "chaewon", name: "Chaewon", nameKo: "채원", profileImageUrl: null, postCount: 28 }, - { id: "yunjin", name: "Yunjin", nameKo: "윤진", profileImageUrl: null, postCount: 20 }, - { id: "kazuha", name: "Kazuha", nameKo: "카즈하", profileImageUrl: null, postCount: 25 }, - { id: "eunchae", name: "Eunchae", nameKo: "은채", profileImageUrl: null, postCount: 15 }, - ], - illit: [ - { id: "yunah", name: "Yunah", nameKo: "윤아", profileImageUrl: null, postCount: 12 }, - { id: "minju", name: "Minju", nameKo: "민주", profileImageUrl: null, postCount: 15 }, - { id: "moka", name: "Moka", nameKo: "모카", profileImageUrl: null, postCount: 10 }, - { id: "wonhee", name: "Wonhee", nameKo: "원희", profileImageUrl: null, postCount: 8 }, - { id: "iroha", name: "Iroha", nameKo: "이로하", profileImageUrl: null, postCount: 10 }, - ], - "lovely-runner": [ - { id: "byeon-wooseok", name: "Byeon Woo-seok", nameKo: "변우석", profileImageUrl: null, postCount: 25 }, - { id: "kim-hyeyoon", name: "Kim Hye-yoon", nameKo: "김혜윤", profileImageUrl: null, postCount: 20 }, - ], - "queen-of-tears": [ - { id: "kim-soohyun", name: "Kim Soo-hyun", nameKo: "김수현", profileImageUrl: null, postCount: 22 }, - { id: "kim-jiwon", name: "Kim Ji-won", nameKo: "김지원", profileImageUrl: null, postCount: 18 }, - ], -}; - -// ============================================ -// Level 4: Context Options (Static) -// ============================================ -export const CONTEXT_OPTIONS: ContextOption[] = [ - { id: "airport", label: "Airport", labelKo: "공항패션" }, - { id: "stage", label: "Stage", labelKo: "무대" }, - { id: "mv", label: "Music Video", labelKo: "뮤비" }, - { id: "drama_scene", label: "Drama Scene", labelKo: "드라마" }, - { id: "variety", label: "Variety Show", labelKo: "예능" }, - { id: "photoshoot", label: "Photoshoot", labelKo: "화보" }, - { id: "daily", label: "Daily", labelKo: "일상" }, - { id: "event", label: "Event", labelKo: "행사" }, -]; - -// ============================================ -// Helper Functions (Mock API simulation) -// ============================================ -export function getMockCategories(): CategoryOption[] { - return MOCK_CATEGORIES; -} - -export function getMockMediaByCategory(category: CategoryType): MediaOption[] { - return MOCK_MEDIA[category] || []; -} - -export function getMockCastByMedia(mediaId: string): CastOption[] { - return MOCK_CAST[mediaId] || []; -} - -export function getMockContextOptions(): ContextOption[] { - return CONTEXT_OPTIONS; -} - -// Search within options -export function searchMockMedia( - category: CategoryType, - query: string -): MediaOption[] { - const media = MOCK_MEDIA[category] || []; - const lowerQuery = query.toLowerCase(); - return media.filter( - (m) => - m.name.toLowerCase().includes(lowerQuery) || - m.nameKo.includes(query) - ); -} - -export function searchMockCast(mediaId: string, query: string): CastOption[] { - const cast = MOCK_CAST[mediaId] || []; - const lowerQuery = query.toLowerCase(); - return cast.filter( - (c) => - c.name.toLowerCase().includes(lowerQuery) || - c.nameKo.includes(query) - ); -} diff --git a/packages/shared/index.ts b/packages/shared/index.ts index e4688030..de20d004 100644 --- a/packages/shared/index.ts +++ b/packages/shared/index.ts @@ -30,31 +30,10 @@ export type { } from "./stores/searchStore"; export { useRecentSearchesStore } from "./stores/recentSearchesStore"; export type { RecentSearchesStore } from "./stores/recentSearchesStore"; -export { - useHierarchicalFilterStore, - CATEGORY_LABELS, - CONTEXT_LABELS, -} from "./stores/hierarchicalFilterStore"; -export type { HierarchicalFilterState } from "./stores/hierarchicalFilterStore"; - // Types export * from "./types/filter"; export * from "./types/search"; -// Mock data (for development) -export { - getMockCategories, - getMockMediaByCategory, - getMockCastByMedia, - getMockContextOptions, - searchMockMedia, - searchMockCast, - MOCK_CATEGORIES, - MOCK_MEDIA, - MOCK_CAST, - CONTEXT_OPTIONS, -} from "./data/mockFilterData"; - // Query functions (for direct use) export { fetchLatestImages, diff --git a/packages/shared/stores/hierarchicalFilterStore.ts b/packages/shared/stores/hierarchicalFilterStore.ts deleted file mode 100644 index d10133cf..00000000 --- a/packages/shared/stores/hierarchicalFilterStore.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { create } from "zustand"; -import { persist, createJSONStorage } from "zustand/middleware"; -import type { - CategoryType, - ContextType, - FilterBreadcrumb, - FilterLevel, - HierarchicalFilterSelection, - HierarchicalFilterMultiSelection, -} from "../types/filter"; - -// ============================================ -// Store State Interface -// ============================================ -export interface HierarchicalFilterState - extends HierarchicalFilterSelection, - HierarchicalFilterMultiSelection { - // Breadcrumb navigation - breadcrumb: FilterBreadcrumb[]; - - // UI state - isFilterOpen: boolean; - activeFilterLevel: FilterLevel | null; - - // Actions - Single selection - setCategory: (category: CategoryType | null, label?: string) => void; - setMedia: (mediaId: string | null, label?: string, labelKo?: string) => void; - setCast: (castId: string | null, label?: string, labelKo?: string) => void; - setContext: (contextType: ContextType | null, label?: string) => void; - - // Actions - Multi selection toggle - toggleMediaSelection: (mediaId: string) => void; - toggleCastSelection: (castId: string) => void; - toggleContextSelection: (contextType: ContextType) => void; - - // Actions - Navigation - clearAll: () => void; - navigateToBreadcrumb: (level: number) => void; - - // Actions - UI state - setFilterOpen: (open: boolean) => void; - setActiveFilterLevel: (level: FilterLevel | null) => void; - - // Computed helpers - hasActiveFilters: () => boolean; - getActiveFilterCount: () => number; -} - -// ============================================ -// Initial State -// ============================================ -const initialState: Omit< - HierarchicalFilterState, - | "setCategory" - | "setMedia" - | "setCast" - | "setContext" - | "toggleMediaSelection" - | "toggleCastSelection" - | "toggleContextSelection" - | "clearAll" - | "navigateToBreadcrumb" - | "setFilterOpen" - | "setActiveFilterLevel" - | "hasActiveFilters" - | "getActiveFilterCount" -> = { - // Single selection - category: null, - mediaId: null, - castId: null, - contextType: null, - - // Multi selection - selectedMediaIds: [], - selectedCastIds: [], - selectedContextTypes: [], - - // Breadcrumb - breadcrumb: [], - - // UI state - isFilterOpen: false, - activeFilterLevel: null, -}; - -// ============================================ -// Category Labels (for breadcrumb) -// ============================================ -const CATEGORY_LABELS: Record = - { - "K-POP": { label: "K-POP", labelKo: "케이팝" }, - "K-Drama": { label: "K-Drama", labelKo: "드라마" }, - "K-Movie": { label: "K-Movie", labelKo: "영화" }, - "K-Variety": { label: "K-Variety", labelKo: "예능" }, - "K-Fashion": { label: "K-Fashion", labelKo: "패션" }, - }; - -const CONTEXT_LABELS: Record = { - airport: { label: "Airport", labelKo: "공항패션" }, - stage: { label: "Stage", labelKo: "무대" }, - mv: { label: "Music Video", labelKo: "뮤비" }, - drama_scene: { label: "Drama", labelKo: "드라마" }, - variety: { label: "Variety", labelKo: "예능" }, - photoshoot: { label: "Photoshoot", labelKo: "화보" }, - daily: { label: "Daily", labelKo: "일상" }, - event: { label: "Event", labelKo: "행사" }, -}; - -// ============================================ -// Store Implementation -// ============================================ -export const useHierarchicalFilterStore = create()( - persist( - (set, get) => ({ - ...initialState, - - // ========== Single Selection Actions ========== - - setCategory: (category, label) => - set((state) => { - if (category === null) { - return { - ...initialState, - isFilterOpen: state.isFilterOpen, - activeFilterLevel: state.activeFilterLevel, - }; - } - - const categoryLabels = CATEGORY_LABELS[category]; - const breadcrumb: FilterBreadcrumb[] = [ - { - level: 1, - type: "category", - id: category, - label: label || categoryLabels.label, - labelKo: categoryLabels.labelKo, - }, - ]; - - return { - category, - mediaId: null, - castId: null, - contextType: null, - selectedMediaIds: [], - selectedCastIds: [], - selectedContextTypes: [], - breadcrumb, - }; - }), - - setMedia: (mediaId, label, labelKo) => - set((state) => { - if (mediaId === null) { - // Clear media and below, keep category - const breadcrumb = state.breadcrumb.filter((b) => b.level < 2); - return { - mediaId: null, - castId: null, - contextType: null, - selectedCastIds: [], - selectedContextTypes: [], - breadcrumb, - }; - } - - const breadcrumb: FilterBreadcrumb[] = [ - ...state.breadcrumb.filter((b) => b.level < 2), - { - level: 2, - type: "media", - id: mediaId, - label: label || mediaId, - labelKo: labelKo || label || mediaId, - }, - ]; - - return { - mediaId, - castId: null, - contextType: null, - selectedCastIds: [], - selectedContextTypes: [], - breadcrumb, - }; - }), - - setCast: (castId, label, labelKo) => - set((state) => { - if (castId === null) { - // Clear cast and below, keep category + media - const breadcrumb = state.breadcrumb.filter((b) => b.level < 3); - return { - castId: null, - contextType: null, - selectedContextTypes: [], - breadcrumb, - }; - } - - const breadcrumb: FilterBreadcrumb[] = [ - ...state.breadcrumb.filter((b) => b.level < 3), - { - level: 3, - type: "cast", - id: castId, - label: label || castId, - labelKo: labelKo || label || castId, - }, - ]; - - return { - castId, - contextType: null, - selectedContextTypes: [], - breadcrumb, - }; - }), - - setContext: (contextType, label) => - set((state) => { - if (contextType === null) { - // Clear context, keep category + media + cast - const breadcrumb = state.breadcrumb.filter((b) => b.level < 4); - return { - contextType: null, - breadcrumb, - }; - } - - const contextLabels = CONTEXT_LABELS[contextType]; - const breadcrumb: FilterBreadcrumb[] = [ - ...state.breadcrumb.filter((b) => b.level < 4), - { - level: 4, - type: "context", - id: contextType, - label: label || contextLabels.label, - labelKo: contextLabels.labelKo, - }, - ]; - - return { - contextType, - breadcrumb, - }; - }), - - // ========== Multi Selection Toggle Actions ========== - - toggleMediaSelection: (mediaId) => - set((state) => { - const isSelected = state.selectedMediaIds.includes(mediaId); - return { - selectedMediaIds: isSelected - ? state.selectedMediaIds.filter((id) => id !== mediaId) - : [...state.selectedMediaIds, mediaId], - }; - }), - - toggleCastSelection: (castId) => - set((state) => { - const isSelected = state.selectedCastIds.includes(castId); - return { - selectedCastIds: isSelected - ? state.selectedCastIds.filter((id) => id !== castId) - : [...state.selectedCastIds, castId], - }; - }), - - toggleContextSelection: (contextType) => - set((state) => { - const isSelected = state.selectedContextTypes.includes(contextType); - return { - selectedContextTypes: isSelected - ? state.selectedContextTypes.filter((t) => t !== contextType) - : [...state.selectedContextTypes, contextType], - }; - }), - - // ========== Navigation Actions ========== - - clearAll: () => - set((state) => ({ - ...initialState, - isFilterOpen: state.isFilterOpen, - activeFilterLevel: null, - })), - - navigateToBreadcrumb: (level) => - set((state) => { - const breadcrumb = state.breadcrumb.filter((b) => b.level <= level); - - // Clear selections below the navigated level - const updates: Partial = { breadcrumb }; - - if (level < 4) { - updates.contextType = null; - updates.selectedContextTypes = []; - } - if (level < 3) { - updates.castId = null; - updates.selectedCastIds = []; - } - if (level < 2) { - updates.mediaId = null; - updates.selectedMediaIds = []; - } - if (level < 1) { - updates.category = null; - } - - return updates; - }), - - // ========== UI State Actions ========== - - setFilterOpen: (open) => set({ isFilterOpen: open }), - - setActiveFilterLevel: (level) => set({ activeFilterLevel: level }), - - // ========== Computed Helpers ========== - - hasActiveFilters: () => { - const state = get(); - return !!( - state.category || - state.mediaId || - state.castId || - state.contextType || - state.selectedMediaIds.length > 0 || - state.selectedCastIds.length > 0 || - state.selectedContextTypes.length > 0 - ); - }, - - getActiveFilterCount: () => { - const state = get(); - let count = 0; - if (state.category) count++; - if (state.mediaId) count++; - if (state.castId) count++; - if (state.contextType) count++; - count += state.selectedMediaIds.length; - count += state.selectedCastIds.length; - count += state.selectedContextTypes.length; - return count; - }, - }), - { - name: "hierarchical-filter-storage", - storage: createJSONStorage(() => localStorage), - partialize: (state) => ({ - category: state.category, - mediaId: state.mediaId, - castId: state.castId, - contextType: state.contextType, - selectedMediaIds: state.selectedMediaIds, - selectedCastIds: state.selectedCastIds, - selectedContextTypes: state.selectedContextTypes, - breadcrumb: state.breadcrumb, - }), - } - ) -); - -// ============================================ -// Export Context Labels for UI -// ============================================ -export { CATEGORY_LABELS, CONTEXT_LABELS }; diff --git a/packages/web/app/@modal/(.)posts/[id]/page.tsx b/packages/web/app/@modal/(.)posts/[id]/page.tsx index d22f5bd5..2b85a7c7 100644 --- a/packages/web/app/@modal/(.)posts/[id]/page.tsx +++ b/packages/web/app/@modal/(.)posts/[id]/page.tsx @@ -2,13 +2,20 @@ import { ImageDetailModal } from "@/lib/components/detail/ImageDetailModal"; type Props = { params: Promise<{ id: string }>; + searchParams: Promise<{ from?: string }>; }; /** * Intercepting route for /posts/[id] - * Renders as modal overlay when navigating from grid (e.g. /images) + * Renders as modal overlay when navigating from grid + * Uses explore-preview variant when navigated from /explore */ -export default async function ModalPostDetailPage({ params }: Props) { +export default async function ModalPostDetailPage({ + params, + searchParams, +}: Props) { const { id } = await params; - return ; + const { from } = await searchParams; + const variant = from === "explore" ? "explore-preview" : "full"; + return ; } diff --git a/packages/web/app/api/v1/posts/route.ts b/packages/web/app/api/v1/posts/route.ts index d6c8bb2f..bcbf2519 100644 --- a/packages/web/app/api/v1/posts/route.ts +++ b/packages/web/app/api/v1/posts/route.ts @@ -8,6 +8,7 @@ import { NextRequest, NextResponse } from "next/server"; import { API_BASE_URL } from "@/lib/server-env"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; /** * GET /api/v1/posts @@ -43,17 +44,114 @@ export async function GET(request: NextRequest) { }; } - // Return the response preserving the backend's status code - return NextResponse.json(data, { status: response.status }); + // Backend succeeded → return as-is + if (response.ok) { + return NextResponse.json(data, { status: response.status }); + } + + // Backend failed → fall back to Supabase + console.warn(`[Posts GET] Backend returned ${response.status}, falling back to Supabase`); + return supabaseFallback(searchParams); } catch (error) { + // Backend unreachable → fall back to Supabase if (process.env.NODE_ENV === "development") { - console.error("Posts GET proxy error:", error); + console.warn("Posts GET proxy error, falling back to Supabase:", error); } - return NextResponse.json( - { - message: error instanceof Error ? error.message : "Proxy error", + const { searchParams } = new URL(request.url); + return supabaseFallback(searchParams); + } +} + +/** + * Supabase fallback for GET /api/v1/posts + * Returns PaginatedResponsePostListItem-compatible shape + */ +async function supabaseFallback(searchParams: URLSearchParams) { + try { + const supabase = await createSupabaseServerClient(); + + const page = Math.max(Number(searchParams.get("page")) || 1, 1); + const perPage = Math.min(Number(searchParams.get("per_page")) || 40, 100); + const sort = searchParams.get("sort") || "recent"; + const artistName = searchParams.get("artist_name"); + const groupName = searchParams.get("group_name"); + const context = searchParams.get("context"); + const hasMagazine = searchParams.get("has_magazine") === "true"; + + const from = (page - 1) * perPage; + const to = from + perPage - 1; + + let query = supabase + .from("posts") + .select("*, users:user_id(id, username, avatar_url, rank), post_magazines:post_magazine_id(title)", { count: "exact" }) + .eq("status", "active") + .not("image_url", "is", null); + + if (hasMagazine) { + query = query.not("post_magazine_id", "is", null); + } + if (artistName) { + query = query.ilike("artist_name", `%${artistName}%`); + } + if (groupName) { + query = query.ilike("group_name", `%${groupName}%`); + } + if (context) { + query = query.eq("context", context); + } + + // Sort + if (sort === "popular") { + query = query.order("view_count", { ascending: false }); + } else if (sort === "trending") { + query = query.order("trending_score", { ascending: false }); + } else { + query = query.order("created_at", { ascending: false }); + } + + query = query.range(from, to); + + const { data: posts, count, error } = await query; + + if (error) { + return NextResponse.json({ message: error.message }, { status: 500 }); + } + + const totalItems = count ?? 0; + const totalPages = Math.ceil(totalItems / perPage); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = (posts ?? []).map((post: any) => ({ + id: post.id, + image_url: post.image_url, + artist_name: post.artist_name ?? null, + group_name: post.group_name ?? null, + context: post.context ?? null, + created_at: post.created_at, + view_count: post.view_count ?? 0, + spot_count: 0, + comment_count: 0, + title: post.post_magazines?.title ?? null, + post_magazine_title: post.post_magazines?.title ?? null, + media_source: { type: post.media_type ?? "unknown", description: null }, + user: post.users + ? { id: post.users.id, username: post.users.username ?? "", avatar_url: post.users.avatar_url ?? null, rank: post.users.rank ?? "member" } + : { id: post.user_id, username: "", avatar_url: null, rank: "member" }, + })); + + return NextResponse.json({ + data, + pagination: { + current_page: page, + per_page: perPage, + total_items: totalItems, + total_pages: totalPages, }, - { status: 502 } + }); + } catch (error) { + return NextResponse.json( + { message: error instanceof Error ? error.message : "Supabase fallback error" }, + { status: 500 } ); } } diff --git a/packages/web/app/api/v1/search/route.ts b/packages/web/app/api/v1/search/route.ts new file mode 100644 index 00000000..4c97de5a --- /dev/null +++ b/packages/web/app/api/v1/search/route.ts @@ -0,0 +1,138 @@ +/** + * Search Proxy API Route + * GET /api/v1/search - Unified search (Supabase text search fallback) + * + * Proxies to backend Meilisearch API. Falls back to Supabase ilike search + * when backend is unavailable. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { API_BASE_URL } from "@/lib/server-env"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + + // Try backend first + try { + const queryString = searchParams.toString(); + const url = queryString + ? `${API_BASE_URL}/api/v1/search?${queryString}` + : `${API_BASE_URL}/api/v1/search`; + + const response = await fetch(url, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + if (response.ok) { + const responseText = await response.text(); + let data; + try { + data = JSON.parse(responseText); + } catch { + data = { message: `Backend error: ${response.status}` }; + } + return NextResponse.json(data, { status: response.status }); + } + + console.warn(`[Search GET] Backend returned ${response.status}, falling back to Supabase`); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.warn("Search proxy error, falling back to Supabase:", error); + } + } + + // Supabase fallback: ilike text search on posts + return supabaseSearchFallback(searchParams); +} + +/** + * Supabase fallback for GET /api/v1/search + * Returns SearchResponse-compatible shape using ilike text matching + */ +async function supabaseSearchFallback(searchParams: URLSearchParams) { + try { + const supabase = await createSupabaseServerClient(); + + const q = searchParams.get("q") || ""; + const context = searchParams.get("context"); + const mediaType = searchParams.get("media_type"); + const page = Math.max(Number(searchParams.get("page")) || 1, 1); + const limit = Math.min(Number(searchParams.get("limit")) || 40, 100); + + const from = (page - 1) * limit; + const to = from + limit - 1; + const startTime = Date.now(); + + let query = supabase + .from("posts") + .select("*", { count: "exact" }) + .eq("status", "active") + .not("image_url", "is", null); + + // Text search: match against artist_name, group_name, or context + // Sanitize to prevent PostgREST filter injection via special chars + if (q.trim()) { + const sanitized = q.replace(/[%.,()"'\\]/g, ""); + if (sanitized) { + query = query.or( + `artist_name.ilike.%${sanitized}%,group_name.ilike.%${sanitized}%,context.ilike.%${sanitized}%` + ); + } + } + + if (context) { + query = query.eq("context", context); + } + if (mediaType) { + query = query.ilike("group_name", `%${mediaType}%`); + } + + query = query.order("view_count", { ascending: false }).range(from, to); + + const { data: posts, count, error } = await query; + + if (error) { + return NextResponse.json({ message: error.message }, { status: 500 }); + } + + const totalItems = count ?? 0; + const totalPages = Math.ceil(totalItems / limit); + const tookMs = Date.now() - startTime; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = (posts ?? []).map((post: any) => ({ + id: post.id, + image_url: post.image_url, + artist_name: post.artist_name ?? null, + group_name: post.group_name ?? null, + context: post.context ?? null, + spot_count: 0, + view_count: post.view_count ?? 0, + type: "post", + media_source: post.media_type + ? { type: post.media_type, title: post.media_title ?? null } + : null, + highlight: null, + })); + + return NextResponse.json({ + data, + query: q, + took_ms: tookMs, + facets: { category: null, context: null, media_type: null }, + pagination: { + current_page: page, + per_page: limit, + total_items: totalItems, + total_pages: totalPages, + }, + }); + } catch (error) { + return NextResponse.json( + { message: error instanceof Error ? error.message : "Search fallback error" }, + { status: 500 } + ); + } +} diff --git a/packages/web/app/explore/ExploreClient.tsx b/packages/web/app/explore/ExploreClient.tsx index 4d6647b0..4d309e41 100644 --- a/packages/web/app/explore/ExploreClient.tsx +++ b/packages/web/app/explore/ExploreClient.tsx @@ -1,54 +1,138 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useRef, useCallback } from "react"; import { AnimatePresence, motion } from "motion/react"; -import { useInfinitePosts, type PostGridItem } from "@/lib/hooks/useImages"; +import { Search, X, ChevronDown } from "lucide-react"; +import { useDebounce, useRecentSearchesStore } from "@decoded/shared"; +import { useExploreData } from "@/lib/hooks/useExploreData"; +import type { PostGridItem } from "@/lib/hooks/useImages"; +import { FilterChip } from "@/lib/components/explore/FilterChip"; import ThiingsGrid, { type GridItem } from "@/lib/components/ThiingsGrid"; import { useSearchStore } from "@/lib/stores/searchStore"; -import { - ExploreCardCell, - ExploreSkeletonCell, - TrendingArtistsSection, -} from "@/lib/components/explore"; +import { ExploreCardCell, ExploreSkeletonCell } from "@/lib/components/explore"; import { LoadingSpinner } from "@/lib/design-system"; +import { SearchSuggestions } from "@/lib/components/search/SearchSuggestions"; +import { cn } from "@/lib/utils"; + +const SORT_OPTIONS = [ + { value: "relevant", label: "Relevant" }, + { value: "recent", label: "Recent" }, + { value: "popular", label: "Popular" }, + { value: "solution_count", label: "Most Solutions" }, +] as const; type Props = { initialPosts?: PostGridItem[]; - /** magazine_id가 있는 post만 표시 (Editorial 탭용) */ hasMagazine?: boolean; + initialQuery?: string; }; -/** - * Explore Client Component - Pinterest-style Masonry Grid - * - * Uses REST API for data fetching: - * - GET /api/v1/posts with pagination - * - Supports category filtering via API params - */ -export function ExploreClient({ initialPosts: _initialPosts, hasMagazine }: Props) { +export function ExploreClient({ + initialPosts: _initialPosts, + hasMagazine, + initialQuery = "", +}: Props) { const debouncedQuery = useSearchStore((state) => state.debouncedQuery); + const query = useSearchStore((state) => state.query); + const setQuery = useSearchStore((state) => state.setQuery); + const setDebouncedQuery = useSearchStore((state) => state.setDebouncedQuery); + + const inputRef = useRef(null); + const containerRef = useRef(null); + const gridRef = useRef(null); + + const debouncedValue = useDebounce(query, 300); + useEffect(() => { + setDebouncedQuery(debouncedValue); + }, [debouncedValue, setDebouncedQuery]); + + // Initialize from server-provided URL query + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + if (initialQuery && !query) { + setQuery(initialQuery); + setDebouncedQuery(initialQuery); + } + }, []); + + const [showSuggestions, setShowSuggestions] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const addRecentSearch = useRecentSearchesStore((s) => s.addSearch); + + useEffect(() => { + if (query.length > 0) { + setShowSuggestions(true); + setSelectedIndex(-1); + } else { + setShowSuggestions(false); + } + }, [query]); + + useEffect(() => { + if (!showSuggestions) return; + const handleClickOutside = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setShowSuggestions(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showSuggestions]); + + const handleSuggestionSelect = useCallback( + (selectedQuery: string) => { + setQuery(selectedQuery); + setDebouncedQuery(selectedQuery); + setShowSuggestions(false); + addRecentSearch(selectedQuery); + inputRef.current?.blur(); + }, + [setQuery, setDebouncedQuery, addRecentSearch], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!showSuggestions) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((prev) => prev + 1); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((prev) => Math.max(-1, prev - 1)); + } else if (e.key === "Escape") { + setShowSuggestions(false); + } + }, + [showSuggestions], + ); + + const handleClear = useCallback(() => { + setQuery(""); + setDebouncedQuery(""); + setShowSuggestions(false); + inputRef.current?.focus(); + }, [setQuery, setDebouncedQuery]); - // Responsive grid size: smaller on mobile, larger on desktop + // Responsive grid size const [gridSize, setGridSize] = useState({ width: 400, height: 500 }); useEffect(() => { const updateGridSize = () => { - const isMobile = window.innerWidth < 768; // md breakpoint + const isMobile = window.innerWidth < 768; setGridSize( - isMobile - ? { width: 180, height: 225 } // Mobile: smaller cells - : { width: 400, height: 500 } // Desktop: original size + isMobile ? { width: 180, height: 225 } : { width: 400, height: 500 }, ); }; - updateGridSize(); window.addEventListener("resize", updateGridSize); return () => window.removeEventListener("resize", updateGridSize); }, []); - // Use the REST API hook for fetching posts const { - data, + items, isLoading, isError, error, @@ -56,17 +140,33 @@ export function ExploreClient({ initialPosts: _initialPosts, hasMagazine }: Prop fetchNextPage, hasNextPage, isFetchingNextPage, - } = useInfinitePosts({ - limit: 40, + mode, + artistFacets, + contextFacets, + selectedArtists, + toggleArtist, + clearArtistFilters, + activeContext, + setContext, + activeSort, + setSort, + } = useExploreData({ hasMagazine: hasMagazine ?? false, }); - // Flatten pages into a single items array - const items: PostGridItem[] = useMemo(() => { - return data ? data.pages.flatMap((page) => page.items) : []; - }, [data]); + // Force card visibility — ThiingsGrid's IntersectionObserver may not fire on initial mount + useEffect(() => { + if (!gridRef.current) return; + const timer = setTimeout(() => { + const cards = gridRef.current?.querySelectorAll('.js-observe'); + cards?.forEach(el => { + el.classList.add('is-visible'); + el.classList.remove('is-hidden'); + }); + }, 800); // Wait for physics engine to settle + return () => clearTimeout(timer); + }, [items.length]); // Re-run when items change - // Map PostGridItem to GridItem[] const gridItems: GridItem[] = useMemo(() => { return items .filter((item) => item.imageUrl != null) @@ -77,31 +177,159 @@ export function ExploreClient({ initialPosts: _initialPosts, hasMagazine }: Prop postSource: item.postSource, postAccount: item.postAccount, postCreatedAt: item.postCreatedAt, - ...(hasMagazine && - item.title != null && { editorialTitle: item.title }), + ...(item.title != null && { editorialTitle: item.title }), ...(item.spotCount != null && item.spotCount > 0 && { spotCount: item.spotCount }), + ...(item.highlight && { highlight: item.highlight }), })); }, [items, hasMagazine]); - // Render full-screen ThiingsGrid with filter bar + // Context dropdown options from facets + const contextOptions = useMemo(() => { + return Object.entries(contextFacets) + .sort(([, a], [, b]) => b - a) + .slice(0, 10); + }, [contextFacets]); + + // Artist badges sorted by count + const artistBadges = useMemo(() => { + return Object.entries(artistFacets) + .sort(([, a], [, b]) => b - a) + .slice(0, 8); + }, [artistFacets]); + + const hasActiveFilters = + selectedArtists.length > 0 || activeContext !== null || activeSort !== "relevant"; + return (
- {/* Trending artists section — only shown on Explore tab, not Editorial */} - {!hasMagazine && } + {/* Search input */} +
+
+ + setQuery(e.target.value)} + onFocus={() => query.length > 0 && setShowSuggestions(true)} + onKeyDown={handleKeyDown} + placeholder="Search people, shows, items..." + className="w-full rounded-full border border-border bg-card/80 py-2 pl-10 pr-10 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" + /> + {query.length > 0 && ( + + )} +
+ {showSuggestions && ( +
+ setShowSuggestions(false)} + /> +
+ )} +
+ + {/* Filter bar — Sort always visible, Context + artist badges in search mode */} +
+ {/* Sort dropdown — always visible */} +
+ + +
+ + {/* Context dropdown — search mode only */} + {mode === "search" && contextOptions.length > 0 && ( +
+ + +
+ )} + + {/* Artist badges — search mode only */} + {mode === "search" && + artistBadges.map(([name, count]) => ( + toggleArtist(name)} + onRemove={() => toggleArtist(name)} + /> + ))} + + {/* Clear all — when any filter is active */} + {hasActiveFilters && ( + + )} +
-
+
- {/* Loading state: show skeleton grid (only on initial load) */} - {isLoading && !data && ( + {/* Loading state */} + {isLoading && items.length === 0 && (
)} - {/* Error state: show error message with retry button */} + {/* Error state */} {isError && (
@@ -123,18 +351,13 @@ export function ExploreClient({ initialPosts: _initialPosts, hasMagazine }: Prop

{(() => { - // Log error for debugging console.error( "[ExploreClient] Posts fetch error:", - error + error, ); - // Display appropriate error message - if (error instanceof Error) { - return error.message; - } - if (typeof error === "object" && error !== null) { + if (error instanceof Error) return error.message; + if (typeof error === "object" && error !== null) return JSON.stringify(error); - } return "Something went wrong while loading posts."; })()}

@@ -149,7 +372,7 @@ export function ExploreClient({ initialPosts: _initialPosts, hasMagazine }: Prop
)} - {/* Empty state: show empty state message */} + {/* Empty state */} {!isError && !isLoading && items.length === 0 && (
@@ -168,7 +391,7 @@ export function ExploreClient({ initialPosts: _initialPosts, hasMagazine }: Prop
)} - {/* Success state: show grid with actual posts */} + {/* Grid */} {!isError && items.length > 0 && (
- {/* Loading indicator for next page */} {isFetchingNextPage && (
diff --git a/packages/web/app/explore/page.tsx b/packages/web/app/explore/page.tsx index 77100da4..2a5ac89a 100644 --- a/packages/web/app/explore/page.tsx +++ b/packages/web/app/explore/page.tsx @@ -1,5 +1,15 @@ +import { Suspense } from "react"; import { ExploreClient } from "./ExploreClient"; -export default function ExplorePage() { - return ; +type Props = { + searchParams: Promise<{ q?: string }>; +}; + +export default async function ExplorePage({ searchParams }: Props) { + const { q } = await searchParams; + return ( + + + + ); } diff --git a/packages/web/app/search/SearchPageClient.tsx b/packages/web/app/search/SearchPageClient.tsx deleted file mode 100644 index 11d9f990..00000000 --- a/packages/web/app/search/SearchPageClient.tsx +++ /dev/null @@ -1,139 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { ArrowLeft } from "lucide-react"; -import { useSearchStore } from "@decoded/shared"; -import type { SearchTab } from "@decoded/shared/types/search"; -import { useSearchURLSync } from "../../lib/hooks/useSearchURLSync"; -import { useGroupedSearch } from "../../lib/hooks/useSearch"; -import { - SearchInput, - SearchTabs, - SearchResults, - RecentSearches, - TrendingSearches, -} from "../../lib/components/search"; - -interface SearchPageClientProps { - initialQuery: string; - initialTab: string; -} - -export function SearchPageClient({ - initialQuery, - initialTab, -}: SearchPageClientProps) { - const router = useRouter(); - - // Initialize store with URL params on mount - const query = useSearchStore((s) => s.query); - const setQuery = useSearchStore((s) => s.setQuery); - const setDebouncedQuery = useSearchStore((s) => s.setDebouncedQuery); - const setActiveTab = useSearchStore((s) => s.setActiveTab); - const debouncedQuery = useSearchStore((s) => s.debouncedQuery); - const activeTab = useSearchStore((s) => s.activeTab); - const filters = useSearchStore((s) => s.filters); - - // Sync URL with store - useSearchURLSync({ skipInitialSync: true }); - - // Initialize from props on mount - useEffect(() => { - if (initialQuery) { - setQuery(initialQuery); - setDebouncedQuery(initialQuery); - } - if ( - initialTab && - ["all", "people", "media", "items"].includes(initialTab) - ) { - setActiveTab(initialTab as SearchTab); - } - }, []); // Only run once on mount - - // Handle recent search selection - const handleRecentSelect = (searchQuery: string) => { - setQuery(searchQuery); - }; - - // Fetch search results - const { data, groupedData, isLoading, isError } = useGroupedSearch({ - query: debouncedQuery, - tab: activeTab, - category: filters.category, - mediaType: filters.mediaType, - context: filters.context, - hasAdopted: filters.hasAdopted, - sort: filters.sort, - enabled: debouncedQuery.length >= 2, - }); - - return ( -
- {/* Header: back button + search input */} -
- -
- -
-
- - {/* Content: recent searches or results */} -
-
- {!query ? ( - // Show recent searches + trending when no query -
- - -
- ) : ( - <> - {/* Query Display */} - {debouncedQuery && ( -
-

- Results for “{debouncedQuery}” -

- {data?.pagination && ( -

- {data.pagination.total_items} results found - {data.took_ms && ` in ${data.took_ms}ms`} -

- )} -
- )} - - {/* Tabs */} - - - {/* Results */} - - - )} -
-
-
- ); -} diff --git a/packages/web/app/search/page.tsx b/packages/web/app/search/page.tsx index 32f60c3b..ee7945d3 100644 --- a/packages/web/app/search/page.tsx +++ b/packages/web/app/search/page.tsx @@ -1,49 +1,11 @@ -import type { Metadata } from "next"; -import { Suspense } from "react"; -import { SearchPageClient } from "./SearchPageClient"; +import { redirect } from "next/navigation"; -export const metadata: Metadata = { - title: "Search | Decoded", - description: "Search for people, media, and items on Decoded", +type Props = { + searchParams: Promise<{ q?: string }>; }; -interface SearchPageProps { - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; -} - -export default async function SearchPage({ searchParams }: SearchPageProps) { - const params = await searchParams; - const query = typeof params.q === "string" ? params.q : ""; - const tab = typeof params.tab === "string" ? params.tab : "all"; - - return ( - }> - - - ); -} - -function SearchPageSkeleton() { - return ( -
-
- {/* Search input skeleton */} -
- - {/* Tabs skeleton */} -
- {[1, 2, 3, 4].map((i) => ( -
- ))} -
- - {/* Results skeleton */} -
- {[1, 2, 3].map((i) => ( -
- ))} -
-
-
- ); +export default async function SearchPage({ searchParams }: Props) { + const { q } = await searchParams; + const target = q ? `/explore?q=${encodeURIComponent(q)}` : "/explore"; + redirect(target); } diff --git a/packages/web/lib/components/ASCIIText.tsx b/packages/web/lib/components/ASCIIText.tsx index b819d142..f529be3b 100644 --- a/packages/web/lib/components/ASCIIText.tsx +++ b/packages/web/lib/components/ASCIIText.tsx @@ -143,12 +143,15 @@ class AsciiFilter { this.pre.style.padding = "0"; this.pre.style.lineHeight = "1em"; this.pre.style.position = "absolute"; - this.pre.style.left = "50%"; - this.pre.style.top = "50%"; - this.pre.style.transform = "translate(-50%, -50%)"; + this.pre.style.left = "0"; + this.pre.style.top = "0"; + this.pre.style.transform = "none"; this.pre.style.zIndex = "9"; this.pre.style.backgroundAttachment = "fixed"; this.pre.style.mixBlendMode = "difference"; + this.pre.style.overflow = "hidden"; + this.pre.style.width = "100%"; + this.pre.style.height = "100%"; } } @@ -656,6 +659,9 @@ export default function ASCIIText({ position: absolute; left: 0; top: 0; + overflow: hidden; + width: 100%; + height: 100%; background-image: radial-gradient(circle, #ff6188 0%, #fc9867 50%, #ffd866 100%); background-attachment: fixed; -webkit-text-fill-color: transparent; diff --git a/packages/web/lib/components/DecodedLogo.tsx b/packages/web/lib/components/DecodedLogo.tsx index ad94ebaf..cbd54c8a 100644 --- a/packages/web/lib/components/DecodedLogo.tsx +++ b/packages/web/lib/components/DecodedLogo.tsx @@ -157,12 +157,15 @@ class AsciiFilter { this.pre.style.padding = "0"; this.pre.style.lineHeight = "1em"; this.pre.style.position = "absolute"; - this.pre.style.left = "50%"; - this.pre.style.top = "50%"; - this.pre.style.transform = "translate(-50%, -50%)"; + this.pre.style.left = "0"; + this.pre.style.top = "0"; + this.pre.style.transform = "none"; this.pre.style.zIndex = "9"; this.pre.style.backgroundAttachment = "fixed"; this.pre.style.mixBlendMode = "difference"; + this.pre.style.overflow = "hidden"; + this.pre.style.width = "100%"; + this.pre.style.height = "100%"; } } @@ -748,11 +751,13 @@ export default function DecodedLogo({ user-select: none; padding: 0; line-height: 1em; - text-align: center; + text-align: left; position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); + left: 0; + top: 0; + overflow: hidden; + width: 100%; + height: 100%; background-image: radial-gradient(circle, #d9fc69 0%, #b8d855 50%, #9ab842 100%); background-attachment: fixed; -webkit-text-fill-color: transparent; diff --git a/packages/web/lib/components/ThiingsGrid.tsx b/packages/web/lib/components/ThiingsGrid.tsx index 74917060..9863d5b7 100644 --- a/packages/web/lib/components/ThiingsGrid.tsx +++ b/packages/web/lib/components/ThiingsGrid.tsx @@ -29,6 +29,7 @@ export type GridItem = { postCreatedAt: string; editorialTitle?: string | null; spotCount?: number; + highlight?: Record | null; }; type GridItemInternal = { position: Position; gridIndex: number }; diff --git a/packages/web/lib/components/detail/EditorialPreviewHeader.tsx b/packages/web/lib/components/detail/EditorialPreviewHeader.tsx new file mode 100644 index 00000000..a49c4041 --- /dev/null +++ b/packages/web/lib/components/detail/EditorialPreviewHeader.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { ArrowRight } from "lucide-react"; +import { useTextLayout } from "@/lib/hooks/usePretext"; + +interface EditorialPreviewHeaderProps { + title: string; + subtitle?: string | null; + description?: string | null; + postId?: string | null; +} + +export function EditorialPreviewHeader({ + title, + subtitle, + description, + postId, +}: EditorialPreviewHeaderProps) { + const displayDescription = description || subtitle; + + const { containerRef: titleRef, height: titleHeight } = useTextLayout({ + text: title, + font: '700 clamp(1.5rem, 3vw, 1.875rem) "Playfair Display", serif', + lineHeight: 1.2 * 30, + }); + + return ( +
+

+ Editorial +

+

} + className="font-serif text-2xl font-bold leading-snug break-keep md:text-3xl" + style={titleHeight > 0 ? { minHeight: titleHeight } : undefined} + > + {title} +

+ {displayDescription && ( +

+ {displayDescription} +

+ )} + {postId && ( + + 전체 에디토리얼 보기 + + + )} +
+
+
+
+
+
+ ); +} diff --git a/packages/web/lib/components/detail/ImageDetailContent.tsx b/packages/web/lib/components/detail/ImageDetailContent.tsx index dd4e630f..56fd1db8 100644 --- a/packages/web/lib/components/detail/ImageDetailContent.tsx +++ b/packages/web/lib/components/detail/ImageDetailContent.tsx @@ -24,7 +24,6 @@ import type { VtonPreloadItem } from "@/lib/stores/vtonStore"; import { useCommentCount } from "@/lib/hooks/useComments"; import { usePostLike } from "@/lib/hooks/usePostLike"; import { useSavedPost } from "@/lib/hooks/useSavedPost"; -import Image from "next/image"; import { MagazineEditorialSection, MagazineCelebSection, @@ -32,7 +31,9 @@ import { MagazineRelatedSection, } from "./magazine"; import { MagazineTitleSection } from "./magazine/MagazineTitleSection"; -import { SpotDot } from "./SpotDot"; +import { EditorialPreviewHeader } from "./EditorialPreviewHeader"; +import DecodeShowcase from "@/lib/components/main-renewal/DecodeShowcase"; +import { toDecodeShowcaseData } from "./adapters/toDecodeShowcaseData"; type Props = { image: ImageDetail & { ai_summary?: string | null }; @@ -44,6 +45,7 @@ type Props = { onActiveIndexChange?: (index: number | null) => void; hideImage?: boolean; onHeroClick?: () => void; + variant?: "full" | "explore-preview"; }; /** @@ -65,7 +67,9 @@ export function ImageDetailContent({ onActiveIndexChange, hideImage = false, onHeroClick, + variant = "full", }: Props) { + const isExplorePreview = variant === "explore-preview"; const hasMagazine = !!magazineLayout; const imageUrl = typeof image.image_url === "string" ? image.image_url : null; // D-08: Always use brand color — per-post design_spec.accent_color override removed @@ -228,40 +232,87 @@ export function ImageDetailContent({ const commentCount = useCommentCount(image.id); + // Build DecodeShowcase data from normalized items (magazine mode) + const decodeShowcaseData = useMemo(() => { + if (!hasMagazine || !imageUrl) return null; + const itemsWithCenter = normalizedItems.filter((i) => i.normalizedCenter); + if (itemsWithCenter.length === 0) return null; + return toDecodeShowcaseData({ + items: normalizedItems, + imageUrl, + artistName: + imageWithOwner.artist_name ?? imageWithOwner.group_name ?? "DECODED", + }); + }, [ + hasMagazine, + imageUrl, + normalizedItems, + imageWithOwner.artist_name, + imageWithOwner.group_name, + ]); + // D-08: Always set --magazine-accent to brand color (accentColor is always "var(--mag-accent)") - const magazineCssVars = { "--magazine-accent": accentColor } as React.CSSProperties; + const magazineCssVars = { + "--magazine-accent": accentColor, + } as React.CSSProperties; // Note: PostBadge intentionally not rendered (D-06 — clean image-centric UX) return (
<> + {/* Editorial Preview Header — explore modal only */} + {isExplorePreview && ( + )?.title as string) ?? + imageWithOwner.artist_name ?? + "Untitled") + } + subtitle={hasMagazine ? magazineLayout.subtitle : null} + description={ + hasMagazine + ? (magazineLayout.editorial.paragraphs?.[0] ?? null) + : aiSummary + } + postId={image.id} + /> + )} + {/* Decorative Vertical Typography - Shown on desktop (Full Page & Modal) */} -
- - Decoded Editorial Archive —{" "} - {new Date(image.created_at).getFullYear()} - -
+ {!isExplorePreview && ( +
+ + Decoded Editorial Archive —{" "} + {new Date(image.created_at).getFullYear()} + +
+ )} {/* Section 1: Magazine Title (text header) or Hero Image */} - {hasMagazine ? ( - - ) : ( - !hideImage && ( - - ) + {!isExplorePreview && ( + <> + {hasMagazine ? ( + + ) : ( + !hideImage && ( + + ) + )} + )} {/* AI Summary Section — only rendered when summary exists */} - {aiSummary && ( + {aiSummary && !isExplorePreview && (
@@ -271,41 +322,10 @@ export function ImageDetailContent({
)} - {/* Section 2: Interactive Showcase (non-magazine) or static post image with spot dots (magazine) */} - {/* In modal mode, the floating left panel already shows this image — skip duplicate */} - {Boolean(hasMagazine && imageUrl && !isModal) && ( -
-
- Post image - {/* Spot overlay dots */} - {normalizedItems.map((item) => { - if (!item.normalizedCenter) return null; - const meta = item.metadata as unknown as - | Record - | undefined; - return ( - - ); - })} -
-
+ {/* Section 2: DecodeShowcase (magazine) or Interactive Showcase (non-magazine) */} + {/* In modal mode, skip — the floating left panel already shows this image */} + {decodeShowcaseData && !isModal && !isExplorePreview && ( + )} {!hasMagazine && hasItemsWithCoordinates && ( setSpotIdToAddSolution(null)} /> - {hasMagazine ? ( + {hasMagazine && ( <> - {/* Magazine: Editorial Section */} - - - {/* Magazine: Celebrity Style Archive */} - + {/* Magazine: Editorial & Celeb — hidden in explore-preview */} + {!isExplorePreview && ( + <> + + + + )} - {/* Magazine: The Look + per-item Related Items */} + {/* Magazine: Items — always visible (compact in explore-preview) */} - ) : ( + )} + + {!hasMagazine && ( <> - {/* Shop Grid (show if any items exist, even without coordinates) */} + {/* Shop Grid — visible in both full and explore-preview */} {hasItems && (
- {itemsFromPost && + {!isExplorePreview && + itemsFromPost && image.postImages && image.postImages.length > 0 && (
@@ -389,18 +415,19 @@ export function ImageDetailContent({ )} {/* Related Posts - 같은 유저가 올린 다른 포스트 */} - {(image.postImages?.[0]?.post as Record)?.account && ( - ).account - )} - userId={ - (image as ImageDetailWithPostOwner).post_owner_id ?? undefined - } - isModal={isModal} - /> - )} + {!isExplorePreview && + (image.postImages?.[0]?.post as Record)?.account && ( + ).account + )} + userId={ + (image as ImageDetailWithPostOwner).post_owner_id ?? undefined + } + isModal={isModal} + /> + )} {/* TODO: Try Gallery Section — temporarily disabled

diff --git a/packages/web/lib/components/detail/ImageDetailModal.tsx b/packages/web/lib/components/detail/ImageDetailModal.tsx index 115ac67f..7dcff47b 100644 --- a/packages/web/lib/components/detail/ImageDetailModal.tsx +++ b/packages/web/lib/components/detail/ImageDetailModal.tsx @@ -16,13 +16,14 @@ import { useImageModalAnimation } from "@/lib/hooks/useImageModalAnimation"; type Props = { imageId: string; + variant?: "full" | "explore-preview"; }; /** * Side Drawer version of image detail page * Used when navigating from grid (intercepting route) */ -export function ImageDetailModal({ imageId }: Props) { +export function ImageDetailModal({ imageId, variant = "full" }: Props) { const router = useRouter(); const { data: image, isLoading, error } = usePostDetailForImage(imageId); const magazineId = (image as ImageDetailWithPostOwner)?.post_magazine_id; @@ -203,6 +204,7 @@ export function ImageDetailModal({ imageId }: Props) { magazineLayout={publishedMagazineLayout} relatedEditorials={magazine?.related_editorials ?? []} isModal + variant={variant} scrollContainerRef={ scrollContainerRef as React.RefObject } @@ -220,6 +222,7 @@ export function ImageDetailModal({ imageId }: Props) { relatedEditorials={[]} isModal hideImage + variant={variant} scrollContainerRef={scrollContainerRef as React.RefObject} activeIndex={activeIndex} onActiveIndexChange={setActiveIndex} diff --git a/packages/web/lib/components/detail/adapters/toDecodeShowcaseData.test.ts b/packages/web/lib/components/detail/adapters/toDecodeShowcaseData.test.ts new file mode 100644 index 00000000..fb03f755 --- /dev/null +++ b/packages/web/lib/components/detail/adapters/toDecodeShowcaseData.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest"; +import { toDecodeShowcaseData } from "./toDecodeShowcaseData"; +import type { NormalizedItem } from "../types"; + +describe("toDecodeShowcaseData", () => { + const baseItem: NormalizedItem = { + id: 1, + image_id: "img-1", + product_name: "Wool Coat", + sam_prompt: null, + description: null, + center: null, + cropped_image_path: null, + metadata: { brand: "COS", sub_category: "Outerwear" } as unknown as string[], + created_at: "2026-01-01", + spot_id: "spot-1", + normalizedCenter: { x: 0.25, y: 0.35 }, + normalizedBox: { top: 0.3, left: 0.2, width: 0.1, height: 0.1 }, + // Required ItemRow fields + brand: null, + price: null, + status: null, + bboxes: null, + scores: null, + ambiguity: null, + citations: null, + }; + + it("converts normalizedItems with coordinates to DetectedItem[]", () => { + const items = [ + baseItem, + { + ...baseItem, + id: 2, + product_name: "Denim Jacket", + normalizedCenter: { x: 0.7, y: 0.6 }, + metadata: { brand: "Acne Studios" }, + }, + ] as NormalizedItem[]; + + const result = toDecodeShowcaseData({ + items, + imageUrl: "https://example.com/image.jpg", + artistName: "NEWJEANS", + }); + + expect(result.sourceImageUrl).toBe("https://example.com/image.jpg"); + expect(result.artistName).toBe("NEWJEANS"); + expect(result.detectedItems).toHaveLength(2); + expect(result.detectedItems[0]).toEqual({ + id: "1", + label: "Wool Coat", + brand: "COS", + imageUrl: undefined, + bbox: { x: 25, y: 35, width: 0, height: 0 }, + }); + expect(result.detectedItems[1]).toEqual({ + id: "2", + label: "Denim Jacket", + brand: "Acne Studios", + imageUrl: undefined, + bbox: { x: 70, y: 60, width: 0, height: 0 }, + }); + }); + + it("skips items without normalizedCenter", () => { + const items = [ + baseItem, + { ...baseItem, id: 3, normalizedCenter: null }, + ] as NormalizedItem[]; + + const result = toDecodeShowcaseData({ + items, + imageUrl: "https://example.com/image.jpg", + artistName: "IVE", + }); + + expect(result.detectedItems).toHaveLength(1); + }); + + it("limits to 4 items max", () => { + const items = Array.from({ length: 6 }, (_, i) => ({ + ...baseItem, + id: i + 1, + normalizedCenter: { x: 0.1 * (i + 1), y: 0.1 * (i + 1) }, + })) as NormalizedItem[]; + + const result = toDecodeShowcaseData({ + items, + imageUrl: "https://example.com/image.jpg", + artistName: "TEST", + }); + + expect(result.detectedItems).toHaveLength(4); + }); + + it("includes cropped_image_path as imageUrl via proxy", () => { + const items = [ + { + ...baseItem, + cropped_image_path: "https://storage.example.com/cropped/1.jpg", + }, + ] as NormalizedItem[]; + + const result = toDecodeShowcaseData({ + items, + imageUrl: "https://example.com/image.jpg", + artistName: "TEST", + }); + + expect(result.detectedItems[0].imageUrl).toBe( + "/api/v1/image-proxy?url=https%3A%2F%2Fstorage.example.com%2Fcropped%2F1.jpg" + ); + }); + + it("returns fallback when no valid items", () => { + const result = toDecodeShowcaseData({ + items: [], + imageUrl: "https://example.com/image.jpg", + artistName: "TEST", + }); + + expect(result.detectedItems).toHaveLength(0); + expect(result.sourceImageUrl).toBe("https://example.com/image.jpg"); + }); +}); diff --git a/packages/web/lib/components/detail/adapters/toDecodeShowcaseData.ts b/packages/web/lib/components/detail/adapters/toDecodeShowcaseData.ts new file mode 100644 index 00000000..b433317a --- /dev/null +++ b/packages/web/lib/components/detail/adapters/toDecodeShowcaseData.ts @@ -0,0 +1,50 @@ +import type { NormalizedItem } from "../types"; +import type { DecodeShowcaseData } from "@/lib/components/main-renewal/types"; + +interface ToDecodeShowcaseDataParams { + items: NormalizedItem[]; + imageUrl: string; + artistName: string; +} + +/** + * Adapts NormalizedItem[] to DecodeShowcaseData for the AI detection showcase section. + * - Filters out items without normalizedCenter + * - Limits to 4 items max + * - Converts normalizedCenter (0-1) to bbox (0-100 percentage) + * - Proxies cropped_image_path through /api/v1/image-proxy + */ +export function toDecodeShowcaseData({ + items, + imageUrl, + artistName, +}: ToDecodeShowcaseDataParams): DecodeShowcaseData { + const detectedItems = items + .filter((item) => item.normalizedCenter !== null) + .slice(0, 4) + .map((item) => { + const meta = item.metadata as unknown as Record | undefined; + const croppedUrl = item.cropped_image_path; + return { + id: String(item.id), + label: item.product_name ?? item.sam_prompt ?? `Item ${item.id}`, + brand: (meta?.brand as string) ?? undefined, + imageUrl: croppedUrl + ? `/api/v1/image-proxy?url=${encodeURIComponent(croppedUrl)}` + : undefined, + bbox: { + x: Math.round(item.normalizedCenter!.x * 100), + y: Math.round(item.normalizedCenter!.y * 100), + width: 0, + height: 0, + }, + }; + }); + + return { + sourceImageUrl: imageUrl, + artistName, + detectedItems, + tagline: "See how it's Decoded", + }; +} diff --git a/packages/web/lib/components/detail/magazine/MagazineItemsSection.tsx b/packages/web/lib/components/detail/magazine/MagazineItemsSection.tsx index bd23b610..4e5eb86a 100644 --- a/packages/web/lib/components/detail/magazine/MagazineItemsSection.tsx +++ b/packages/web/lib/components/detail/magazine/MagazineItemsSection.tsx @@ -21,6 +21,7 @@ type Props = { relatedItems?: PostMagazineRelatedItem[]; accentColor?: string; isModal?: boolean; + compact?: boolean; scrollContainerRef?: RefObject; onActiveIndexChange?: (index: number | null) => void; }; @@ -30,6 +31,7 @@ export function MagazineItemsSection({ relatedItems = [], accentColor, isModal, + compact = false, scrollContainerRef, onActiveIndexChange, }: Props) { @@ -108,49 +110,49 @@ export function MagazineItemsSection({ } // Modal: ScrollTrigger for scroll-spot sync + // Defer until modal open animation completes (~700ms) to prevent first-scroll jank if (isModal && onActiveIndexChange && cards.length > 0) { const scroller = scrollContainerRef?.current || window; - cards.forEach((card, index) => { - ScrollTrigger.create({ - scroller, - trigger: card, - start: "top center", - end: "bottom center", - invalidateOnRefresh: true, - onEnter: () => { - activeIndexRef.current = index; - onActiveIndexChange(index); - }, - onEnterBack: () => { - activeIndexRef.current = index; - onActiveIndexChange(index); - }, - onLeave: () => { - if (activeIndexRef.current === index) { - onActiveIndexChange(null); - } - }, - onLeaveBack: () => { - if (activeIndexRef.current === index) { - onActiveIndexChange(null); - } - }, + const initTimer = setTimeout(() => { + cards.forEach((card, index) => { + ScrollTrigger.create({ + scroller, + trigger: card, + start: "top center", + end: "bottom center", + invalidateOnRefresh: true, + onEnter: () => { + activeIndexRef.current = index; + onActiveIndexChange(index); + }, + onEnterBack: () => { + activeIndexRef.current = index; + onActiveIndexChange(index); + }, + onLeave: () => { + if (activeIndexRef.current === index) { + onActiveIndexChange(null); + } + }, + onLeaveBack: () => { + if (activeIndexRef.current === index) { + onActiveIndexChange(null); + } + }, + }); }); - }); + ScrollTrigger.refresh(); + }, 700); - // Refresh after layout stabilizes - if (scrollContainerRef?.current) { - const timer = setTimeout(() => ScrollTrigger.refresh(), 300); - return () => { - clearTimeout(timer); - ScrollTrigger.getAll().forEach((trigger) => { - if (cards.includes(trigger.vars.trigger as HTMLElement)) { - trigger.kill(); - } - }); - }; - } + return () => { + clearTimeout(initTimer); + ScrollTrigger.getAll().forEach((trigger) => { + if (cards.includes(trigger.vars.trigger as HTMLElement)) { + trigger.kill(); + } + }); + }; } }, { scope: sectionRef, dependencies: [items.length, isModal] } @@ -161,14 +163,18 @@ export function MagazineItemsSection({ return (

-

The Look

-

- 아이템 상세 & 에디토리얼 -

+ {!compact && ( + <> +

The Look

+

+ 아이템 상세 & 에디토리얼 +

+ + )} -
+
{items.map((item, i) => { const meta = item.metadata as | { @@ -182,14 +188,12 @@ export function MagazineItemsSection({ const titleHeight = titleLayouts[item.spot_id]?.height ?? 0; return ( -
+
{/* Item Image */} -
+
{item.image_url ? ( ) : (
- + {item.brand} @@ -225,7 +229,7 @@ export function MagazineItemsSection({ )} - {meta?.material && meta.material.length > 0 && ( + {!compact && meta?.material && meta.material.length > 0 && ( <>
@@ -237,66 +241,98 @@ export function MagazineItemsSection({ )}
- {/* Item Details + Editorial */} + {/* Item Details */}
{item.brand && ( -

+

{item.brand}

)}

0 ? { minHeight: titleHeight } : undefined + !compact && titleHeight > 0 ? { minHeight: titleHeight } : undefined } > {item.title}

- {price && ( -

- {price} -

- )} - - {item.editorial_paragraphs.length > 0 && ( -
- {item.editorial_paragraphs.map((p, j) => ( -

- {p} + {compact ? ( + /* Compact: description + price + Shop Now inline */ + <> + {item.editorial_paragraphs.length > 0 && ( +

+ {item.editorial_paragraphs[0]}

- ))} + )} +
+ {price && ( + + {price} + + )} + {item.original_url && ( + + Shop + + + )}
- )} + + ) : ( + /* Full: original layout */ + <> + {price && ( +

+ {price} +

+ )} - {item.original_url && ( - - Shop Now - - + {item.editorial_paragraphs.length > 0 && ( +
+ {item.editorial_paragraphs.map((p, j) => ( +

+ {p} +

+ ))} +
+ )} + + {item.original_url && ( + + Shop Now + + + )} + )}
{/* Similar Items for this spot */} {spotRelated.length > 0 && ( -
-

+

+

Similar Items

-
+
{spotRelated.slice(0, 3).map((ri, j) => ( -
+
{ri.brand && ( -

+

{ri.brand}

)} -

+

{ri.title}

diff --git a/packages/web/lib/components/detail/types.ts b/packages/web/lib/components/detail/types.ts index 70eb82df..b9d71c26 100644 --- a/packages/web/lib/components/detail/types.ts +++ b/packages/web/lib/components/detail/types.ts @@ -128,7 +128,10 @@ export function spotToItemRow(spot: SpotRow, solution?: SolutionRow): ItemRow { status: solution?.status || spot.status || null, created_at: spot.created_at || null, bboxes: null, - center: [parseFloat(spot.position_left), parseFloat(spot.position_top)], + center: [ + normalizePos(parseFloat(spot.position_left)), + normalizePos(parseFloat(spot.position_top)), + ], scores: null, ambiguity: null, citations: null, @@ -137,6 +140,12 @@ export function spotToItemRow(spot: SpotRow, solution?: SolutionRow): ItemRow { }; } +/** Normalize position: if 0-100 percentage → divide by 100, then clamp to 0-1 */ +function normalizePos(v: number): number { + const n = v > 1 ? v / 100 : v; + return Math.max(0, Math.min(1, n)); +} + /** * Helper: Convert pixel value to relative position (0.0 ~ 1.0) */ diff --git a/packages/web/lib/components/explore/ExploreCardCell.tsx b/packages/web/lib/components/explore/ExploreCardCell.tsx index 020ba7a8..87989753 100644 --- a/packages/web/lib/components/explore/ExploreCardCell.tsx +++ b/packages/web/lib/components/explore/ExploreCardCell.tsx @@ -14,6 +14,36 @@ if (typeof window !== "undefined") { gsap.registerPlugin(Flip); } +/** Parse Meilisearch highlight into React nodes */ +function HighlightText({ + html, + fallback, +}: { + html?: string; + fallback: string; +}) { + if (!html) return <>{fallback}; + const parts = html.split(/(.*?<\/em>)/g); + return ( + <> + {parts.map((part, i) => { + const match = part.match(/^(.*)<\/em>$/); + if (match) { + return ( + + {match[1]} + + ); + } + return {part}; + })} + + ); +} + export type ExploreCardCellProps = ItemConfig; /** @@ -71,7 +101,7 @@ export const ExploreCardCell = memo(function ExploreCardCell({ return ( setIsLoaded(true)} onError={() => setImageError(true)} /> + {/* Artist name overlay — search mode only (when highlight exists) */} + {item?.highlight && item.postAccount && ( +
+

+ +

+
+ )} diff --git a/packages/web/lib/components/explore/ExploreFilterBar.tsx b/packages/web/lib/components/explore/ExploreFilterBar.tsx deleted file mode 100644 index 9b47c857..00000000 --- a/packages/web/lib/components/explore/ExploreFilterBar.tsx +++ /dev/null @@ -1,191 +0,0 @@ -"use client"; - -import { useState, useEffect, useRef } from "react"; -import { ChevronDown, SlidersHorizontal } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { useHierarchicalFilterStore } from "@decoded/shared/stores/hierarchicalFilterStore"; -import { - MOCK_MEDIA, - getMockCastByMedia, -} from "@decoded/shared/data/mockFilterData"; -import { FilterChip } from "./FilterChip"; - -export interface ExploreFilterBarProps { - className?: string; -} - -const allMediaOptions = Object.values(MOCK_MEDIA).flat(); - -export function ExploreFilterBar({ className }: ExploreFilterBarProps) { - const { - mediaId, - castId, - breadcrumb, - setMedia, - setCast, - clearAll, - hasActiveFilters, - } = useHierarchicalFilterStore(); - - const [openDropdown, setOpenDropdown] = useState(null); - const dropdownRef = useRef(null); - - useEffect(() => { - if (openDropdown === null) return; - const handleClickOutside = (e: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { - setOpenDropdown(null); - } - }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [openDropdown]); - - const castOptions = mediaId ? getMockCastByMedia(mediaId) : []; - const activeFilters = hasActiveFilters(); - - const toggleDropdown = (level: number) => { - setOpenDropdown(openDropdown === level ? null : level); - }; - - const handleMediaSelect = (id: string, name: string, nameKo: string) => { - setMedia(id, name, nameKo); - setOpenDropdown(null); - }; - - const handleCastSelect = (id: string, name: string, nameKo: string) => { - setCast(id, name, nameKo); - setOpenDropdown(null); - }; - - const mediaLabel = breadcrumb.find((b) => b.level === 2)?.label || "Media"; - const castLabel = breadcrumb.find((b) => b.level === 3)?.label || "Cast"; - - return ( -
-
- - - {/* Media dropdown */} -
- - {openDropdown === 2 && ( -
- {allMediaOptions.map((m) => ( - - ))} -
- )} -
- - {/* Cast dropdown (visible when media selected) */} - {mediaId && ( -
- - {openDropdown === 3 && ( -
- {castOptions.map((c) => ( - - ))} -
- )} -
- )} -
- - {/* Active filter chips */} - {activeFilters && ( -
- {breadcrumb - .filter((b) => b.level === 2 || b.level === 3) - .map((b) => ( - { - if (b.level === 2) setMedia(null); - else if (b.level === 3) setCast(null); - }} - /> - ))} - -
- )} -
- ); -} diff --git a/packages/web/lib/components/explore/ExploreFilterSheet.tsx b/packages/web/lib/components/explore/ExploreFilterSheet.tsx deleted file mode 100644 index 12a36541..00000000 --- a/packages/web/lib/components/explore/ExploreFilterSheet.tsx +++ /dev/null @@ -1,178 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { BottomSheet } from "@/lib/design-system"; -import { cn } from "@/lib/utils"; -import { useHierarchicalFilterStore } from "@decoded/shared/stores/hierarchicalFilterStore"; -import { - getMockCategories, - getMockMediaByCategory, - getMockCastByMedia, - getMockContextOptions, -} from "@decoded/shared/data/mockFilterData"; -import type { - CategoryType, - ContextType, - FilterLevel, -} from "@decoded/shared/types/filter"; - -export interface ExploreFilterSheetProps { - open: boolean; - onClose: () => void; -} - -export function ExploreFilterSheet({ open, onClose }: ExploreFilterSheetProps) { - const { - category, - mediaId, - castId, - contextType, - setCategory, - setMedia, - setCast, - setContext, - clearAll, - } = useHierarchicalFilterStore(); - - const [activeLevel, setActiveLevel] = useState(1); - - const categories = getMockCategories(); - const mediaOptions = category ? getMockMediaByCategory(category) : []; - const castOptions = mediaId ? getMockCastByMedia(mediaId) : []; - const contextOptions = getMockContextOptions(); - - const levels: { id: FilterLevel; label: string; enabled: boolean }[] = [ - { id: 1, label: "Category", enabled: true }, - { id: 2, label: "Media", enabled: !!category }, - { id: 3, label: "Cast", enabled: !!mediaId }, - { id: 4, label: "Context", enabled: !!castId }, - ]; - - return ( - -
- {/* Level tabs */} -
- {levels.map((level) => ( - - ))} -
- - {/* Level content */} -
- {activeLevel === 1 && - categories.map((cat) => ( - - ))} - - {activeLevel === 2 && - mediaOptions.map((m) => ( - - ))} - - {activeLevel === 3 && - castOptions.map((c) => ( - - ))} - - {activeLevel === 4 && - contextOptions.map((ct) => ( - - ))} -
- - {/* Actions */} -
- - -
-
-
- ); -} diff --git a/packages/web/lib/components/explore/FilterChip.tsx b/packages/web/lib/components/explore/FilterChip.tsx index 7b3e44ca..d8716074 100644 --- a/packages/web/lib/components/explore/FilterChip.tsx +++ b/packages/web/lib/components/explore/FilterChip.tsx @@ -5,26 +5,42 @@ import { cn } from "@/lib/utils"; export interface FilterChipProps { label: string; - onRemove: () => void; + count?: number; + active?: boolean; + onClick?: () => void; + onRemove?: () => void; className?: string; } -export function FilterChip({ label, onRemove, className }: FilterChipProps) { +export function FilterChip({ + label, + count, + active = false, + onClick, + onRemove, + className, +}: FilterChipProps) { return ( - {label} - - + {count != null && ( + + {count} + + )} + {active && ( + + )} + ); } diff --git a/packages/web/lib/hooks/useExploreData.ts b/packages/web/lib/hooks/useExploreData.ts new file mode 100644 index 00000000..65b648c7 --- /dev/null +++ b/packages/web/lib/hooks/useExploreData.ts @@ -0,0 +1,202 @@ +"use client"; + +import { useMemo, useState, useCallback } from "react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useSearchStore } from "@/lib/stores/searchStore"; +import { useInfinitePosts, type PostGridItem } from "./useImages"; +import { search } from "@/lib/api/generated/search/search"; +import type { SearchResultItem } from "@/lib/api/generated/models"; + +interface UseExploreDataOptions { + hasMagazine?: boolean; +} + +type FacetMap = Record; + +interface UseExploreDataReturn { + items: PostGridItem[]; + isLoading: boolean; + isError: boolean; + error: Error | null; + fetchNextPage: () => void; + hasNextPage: boolean; + isFetchingNextPage: boolean; + mode: "search" | "browse"; + // Facets (client-side aggregated) + artistFacets: FacetMap; + contextFacets: FacetMap; + // Artist filter (client-side, multi-select) + selectedArtists: string[]; + toggleArtist: (name: string) => void; + clearArtistFilters: () => void; + // Context filter (server-side via API) + activeContext: string | null; + setContext: (ctx: string | null) => void; + // Sort (server-side via API) + activeSort: string; + setSort: (sort: string) => void; + refetch: () => void; +} + +function mapSearchResultToGridItem(item: SearchResultItem): PostGridItem { + return { + id: item.id, + imageUrl: item.image_url, + postId: item.id, + postSource: "post" as const, + postAccount: item.artist_name ?? item.group_name ?? "", + postCreatedAt: "", + spotCount: item.spot_count ?? 0, + viewCount: item.view_count ?? 0, + title: null, + highlight: item.highlight ?? null, + }; +} + +/** Build artist + context facets from search results */ +function buildFacets(items: SearchResultItem[]): { + artist: FacetMap; + context: FacetMap; +} { + const artist: FacetMap = {}; + const context: FacetMap = {}; + + for (const item of items) { + const name = item.artist_name ?? item.group_name; + if (name) { + artist[name] = (artist[name] || 0) + 1; + } + if (item.context) { + context[item.context] = (context[item.context] || 0) + 1; + } + } + + return { artist, context }; +} + +export function useExploreData( + options: UseExploreDataOptions = {}, +): UseExploreDataReturn { + const { hasMagazine = false } = options; + + const debouncedQuery = useSearchStore((state) => state.debouncedQuery); + const isSearchMode = debouncedQuery.trim().length > 0; + + // Multi-select artist filter (client-side) + const [selectedArtists, setSelectedArtists] = useState([]); + const toggleArtist = useCallback((name: string) => { + setSelectedArtists((prev) => + prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name], + ); + }, []); + const clearArtistFilters = useCallback(() => setSelectedArtists([]), []); + + // Context filter (server-side via API) + const [activeContext, setActiveContext] = useState(null); + const setContext = useCallback((ctx: string | null) => { + setActiveContext((prev) => (prev === ctx ? null : ctx)); + }, []); + + // Sort (server-side via API) + const [activeSort, setActiveSort] = useState("relevant"); + const setSort = useCallback((sort: string) => setActiveSort(sort), []); + + // Browse mode: Supabase via useInfinitePosts + const browseResult = useInfinitePosts({ + enabled: !isSearchMode, + limit: 40, + hasMagazine, + sort: (activeSort === "recent" ? "recent" : activeSort === "popular" ? "popular" : "recent") as "recent" | "popular" | "trending", + }); + + // Search mode: Meilisearch with API filters + const searchResult = useInfiniteQuery({ + queryKey: [ + "search", + "infinite", + { q: debouncedQuery, context: activeContext, sort: activeSort }, + ], + queryFn: async ({ pageParam = 1 }) => { + return search({ + q: debouncedQuery, + context: activeContext ?? undefined, + sort: activeSort !== "relevant" ? activeSort : undefined, + page: pageParam, + limit: 40, + }); + }, + initialPageParam: 1, + getNextPageParam: (lastPage) => { + const { current_page, total_pages } = lastPage.pagination; + return current_page < total_pages ? current_page + 1 : undefined; + }, + enabled: isSearchMode, + }); + + // Build facets from all loaded search results + const { artistFacets, contextFacets } = useMemo(() => { + if (!isSearchMode || !searchResult.data?.pages?.length) + return { artistFacets: {} as FacetMap, contextFacets: {} as FacetMap }; + const allItems = searchResult.data.pages.flatMap((page) => page.data); + const { artist, context } = buildFacets(allItems); + return { artistFacets: artist, contextFacets: context }; + }, [isSearchMode, searchResult.data]); + + // Map items + client-side artist filter + const items: PostGridItem[] = useMemo(() => { + if (isSearchMode) { + const allItems = (searchResult.data?.pages ?? []).flatMap((page) => + page.data.map(mapSearchResultToGridItem), + ); + if (selectedArtists.length > 0) { + return allItems.filter((item) => + selectedArtists.includes(item.postAccount), + ); + } + return allItems; + } + return browseResult.data + ? browseResult.data.pages.flatMap((page) => page.items) + : []; + }, [isSearchMode, searchResult.data, browseResult.data, selectedArtists]); + + const shared = { + artistFacets, + contextFacets, + selectedArtists, + toggleArtist, + clearArtistFilters, + activeContext, + setContext, + activeSort, + setSort, + }; + + if (isSearchMode) { + return { + items, + isLoading: searchResult.isLoading, + isError: searchResult.isError, + error: searchResult.error as Error | null, + fetchNextPage: searchResult.fetchNextPage, + hasNextPage: !!searchResult.hasNextPage, + isFetchingNextPage: searchResult.isFetchingNextPage, + mode: "search", + ...shared, + refetch: searchResult.refetch, + }; + } + + return { + items, + isLoading: browseResult.isLoading, + isError: browseResult.isError, + error: browseResult.error as Error | null, + fetchNextPage: browseResult.fetchNextPage, + hasNextPage: !!browseResult.hasNextPage, + isFetchingNextPage: browseResult.isFetchingNextPage, + mode: "browse", + ...shared, + refetch: browseResult.refetch, + }; +} diff --git a/packages/web/lib/hooks/useImages.ts b/packages/web/lib/hooks/useImages.ts index b49437b7..b47dbca0 100644 --- a/packages/web/lib/hooks/useImages.ts +++ b/packages/web/lib/hooks/useImages.ts @@ -142,6 +142,8 @@ export type PostGridItem = { viewCount: number; /** 에디토리얼 그리드 오버레이용 (post.title 또는 post_magazine_title) */ title?: string | null; + /** Meilisearch highlight (검색 결과에서만) */ + highlight?: Record | null; }; /** @@ -169,6 +171,7 @@ export function useInfinitePosts(params: { /** Display name from hierarchical filter (e.g., "Minji") — matched via ilike on artist_name */ castName?: string; contextType?: string; + enabled?: boolean; }) { const { limit = 40, @@ -181,14 +184,27 @@ export function useInfinitePosts(params: { mediaName, castName, contextType, + enabled = true, } = params; return useInfiniteQuery({ queryKey: [ "posts", "infinite", - { category, search, artistName, groupName, sort, limit, hasMagazine, mediaName, castName, contextType }, + { + category, + search, + artistName, + groupName, + sort, + limit, + hasMagazine, + mediaName, + castName, + contextType, + }, ], + enabled, queryFn: async ({ pageParam }) => { const page = (pageParam as number) ?? 1; @@ -201,7 +217,9 @@ export function useInfinitePosts(params: { has_magazine: true, artist_name: mediaName ?? artistName, group_name: castName ? undefined : groupName, - context: contextType ?? (category && category !== "all" ? category : undefined), + context: + contextType ?? + (category && category !== "all" ? category : undefined), }); const items: PostGridItem[] = response.data.map((post) => ({ @@ -225,11 +243,10 @@ export function useInfinitePosts(params: { const from = (page - 1) * limit; const to = from + limit - 1; - let query = supabaseBrowserClient - .from("posts") - .select("*", { count: "exact" }) - .eq("status", "active") - .not("image_url", "is", null); + // explore_posts is a DB view not yet in generated Supabase types + let query = (supabaseBrowserClient as any) + .from("explore_posts") + .select("*", { count: "exact" }); // category filter (flat) — skip if contextType is set (hierarchical takes precedence) if (category && category !== "all" && !contextType) { @@ -286,7 +303,7 @@ export function useInfinitePosts(params: { postCreatedAt: post.created_at, spotCount: post.spot_count ?? 0, viewCount: post.view_count, - title: post.title ?? null, + title: post.post_magazine_title ?? post.title ?? null, })); return { items, nextPage: hasMore ? page + 1 : null, hasMore }; @@ -360,7 +377,11 @@ export function usePostDetailForImage(postId: string) { id: post.id, image_hash: "", image_url: post.image_url, - status: (post.status ?? "pending") as "pending" | "extracted" | "skipped" | "extracted_metadata", + status: (post.status ?? "pending") as + | "pending" + | "extracted" + | "skipped" + | "extracted_metadata", with_items: items.length > 0, created_at: post.created_at, items, @@ -386,7 +407,26 @@ export function usePostDetailForImage(postId: string) { created_at: post.created_at, item_locations: spots.map((s, idx) => ({ item_id: idx + 1, - center: [parseFloat(s.position_left), parseFloat(s.position_top)], + center: [ + Math.max( + 0, + Math.min( + 1, + parseFloat(s.position_left) > 1 + ? parseFloat(s.position_left) / 100 + : parseFloat(s.position_left) + ) + ), + Math.max( + 0, + Math.min( + 1, + parseFloat(s.position_top) > 1 + ? parseFloat(s.position_top) / 100 + : parseFloat(s.position_top) + ) + ), + ], })), item_locations_updated_at: post.updated_at, } as any, @@ -397,7 +437,8 @@ export function usePostDetailForImage(postId: string) { ai_summary: (postAny.ai_summary as string) ?? null, artist_name: post.artist_name ?? null, group_name: post.group_name ?? null, - created_with_solutions: (postAny.created_with_solutions as boolean) ?? null, + created_with_solutions: + (postAny.created_with_solutions as boolean) ?? null, like_count: (postAny.like_count as number) ?? 0, } as ImageDetail; } catch { diff --git a/packages/web/lib/stores/activeSpotStore.ts b/packages/web/lib/stores/activeSpotStore.ts new file mode 100644 index 00000000..5eb418b3 --- /dev/null +++ b/packages/web/lib/stores/activeSpotStore.ts @@ -0,0 +1,11 @@ +import { create } from "zustand"; + +interface ActiveSpotState { + activeIndex: number | null; + setActiveIndex: (index: number | null) => void; +} + +export const useActiveSpotStore = create((set) => ({ + activeIndex: null, + setActiveIndex: (index) => set({ activeIndex: index }), +})); diff --git a/packages/web/next-env.d.ts b/packages/web/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/packages/web/next-env.d.ts +++ b/packages/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts index 894cadfe..a3147d6e 100644 --- a/packages/web/vitest.config.ts +++ b/packages/web/vitest.config.ts @@ -9,6 +9,8 @@ export default defineConfig({ "tests/**/*.test.ts", "tests/**/*.test.tsx", "lib/**/__tests__/**/*.test.tsx", + "lib/**/*.test.ts", + "lib/**/*.test.tsx", ], env: { NEXT_PUBLIC_SUPABASE_URL: "http://localhost:54321",