diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index a04df1f4..43d6fe74 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -14,7 +14,7 @@ - [x] **v8.0 Monorepo Consolidation & Bun Migration** — m10-Phases 01-04 (shipped 2026-03-23) - [x] **v9.0 Type-Safe API Generation** — Phases 39-43 (shipped 2026-03-24) - [x] **v10.0 Profile Page Completion** — Phases 44-50 (shipped 2026-03-26) -- [ ] **v11.0 Explore & Editorial Data Integration** — Phases 51-55 +- [x] **v11.0 Explore & Editorial Data Integration** — Phases 51-55 (shipped 2026-04-02) ## Phases @@ -130,7 +130,7 @@ See archived roadmap: `.planning/milestones/v9.0-ROADMAP.md` - [x] **Phase 52: Editorial Filter Fix** - hasMagazine 필터 실제 동작 + OpenAPI has_magazine 파라미터 추가 (completed 2026-04-01) - [x] **Phase 53: Detail Data Migration** - usePostDetailForImage REST 마이그레이션 + Maximize 버튼 soft navigation (completed 2026-04-02) - [x] **Phase 54: Card Enrichment** - Explore spot_count 배지 + Editorial 매거진 타이틀 오버레이 (completed 2026-04-02) -- [ ] **Phase 55: End-to-End Verification** - 탐색 → 드로어 → 풀 페이지 전체 플로우 검증 +- [x] **Phase 55: End-to-End Verification** - 탐색 → 드로어 → 풀 페이지 전체 플로우 검증 (completed 2026-04-02) ## Phase Details diff --git a/.planning/STATE.md b/.planning/STATE.md index 71fe00c2..8c1c1b55 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,12 +1,12 @@ --- gsd_state_version: 1.0 -milestone: v10.0 -milestone_name: Profile Page Completion -status: active +milestone: v11.0 +milestone_name: Explore & Editorial Data Integration +status: shipped last_updated: "2026-04-02" progress: - total_phases: 20 - completed_phases: 17 + total_phases: 5 + completed_phases: 5 --- # Project State @@ -30,7 +30,8 @@ See: .planning/PROJECT.md | v7.0 Sticker Canvas | Paused | - | | v8.0 Monorepo & Bun | Shipped | 2026-03-23 | | v9.0 API Generation | Shipped | 2026-03-23 | -| **v10.0 Profile Completion** | **Active** | - | +| v10.0 Profile Completion | Shipped | 2026-03-26 | +| **v11.0 Explore & Editorial** | **Shipped** | 2026-04-02 | ## Pending Todos diff --git a/docs/agent/api-v1-routes.md b/docs/agent/api-v1-routes.md index d12cf701..b7a6cd43 100644 --- a/docs/agent/api-v1-routes.md +++ b/docs/agent/api-v1-routes.md @@ -2,6 +2,14 @@ `packages/web/app/api/v1/` 기준. 메서드·경로 추가 시 이 파일과 실제 라우트 핸들러를 함께 갱신합니다. +## Search + +| Route | Methods | Description | +| ------------------ | ------- | ------------------------------------------------------------------------ | +| `/api/v1/search` | GET | Unified search — proxies to backend Meilisearch; Supabase ilike fallback | + +Params: `q`, `context`, `media_type`, `sort`, `page`, `limit`. + ## Posts & content | Route | Methods | Description | @@ -17,6 +25,7 @@ | `/api/v1/posts/[postId]/likes` | POST | Like/unlike a post | | `/api/v1/posts/[postId]/saved` | POST | Save/unsave a post | | `/api/v1/post-magazines/[id]` | GET | Post magazine data | +| `/api/v1/post-magazines/generate` | POST | Trigger editorial generation for a post (admin only, proxy → Rust) | ## Solutions & spots @@ -77,5 +86,14 @@ | `/api/v1/admin/reports/[reportId]` | PATCH | Update report status (proxy → Rust) | | `/api/v1/admin/server-logs` | GET | Server logs | | `/api/v1/admin/server-logs/stream` | GET | Server logs (SSE stream) | +| `/api/v1/admin/editorial-candidates` | GET | Posts eligible for editorial (spot ≥ 4, solution ≥ 1/spot; proxy → Rust) | +| `/api/v1/admin/picks` | GET/POST | List / create decoded picks (Supabase `decoded_picks` table) | +| `/api/v1/admin/picks/[pickId]` | PATCH/DELETE | Update / delete a decoded pick | + +## Utility + +| Route | Methods | Description | +| ------------------------ | ------- | ------------------------------------------------ | +| `/api/v1/image-proxy` | GET | Proxy external image URLs (avoids CORS/hotlink) | Rust REST API와의 관계는 [`.planning/codebase/INTEGRATIONS.md`](../../.planning/codebase/INTEGRATIONS.md) 및 `packages/api-server` 문서를 참고합니다. diff --git a/docs/agent/warehouse-schema.md b/docs/agent/warehouse-schema.md index d2d77ba2..2a160d98 100644 --- a/docs/agent/warehouse-schema.md +++ b/docs/agent/warehouse-schema.md @@ -3,9 +3,12 @@ > ETL 파이프라인 데이터를 저장하는 Supabase `warehouse` 스키마. > Instagram 수집 → 엔티티 관리 → Seed 퍼블리싱 파이프라인 전체를 커버한다. +> **참고**: `public.posts` 및 `public.solutions` 테이블(앱 스키마)에는 warehouse 엔티티를 참조하는 FK 컬럼이 추가되었습니다. 자세한 내용은 아래 [App 스키마 FK 컬럼](#app-스키마-fk-컬럼-warehouse-참조) 섹션을 참고합니다. + **Project ID:** `fvxchskblyhuswzlcmql` **Schema:** `warehouse` (API Exposed) -**Types 파일:** `packages/web/lib/supabase/warehouse-types.ts` (2026-03-26 생성) +**Types 파일:** `packages/web/lib/supabase/warehouse-types.ts` (2026-03-26 생성, warehouse 스키마 전용) +**Public Types 파일:** `packages/web/lib/supabase/types.ts` (2026-04-04 재생성, `supabase gen types` 기반) **Client 파일:** `packages/web/lib/supabase/warehouse.ts` --- @@ -335,6 +338,38 @@ Seed post의 아카이브된 이미지 에셋. --- +## App 스키마 FK 컬럼 (warehouse 참조) + +PR #69 / SeaORM migration `m20260402_000001_add_warehouse_fk_posts_solutions`에서 추가됨. +`public` 스키마(앱 데이터)의 테이블에 warehouse 엔티티 FK를 연결한다. + +### `public.posts` 추가 컬럼 + +| Column | Type | Constraint | Note | +|--------|------|------------|------| +| `artist_id` | uuid | nullable, FK → `warehouse.artists.id` ON DELETE SET NULL | `artist_name`에서 이름 매칭으로 백필됨 | +| `group_id` | uuid | nullable, FK → `warehouse.groups.id` ON DELETE SET NULL | `group_name`에서 이름 매칭으로 백필됨 | + +인덱스: `idx_posts_artist_id`, `idx_posts_group_id` + +### `public.solutions` 추가 컬럼 + +| Column | Type | Constraint | Note | +|--------|------|------------|------| +| `brand_id` | uuid | nullable, FK → `warehouse.brands.id` ON DELETE SET NULL | `metadata.brand` 또는 `title` prefix 매칭으로 백필됨 | +| `price_amount` | numeric(12,2) | nullable | 상품 가격 (2026-04-04 추가) | +| `price_currency` | varchar(10) | nullable, DEFAULT 'KRW' | 통화 코드 (2026-04-04 추가) | + +인덱스: `idx_solutions_brand_id` + +### 백필 전략 + +- `posts.artist_id`: `artist_name` → `warehouse.artists.name_ko / name_en` (unaccent + lower 정규화, 고유 매칭만) +- `posts.group_id`: `group_name` → `warehouse.groups.name_ko / name_en` (동일 방식) +- `solutions.brand_id`: `metadata->>'brand'` 값 우선, 없으면 `title` prefix로 `warehouse.brands` 매칭 + +--- + ## Type Aliases `warehouse-types.ts`에서 export하는 편의 타입: diff --git a/docs/agent/web-hooks-and-stores.md b/docs/agent/web-hooks-and-stores.md index 66753ceb..6323d129 100644 --- a/docs/agent/web-hooks-and-stores.md +++ b/docs/agent/web-hooks-and-stores.md @@ -7,12 +7,18 @@ Paths below are under `packages/web/` unless absolute from repo root. | Area | Location | Description | | ------------------ | ----------------------------------- | ------------------------------------------------------------ | | **Auth** | `lib/stores/authStore.ts` | OAuth (Kakao, Google, Apple) + session | -| **Search State** | `lib/stores/searchStore.ts` | Search query, filters, results | +| **Search State** | `lib/stores/searchStore.ts` | Search query, debouncedQuery, filters (category/mediaType/context/sort), page; re-exported from `@decoded/shared` | +| **Filter** | `lib/stores/filterStore.ts` | Category filter key (all/fashion/beauty/…); re-exported from `@decoded/shared` | | **Behavior** | `lib/stores/behaviorStore.ts` | Behavioral tracking state | | **VTON** | `lib/stores/vtonStore.ts` | Virtual try-on state | | **Collection** | `lib/stores/collectionStore.ts` | Collection/studio state | | **Magazine** | `lib/stores/magazineStore.ts` | Magazine/editorial state | +| **Active Spot** | `lib/stores/activeSpotStore.ts` | Currently selected spot on image canvas | +| **Studio** | `lib/stores/studioStore.ts` | Studio/collage creation state | +| **Request** | `lib/stores/requestStore.ts` | Post request/upload flow state | +| **Transition** | `lib/stores/transitionStore.ts` | Page transition animation state | | **API Client** | `lib/api/` | Backend API calls | +| **API Generated** | `lib/api/generated/` | Orval 자동 생성 — 절대 수동 편집 금지 (아래 섹션 참조) | | **API Routes** | `app/api/v1/` | Next.js API proxy & server logic | | **Supabase** | `lib/supabase/queries/` | DB queries (events, images, posts, profile, personalization) | | **Shared Queries** | `packages/shared/supabase/queries/` | Cross-package queries (images, items) | @@ -27,11 +33,84 @@ Paths below are under `packages/web/` unless absolute from repo root. | **Git workflow** | `docs/GIT-WORKFLOW.md` | Branch, commit, PR conventions | | **Code reviewer** | `.claude/agents/code-reviewer.md` | Repository code-review agent notes | +## Generated API (Orval + Zod) + +> `lib/api/generated/`는 **자동 생성 코드** — 절대 수동 편집하지 않는다. Gitignored (`.gitkeep`만 트래킹). + +### Source of truth + +`packages/api-server/openapi.json` (Rust backend utoipa에서 생성) + +### 재생성 + +```bash +cd packages/web && bun run generate:api +``` + +### 구조 + +``` +lib/api/ +├── generated/ # Orval 자동 생성 (수동 편집 금지) +│ ├── models/ # TypeScript 인터페이스 (response/request types) +│ ├── admin/ # Admin endpoint hooks (useListAdminPosts 등) +│ ├── posts/ # Posts endpoint hooks (useListPosts 등) +│ ├── search/ # Search endpoint hooks +│ ├── solutions/ # Solutions endpoint hooks +│ ├── spots/ # Spots endpoint hooks +│ ├── users/ # Users endpoint hooks +│ ├── zod/ # Zod 스키마 (decodedApi.zod.ts — 전체 ��드포인트 검증) +│ └── ... # 태그별 분리 (badges, categories, rankings 등) +├── mutator/ +│ └── custom-instance.ts # Axios 인스턴스 커스텀 (baseURL, 인터셉터) +├── server-instance.ts # 서버 컴포넌트용 API 클라이언트 +└── adapters/ # API 응답 → UI 모델 변환 +``` + +### Orval 설정 (`orval.config.ts`) + +| 설정 | 값 | +|------|-----| +| **Input** | `../api-server/openapi.json` | +| **Output mode** | `tags-split` (태그별 파일 분리) | +| **Client** | `react-query` (TanStack Query 5) | +| **HTTP client** | `axios` (custom-instance.ts mutator) | +| **Zod output** | `lib/api/generated/zod/` (별도 client: zod) | +| **제외 엔드포인트** | multipart POST 4개 (create_post, with-solutions, upload, analyze) | + +### 사용 패턴 + +```ts +// Hook import (태그/operationId 기반) +import { useListPosts } from "@/lib/api/generated/posts/posts"; + +// Zod 스키마 import +import { listPostsQueryParams } from "@/lib/api/generated/zod/decodedApi.zod"; + +// Type import +import type { PaginatedResponsePostListItem } from "@/lib/api/generated/models"; +``` + +### 새 엔드포인트 추가 시 + +1. Backend에서 OpenAPI spec 업데이트 +2. `packages/api-server/openapi.json` 복사 +3. `cd packages/web && bun run generate:api` +4. `@/lib/api/generated/{tag}/{operationId}`에서 생성된 hook import + +### 동작 확장 + +- **Axios 인터셉터**: `lib/api/mutator/custom-instance.ts` 편집 +- **Orval 설정**: `orval.config.ts` 편집 +- **생성 코드 자체**: 절대 편집하지 않음 + +--- + ## Custom hooks ### Data fetching -- `useImages()` - Fetch and paginate images with filters +- `useImages()` / `useInfinitePosts()` - Fetch and paginate images/posts with filters - `usePosts()` - Fetch and manage posts - `useProfile()` - Fetch user profile data - `useCategories()` - Fetch category list @@ -41,11 +120,15 @@ Paths below are under `packages/web/` unless absolute from repo root. - `useSpots()` - Fetch spot data for images - `useComments()` - Fetch and manage comments - `useTries()` - Fetch try-on results +- `useTrendingArtists()` - Fetch trending artist list +- `useExploreData()` - Unified explore hook: switches between browse mode (Supabase) and search mode (Meilisearch via `/api/v1/search`); exposes `mode`, artist/context facets, multi-select artist filter, sort, and pagination ### Social actions - `usePostLike()` - Like/unlike posts - `useSavedPost()` - Save/unsave posts +- `useReport()` - Submit content reports +- `useAdoptDropdown()` - Adopt a solution from dropdown ### Behavioral tracking @@ -59,6 +142,7 @@ Paths below are under `packages/web/` unless absolute from repo root. - `useImageUpload()` - Image uploads with compression - `useSearch()` - Search with debouncing - `useSearchURLSync()` - URL-based search state sync +- `usePretext()` - Pretext/context text generation ### UI & animation @@ -68,6 +152,15 @@ Paths below are under `packages/web/` unless absolute from repo root. - `useMediaQuery()` - Responsive breakpoint detection - `useSpotCardSync()` - Sync spot selection with card UI - `useDebounce()` - Debounce value changes +- `useItemCardGSAP()` - GSAP animation for item cards +- `useImageDimensions()` - Get image natural dimensions +- `useImageModalAnimation()` - Lightbox/modal open-close animation + +### VTON + +- `useVtonTryOn()` - Submit and poll VTON job +- `useVtonItemFetch()` - Fetch items compatible with VTON +- `useVtonScrollLock()` - Lock scroll while VTON modal is open ### Admin @@ -76,3 +169,7 @@ Paths below are under `packages/web/` unless absolute from repo root. - `useDashboard()` - Dashboard statistics - `usePipeline()` - Pipeline monitoring - `useServerLogs()` - Server log streaming +- `useAdminPosts()` / `useAdminPostEdit()` - Admin post list and metadata editing +- `useAdminReports()` - Admin content report list +- `useEditorialCandidates()` - Posts eligible for editorial promotion +- `useAdminPickList()` / `useCreatePick()` / `useUpdatePick()` / `useDeletePick()` - Decoded Pick CRUD (from `lib/hooks/admin/useAdminPicks.ts`) diff --git a/docs/agent/web-routes-and-features.md b/docs/agent/web-routes-and-features.md index 4e03ab2e..973d1b2b 100644 --- a/docs/agent/web-routes-and-features.md +++ b/docs/agent/web-routes-and-features.md @@ -6,8 +6,8 @@ App Router 기준 (`packages/web/app/`). 작업 시 이 표와 실제 `app/` 트 | Route | Description | | -------------------- | ---------------------------------------------------------------------------------- | -| `/` | Home - Hero carousel, trending, best sections, celebrity grid | -| `/explore` | Grid view with category filtering | +| `/` | Home — HeroItemSync, TrendingPostsSection, HelpFindSection, EditorialMagazine, DecodedPickSection, MasonryGrid, DomeGallerySection | +| `/explore` | Grid view with Meilisearch search, hierarchical filters, artist/context facets | | `/feed` | Social feed timeline | | `/search` | Full-screen overlay search with multi-tab results | | `/images` | Image discovery grid with infinite scroll | @@ -15,22 +15,42 @@ App Router 기준 (`packages/web/app/`). 작업 시 이 표와 실제 `app/` 트 | `/profile` | User profile with activity, badges, tries, stats, rankings, style DNA, collections | | `/editorial` | Daily editorial page with curated content | | `/magazine/personal` | Personal magazine issue viewer with decoding ritual | -| `/admin` | Admin dashboard (AI cost, audit, content, pipeline, server logs) | +| `/admin` | Admin dashboard (AI cost, audit, content, pipeline, server logs, picks) | +| `/admin/login` | Admin email/password login (exempted from proxy.ts auth middleware) | | `/admin/content` | Content management — post visibility, status control | +| `/admin/editorial-candidates` | Posts eligible for editorial promotion (spot ≥ 4, solution ≥ 1/spot) | +| `/admin/picks` | Decoded Pick curation — create/edit daily curated picks | | `/request/upload` | Image upload with DropZone | | `/request/detect` | AI detection results with item spotting | | `/login` | OAuth authentication (Kakao, Google, Apple) | | `/debug/supabase` | Supabase debug tools | | `/lab/*` | Experimental (ascii-text, fashion-scan) | +## Main page sections (`/`) + +Sections rendered in order: + +| Component | Description | +| --------- | ----------- | +| `HeroItemSync` | Hero carousel synced with item annotations; slides through recent + popular posts | +| `TrendingPostsSection` | Horizontal scroll of trending (popular) posts — up to 16 cards | +| `HelpFindSection` | Posts where `created_with_solutions = false` — community sourcing | +| `EditorialMagazine` | Magazine-style cards from posts with `post_magazine_title` | +| `DecodedPickSection` | Daily curated pick with style card + item grid; sourced from `decoded_picks` table | +| `MasonryGrid` | Masonry layout of popular posts (up to 16) | +| `DomeGallerySection` | Dome/panoramic gallery of popular post images (up to 20) | + ## Key feature areas - **Editorial & Magazine**: AI-curated editorial content, personal magazine issues, decoding ritual animations +- **Explore Search**: Meilisearch full-text search via `/api/v1/search` proxy; falls back to Supabase ilike. `useExploreData` hook switches between browse/search modes. Artist facets (client-side multi-select) and context filter (server-side). +- **Decoded Pick Curation**: Daily curated post managed from `/admin/picks`. `DecodedPickSection` on homepage displays the active pick with spot annotations and item cards. - **Social Actions**: Like, save, comment on posts with real-time counts - **Virtual Try-On (VTON)**: AI-powered virtual try-on with lazy-loaded modal - **Behavioral Intelligence**: Event tracking (dwell time, scroll depth), personalization engine - **Collection & Studio**: Boards, bookshelf, collage views, pins, issue management -- **Admin Dashboard**: AI cost tracking, audit logs, pipeline monitoring, server logs streaming +- **Admin Dashboard**: AI cost tracking, audit logs, pipeline monitoring, server logs streaming, editorial candidates, picks management +- **Error Handling**: `app/error.tsx` (route-level 500, inside layout, reports to Sentry), `app/global-error.tsx` (top-level fallback) - **Design System v2.0**: 36 components with comprehensive token system Next.js API proxy 목록은 [api-v1-routes.md](api-v1-routes.md)를 참고합니다. diff --git a/docs/superpowers/plans/2026-04-02-image-ratio-improvement.md b/docs/superpowers/plans/2026-04-02-image-ratio-improvement.md deleted file mode 100644 index 29f36a57..00000000 --- a/docs/superpowers/plans/2026-04-02-image-ratio-improvement.md +++ /dev/null @@ -1,684 +0,0 @@ -# Image Ratio Improvement Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace fixed aspect ratios and `object-cover` with Reddit-style `object-contain` + `max-height` so every post image is fully visible with zero crop and zero distortion. - -**Architecture:** Client-side `useImageDimensions` hook detects image dimensions via `Image.onload`, caches in memory + localStorage. Each component switches from `object-cover` to `object-contain` with a per-component `max-height`. Feature flags enable per-component rollback. - -**Tech Stack:** React 19, Next.js 16, TypeScript, Tailwind CSS - ---- - -## File Map - -| Action | File | Responsibility | -|--------|------|----------------| -| Create | `packages/web/lib/hooks/useImageDimensions.ts` | Client-side dimension detection + caching | -| Create | `packages/web/lib/config/feature-flags.ts` | Per-component feature flag config | -| Modify | `packages/web/lib/components/FeedCard.tsx` | object-contain + max-h-[80vh] | -| Modify | `packages/web/lib/components/explore/ExploreCardCell.tsx` | object-contain + max-h-[60vh] | -| Modify | `packages/web/lib/components/main-renewal/MasonryGridItem.tsx` | Real ratio heights + contain | -| Modify | `packages/web/lib/components/profile/PostsGrid.tsx` | object-contain + max-h-[300px] | - ---- - -### Task 1: Feature Flags Config - -**Files:** -- Create: `packages/web/lib/config/feature-flags.ts` - -- [ ] **Step 1: Create feature flags file** - -```typescript -// packages/web/lib/config/feature-flags.ts - -/** - * Per-component feature flags for image ratio improvement. - * Set to false to instantly rollback to original object-cover behavior. - */ -export const FEATURE_FLAGS = { - dynamicImageRatio: { - FeedCard: true, - ExploreCardCell: true, - MasonryGridItem: true, - PostsGrid: true, - }, -} as const; - -export type DynamicImageRatioComponent = keyof typeof FEATURE_FLAGS.dynamicImageRatio; -``` - -- [ ] **Step 2: Verify TypeScript compiles** - -Run: `cd packages/web && npx tsc --noEmit --pretty 2>&1 | head -20` -Expected: No errors related to feature-flags.ts - -- [ ] **Step 3: Commit** - -```bash -git add packages/web/lib/config/feature-flags.ts -git commit -m "feat: add feature flags for dynamic image ratio" -``` - ---- - -### Task 2: useImageDimensions Hook - -**Files:** -- Create: `packages/web/lib/hooks/useImageDimensions.ts` - -- [ ] **Step 1: Create the hook** - -```typescript -// packages/web/lib/hooks/useImageDimensions.ts -"use client"; - -import { useState, useEffect } from "react"; - -export interface ImageDimensions { - width: number | undefined; - height: number | undefined; - loading: boolean; -} - -/** Simple djb2 string hash → positive integer */ -function djb2Hash(str: string): number { - let hash = 5381; - for (let i = 0; i < str.length; i++) { - hash = (hash * 33) ^ str.charCodeAt(i); - } - return hash >>> 0; // unsigned 32-bit -} - -// ── Caches ────────────────────────────────────────────────────────── - -const memoryCache = new Map(); - -const LS_PREFIX = "img-dims:"; -const LS_MAX_ENTRIES = 500; -const LS_EVICT_COUNT = 100; - -function lsKey(url: string): string { - return `${LS_PREFIX}${djb2Hash(url)}`; -} - -function readLS(url: string): { w: number; h: number } | null { - try { - const raw = localStorage.getItem(lsKey(url)); - if (!raw) return null; - const parsed = JSON.parse(raw); - if (parsed && typeof parsed.w === "number" && typeof parsed.h === "number") { - return parsed; - } - return null; - } catch { - return null; - } -} - -function writeLS(url: string, w: number, h: number): void { - try { - // Evict old entries if at capacity - const keys: string[] = []; - for (let i = 0; i < localStorage.length; i++) { - const k = localStorage.key(i); - if (k?.startsWith(LS_PREFIX)) keys.push(k); - } - if (keys.length >= LS_MAX_ENTRIES) { - // Remove oldest entries (first inserted = first in iteration order) - keys.slice(0, LS_EVICT_COUNT).forEach((k) => localStorage.removeItem(k)); - } - localStorage.setItem(lsKey(url), JSON.stringify({ w, h })); - } catch { - // localStorage full or unavailable — memory cache still works - } -} - -// ── Inflight dedup ────────────────────────────────────────────────── - -const inflight = new Map>(); - -function detectDimensions(url: string): Promise<{ w: number; h: number }> { - const existing = inflight.get(url); - if (existing) return existing; - - const promise = new Promise<{ w: number; h: number }>((resolve, reject) => { - const img = new window.Image(); - img.onload = () => { - const dims = { w: img.naturalWidth, h: img.naturalHeight }; - memoryCache.set(url, dims); - writeLS(url, dims.w, dims.h); - inflight.delete(url); - resolve(dims); - }; - img.onerror = () => { - inflight.delete(url); - reject(new Error(`Failed to load: ${url}`)); - }; - img.src = url; - }); - - inflight.set(url, promise); - return promise; -} - -// ── Hook ──────────────────────────────────────────────────────────── - -/** - * Detects image dimensions client-side with memory + localStorage caching. - * - * Returns `{ width, height, loading }`. - * - While loading: width/height are undefined, loading is true. - * - After load: width/height are set, loading is false. - * - On error: width/height stay undefined, loading is false. - */ -export function useImageDimensions( - url: string | null | undefined -): ImageDimensions { - const [dims, setDims] = useState(() => { - if (!url) return { width: undefined, height: undefined, loading: false }; - - // Sync: check memory cache - const mem = memoryCache.get(url); - if (mem) return { width: mem.w, height: mem.h, loading: false }; - - // Sync: check localStorage - if (typeof window !== "undefined") { - const ls = readLS(url); - if (ls) { - memoryCache.set(url, ls); - return { width: ls.w, height: ls.h, loading: false }; - } - } - - return { width: undefined, height: undefined, loading: true }; - }); - - useEffect(() => { - if (!url) return; - - // Already resolved from initializer - if (dims.width !== undefined && !dims.loading) return; - - let cancelled = false; - detectDimensions(url) - .then(({ w, h }) => { - if (!cancelled) setDims({ width: w, height: h, loading: false }); - }) - .catch(() => { - if (!cancelled) - setDims({ width: undefined, height: undefined, loading: false }); - }); - - return () => { - cancelled = true; - }; - }, [url]); // eslint-disable-line react-hooks/exhaustive-deps - - return dims; -} -``` - -- [ ] **Step 2: Verify TypeScript compiles** - -Run: `cd packages/web && npx tsc --noEmit --pretty 2>&1 | head -20` -Expected: No errors related to useImageDimensions.ts - -- [ ] **Step 3: Commit** - -```bash -git add packages/web/lib/hooks/useImageDimensions.ts -git commit -m "feat: add useImageDimensions hook with memory + localStorage cache" -``` - ---- - -### Task 3: FeedCard — object-contain - -**Files:** -- Modify: `packages/web/lib/components/FeedCard.tsx:1-4` (imports) -- Modify: `packages/web/lib/components/FeedCard.tsx:218-232` (image container) - -- [ ] **Step 1: Add imports** - -At the top of `FeedCard.tsx`, after the existing imports (around line 15), add: - -```typescript -import { useImageDimensions } from "@/lib/hooks/useImageDimensions"; -import { FEATURE_FLAGS } from "@/lib/config/feature-flags"; -``` - -- [ ] **Step 2: Use the hook inside the component** - -Inside the `FeedCardInner` function body (after existing hooks around line 80), add: - -```typescript -const { width: imgW, height: imgH } = useImageDimensions(imageUrl); -const useDynamicRatio = FEATURE_FLAGS.dynamicImageRatio.FeedCard; -``` - -- [ ] **Step 3: Replace the image container** - -Replace lines 218-232 (the image container block): - -``` -{/* Image container - 4:5 aspect ratio like Instagram */} -
- {imageUrl && !imageError ? ( - {`Image setImageError(true)} - onLoad={() => setIsLoaded(true)} - /> - ) : ( -
- )} -``` - -With: - -```tsx -{/* Image container — dynamic ratio with object-contain */} -
- {imageUrl && !imageError ? ( - {`Image setImageError(true)} - onLoad={() => setIsLoaded(true)} - /> - ) : ( -
- )} -``` - -- [ ] **Step 4: Verify build** - -Run: `cd packages/web && npx tsc --noEmit --pretty 2>&1 | head -20` -Expected: No type errors - -- [ ] **Step 5: Commit** - -```bash -git add packages/web/lib/components/FeedCard.tsx -git commit -m "feat(FeedCard): switch to object-contain with max-height" -``` - ---- - -### Task 4: ExploreCardCell — object-contain - -**Files:** -- Modify: `packages/web/lib/components/explore/ExploreCardCell.tsx:1-11` (imports) -- Modify: `packages/web/lib/components/explore/ExploreCardCell.tsx:85-100` (image block) - -- [ ] **Step 1: Add imports** - -After existing imports (around line 11), add: - -```typescript -import { useImageDimensions } from "@/lib/hooks/useImageDimensions"; -import { FEATURE_FLAGS } from "@/lib/config/feature-flags"; -``` - -- [ ] **Step 2: Use hook in component** - -Inside the `ExploreCardCell` function body (after line 30 `const [isLoaded, setIsLoaded] = useState(false);`), add: - -```typescript -const { width: imgW, height: imgH } = useImageDimensions(imageUrl); -const useDynamicRatio = FEATURE_FLAGS.dynamicImageRatio.ExploreCardCell; -``` - -Note: `imageUrl` is from `item` — check what property name it uses. Looking at the code, it uses `item` from `ItemConfig`. Find the image URL field name and use that. - -- [ ] **Step 3: Replace image block** - -Replace lines 87-100 (the article with Image): - -``` -
- {`Image setImageError(true)} - onLoad={() => setIsLoaded(true)} - /> -``` - -With: - -```tsx -
- {useDynamicRatio ? ( - {`Image setImageError(true)} - onLoad={() => setIsLoaded(true)} - /> - ) : ( - {`Image setImageError(true)} - onLoad={() => setIsLoaded(true)} - /> - )} -``` - -Note: Add `import { cn } from "@/lib/utils";` if not already imported. - -- [ ] **Step 4: Verify build** - -Run: `cd packages/web && npx tsc --noEmit --pretty 2>&1 | head -20` -Expected: No type errors - -- [ ] **Step 5: Commit** - -```bash -git add packages/web/lib/components/explore/ExploreCardCell.tsx -git commit -m "feat(ExploreCardCell): switch to object-contain with max-height" -``` - ---- - -### Task 5: MasonryGridItem — real dimensions - -**Files:** -- Modify: `packages/web/lib/components/main-renewal/MasonryGridItem.tsx:1-32` (imports + height calc) -- Modify: `packages/web/lib/components/main-renewal/MasonryGridItem.tsx:153-168` (render) - -- [ ] **Step 1: Add imports** - -After existing imports (around line 8), add: - -```typescript -import { useImageDimensions } from "@/lib/hooks/useImageDimensions"; -import { FEATURE_FLAGS } from "@/lib/config/feature-flags"; -``` - -- [ ] **Step 2: Add dynamic height calculation** - -After the existing `clampHeight` function (line 32), add: - -```typescript -/** Calculate card height from real image dimensions, clamped to 200-500px range */ -function realHeight(imgW: number | undefined, imgH: number | undefined, columnWidth: number): number { - if (!imgW || !imgH) return 320; // fallback - const ratio = imgH / imgW; - const height = Math.round(columnWidth * ratio); - return Math.min(500, Math.max(200, height)); -} -``` - -- [ ] **Step 3: Use hook and dynamic height in component** - -Inside the `MasonryGridItem` function body, before the existing `const height = ...` line, add: - -```typescript -const { width: imgW, height: imgH } = useImageDimensions(item.imageUrl); -const useDynamicRatio = FEATURE_FLAGS.dynamicImageRatio.MasonryGridItem; -``` - -Replace the existing height calculation: - -``` -const height = clampHeight(item.aspectRatio ?? 1, index); -``` - -With: - -```typescript -const height = useDynamicRatio - ? realHeight(imgW, imgH, 280) // 280px approximate column width - : clampHeight(item.aspectRatio ?? 1, index); -``` - -- [ ] **Step 4: Replace Image render** - -Replace lines 162-168 (the Image element): - -``` -{item.title} -``` - -With: - -```tsx -{useDynamicRatio ? ( - {item.title} -) : ( - {item.title} -)} -``` - -- [ ] **Step 5: Verify build** - -Run: `cd packages/web && npx tsc --noEmit --pretty 2>&1 | head -20` -Expected: No type errors - -- [ ] **Step 6: Commit** - -```bash -git add packages/web/lib/components/main-renewal/MasonryGridItem.tsx -git commit -m "feat(MasonryGridItem): use real image dimensions for card height" -``` - ---- - -### Task 6: PostsGrid — object-contain - -**Files:** -- Modify: `packages/web/lib/components/profile/PostsGrid.tsx` - -- [ ] **Step 1: Add imports** - -After existing imports (around line 6), add: - -```typescript -import { useImageDimensions } from "@/lib/hooks/useImageDimensions"; -import { FEATURE_FLAGS } from "@/lib/config/feature-flags"; -``` - -- [ ] **Step 2: Create a wrapper component for each grid item** - -The current PostsGrid renders items inline in a `.map()`. We need `useImageDimensions` per item, which requires a component boundary. Add before the `PostsGrid` function: - -```typescript -function PostsGridItem({ post }: { post: PostItem }) { - const { width: imgW, height: imgH } = useImageDimensions(post.imageUrl); - const useDynamicRatio = FEATURE_FLAGS.dynamicImageRatio.PostsGrid; - - return ( - - {post.title -
-
-

- {post.title} -

-

{post.itemCount} items

-
- - ); -} -``` - -- [ ] **Step 3: Update PostsGrid to use the new component** - -Replace the grid rendering (lines 54-76): - -``` -
- {displayPosts.map((post) => ( - - {post.title -
-
-

- {post.title} -

-

{post.itemCount} items

-
- - ))} -
-``` - -With: - -```tsx -
- {displayPosts.map((post) => ( - - ))} -
-``` - -- [ ] **Step 4: Verify build** - -Run: `cd packages/web && npx tsc --noEmit --pretty 2>&1 | head -20` -Expected: No type errors - -- [ ] **Step 5: Commit** - -```bash -git add packages/web/lib/components/profile/PostsGrid.tsx -git commit -m "feat(PostsGrid): switch to object-contain with max-height" -``` - ---- - -### Task 7: Visual QA — Before/After Verification - -- [ ] **Step 1: Start dev server** - -Run: `cd packages/web && bun dev` - -- [ ] **Step 2: Check each page visually** - -Open in browser and verify: -- `/` (main page) — MasonryGrid items show full images, no crop -- `/explore` — ExploreCardCell shows full images, dark bg absorbs letterbox -- `/posts/{id}` — FeedCard shows full image with max-h constraint -- Profile page — PostsGrid items show full images - -- [ ] **Step 3: Test feature flag rollback** - -In `packages/web/lib/config/feature-flags.ts`, set all flags to `false`. -Verify all components revert to original fixed-ratio + object-cover behavior. -Then set flags back to `true`. - -- [ ] **Step 4: Test edge cases** - -- Refresh page — images should load from localStorage cache (instant) -- Open DevTools → Application → Local Storage — verify `img-dims:*` keys exist -- Test with slow network (DevTools throttle) — skeleton shows, then image appears -- Very tall/wide images display correctly within max-height constraints - -- [ ] **Step 5: Commit any adjustments** - -```bash -git add -A -git commit -m "fix: visual QA adjustments for image ratio improvement" -``` - -(Skip this commit if no adjustments were needed.) diff --git a/docs/superpowers/plans/2026-04-02-item-image-unification.md b/docs/superpowers/plans/2026-04-02-item-image-unification.md deleted file mode 100644 index c3bac354..00000000 --- a/docs/superpowers/plans/2026-04-02-item-image-unification.md +++ /dev/null @@ -1,712 +0,0 @@ -# ItemImage 컴포넌트 통일 구현 계획 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 프로젝트 전역의 아이템 이미지를 하나의 `ItemImage` 공통 컴포넌트로 통일하여 일관된 contain + blur 배경 UI 제공 - -**Architecture:** 새 `ItemImage` 컴포넌트를 `shared/`에 생성하고, 4개의 size 프리셋(thumbnail, card, detail, hero)으로 모든 아이템 이미지 사용처를 교체한다. next/image로 최적화하고 blur 배경은 CSS background-image + filter로 처리한다. - -**Tech Stack:** React 19, Next.js 16 (next/image), TypeScript, Tailwind CSS - -**Spec:** `docs/superpowers/specs/2026-04-02-item-image-unification-design.md` - ---- - -## File Structure - -| Action | File | Responsibility | -|--------|------|---------------| -| Create | `packages/web/lib/components/shared/ItemImage.tsx` | 공통 아이템 이미지 컴포넌트 | -| Modify | `packages/web/lib/components/detail/ShopGrid.tsx` | ShopGrid 아이템 카드 이미지 교체 | -| Modify | `packages/web/lib/components/detail/ItemDetailCard.tsx` | ItemDetailCard 이미지 교체 | -| Modify | `packages/web/lib/components/detail/TopSolutionCard.tsx` | TopSolutionCard 썸네일 교체 | -| Modify | `packages/web/lib/components/detail/OtherSolutionsList.tsx` | OtherSolutionsList 썸네일 교체 | -| Modify | `packages/web/lib/components/detail/magazine/MagazineItemsSection.tsx` | 메인 이미지 + 그리드 이미지 교체 | -| Modify | `packages/web/lib/components/main-renewal/DecodeShowcase.tsx` | 모바일/데스크톱 썸네일 교체 | - ---- - -### Task 1: ItemImage 컴포넌트 생성 - -**Files:** -- Create: `packages/web/lib/components/shared/ItemImage.tsx` - -- [ ] **Step 1: ItemImage 컴포넌트 작성** - -```tsx -"use client"; - -import { useState } from "react"; -import Image from "next/image"; -import { cn } from "@/lib/utils"; - -const SIZE_CONFIG = { - thumbnail: { - aspectRatio: "1/1", - sizes: "56px", - blur: false, - }, - card: { - aspectRatio: "3/4", - sizes: "(max-width: 768px) 50vw, 25vw", - blur: true, - }, - detail: { - aspectRatio: "3/4", - sizes: "(max-width: 768px) 100vw, 800px", - blur: true, - }, - hero: { - aspectRatio: "3/4", - sizes: "(max-width: 768px) 100vw, 50vw", - blur: true, - }, -} as const; - -type ItemImageSize = keyof typeof SIZE_CONFIG; - -interface ItemImageProps { - src: string; - alt: string; - size: ItemImageSize; - className?: string; - imgClassName?: string; - priority?: boolean; - onLoad?: () => void; - onError?: () => void; -} - -export function ItemImage({ - src, - alt, - size, - className, - imgClassName, - priority = false, - onLoad, - onError, -}: ItemImageProps) { - const [isLoaded, setIsLoaded] = useState(false); - const [hasError, setHasError] = useState(false); - const config = SIZE_CONFIG[size]; - - const handleLoad = () => { - setIsLoaded(true); - onLoad?.(); - }; - - const handleError = () => { - setHasError(true); - onError?.(); - }; - - if (hasError || !src) { - return ( -
- ); - } - - return ( -
- {/* Blur background for card/detail/hero */} - {config.blur && ( -
- )} - - {alt} -
- ); -} -``` - -- [ ] **Step 2: 타입 체크 확인** - -Run: `cd packages/web && npx tsc --noEmit --pretty 2>&1 | head -20` -Expected: ItemImage.tsx 관련 에러 없음 - -- [ ] **Step 3: 커밋** - -```bash -git add packages/web/lib/components/shared/ItemImage.tsx -git commit -m "feat: add ItemImage shared component with contain + blur background" -``` - ---- - -### Task 2: ShopGrid 아이템 카드 교체 - -**Files:** -- Modify: `packages/web/lib/components/detail/ShopGrid.tsx:258-281` - -- [ ] **Step 1: ShopGrid 이미지 영역 교체** - -`ShopGrid.tsx`에서 아이템 이미지 렌더링 부분(lines 258-281)을 찾아 교체한다. - -**Before** (lines 258-281): -```tsx -{/* Item Image */} -
- {item.imageUrl ? ( - <> - {item.product_name - - ) : ( -
- - No Image - -
- )} -
-``` - -**After:** -```tsx -{/* Item Image */} - -``` - -또한 파일 상단에 import 추가: -```tsx -import { ItemImage } from "@/lib/components/shared/ItemImage"; -``` - -기존 `Image` import에서 ShopGrid 내에서 다른 곳에서도 `Image`를 사용하는지 확인하고, 사용하지 않으면 import를 제거한다. - -- [ ] **Step 2: 타입 체크** - -Run: `cd packages/web && npx tsc --noEmit --pretty 2>&1 | grep -i "ShopGrid\|error" | head -10` -Expected: 에러 없음 - -- [ ] **Step 3: 커밋** - -```bash -git add packages/web/lib/components/detail/ShopGrid.tsx -git commit -m "refactor: replace ShopGrid item image with ItemImage component" -``` - ---- - -### Task 3: ItemDetailCard 교체 - -**Files:** -- Modify: `packages/web/lib/components/detail/ItemDetailCard.tsx:117-132` - -- [ ] **Step 1: ItemDetailCard 이미지 영역 교체** - -**Before** (lines 117-132): -```tsx -{/* Item Image - Layered Collage Style */} -
-
-
- {item.imageUrl && ( -
- {item.product_name -
- )} -
-``` - -**After:** -```tsx -{/* Item Image */} -
- -
-``` - -파일 상단 import 추가: -```tsx -import { ItemImage } from "@/lib/components/shared/ItemImage"; -``` - -기존 `Image` import가 더 이상 사용되지 않으면 제거. - -- [ ] **Step 2: 타입 체크** - -Run: `cd packages/web && npx tsc --noEmit --pretty 2>&1 | grep -i "ItemDetailCard\|error" | head -10` -Expected: 에러 없음 - -- [ ] **Step 3: 커밋** - -```bash -git add packages/web/lib/components/detail/ItemDetailCard.tsx -git commit -m "refactor: replace ItemDetailCard image with ItemImage component" -``` - ---- - -### Task 4: TopSolutionCard 썸네일 교체 - -**Files:** -- Modify: `packages/web/lib/components/detail/TopSolutionCard.tsx:68-90` - -- [ ] **Step 1: TopSolutionCard 썸네일 교체** - -이 컴포넌트에는 링크 감싸는 ``와 아닌 `
` 두 분기가 있다. 두 곳 모두 내부 이미지를 교체한다. - -**Before** (lines 68-90, 두 분기): -```tsx -{topSolution.thumbnail_url && - (linkUrl ? ( - - - - ) : ( -
- -
- ))} -``` - -**After:** -```tsx -{topSolution.thumbnail_url && - (linkUrl ? ( - - - - ) : ( -
- -
- ))} -``` - -파일 상단 import 추가: -```tsx -import { ItemImage } from "@/lib/components/shared/ItemImage"; -``` - -- [ ] **Step 2: 타입 체크** - -Run: `cd packages/web && npx tsc --noEmit --pretty 2>&1 | grep -i "TopSolutionCard\|error" | head -10` -Expected: 에러 없음 - -- [ ] **Step 3: 커밋** - -```bash -git add packages/web/lib/components/detail/TopSolutionCard.tsx -git commit -m "refactor: replace TopSolutionCard thumbnail with ItemImage component" -``` - ---- - -### Task 5: OtherSolutionsList 썸네일 교체 - -**Files:** -- Modify: `packages/web/lib/components/detail/OtherSolutionsList.tsx:93-115` - -- [ ] **Step 1: OtherSolutionsList 썸네일 교체** - -TopSolutionCard와 동일한 패턴. 두 분기(링크/비링크) 모두 교체. - -**Before** (lines 93-115): -```tsx -{sol.thumbnail_url && - (linkUrl ? ( - - - - ) : ( -
- -
- ))} -``` - -**After:** -```tsx -{sol.thumbnail_url && - (linkUrl ? ( - - - - ) : ( -
- -
- ))} -``` - -파일 상단 import 추가: -```tsx -import { ItemImage } from "@/lib/components/shared/ItemImage"; -``` - -- [ ] **Step 2: 타입 체크** - -Run: `cd packages/web && npx tsc --noEmit --pretty 2>&1 | grep -i "OtherSolutionsList\|error" | head -10` -Expected: 에러 없음 - -- [ ] **Step 3: 커밋** - -```bash -git add packages/web/lib/components/detail/OtherSolutionsList.tsx -git commit -m "refactor: replace OtherSolutionsList thumbnails with ItemImage component" -``` - ---- - -### Task 6: MagazineItemsSection 메인 이미지 + 그리드 교체 - -**Files:** -- Modify: `packages/web/lib/components/detail/magazine/MagazineItemsSection.tsx:186-194, 292-299` - -- [ ] **Step 1: MagazineItemsSection 메인 이미지 교체** - -**Before** (line 186): -```tsx -{/* Item Image */} -
- {item.image_url ? ( - {item.title} - ) : ( - ...placeholder... - )} -
-``` - -**After:** -```tsx -{/* Item Image */} -
- {item.image_url ? ( - - ) : ( -
- - {(i + 1).toString().padStart(2, "0")} - - {item.brand && ( - - {item.brand} - - )} -
- )} -
-``` - -- [ ] **Step 2: MagazineItemsSection Similar Items 그리드 교체** - -**Before** (line 292): -```tsx -
- {ri.image_url ? ( - {ri.title} - ) : ( - ...placeholder... - )} -
-``` - -**After:** -```tsx - -``` - -파일 상단 import 추가: -```tsx -import { ItemImage } from "@/lib/components/shared/ItemImage"; -``` - -기존 `Image` import가 더 이상 사용되지 않으면 제거. - -- [ ] **Step 3: Similar Items 마진 조정** - -메인 이미지가 aspect-square에서 3:4로 변경되므로, Similar Items 영역의 `ml-` offset을 확인하고 필요시 조정: -```tsx -{/* line 279: md:ml 값이 md:w-72/lg:w-80 + gap에 맞는지 확인 */} -
-``` -이 값은 `md:w-72`(18rem) + `md:gap-10`(2.5rem) 기준이므로 변경 불필요. - -- [ ] **Step 4: 타입 체크** - -Run: `cd packages/web && npx tsc --noEmit --pretty 2>&1 | grep -i "MagazineItemsSection\|error" | head -10` -Expected: 에러 없음 - -- [ ] **Step 5: 커밋** - -```bash -git add packages/web/lib/components/detail/magazine/MagazineItemsSection.tsx -git commit -m "refactor: replace MagazineItemsSection images with ItemImage component" -``` - ---- - -### Task 7: DecodeShowcase 썸네일 교체 - -**Files:** -- Modify: `packages/web/lib/components/main-renewal/DecodeShowcase.tsx:320-334, 361-375` - -- [ ] **Step 1: 모바일 카드 썸네일 교체** - -**Before** (lines 320-334): -```tsx -{item.imageUrl ? ( -
- {item.label} -
-) : ( -
- IMG -
-)} -``` - -**After:** -```tsx -{item.imageUrl ? ( -
- -
-) : ( -
- IMG -
-)} -``` - -- [ ] **Step 2: 데스크톱 카드 썸네일 교체** - -**Before** (lines 361-375): -```tsx -{item.imageUrl ? ( -
- {item.label} -
-) : ( -
- IMG -
-)} -``` - -**After:** -```tsx -{item.imageUrl ? ( -
- -
-) : ( -
- IMG -
-)} -``` - -파일 상단 import 추가: -```tsx -import { ItemImage } from "@/lib/components/shared/ItemImage"; -``` - -기존 `Image` import가 더 이상 사용되지 않으면 제거. DecodeShowcase에서 `Image`를 다른 곳(PostImage가 아닌 곳)에서도 사용하는지 확인. - -- [ ] **Step 3: 타입 체크** - -Run: `cd packages/web && npx tsc --noEmit --pretty 2>&1 | grep -i "DecodeShowcase\|error" | head -10` -Expected: 에러 없음 - -- [ ] **Step 4: 커밋** - -```bash -git add packages/web/lib/components/main-renewal/DecodeShowcase.tsx -git commit -m "refactor: replace DecodeShowcase thumbnails with ItemImage component" -``` - ---- - -### Task 8: 전체 빌드 검증 - -- [ ] **Step 1: 전체 타입 체크** - -Run: `cd packages/web && npx tsc --noEmit --pretty 2>&1 | tail -5` -Expected: 에러 0개 - -- [ ] **Step 2: 린트 체크** - -Run: `cd packages/web && npx eslint lib/components/shared/ItemImage.tsx lib/components/detail/ShopGrid.tsx lib/components/detail/ItemDetailCard.tsx lib/components/detail/TopSolutionCard.tsx lib/components/detail/OtherSolutionsList.tsx lib/components/detail/magazine/MagazineItemsSection.tsx lib/components/main-renewal/DecodeShowcase.tsx --no-warn-ignored 2>&1 | tail -10` -Expected: 에러 없음 - -- [ ] **Step 3: 빌드 체크** - -Run: `cd packages/web && bun run build 2>&1 | tail -10` -Expected: 빌드 성공 - -- [ ] **Step 4: 미사용 import 정리 커밋 (필요시)** - -각 파일에서 next/image의 `Image` import가 더 이상 사용되지 않으면 제거되었는지 최종 확인. - -```bash -git add -u -git commit -m "chore: clean up unused imports after ItemImage migration" -``` diff --git a/docs/superpowers/plans/2026-04-04-editorial-ui-sizing-unification.md b/docs/superpowers/plans/2026-04-04-editorial-ui-sizing-unification.md new file mode 100644 index 00000000..0a7657b1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-04-editorial-ui-sizing-unification.md @@ -0,0 +1,170 @@ +# Editorial UI Sizing Unification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Unify artist profile, item image, and brand logo sizing across explore-preview panel, full editorial page, mobile, and desktop. + +**Architecture:** CSS class changes only in 2 files. No logic, prop, or structural changes. Artist profile switches from horizontal left-aligned to vertical centered layout. + +**Tech Stack:** Tailwind CSS classes, React/TSX + +--- + +### Task 1: Artist Profile — explore-preview (right panel) + +**Files:** +- Modify: `packages/web/lib/components/detail/ImageDetailContent.tsx:295-313` + +- [ ] **Step 1: Update explore-preview artist profile layout and sizes** + +Change the artist profile section at line 288-313. Replace the existing render block: + +```tsx +{isExplorePreview && (() => { + const profile = + artistProfiles?.[imageWithOwner.artist_name?.toLowerCase() ?? ""] || + artistProfiles?.[imageWithOwner.group_name?.toLowerCase() ?? ""]; + const displayName = profile?.name || imageWithOwner.artist_name || imageWithOwner.group_name; + if (!displayName) return null; + return ( +
+ {profile?.profileImageUrl ? ( + + ) : ( +
+ {displayName.charAt(0).toUpperCase()} +
+ )} +
+

{displayName}

+

Artist

+
+
+ ); +})()} +``` + +Key changes: +- Layout: `flex items-center gap-3 px-6 py-3` → `flex flex-col items-center gap-2 px-6 py-5` +- Image: `w-8 h-8` → `w-12 h-12` +- Fallback: `w-8 h-8 text-xs` → `w-12 h-12 text-base` +- Name: `text-sm` → `text-lg` +- Label: `text-[10px]` → `text-[11px]` +- Text wrapper: add `text-center` + +--- + +### Task 2: Artist Profile — full page + +**Files:** +- Modify: `packages/web/lib/components/detail/ImageDetailContent.tsx:347-372` + +- [ ] **Step 1: Update full page artist profile (identical to Task 1)** + +Change the full page artist profile section at line 347-372. Replace the existing render block: + +```tsx +{!isExplorePreview && (() => { + const profile = + artistProfiles?.[imageWithOwner.artist_name?.toLowerCase() ?? ""] || + artistProfiles?.[imageWithOwner.group_name?.toLowerCase() ?? ""]; + const displayName = profile?.name || imageWithOwner.artist_name || imageWithOwner.group_name; + if (!displayName) return null; + return ( +
+ {profile?.profileImageUrl ? ( + + ) : ( +
+ {displayName.charAt(0).toUpperCase()} +
+ )} +
+

{displayName}

+

Artist

+
+
+ ); +})()} +``` + +- [ ] **Step 2: Verify both profiles are now identical** + +Run: `grep -n 'w-12 h-12' packages/web/lib/components/detail/ImageDetailContent.tsx` +Expected: 4 matches (2 img + 2 fallback div) + +--- + +### Task 3: Magazine Items — image size + brand logo + +**Files:** +- Modify: `packages/web/lib/components/detail/magazine/MagazineItemsSection.tsx:198,257,261` + +- [ ] **Step 1: Increase compact item image width** + +Line 198 — change compact image wrapper size: + +```tsx +// Before: +
+ +// After: +
+``` + +- [ ] **Step 2: Increase brand logo size** + +Line 257 — change brand profile image: + +```tsx +// Before: +className="w-5 h-5 rounded-full object-cover flex-shrink-0" + +// After: +className="w-7 h-7 rounded-full object-cover flex-shrink-0" +``` + +- [ ] **Step 3: Unify brand text style** + +Line 261 — remove compact text override: + +```tsx +// Before: +

+ +// After: +

+``` + +--- + +### Task 4: Build Verification + Commit + +- [ ] **Step 1: Type check** + +Run: `cd packages/web && npx tsc --noEmit --pretty 2>&1 | head -20` +Expected: No errors in modified files + +- [ ] **Step 2: Build check** + +Run: `cd packages/web && bun run build 2>&1 | tail -10` +Expected: Build success + +- [ ] **Step 3: Commit** + +```bash +git add packages/web/lib/components/detail/ImageDetailContent.tsx packages/web/lib/components/detail/magazine/MagazineItemsSection.tsx +git commit -m "style(detail): unify artist profile, item image, and brand sizing + +- Artist profile: 48px centered layout (both explore-preview and full page) +- Magazine items: compact image md:w-48, brand logo 28px +- Brand text: unified typography-overline across compact/full" +``` diff --git a/docs/superpowers/specs/2026-04-03-explore-search-filter-modal-design.md b/docs/superpowers/specs/2026-04-03-explore-search-filter-modal-design.md new file mode 100644 index 00000000..b8d364ac --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-explore-search-filter-modal-design.md @@ -0,0 +1,235 @@ +# Explore 검색/필터 연동 및 패널 모달 개선 + +**Issue**: [#61](https://github.com/decodedcorp/decoded/issues/61) +**Date**: 2026-04-03 +**Status**: Approved +**Epic**: #35 (1차 릴리즈) + +## Overview + +Explore 페이지에 Meilisearch 검색 연결, hierarchical filter 로직 연동, 패널 모달 editorial 미리보기 개선. 하이브리드 검색 아키텍처(검색어 → Meilisearch, 필터 → Supabase)를 통합 훅으로 캡슐화. + +## Decisions + +| 결정 사항 | 선택 | 이유 | +|-----------|------|------| +| 검색 방식 | 하이브리드 (Meilisearch + Supabase) | 검색어 최적화 + 필터 성능 양립 | +| 필터 데이터 | Mock 유지 | 1차 릴리즈 스코프, 연결 검증 우선 | +| 모달 변경 | Right drawer 콘텐츠만 | 기존 레이아웃/트리거/spot scroll 보존 | +| 아키텍처 | 통합 훅 useExploreData | ExploreClient 단순 유지, 데이터 소스 격리 | + +## Architecture + +### 데이터 흐름 + +``` +searchStore.debouncedQuery ──┐ + ├──→ useExploreData({ hasMagazine }) +hierarchicalFilterStore ─────┘ │ + (mediaId, castId, contextType) │ + ├── query 있음 → useSearch (Meilisearch) + ├── query 없음 → useInfinitePosts (Supabase) + │ + └── return { items, isLoading, isError, + fetchNextPage, hasNextPage, + isFetchingNextPage, mode, activeFilters } +``` + +### ExploreClient 렌더 구조 (변경 후) + +``` +ExploreClient.tsx +├── TrendingArtistsSection (기존 유지) +├── ExploreFilterBar (새로 추가 — 기존 컴포넌트 import) +│ └── 칩: mediaId, castId, contextType 표시 +├── ThiingsGrid (기존 유지, data source → useExploreData) +│ └── items from useExploreData +└── ExploreFilterSheet (모바일 바텀시트, 새로 추가) +``` + +## Component Changes + +### 1. 새 파일: `useExploreData` 훅 + +**경로**: `packages/web/lib/hooks/useExploreData.ts` + +**역할**: 검색/필터 상태를 읽어 적절한 데이터 소스를 선택하고 통합된 인터페이스 반환. + +```typescript +interface UseExploreDataOptions { + hasMagazine?: boolean; +} + +interface UseExploreDataReturn { + items: PostGridItem[]; + isLoading: boolean; + isError: boolean; + fetchNextPage: () => void; + hasNextPage: boolean; + isFetchingNextPage: boolean; + mode: "search" | "browse"; + activeFilters: { + mediaId: string | null; + castId: string | null; + contextType: string | null; + }; +} +``` + +**내부 로직**: +- 두 훅 모두 항상 호출 (React 규칙), `enabled` 옵션으로 비활성화 +- browse 모드: `useInfinitePosts({ enabled: !debouncedQuery, mediaName: mediaId, castName: castId, contextType, hasMagazine, limit: 40 })` +- search 모드: `useSearch({ enabled: !!debouncedQuery, query: debouncedQuery })` +- search 결과를 `PostGridItem[]`으로 매핑하는 변환 함수 포함 + +**queryKey 구조**: +```typescript +// browse 모드 +["explore-posts", { mediaId, castId, contextType, hasMagazine }] + +// search 모드 (useSearch 내부 키 사용) +["search", { query: debouncedQuery }] +``` + +### 2. 변경: `ExploreClient.tsx` + +**변경 범위**: 데이터 소스를 `useExploreData`로 교체, 필터 UI 추가 + +**변경 전**: +```typescript +const debouncedQuery = useSearchStore((state) => state.debouncedQuery); +const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } + = useInfinitePosts({ limit: 40, hasMagazine: hasMagazine ?? false }); +``` + +**변경 후**: +```typescript +const { items, isLoading, isError, fetchNextPage, hasNextPage, + isFetchingNextPage, mode, activeFilters } = useExploreData({ + hasMagazine: hasMagazine ?? false, +}); +``` + +**렌더 추가**: +- `` — TrendingArtistsSection 아래에 배치 +- `` — 컴포넌트 하단에 배치 + +**데이터 매핑 변경**: +- 기존: `data.pages.flatMap(page => page.items)` +- 변경: `items` 직접 사용 (useExploreData가 flatten 처리) + +### 3. 변경: `useInfinitePosts` (minor) + +**경로**: `packages/web/lib/hooks/useImages.ts` + +**변경**: `enabled` 옵션 지원 추가 + +```typescript +export function useInfinitePosts(params: { + // ... 기존 params + enabled?: boolean; // 추가 — default: true +}) +``` + +`useInfiniteQuery`의 `enabled` 옵션으로 전달. + +### 4. 변경: `ImageDetailContent` — Editorial 미리보기 + +**variant prop 추가**: +```typescript +type ImageDetailContentProps = { + variant?: "full" | "explore-preview"; // default: "full" + // ... 기존 props +}; +``` + +**variant="explore-preview" 동작**: +- `EditorialPreviewHeader` 표시: title(h2), description(2-3줄 truncate), 메타(작성자/날짜), "전체 editorial 보기" 버튼 +- `ShowcaseSection` 유지 (spot scroll 그대로) +- `ShopSection`, `CommentsSection`, `RelatedSection` 숨김 + +**variant="full" 동작**: 기존과 동일 (변경 없음) + +### 5. 새 컴포넌트: `EditorialPreviewHeader` + +**경로**: `packages/web/lib/components/detail/EditorialPreviewHeader.tsx` + +```typescript +interface EditorialPreviewHeaderProps { + title: string; + description?: string; + author?: string; + date?: string; + postId: string; + onNavigateToFull: () => void; +} +``` + +**렌더링**: +- Post title (h2, font-bold) +- Description 요약 (line-clamp-3) +- 메타 정보 행 (작성자 + 날짜) +- "전체 editorial 보기 →" 버튼 (Link to `/posts/[id]`, 모달 close) + +### 6. 변경: Intercepting route + ExploreCardCell + +**Intercepting route 경로**: `packages/web/app/@modal/(.)posts/[id]/page.tsx` + +이 route는 루트 레벨 공유 모달이므로, Explore에서 온 것인지 구분이 필요합니다. + +**방식**: ExploreCardCell의 Link에 query param 추가 + +```typescript +// ExploreCardCell.tsx + +``` + +**Intercepting route에서 variant 결정**: +```typescript +// @modal/(.)posts/[id]/page.tsx +type Props = { + params: Promise<{ id: string }>; + searchParams: Promise<{ from?: string }>; +}; + +export default async function ModalPostDetailPage({ params, searchParams }: Props) { + const { id } = await params; + const { from } = await searchParams; + const variant = from === "explore" ? "explore-preview" : "full"; + return ; +} +``` + +`ImageDetailModal`이 variant를 `ImageDetailContent`에 전달. + +## Files Changed + +| 파일 | 변경 유형 | 설명 | +|------|----------|------| +| `packages/web/lib/hooks/useExploreData.ts` | 신규 | 통합 데이터 훅 | +| `packages/web/app/explore/ExploreClient.tsx` | 수정 | 데이터 소스 교체, 필터 UI 추가 | +| `packages/web/lib/hooks/useImages.ts` | 수정 | useInfinitePosts에 enabled 옵션 추가 | +| `packages/web/lib/components/detail/ImageDetailContent.tsx` | 수정 | variant prop, 조건부 섹션 렌더링 | +| `packages/web/lib/components/detail/EditorialPreviewHeader.tsx` | 신규 | Editorial 미리보기 헤더 | +| `packages/web/app/@modal/(.)posts/[id]/page.tsx` | 수정 | searchParams에서 from 읽어 variant 결정 | +| `packages/web/lib/components/explore/ExploreCardCell.tsx` | 수정 | Link href에 ?from=explore 추가 | + +## Error Handling + +- **검색 실패**: Meilisearch 에러 시 빈 결과 + 에러 토스트. Supabase 필터 브라우징으로 자동 폴백하지 않음 (사용자 의도 존중) +- **필터 결과 없음**: "선택한 필터에 맞는 결과가 없습니다" empty state 표시 +- **모달 데이터 로딩 실패**: 기존 ImageDetailModal 에러 핸들링 유지 + +## Testing Strategy + +- `useExploreData`: 모드 전환 테스트 (query 유무에 따른 분기) +- Filter → refetch: hierarchicalFilterStore 변경 시 queryKey 변경 확인 +- 모달 variant: explore-preview에서 숨겨지는 섹션 확인 +- "전체 보기" 버튼: 라우팅 + 모달 close 동작 + +## Out of Scope + +- 필터 옵션을 실제 API에서 가져오기 (mock 유지) +- URL에 필터 상태 반영 (후속 이슈) +- 검색 자동완성/추천 +- 필터 간 종속 관계 (media 선택 시 cast 필터링 — mock에서만 동작) diff --git a/docs/superpowers/specs/2026-04-04-editorial-ui-sizing-unification.md b/docs/superpowers/specs/2026-04-04-editorial-ui-sizing-unification.md new file mode 100644 index 00000000..b28d0491 --- /dev/null +++ b/docs/superpowers/specs/2026-04-04-editorial-ui-sizing-unification.md @@ -0,0 +1,70 @@ +# Editorial UI Sizing Unification + +**Date:** 2026-04-04 +**Scope:** explore-preview (right panel), full editorial page, mobile/desktop +**Branch:** feat/phase1-remaining + +## Problem + +아티스트 프로필, 아이템 이미지, 브랜드 로고의 크기가 작고, explore-preview(오른쪽 패널)와 full page 간 사이즈 불일치. +Similar Items가 메인 아이템보다 시각적으로 큰 문제. + +## Design Decisions + +### 1. Artist Profile — 가운데 정렬 + 크기 확대 + +**대상 파일:** `packages/web/lib/components/detail/ImageDetailContent.tsx` +**대상 위치:** 2곳 (explore-preview 라인 288-313, full page 라인 347-372) + +| 속성 | Before | After | +|------|--------|-------| +| 레이아웃 | `flex items-center gap-3 px-6 py-3` (왼쪽 수평) | `flex flex-col items-center gap-2 px-6 py-5` (가운데 수직) | +| 이미지 크기 | `w-8 h-8` (32px) | `w-12 h-12` (48px) | +| 이름 폰트 | `text-sm font-semibold` (14px) | `text-lg font-semibold` (18px) | +| 라벨 폰트 | `text-[10px]` | `text-[11px]` | +| Fallback 아바타 | `w-8 h-8 text-xs` | `w-12 h-12 text-base` | + +두 위치 모두 동일한 스타일 적용 (explore-preview와 full page 통일). + +### 2. Magazine Items — 아이템 이미지 확대 + +**대상 파일:** `packages/web/lib/components/detail/magazine/MagazineItemsSection.tsx` + +| 속성 | Before (compact) | Before (full) | After (compact) | After (full) | +|------|-----------------|---------------|-----------------|-------------| +| 이미지 wrapper | `md:w-36 lg:w-40` | `md:w-60 lg:w-64` | `md:w-48 lg:w-52` | `md:w-60 lg:w-64` (유지) | +| 브랜드 로고 | `w-5 h-5` (20px) | `w-5 h-5` (20px) | `w-7 h-7` (28px) | `w-7 h-7` (28px) | +| 브랜드 텍스트 | `text-[10px]` | `typography-overline` | `typography-overline` | `typography-overline` (통일) | + +### 3. Similar Items — 변경 없음 + +현재 compact에서 `size="thumbnail"` (56px), full에서 `size="card"`. +메인 아이템이 커지면 자연스럽게 비율 해결. + +## Files to Modify + +1. **`packages/web/lib/components/detail/ImageDetailContent.tsx`** + - 아티스트 프로필 섹션 2곳: 가운데 정렬 + 크기 증가 + +2. **`packages/web/lib/components/detail/magazine/MagazineItemsSection.tsx`** + - compact 모드 아이템 이미지 wrapper 크기 증가 + - 브랜드 로고 크기 증가 (compact + full 모두) + - 브랜드 텍스트 통일 + +## Out of Scope + +- ShopGrid (non-magazine post) — 이번 변경 대상 아님 +- ShopCarouselSection — 별도 컴포넌트, 이번 대상 아님 +- Similar Items 크기 — 메인 아이템 확대로 자연 해결 +- compact/full 분기 구조 자체 — 유지 +- 애니메이션/GSAP — 변경 없음 + +## Success Criteria + +- [ ] 아티스트 프로필: 48px 이미지, 18px 이름, 가운데 정렬 (모든 뷰) +- [ ] 메인 아이템 이미지: compact md:w-48, full md:w-60 유지 +- [ ] 브랜드 로고: 28px (모든 뷰) +- [ ] 브랜드 텍스트: typography-overline 통일 +- [ ] 오른쪽 패널 / full page / 모바일 / 데스크탑 모두 일관된 사이즈 +- [ ] 기존 GSAP 애니메이션 깨지지 않음 +- [ ] 빌드 성공 diff --git a/packages/api-server/README.md b/packages/api-server/README.md index 1c3270f4..c22589f0 100644 --- a/packages/api-server/README.md +++ b/packages/api-server/README.md @@ -37,7 +37,7 @@ just dev # pre-push 훅 + .env.dev 준비 + docker/dev (API :8000, Meilisearc ## 프로젝트 통계 -- **총 라인 수**: ~28,170 lines (Rust) +- **총 라인 수**: ~28,425 lines (Rust) - **파일 수**: 182 files - **도메인**: 16 domains (users, categories, posts, spots, solutions, comments, votes, feed, rankings, badges, earnings, search, admin, post_magazines, post_likes, saved_posts) - **마지막 업데이트**: 2026.03.26 diff --git a/packages/api-server/openapi.json b/packages/api-server/openapi.json index c8b32941..98259b23 100644 --- a/packages/api-server/openapi.json +++ b/packages/api-server/openapi.json @@ -1944,6 +1944,26 @@ "format": "uuid" } }, + { + "name": "artist_id", + "in": "query", + "description": "아티스트 ID 필터 (warehouse FK)", + "required": false, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "group_id", + "in": "query", + "description": "그룹 ID 필터 (warehouse FK)", + "required": false, + "schema": { + "type": "string", + "format": "uuid" + } + }, { "name": "sort", "in": "query", @@ -6404,6 +6424,14 @@ "created_at" ], "properties": { + "artist_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "아티스트 ID (warehouse FK, nullable)" + }, "artist_name": { "type": [ "string", @@ -6435,6 +6463,14 @@ ], "description": "포스트 생성 시 솔루션을 알고 등록했는지. true=with-solutions, false=without, null=기존 데이터" }, + "group_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "그룹 ID (warehouse FK, nullable)" + }, "group_name": { "type": [ "string", @@ -6574,6 +6610,14 @@ ], "description": "제휴 링크 URL" }, + "brand_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "브랜드 ID (warehouse FK, nullable)" + }, "created_at": { "type": "string", "format": "date-time", @@ -7025,6 +7069,14 @@ "created_at" ], "properties": { + "artist_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "아티스트 ID (warehouse FK, nullable)" + }, "artist_name": { "type": [ "string", @@ -7050,10 +7102,20 @@ "description": "생성일시" }, "created_with_solutions": { - "type": "boolean", - "nullable": true, + "type": [ + "boolean", + "null" + ], "description": "포스트 생성 시 솔루션을 알고 등록했는지. true=with-solutions, false=without, null=기존 데이터" }, + "group_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "그룹 ID (warehouse FK, nullable)" + }, "group_name": { "type": [ "string", @@ -7780,6 +7842,14 @@ ], "description": "제휴 링크 URL" }, + "brand_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "브랜드 ID (warehouse FK, nullable)" + }, "created_at": { "type": "string", "format": "date-time", @@ -7874,6 +7944,14 @@ ], "description": "제휴 링크 URL" }, + "brand_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "브랜드 ID (warehouse FK, nullable)" + }, "click_count": { "type": "integer", "format": "int32", @@ -9210,4 +9288,4 @@ "description": "Admin operations" } ] -} \ No newline at end of file +} diff --git a/packages/api-server/src/domains/admin/editorial_candidates.rs b/packages/api-server/src/domains/admin/editorial_candidates.rs index 4a56b547..046f239b 100644 --- a/packages/api-server/src/domains/admin/editorial_candidates.rs +++ b/packages/api-server/src/domains/admin/editorial_candidates.rs @@ -139,7 +139,11 @@ pub async fn list_candidates( // Step 3: Paginate in-memory let total = eligible.len() as u64; let offset = ((page - 1) * per_page) as usize; - let data: Vec = eligible.into_iter().skip(offset).take(per_page as usize).collect(); + let data: Vec = eligible + .into_iter() + .skip(offset) + .take(per_page as usize) + .collect(); Ok(Json(EditorialCandidateListResponse { data, @@ -167,7 +171,10 @@ mod tests { use super::*; fn meets_editorial_criteria(spot_count: usize, solutions_per_spot: &[u64]) -> bool { - spot_count >= MIN_SPOTS && solutions_per_spot.iter().all(|&c| c >= MIN_SOLUTIONS_PER_SPOT) + spot_count >= MIN_SPOTS + && solutions_per_spot + .iter() + .all(|&c| c >= MIN_SOLUTIONS_PER_SPOT) } #[test] diff --git a/packages/api-server/src/domains/admin/handlers.rs b/packages/api-server/src/domains/admin/handlers.rs index aef9d602..55e5b0ba 100644 --- a/packages/api-server/src/domains/admin/handlers.rs +++ b/packages/api-server/src/domains/admin/handlers.rs @@ -30,10 +30,7 @@ pub fn router(state: AppState, app_config: AppConfig) -> Router { dashboard::router(state.clone(), app_config.clone()), ) .nest("/badges", badges::router(app_config.clone())) - .nest( - "/reports", - reports::admin_router(app_config.clone()), - ) + .nest("/reports", reports::admin_router(app_config.clone())) .nest( "/magazine-sessions", magazine_sessions::router(state, app_config), diff --git a/packages/api-server/src/domains/admin/posts.rs b/packages/api-server/src/domains/admin/posts.rs index 7b2f8810..94499d44 100644 --- a/packages/api-server/src/domains/admin/posts.rs +++ b/packages/api-server/src/domains/admin/posts.rs @@ -110,7 +110,9 @@ pub async fn update_post_status( ))); } - let post = service::admin_update_post_status(&state.search_client, &state.db, post_id, &dto.status).await?; + let post = + service::admin_update_post_status(&state.search_client, &state.db, post_id, &dto.status) + .await?; Ok(Json(post)) } diff --git a/packages/api-server/src/domains/posts/dto.rs b/packages/api-server/src/domains/posts/dto.rs index d7d37c93..b0219ce5 100644 --- a/packages/api-server/src/domains/posts/dto.rs +++ b/packages/api-server/src/domains/posts/dto.rs @@ -159,6 +159,14 @@ pub struct PostListQuery { #[serde(skip_serializing_if = "Option::is_none")] pub user_id: Option, + /// 아티스트 ID 필터 (warehouse FK) + #[serde(skip_serializing_if = "Option::is_none")] + pub artist_id: Option, + + /// 그룹 ID 필터 (warehouse FK) + #[serde(skip_serializing_if = "Option::is_none")] + pub group_id: Option, + /// 정렬 방식 #[serde(default = "default_sort")] pub sort: String, // 'recent' | 'popular' | 'trending' @@ -269,6 +277,14 @@ pub struct PostListItem { #[serde(skip_serializing_if = "Option::is_none")] pub group_name: Option, + /// 아티스트 ID (warehouse FK, nullable) + #[serde(skip_serializing_if = "Option::is_none")] + pub artist_id: Option, + + /// 그룹 ID (warehouse FK, nullable) + #[serde(skip_serializing_if = "Option::is_none")] + pub group_id: Option, + /// 상황 정보 #[serde(skip_serializing_if = "Option::is_none")] pub context: Option, diff --git a/packages/api-server/src/domains/posts/handlers.rs b/packages/api-server/src/domains/posts/handlers.rs index cfeed746..d382fd19 100644 --- a/packages/api-server/src/domains/posts/handlers.rs +++ b/packages/api-server/src/domains/posts/handlers.rs @@ -204,6 +204,8 @@ pub async fn create_post_with_solutions( ("context" = Option, Query, description = "상황 필터"), ("category" = Option, Query, description = "카테고리 필터 (Phase 7)"), ("user_id" = Option, Query, description = "사용자 ID 필터"), + ("artist_id" = Option, Query, description = "아티스트 ID 필터 (warehouse FK)"), + ("group_id" = Option, Query, description = "그룹 ID 필터 (warehouse FK)"), ("sort" = Option, Query, description = "정렬: recent | popular | trending"), ("page" = Option, Query, description = "페이지 번호"), ("per_page" = Option, Query, description = "페이지당 개수"), diff --git a/packages/api-server/src/domains/posts/service.rs b/packages/api-server/src/domains/posts/service.rs index 6653fdb9..11c29558 100644 --- a/packages/api-server/src/domains/posts/service.rs +++ b/packages/api-server/src/domains/posts/service.rs @@ -612,6 +612,14 @@ pub async fn list_posts( select = select.filter(Column::GroupName.eq(group_name)); } + if let Some(artist_id) = query.artist_id { + select = select.filter(Column::ArtistId.eq(artist_id)); + } + + if let Some(group_id) = query.group_id { + select = select.filter(Column::GroupId.eq(group_id)); + } + if let Some(ref context) = query.context { select = select.filter(Column::Context.eq(context)); } @@ -825,6 +833,8 @@ pub async fn list_posts( title: post.title.clone(), artist_name: post.artist_name, group_name: post.group_name, + artist_id: post.artist_id, + group_id: post.group_id, context: post.context, spot_count, view_count: post.view_count, @@ -1110,6 +1120,8 @@ pub async fn admin_list_posts( title: post.title.clone(), artist_name: post.artist_name, group_name: post.group_name, + artist_id: post.artist_id, + group_id: post.group_id, context: post.context, spot_count, view_count: post.view_count, @@ -1149,11 +1161,7 @@ pub async fn admin_update_post_status( match status { "hidden" | "deleted" => { if let Err(e) = search_client.delete("posts", &post_id.to_string()).await { - tracing::warn!( - "Failed to delete post {} from Meilisearch: {}", - post_id, - e - ); + tracing::warn!("Failed to delete post {} from Meilisearch: {}", post_id, e); } } "active" => { @@ -1165,11 +1173,7 @@ pub async fn admin_update_post_status( .update_document("posts", &post_id.to_string(), doc) .await { - tracing::warn!( - "Failed to update post {} in Meilisearch: {}", - post_id, - e - ); + tracing::warn!("Failed to update post {} in Meilisearch: {}", post_id, e); } } _ => {} diff --git a/packages/api-server/src/domains/reports/handlers.rs b/packages/api-server/src/domains/reports/handlers.rs index 2fadd7dc..d19f1c35 100644 --- a/packages/api-server/src/domains/reports/handlers.rs +++ b/packages/api-server/src/domains/reports/handlers.rs @@ -137,8 +137,7 @@ async fn admin_update_report( Path(report_id): Path, Json(dto): Json, ) -> AppResult> { - let report = - service::admin_update_report_status(&state.db, report_id, user.id, dto).await?; + let report = service::admin_update_report_status(&state.db, report_id, user.id, dto).await?; Ok(Json(report)) } diff --git a/packages/api-server/src/domains/reports/service.rs b/packages/api-server/src/domains/reports/service.rs index 4d640c51..646709b4 100644 --- a/packages/api-server/src/domains/reports/service.rs +++ b/packages/api-server/src/domains/reports/service.rs @@ -92,11 +92,7 @@ pub async fn admin_list_reports( q = q.order_by_desc(Column::CreatedAt); - let total = q - .clone() - .count(db) - .await - .map_err(AppError::DatabaseError)?; + let total = q.clone().count(db).await.map_err(AppError::DatabaseError)?; let reports = q .offset((pagination.page - 1) * pagination.per_page) diff --git a/packages/api-server/src/domains/solutions/dto.rs b/packages/api-server/src/domains/solutions/dto.rs index 2fdc4f6a..b395a72a 100644 --- a/packages/api-server/src/domains/solutions/dto.rs +++ b/packages/api-server/src/domains/solutions/dto.rs @@ -129,6 +129,10 @@ pub struct SolutionResponse { /// Spot ID pub spot_id: Uuid, + /// 브랜드 ID (warehouse FK, nullable) + #[serde(skip_serializing_if = "Option::is_none")] + pub brand_id: Option, + /// 사용자 정보 pub user: UserResponse, @@ -196,6 +200,10 @@ pub struct SolutionListItem { /// Solution ID pub id: Uuid, + /// 브랜드 ID (warehouse FK, nullable) + #[serde(skip_serializing_if = "Option::is_none")] + pub brand_id: Option, + /// 사용자 정보 pub user: UserResponse, diff --git a/packages/api-server/src/domains/solutions/service.rs b/packages/api-server/src/domains/solutions/service.rs index 6503d26e..86c3c82d 100644 --- a/packages/api-server/src/domains/solutions/service.rs +++ b/packages/api-server/src/domains/solutions/service.rs @@ -63,6 +63,7 @@ pub async fn list_solutions_by_spot_id( items.push(SolutionListItem { id: solution.id, + brand_id: solution.brand_id, user: user.into(), match_type: solution.match_type, link_type: solution.link_type, @@ -100,6 +101,7 @@ pub async fn get_solution_by_id( Ok(SolutionResponse { id: solution.id, spot_id: solution.spot_id, + brand_id: solution.brand_id, user: user.into(), match_type: solution.match_type, link_type: solution.link_type, @@ -268,6 +270,7 @@ pub async fn admin_list_solutions( }; items.push(SolutionListItem { id: solution.id, + brand_id: solution.brand_id, user: user.into(), match_type: solution.match_type, link_type: solution.link_type, diff --git a/packages/web/app/@modal/(.)posts/[id]/page.tsx b/packages/web/app/@modal/(.)posts/[id]/page.tsx index 2b85a7c7..285ffa5d 100644 --- a/packages/web/app/@modal/(.)posts/[id]/page.tsx +++ b/packages/web/app/@modal/(.)posts/[id]/page.tsx @@ -1,4 +1,5 @@ import { ImageDetailModal } from "@/lib/components/detail/ImageDetailModal"; +import { buildArtistProfileMap, buildBrandProfileMap } from "@/lib/supabase/queries/warehouse-entities.server"; type Props = { params: Promise<{ id: string }>; @@ -17,5 +18,22 @@ export default async function ModalPostDetailPage({ const { id } = await params; const { from } = await searchParams; const variant = from === "explore" ? "explore-preview" : "full"; - return ; + + // Fetch artist/group + brand profile data in parallel + const [artistProfileMap, brandProfileMap] = await Promise.all([ + buildArtistProfileMap(), + buildBrandProfileMap(), + ]); + + const artistProfiles: Record = {}; + artistProfileMap.forEach((value, key) => { + artistProfiles[key] = value; + }); + + const brandProfiles: Record = {}; + brandProfileMap.forEach((value, key) => { + brandProfiles[key] = value; + }); + + return ; } diff --git a/packages/web/app/admin/layout.tsx b/packages/web/app/admin/layout.tsx index c6574fcb..f32e1b5a 100644 --- a/packages/web/app/admin/layout.tsx +++ b/packages/web/app/admin/layout.tsx @@ -1,4 +1,3 @@ -import { redirect } from "next/navigation"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { checkIsAdmin } from "@/lib/supabase/admin"; import { AdminLayoutClient } from "@/lib/components/admin/AdminLayoutClient"; @@ -6,16 +5,16 @@ import { AdminLayoutClient } from "@/lib/components/admin/AdminLayoutClient"; /** * Admin Layout - Server component * - * Double-checks admin status server-side (middleware is the first check; - * this is the second check at layout level as specified by AAUTH-03). + * Auth strategy: + * - proxy.ts is the first line of defense: redirects unauthenticated/non-admin + * users to /admin/login (except /admin/login itself which is exempt). + * - This layout is the second check (AAUTH-03): if no user or not admin, + * renders children WITHOUT the admin chrome (sidebar) instead of redirecting. + * This avoids redirect loops for /admin/login while keeping the security check. * - * - No session → redirect to / - * - Not admin → redirect to / + * - No session → render children only (login page shows without admin chrome) + * - Not admin → render children only * - Is admin → render AdminLayoutClient with sidebar + content - * - * This layout is completely separate from the main app layout: - * ConditionalNav, ConditionalFooter, and MainContentWrapper are hidden - * on /admin/* routes (handled in their respective components). */ export default async function AdminLayout({ children, @@ -28,13 +27,15 @@ export default async function AdminLayout({ data: { user }, } = await supabase.auth.getUser(); + // No user: render children directly (proxy.ts handles redirecting + // non-login admin routes to /admin/login) if (!user) { - redirect("/"); + return <>{children}; } const isAdmin = await checkIsAdmin(supabase, user.id); if (!isAdmin) { - redirect("/"); + return <>{children}; } // Extract display name from user metadata diff --git a/packages/web/app/admin/login/page.tsx b/packages/web/app/admin/login/page.tsx new file mode 100644 index 00000000..cc3d1836 --- /dev/null +++ b/packages/web/app/admin/login/page.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { supabaseBrowserClient } from "@/lib/supabase/client"; + +export default function AdminLoginPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + + const { error: authError } = await supabaseBrowserClient.auth.signInWithPassword({ + email, + password, + }); + + if (authError) { + setError(authError.message); + setLoading(false); + return; + } + + router.replace("/admin"); + router.refresh(); + }; + + return ( +

+
+
+

DECODED

+

Admin Dashboard

+
+ +
+
+ + setEmail(e.target.value)} + required + autoComplete="email" + className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-sm text-white placeholder:text-white/20 focus:border-[#eafd67]/50 focus:outline-none focus:ring-1 focus:ring-[#eafd67]/50" + placeholder="admin@decoded.style" + /> +
+ +
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-sm text-white placeholder:text-white/20 focus:border-[#eafd67]/50 focus:outline-none focus:ring-1 focus:ring-[#eafd67]/50" + placeholder="Enter password" + /> +
+ + {error &&

{error}

} + + +
+ +

Authorized personnel only

+
+
+ ); +} diff --git a/packages/web/app/api/v1/search/route.ts b/packages/web/app/api/v1/search/route.ts index 4c97de5a..e1220214 100644 --- a/packages/web/app/api/v1/search/route.ts +++ b/packages/web/app/api/v1/search/route.ts @@ -112,7 +112,7 @@ async function supabaseSearchFallback(searchParams: URLSearchParams) { view_count: post.view_count ?? 0, type: "post", media_source: post.media_type - ? { type: post.media_type, title: post.media_title ?? null } + ? { type: post.media_type, title: post.title ?? null } : null, highlight: null, })); diff --git a/packages/web/app/error.tsx b/packages/web/app/error.tsx new file mode 100644 index 00000000..fc5de21f --- /dev/null +++ b/packages/web/app/error.tsx @@ -0,0 +1,49 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import Link from "next/link"; +import { useEffect } from "react"; + +/** + * Route-level error boundary (500) + * + * Renders inside the app layout — Tailwind classes work here. + * Called when a route segment throws during render or data fetching. + */ +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( +
+

500

+

+ Something went wrong +

+

+ An unexpected error occurred. Please try again or return home. +

+
+ + + Go Home + +
+
+ ); +} diff --git a/packages/web/app/explore/ExploreClient.tsx b/packages/web/app/explore/ExploreClient.tsx index 4d309e41..8c2926c7 100644 --- a/packages/web/app/explore/ExploreClient.tsx +++ b/packages/web/app/explore/ExploreClient.tsx @@ -47,7 +47,7 @@ export function ExploreClient({ }, [debouncedValue, setDebouncedQuery]); // Initialize from server-provided URL query - // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { if (initialQuery && !query) { setQuery(initialQuery); @@ -90,7 +90,7 @@ export function ExploreClient({ addRecentSearch(selectedQuery); inputRef.current?.blur(); }, - [setQuery, setDebouncedQuery, addRecentSearch], + [setQuery, setDebouncedQuery, addRecentSearch] ); const handleKeyDown = useCallback( @@ -106,7 +106,7 @@ export function ExploreClient({ setShowSuggestions(false); } }, - [showSuggestions], + [showSuggestions] ); const handleClear = useCallback(() => { @@ -123,7 +123,7 @@ export function ExploreClient({ const updateGridSize = () => { const isMobile = window.innerWidth < 768; setGridSize( - isMobile ? { width: 180, height: 225 } : { width: 400, height: 500 }, + isMobile ? { width: 180, height: 225 } : { width: 400, height: 500 } ); }; updateGridSize(); @@ -158,10 +158,10 @@ export function ExploreClient({ 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'); + 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); @@ -199,7 +199,9 @@ export function ExploreClient({ }, [artistFacets]); const hasActiveFilters = - selectedArtists.length > 0 || activeContext !== null || activeSort !== "relevant"; + selectedArtists.length > 0 || + activeContext !== null || + activeSort !== "relevant"; return (
@@ -251,7 +253,7 @@ export function ExploreClient({ "appearance-none rounded-full pl-3 pr-7 py-1 text-xs font-medium border transition-colors cursor-pointer bg-transparent", activeSort !== "relevant" ? "border-primary/30 bg-primary/10 text-primary" - : "border-border text-muted-foreground hover:bg-accent", + : "border-border text-muted-foreground hover:bg-accent" )} > {SORT_OPTIONS.map((opt) => ( @@ -275,7 +277,7 @@ export function ExploreClient({ "appearance-none rounded-full pl-3 pr-7 py-1 text-xs font-medium border transition-colors cursor-pointer bg-transparent", activeContext ? "border-primary/30 bg-primary/10 text-primary" - : "border-border text-muted-foreground hover:bg-accent", + : "border-border text-muted-foreground hover:bg-accent" )} > @@ -353,7 +355,7 @@ export function ExploreClient({ {(() => { console.error( "[ExploreClient] Posts fetch error:", - error, + error ); if (error instanceof Error) return error.message; if (typeof error === "object" && error !== null) @@ -396,7 +398,11 @@ export function ExploreClient({
} + renderItem={(config) => ( + + )} initialPosition={{ x: 0, y: 0 }} items={gridItems} onReachEnd={() => { diff --git a/packages/web/app/explore/page.tsx b/packages/web/app/explore/page.tsx index 2a5ac89a..d519e990 100644 --- a/packages/web/app/explore/page.tsx +++ b/packages/web/app/explore/page.tsx @@ -5,11 +5,35 @@ type Props = { searchParams: Promise<{ q?: string }>; }; +function ExploreSkeleton() { + return ( +
+
+ {/* Search bar skeleton */} +
+ {/* Grid skeleton */} +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+
+
+ ); +} + export default async function ExplorePage({ searchParams }: Props) { const { q } = await searchParams; + return ( - - + }> + ); } diff --git a/packages/web/app/global-error.tsx b/packages/web/app/global-error.tsx index d8388b8b..f3367735 100644 --- a/packages/web/app/global-error.tsx +++ b/packages/web/app/global-error.tsx @@ -3,6 +3,12 @@ import * as Sentry from "@sentry/nextjs"; import { useEffect } from "react"; +/** + * Global error boundary — renders OUTSIDE the app layout. + * + * Tailwind is not available here, so inline styles are used + * to match brand: bg #050505, text #fafafa, accent #eafd67. + */ export default function GlobalError({ error, reset, @@ -15,10 +21,69 @@ export default function GlobalError({ }, [error]); return ( - - -

Something went wrong!

- + + +

+ 500 +

+

+ Something went wrong +

+

+ An unexpected error occurred. Please try again or return home. +

+
+ + + Go Home + +
); diff --git a/packages/web/app/not-found.tsx b/packages/web/app/not-found.tsx index 7da4bc46..c6f288ce 100644 --- a/packages/web/app/not-found.tsx +++ b/packages/web/app/not-found.tsx @@ -3,25 +3,38 @@ import Link from "next/link"; /** * Custom 404 Not Found Page * + * Brand-consistent design with DECODED identity. * This replaces Next.js's auto-generated /_not-found page * which was causing React.Children.only errors with parallel routes. */ export default function NotFound() { return ( -
-

404

-

+
+

+ DECODED +

+

404

+
+

Page Not Found

-

+

The page you're looking for doesn't exist or has been moved.

- - Go Home - +
+ + Go Home + + + Explore + +
); } diff --git a/packages/web/app/page.tsx b/packages/web/app/page.tsx index 2959ec71..844e4ca7 100644 --- a/packages/web/app/page.tsx +++ b/packages/web/app/page.tsx @@ -13,8 +13,6 @@ import { HeroItemSync, TrendingPostsSection, HelpFindSection, - EditorialSection, - TrendingListSection, DecodedPickSection, } from "@/lib/components/main"; import type { LatestPostCardData, StyleCardData, ItemCardData } from "@/lib/components/main"; diff --git a/packages/web/app/posts/[id]/page.tsx b/packages/web/app/posts/[id]/page.tsx index 859552d0..0fa9836f 100644 --- a/packages/web/app/posts/[id]/page.tsx +++ b/packages/web/app/posts/[id]/page.tsx @@ -1,4 +1,5 @@ import { ImageDetailPage } from "@/lib/components/detail/ImageDetailPage"; +import { buildArtistProfileMap, buildBrandProfileMap } from "@/lib/supabase/queries/warehouse-entities.server"; type Props = { params: Promise<{ id: string }>; @@ -10,5 +11,22 @@ type Props = { */ export default async function PostDetailPageRoute({ params }: Props) { const { id } = await params; - return ; + + // Fetch artist/group + brand profile data in parallel + const [artistProfileMap, brandProfileMap] = await Promise.all([ + buildArtistProfileMap(), + buildBrandProfileMap(), + ]); + + const artistProfiles: Record = {}; + artistProfileMap.forEach((value, key) => { + artistProfiles[key] = value; + }); + + const brandProfiles: Record = {}; + brandProfileMap.forEach((value, key) => { + brandProfiles[key] = value; + }); + + return ; } diff --git a/packages/web/lib/components/detail/DecodedItemsSection.tsx b/packages/web/lib/components/detail/DecodedItemsSection.tsx index e962303a..9871c3c9 100644 --- a/packages/web/lib/components/detail/DecodedItemsSection.tsx +++ b/packages/web/lib/components/detail/DecodedItemsSection.tsx @@ -50,8 +50,9 @@ function formatPrice(amount: number | null, currency: string): string { function extractBrand(solution: SolutionRow | undefined): string { if (!solution) return "Unknown"; // Try keywords first - if (solution.keywords && solution.keywords.length > 0) { - return solution.keywords[0].toUpperCase(); + const kw = solution.keywords as string[] | null; + if (kw && kw.length > 0) { + return kw[0].toUpperCase(); } // Try extracting from title -- take first word as brand if (solution.title) { diff --git a/packages/web/lib/components/detail/ImageDetailContent.tsx b/packages/web/lib/components/detail/ImageDetailContent.tsx index 56fd1db8..945177ad 100644 --- a/packages/web/lib/components/detail/ImageDetailContent.tsx +++ b/packages/web/lib/components/detail/ImageDetailContent.tsx @@ -46,6 +46,8 @@ type Props = { hideImage?: boolean; onHeroClick?: () => void; variant?: "full" | "explore-preview"; + artistProfiles?: Record; + brandProfiles?: Record; }; /** @@ -68,6 +70,8 @@ export function ImageDetailContent({ hideImage = false, onHeroClick, variant = "full", + artistProfiles, + brandProfiles, }: Props) { const isExplorePreview = variant === "explore-preview"; const hasMagazine = !!magazineLayout; @@ -280,6 +284,34 @@ export function ImageDetailContent({ /> )} + {/* Artist/Group profile — explore-preview only */} + {isExplorePreview && (() => { + const profile = + artistProfiles?.[imageWithOwner.artist_name?.toLowerCase() ?? ""] || + artistProfiles?.[imageWithOwner.group_name?.toLowerCase() ?? ""]; + const displayName = profile?.name || imageWithOwner.artist_name || imageWithOwner.group_name; + if (!displayName) return null; + return ( +
+ {profile?.profileImageUrl ? ( + + ) : ( +
+ {displayName.charAt(0).toUpperCase()} +
+ )} +
+

{displayName}

+

Artist

+
+
+ ); + })()} + {/* Decorative Vertical Typography - Shown on desktop (Full Page & Modal) */} {!isExplorePreview && (
@@ -311,6 +343,34 @@ export function ImageDetailContent({ )} + {/* Artist/Group profile — full page and magazine mode */} + {!isExplorePreview && (() => { + const profile = + artistProfiles?.[imageWithOwner.artist_name?.toLowerCase() ?? ""] || + artistProfiles?.[imageWithOwner.group_name?.toLowerCase() ?? ""]; + const displayName = profile?.name || imageWithOwner.artist_name || imageWithOwner.group_name; + if (!displayName) return null; + return ( +
+ {profile?.profileImageUrl ? ( + + ) : ( +
+ {displayName.charAt(0).toUpperCase()} +
+ )} +
+

{displayName}

+

Artist

+
+
+ ); + })()} + {/* AI Summary Section — only rendered when summary exists */} {aiSummary && !isExplorePreview && (
)} @@ -408,6 +469,7 @@ export function ImageDetailContent({ onAddSolutionClick={(spotId) => setSpotIdToAddSolution(spotId) } + brandProfiles={brandProfiles} />
)} diff --git a/packages/web/lib/components/detail/ImageDetailModal.tsx b/packages/web/lib/components/detail/ImageDetailModal.tsx index 7dcff47b..e1c0bf74 100644 --- a/packages/web/lib/components/detail/ImageDetailModal.tsx +++ b/packages/web/lib/components/detail/ImageDetailModal.tsx @@ -17,13 +17,15 @@ import { useImageModalAnimation } from "@/lib/hooks/useImageModalAnimation"; type Props = { imageId: string; variant?: "full" | "explore-preview"; + artistProfiles?: Record; + brandProfiles?: Record; }; /** * Side Drawer version of image detail page * Used when navigating from grid (intercepting route) */ -export function ImageDetailModal({ imageId, variant = "full" }: Props) { +export function ImageDetailModal({ imageId, variant = "full", artistProfiles, brandProfiles }: Props) { const router = useRouter(); const { data: image, isLoading, error } = usePostDetailForImage(imageId); const magazineId = (image as ImageDetailWithPostOwner)?.post_magazine_id; @@ -210,6 +212,8 @@ export function ImageDetailModal({ imageId, variant = "full" }: Props) { } activeIndex={activeIndex} onActiveIndexChange={setActiveIndex} + artistProfiles={artistProfiles} + brandProfiles={brandProfiles} /> ); } @@ -226,6 +230,8 @@ export function ImageDetailModal({ imageId, variant = "full" }: Props) { scrollContainerRef={scrollContainerRef as React.RefObject} activeIndex={activeIndex} onActiveIndexChange={setActiveIndex} + artistProfiles={artistProfiles} + brandProfiles={brandProfiles} /> ); }; diff --git a/packages/web/lib/components/detail/ImageDetailPage.tsx b/packages/web/lib/components/detail/ImageDetailPage.tsx index c96fcd38..85f9535f 100644 --- a/packages/web/lib/components/detail/ImageDetailPage.tsx +++ b/packages/web/lib/components/detail/ImageDetailPage.tsx @@ -14,6 +14,8 @@ import type { ImageDetailWithPostOwner } from "@/lib/api/adapters/postDetailToIm type Props = { imageId: string; + artistProfiles?: Record; + brandProfiles?: Record; }; /** @@ -21,7 +23,7 @@ type Props = { * Used when directly accessing URL or refreshing page * Now renders post data instead of old image data */ -export function ImageDetailPage({ imageId }: Props) { +export function ImageDetailPage({ imageId, artistProfiles, brandProfiles }: Props) { const router = useRouter(); const { data: image, isLoading, error } = usePostDetailForImage(imageId); const magazineId = (image as ImageDetailWithPostOwner)?.post_magazine_id; @@ -106,6 +108,8 @@ export function ImageDetailPage({ imageId }: Props) { image={image} magazineLayout={showMagazine ? magazine!.layout_json : null} relatedEditorials={magazine?.related_editorials ?? []} + artistProfiles={artistProfiles} + brandProfiles={brandProfiles} /> {/* Lightbox */} diff --git a/packages/web/lib/components/detail/ShopCarouselSection.tsx b/packages/web/lib/components/detail/ShopCarouselSection.tsx index f535702a..99aeea12 100644 --- a/packages/web/lib/components/detail/ShopCarouselSection.tsx +++ b/packages/web/lib/components/detail/ShopCarouselSection.tsx @@ -35,8 +35,9 @@ function formatPrice(amount: number | null, currency: string): string { * Extract brand from solution */ function extractBrand(solution: SolutionRow): string { - if (solution.keywords && solution.keywords.length > 0) { - return solution.keywords[0].toUpperCase(); + const kw = solution.keywords as string[] | null; + if (kw && kw.length > 0) { + return kw[0].toUpperCase(); } if (solution.title) { const firstWord = solution.title.split(" ")[0]; @@ -166,7 +167,7 @@ export function ShopCarouselSection({ solutions }: Props) { const brand = extractBrand(solution); const price = formatPrice( solution.price_amount, - solution.price_currency + solution.price_currency ?? "KRW" ); const shopUrl = solution.affiliate_url || solution.original_url || "#"; diff --git a/packages/web/lib/components/detail/ShopGrid.tsx b/packages/web/lib/components/detail/ShopGrid.tsx index b4643725..07ea57cf 100644 --- a/packages/web/lib/components/detail/ShopGrid.tsx +++ b/packages/web/lib/components/detail/ShopGrid.tsx @@ -23,6 +23,8 @@ type Props = { postId?: string; /** CTA 클릭 시 솔루션 등록 시트 열기 (postId 필요) */ onAddSolutionClick?: (spotId: string) => void; + /** Brand profile lookup for displaying brand logos */ + brandProfiles?: Record; }; /** @@ -42,6 +44,7 @@ export function ShopGrid({ isModal = false, postId, onAddSolutionClick, + brandProfiles, }: Props) { const router = useRouter(); const containerRef = useRef(null); @@ -269,13 +272,26 @@ export function ShopGrid({ {/* Item Details */}
{item.brand && ( -

- {item.brand} -

+ {(() => { + const brandProfile = brandProfiles?.[item.brand.toLowerCase()]; + if (!brandProfile?.profileImageUrl) return null; + return ( + {brandProfile.name} + ); + })()} +

+ {item.brand} +

+
)}

handleTryClick(tryPost.id)} /> ))} diff --git a/packages/web/lib/components/detail/magazine/MagazineItemsSection.tsx b/packages/web/lib/components/detail/magazine/MagazineItemsSection.tsx index 4e5eb86a..848ca9fd 100644 --- a/packages/web/lib/components/detail/magazine/MagazineItemsSection.tsx +++ b/packages/web/lib/components/detail/magazine/MagazineItemsSection.tsx @@ -24,6 +24,7 @@ type Props = { compact?: boolean; scrollContainerRef?: RefObject; onActiveIndexChange?: (index: number | null) => void; + brandProfiles?: Record; }; export function MagazineItemsSection({ @@ -34,6 +35,7 @@ export function MagazineItemsSection({ compact = false, scrollContainerRef, onActiveIndexChange, + brandProfiles, }: Props) { const sectionRef = useRef(null); const [sectionWidth, setSectionWidth] = useState(0); @@ -193,7 +195,7 @@ export function MagazineItemsSection({ className={`flex flex-col ${compact ? "gap-4 md:flex-row md:gap-5" : `gap-6 md:flex-row md:gap-10 ${i % 2 === 1 ? "md:flex-row-reverse" : ""}`}`} > {/* Item Image */} -
+
{item.image_url ? ( {item.brand && ( -

- {item.brand} -

+
+ {(() => { + const bp = brandProfiles?.[item.brand.toLowerCase()]; + if (!bp?.profileImageUrl) return null; + return ( + {bp.name} + ); + })()} +

+ {item.brand} +

+
)}

)?.brand as string ?? null, product_name: solution?.title || null, // Use title as product_name cropped_image_path: solution?.thumbnail_url || null, price: solution?.price_amount?.toString() || null, diff --git a/packages/web/lib/components/explore/ExploreCardCell.tsx b/packages/web/lib/components/explore/ExploreCardCell.tsx index 87989753..20036d05 100644 --- a/packages/web/lib/components/explore/ExploreCardCell.tsx +++ b/packages/web/lib/components/explore/ExploreCardCell.tsx @@ -126,12 +126,12 @@ export const ExploreCardCell = memo(function ExploreCardCell({ onLoad={() => setIsLoaded(true)} onError={() => setImageError(true)} /> - {/* Artist name overlay — search mode only (when highlight exists) */} + {/* Search highlight overlay — only visible when search highlights exist */} {item?.highlight && item.postAccount && (

diff --git a/packages/web/lib/components/main-renewal/CommunityLeaderboard.tsx b/packages/web/lib/components/main-renewal/CommunityLeaderboard.tsx deleted file mode 100644 index 7ddf38c7..00000000 --- a/packages/web/lib/components/main-renewal/CommunityLeaderboard.tsx +++ /dev/null @@ -1,191 +0,0 @@ -"use client"; - -import Image from "next/image"; -import type { CommunityLeaderboardData } from "./types"; - -interface CommunityLeaderboardProps { - data: CommunityLeaderboardData; - className?: string; -} - -const RANK_COLORS = [ - "from-yellow-400 to-amber-500", // #1 Gold - "from-slate-300 to-slate-400", // #2 Silver - "from-amber-600 to-orange-700", // #3 Bronze -]; - -const RANK_BORDER = [ - "border-yellow-500/40", - "border-slate-400/40", - "border-amber-600/40", -]; - -export default function CommunityLeaderboard({ - data, - className, -}: CommunityLeaderboardProps) { - const top3 = data.trendingUsers.slice(0, 3); - const rest = data.trendingUsers.slice(3); - - return ( -
- {/* Section header */} -
-

- Community -

-

- Style DNA & Rank -

-
- - {/* Top 3 Podium */} - {top3.length > 0 && ( -
-
- {top3.map((user, i) => ( -
- {/* Rank badge */} -
- {i + 1} -
- -
- {/* Avatar */} -
- {user.avatarUrl ? ( - {user.username} - ) : ( -
- {user.username.charAt(0).toUpperCase()} -
- )} -
- -
-

- {user.username} -

-

- {user.score > 0 - ? `${user.score.toLocaleString()} ink` - : "Style Pioneer"} -

-
-
- - {/* Style DNA tags */} - {user.styleTags.length > 0 && ( -
- {user.styleTags.map((tag) => ( - - {tag} - - ))} -
- )} -
- ))} -
-
- )} - - {/* Rest of leaderboard (horizontal scroll) */} - {rest.length > 0 && ( -
-
- {rest.map((user, i) => ( -
-
-
- #{i + 4} -
-
-

- {user.username} -

-

- {user.score > 0 - ? `${user.score.toLocaleString()} ink` - : "Style Pioneer"} -

-
-
- - {user.styleTags.length > 0 && ( -
- {user.styleTags.map((tag) => ( - - {tag} - - ))} -
- )} -
- ))} -
-
- )} - - {/* Empty state */} - {data.trendingUsers.length === 0 && ( -
-
-

Leaderboard data will appear here

-
-
- )} - - {/* Trending tags */} - {data.trendingTags && data.trendingTags.length > 0 && ( -
-

- Trending This Week -

-
- {data.trendingTags.map((tag) => ( - - #{tag} - - ))} -
-
- )} -
- ); -} diff --git a/packages/web/lib/components/main-renewal/PersonalizeBanner.tsx b/packages/web/lib/components/main-renewal/PersonalizeBanner.tsx deleted file mode 100644 index 5b831caa..00000000 --- a/packages/web/lib/components/main-renewal/PersonalizeBanner.tsx +++ /dev/null @@ -1,189 +0,0 @@ -"use client"; - -import { useRef, useEffect, useMemo, useState, useCallback } from "react"; -import gsap from "gsap"; -import { ScrollTrigger } from "gsap/ScrollTrigger"; -import { DomeGallery } from "@/lib/components/dome"; - -import type { PersonalizeBannerData } from "./types"; - -gsap.registerPlugin(ScrollTrigger); - -interface PersonalizeBannerProps { - data: PersonalizeBannerData; - className?: string; -} - -/** - * PersonalizeBanner -- Soft Wall CTA section. - * - * Uses DomeGallery (3D sphere gallery) as immersive background. - * Headline + CTA encourage login through compelling visual experience. - */ -const SNS_NAMES = [ - "Instagram", - "Facebook", - "YouTube", - "TikTok", - "Pinterest", - "X", -]; - -export default function PersonalizeBanner({ - data, - className, -}: PersonalizeBannerProps) { - const sectionRef = useRef(null); - const textRef = useRef(null); - const ctaRef = useRef(null); - const slotRef = useRef(null); - const [currentIndex, setCurrentIndex] = useState(0); - - const snsNames = data.snsNames ?? SNS_NAMES; - - const galleryImages = useMemo( - () => - data.images.map((src, i) => ({ src, alt: `Magazine image ${i + 1}` })), - [data.images] - ); - - // Slot machine animation -- cycles SNS names vertically - const animateSlot = useCallback(() => { - const el = slotRef.current; - if (!el) return; - - // Slide current name up and fade out - gsap.to(el, { - yPercent: -100, - opacity: 0, - duration: 0.4, - ease: "power2.in", - onComplete: () => { - setCurrentIndex((prev) => (prev + 1) % snsNames.length); - // Position new name below, then slide up into view - gsap.set(el, { yPercent: 100, opacity: 0 }); - gsap.to(el, { - yPercent: 0, - opacity: 1, - duration: 0.4, - ease: "power2.out", - }); - }, - }); - }, [snsNames.length]); - - // Start slot machine cycling - useEffect(() => { - const interval = setInterval(animateSlot, 2000); - return () => clearInterval(interval); - }, [animateSlot]); - - useEffect(() => { - const section = sectionRef.current; - if (!section) return; - - const ctx = gsap.context(() => { - // Text entry: fade up - if (textRef.current) { - gsap.fromTo( - textRef.current, - { opacity: 0, y: 40 }, - { - opacity: 1, - y: 0, - duration: 0.8, - ease: "power2.out", - scrollTrigger: { - trigger: section, - start: "top 70%", - toggleActions: "play none none none", - }, - } - ); - } - - // CTA entry: slide up after text - if (ctaRef.current) { - gsap.fromTo( - ctaRef.current, - { opacity: 0, y: 30 }, - { - opacity: 1, - y: 0, - duration: 0.6, - delay: 0.3, - ease: "power2.out", - scrollTrigger: { - trigger: section, - start: "top 65%", - toggleActions: "play none none none", - }, - } - ); - } - }, section); - - return () => ctx.revert(); - }, []); - - const handleCtaClick = () => { - console.log("Soft wall: navigate to /magazine/personal"); - }; - - return ( -
- {/* DomeGallery background -- 3D sphere of images */} -
- -
- - {/* Dark overlay for text readability */} -
- - {/* Center content: headline + CTA */} -
-
-

- 당신의{" "} - - - {snsNames[currentIndex]} - - - 를 -
한 권의 잡지로 -

- {data.subtext && ( -

- {data.subtext} -

- )} -
- - -
-
- ); -} diff --git a/packages/web/lib/components/main-renewal/VirtualTryOnTeaser.tsx b/packages/web/lib/components/main-renewal/VirtualTryOnTeaser.tsx deleted file mode 100644 index a901ed75..00000000 --- a/packages/web/lib/components/main-renewal/VirtualTryOnTeaser.tsx +++ /dev/null @@ -1,347 +0,0 @@ -"use client"; - -import { useRef, useState, useCallback, useEffect } from "react"; -import Image from "next/image"; -import gsap from "gsap"; -import { ScrollTrigger } from "gsap/ScrollTrigger"; -import { useGSAP } from "@gsap/react"; -import type { VTONTeaserData } from "./types"; - -if (typeof window !== "undefined") { - gsap.registerPlugin(ScrollTrigger); -} - -interface VirtualTryOnTeaserProps { - data: VTONTeaserData; - className?: string; -} - -/** Threshold (0-1) at which the CTA button activates */ -const CTA_THRESHOLD = 0.85; - -/** - * VTON Teaser — Draggable before/after slider. - * - * Dragging the handle left/right reveals more of the after (try-on) image via clip-path. - * When slider passes 85%, the CTA button activates with a neon glow animation. - */ -export default function VirtualTryOnTeaser({ - data, - className, -}: VirtualTryOnTeaserProps) { - const sectionRef = useRef(null); - const sliderContainerRef = useRef(null); - const handleRef = useRef(null); - const ctaRef = useRef(null); - const headerRef = useRef(null); - - const [activePairIndex, setActivePairIndex] = useState(0); - const [sliderPct, setSliderPct] = useState(0.5); // 0 = all before, 1 = all after - const [ctaActive, setCtaActive] = useState(false); - - const isDragging = useRef(false); - const activePair = data.pairs[activePairIndex]; - - // ─── Scroll entrance animation ─────────────────────────────────────── - useGSAP( - () => { - if (!sectionRef.current || !headerRef.current) return; - - gsap.fromTo( - sectionRef.current, - { opacity: 0, y: 60, scale: 0.97 }, - { - opacity: 1, - y: 0, - scale: 1, - ease: "none", - scrollTrigger: { - trigger: sectionRef.current, - start: "top 85%", - end: "top 50%", - scrub: 0.8, - }, - } - ); - - gsap.fromTo( - headerRef.current, - { opacity: 0, x: -40 }, - { - opacity: 1, - x: 0, - ease: "none", - scrollTrigger: { - trigger: sectionRef.current, - start: "top 80%", - end: "top 45%", - scrub: 0.6, - }, - } - ); - }, - { scope: sectionRef } - ); - - // ─── CTA activation animation ───────────────────────────────────────── - useEffect(() => { - if (!ctaRef.current) return; - gsap.killTweensOf(ctaRef.current); - if (ctaActive) { - gsap.to(ctaRef.current, { - opacity: 1, - scale: 1, - cursor: "pointer", - boxShadow: "0 0 24px 4px var(--mag-accent)", - duration: 0.4, - ease: "back.out(1.5)", - }); - } else { - gsap.to(ctaRef.current, { - opacity: 0.35, - scale: 0.96, - boxShadow: "none", - duration: 0.25, - ease: "power2.in", - }); - } - }, [ctaActive]); - - // ─── Handle position sync ───────────────────────────────────────────── - useEffect(() => { - if (!handleRef.current) return; - handleRef.current.style.left = `${sliderPct * 100}%`; - }, [sliderPct]); - - // ─── Pointer drag logic ─────────────────────────────────────────────── - const updateSlider = useCallback((clientX: number) => { - const container = sliderContainerRef.current; - if (!container) return; - const rect = container.getBoundingClientRect(); - const rawPct = (clientX - rect.left) / rect.width; - const clamped = Math.max(0, Math.min(1, rawPct)); - setSliderPct(clamped); - setCtaActive(clamped >= CTA_THRESHOLD); - }, []); - - const onPointerDown = useCallback( - (e: React.PointerEvent) => { - isDragging.current = true; - (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); - updateSlider(e.clientX); - }, - [updateSlider] - ); - - const onPointerMove = useCallback( - (e: React.PointerEvent) => { - if (!isDragging.current) return; - updateSlider(e.clientX); - }, - [updateSlider] - ); - - const onPointerUp = useCallback(() => { - isDragging.current = false; - }, []); - - // ─── Pair navigation ────────────────────────────────────────────────── - const selectPair = useCallback((idx: number) => { - setActivePairIndex(idx); - setSliderPct(0.5); - setCtaActive(false); - }, []); - - return ( -
- {/* Section header */} -
-

- Virtual Try-On -

-

- Try it on yourself -

-
- - {/* Slider area */} -
- {/* Before/After container */} -
- {/* Prerender all pairs so images are cached; only the active pair is visible */} - {data.pairs.map((pair, i) => ( -
- {/* Before image — full coverage, base layer */} -
- {pair.beforeImageUrl ? ( - {`Before: - ) : ( -
- Before -
- )} - {/* Label */} -
- - Original - -
-
- - {/* After image — clipped from the right, revealed by slider */} -
- {pair.afterImageUrl ? ( - {`After: - ) : ( -
- Try-On -
- )} - {/* Label */} -
- - Try-On - -
-
-
- ))} - - {/* Drag handle — vertical line + circle grip */} -
- {/* Grip circle */} -
- -
-
- - {/* CTA activation hint — appears near threshold */} - {sliderPct > 0.7 && ( -
-

- Pull further to unlock -

-
- )} -
- - {/* Item info */} - {activePair && ( -
-

{activePair.itemBrand}

-

- {activePair.itemName} -

-
- )} - - {/* Pair navigation dots */} - {data.pairs.length > 1 && ( -
- {data.pairs.map((pair, i) => ( -
- )} - - {/* CTA button */} -
- -
-
-
- ); -} diff --git a/packages/web/lib/components/main-renewal/index.ts b/packages/web/lib/components/main-renewal/index.ts index 4470c25c..c766faf9 100644 --- a/packages/web/lib/components/main-renewal/index.ts +++ b/packages/web/lib/components/main-renewal/index.ts @@ -2,10 +2,7 @@ export { MainHero } from "./MainHero"; export { NeonGlow } from "./NeonGlow"; export { default as MasonryGrid } from "./MasonryGrid"; export { default as MasonryGridItem } from "./MasonryGridItem"; -export { default as PersonalizeBanner } from "./PersonalizeBanner"; export { default as DecodeShowcase } from "./DecodeShowcase"; -export { default as VirtualTryOnTeaser } from "./VirtualTryOnTeaser"; -export { default as CommunityLeaderboard } from "./CommunityLeaderboard"; export { default as EditorialMagazine } from "./EditorialMagazine"; export { SmartNav } from "./SmartNav"; export * from "./types"; diff --git a/packages/web/lib/components/main/DiscoverSection.tsx b/packages/web/lib/components/main/DiscoverSection.tsx index df457439..0931d82e 100644 --- a/packages/web/lib/components/main/DiscoverSection.tsx +++ b/packages/web/lib/components/main/DiscoverSection.tsx @@ -139,7 +139,7 @@ export function DiscoverItemsSection({ className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6 md:gap-8" > {displayItems.slice(0, 6).map((item, index) => ( - + ))} @@ -204,7 +204,7 @@ export function DiscoverProductsSection({ className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6 md:gap-8" > {items.slice(0, 6).map((item, index) => ( - + ))} diff --git a/packages/web/lib/components/main/HeroSection.tsx b/packages/web/lib/components/main/HeroSection.tsx index cddc65f0..040bb9a0 100644 --- a/packages/web/lib/components/main/HeroSection.tsx +++ b/packages/web/lib/components/main/HeroSection.tsx @@ -39,7 +39,7 @@ export function HeroSection({ data = defaultHeroData }: HeroSectionProps) { className="absolute inset-0 z-0" > {data.imageUrl ? ( - + {isNeonMode ? ( { - e.preventDefault(); - if (!email) return; - - setIsSubmitting(true); - await new Promise((resolve) => setTimeout(resolve, 1000)); - setIsSuccess(true); - setEmail(""); - setIsSubmitting(false); - setTimeout(() => setIsSuccess(false), 3000); - }; - - return ( -
-

- Stay Connected -

-
- setEmail(e.target.value)} - placeholder="EMAIL ADDRESS" - className="w-full bg-transparent border-b border-white/10 py-4 text-xs font-sans tracking-[0.1em] text-white placeholder:text-white/20 focus:outline-none focus:border-primary transition-all pr-12" - disabled={isSubmitting} - /> - -
- {isSuccess && ( -

- Subscription Confirmed -

- )} -
- ); -} - -export function MainFooter() { - return ( -
- {/* Dynamic Background Texture/Texture Overlay */} -
- -
-
- {/* Brand Presence */} -
-

- DECODED -

-

- A curated narrative of global style, culture, and creation. We - decode the visual language of the present to document the style of - the future. -

-
- {["Instagram", "Twitter", "Vimeo", "Archive"].map((social) => ( - - {social} - - ))} -
-
- - {/* Navigation Matrix */} -
-
-

- Journal -

-
    - {["Narratives", "Editorials", "Interviews", "Features"].map( - (item) => ( -
  • - - {item} - -
  • - ) - )} -
-
-
-

- Platform -

-
    - {["Directory", "Collective", "Collaborations", "About"].map( - (item) => ( -
  • - - {item} - -
  • - ) - )} -
-
-
- - {/* Engagement */} -
- -
-
- - {/* Legal & Architectural Metadata */} -
-
-

- DECODED INC. ALL RIGHTS RESERVED © {new Date().getFullYear()} -

-
- - Privacy Policy - - - Terms of Service - -
-
- -
- - Architecture Version 3.1.0-Release - -
-
-
-
- ); -} diff --git a/packages/web/lib/components/main/index.ts b/packages/web/lib/components/main/index.ts index f32ead4f..cb97b54d 100644 --- a/packages/web/lib/components/main/index.ts +++ b/packages/web/lib/components/main/index.ts @@ -1,5 +1,4 @@ // Layout components -export { MainFooter } from "./MainFooter"; export { HomeAnimatedContent } from "./HomeAnimatedContent"; // Section components diff --git a/packages/web/lib/components/profile/PostsGrid.tsx b/packages/web/lib/components/profile/PostsGrid.tsx index 124a24b4..43cd77a1 100644 --- a/packages/web/lib/components/profile/PostsGrid.tsx +++ b/packages/web/lib/components/profile/PostsGrid.tsx @@ -51,7 +51,7 @@ export function PostsGrid({ userId, posts, className }: PostsGridProps) { rows.map((row) => ({ id: row.id, imageUrl: row.image_url || "", - title: row.media_title || row.artist_name || "Untitled", + title: row.title || row.artist_name || "Untitled", itemCount: 0, })), }); diff --git a/packages/web/lib/components/profile/SolutionsList.tsx b/packages/web/lib/components/profile/SolutionsList.tsx index df46d467..8ab0ae6d 100644 --- a/packages/web/lib/components/profile/SolutionsList.tsx +++ b/packages/web/lib/components/profile/SolutionsList.tsx @@ -46,7 +46,7 @@ export function SolutionsList({ itemName: row.title, brand: row.description || "", price: row.price_amount - ? formatPrice(row.price_amount, row.price_currency) + ? formatPrice(row.price_amount, row.price_currency ?? "KRW") : undefined, verified: row.is_verified, })), diff --git a/packages/web/lib/components/shared/PostImage.tsx b/packages/web/lib/components/shared/PostImage.tsx index cb66abe9..86048f66 100644 --- a/packages/web/lib/components/shared/PostImage.tsx +++ b/packages/web/lib/components/shared/PostImage.tsx @@ -70,7 +70,7 @@ export function PostImage({ if (!useDynamic) { // Fallback: original object-cover behavior return ( -
+
{ const tl = gsap.timeline({ onComplete: () => { + // Hide container before navigation to prevent GSAP context revert + // from flashing elements back to visible during unmount + if (containerRef.current) { + containerRef.current.style.visibility = "hidden"; + } gsap.delayedCall(0.05, () => { reset(); if (window.history.length > 1) { @@ -259,6 +264,10 @@ export function useImageModalAnimation({ ctxRef.current.add(() => { const tl = gsap.timeline({ onComplete: () => { + // Hide container before navigation to prevent GSAP revert flash + if (containerRef.current) { + containerRef.current.style.visibility = "hidden"; + } gsap.delayedCall(0.05, () => { reset(); if (window.history.length > 1) { diff --git a/packages/web/lib/hooks/useImages.ts b/packages/web/lib/hooks/useImages.ts index b47dbca0..b4dc869b 100644 --- a/packages/web/lib/hooks/useImages.ts +++ b/packages/web/lib/hooks/useImages.ts @@ -389,7 +389,7 @@ export function usePostDetailForImage(postId: string) { { id: post.id, account: post.artist_name ?? post.group_name ?? "", - article: post.media_title ?? null, + article: post.title ?? null, created_at: post.created_at, item_ids: null, metadata: [], @@ -401,7 +401,7 @@ export function usePostDetailForImage(postId: string) { post: { id: post.id, account: post.artist_name ?? post.group_name ?? "", - article: post.media_title ?? null, + article: post.title ?? null, created_at: post.created_at, } as any, created_at: post.created_at, diff --git a/packages/web/lib/hooks/useTries.ts b/packages/web/lib/hooks/useTries.ts index e669f3f4..713e5ce4 100644 --- a/packages/web/lib/hooks/useTries.ts +++ b/packages/web/lib/hooks/useTries.ts @@ -8,7 +8,7 @@ export interface TryPost { id: string; user_id: string; image_url: string; - media_title: string | null; + title: string | null; created_at: string; user: { display_name: string; diff --git a/packages/web/lib/stores/authStore.ts b/packages/web/lib/stores/authStore.ts index 3f95bb67..dafd95ac 100644 --- a/packages/web/lib/stores/authStore.ts +++ b/packages/web/lib/stores/authStore.ts @@ -282,7 +282,7 @@ export const useAuthStore = create((set, get) => ({ try { const { error } = await supabaseBrowserClient .from("users") - .update(updates) + .update(updates as Record) .eq("id", user.id); if (error) { diff --git a/packages/web/lib/supabase/queries/main-page.server.ts b/packages/web/lib/supabase/queries/main-page.server.ts index 4fa78fb6..b83077eb 100644 --- a/packages/web/lib/supabase/queries/main-page.server.ts +++ b/packages/web/lib/supabase/queries/main-page.server.ts @@ -146,7 +146,7 @@ function toPostData(row: PostRow): PostData { imageUrl: row.image_url, artistName: row.artist_name, groupName: row.group_name, - mediaTitle: row.media_title, + mediaTitle: row.title, mediaType: row.media_type, context: row.context, viewCount: row.view_count, diff --git a/packages/web/lib/supabase/queries/personalization.server.ts b/packages/web/lib/supabase/queries/personalization.server.ts index 68a83d47..407ad8fe 100644 --- a/packages/web/lib/supabase/queries/personalization.server.ts +++ b/packages/web/lib/supabase/queries/personalization.server.ts @@ -52,7 +52,7 @@ function toPersonalizedPostData(row: { image_url: string | null; artist_name: string | null; group_name: string | null; - media_title: string | null; + title: string | null; media_type: string | null; context: string | null; view_count: number; @@ -63,7 +63,7 @@ function toPersonalizedPostData(row: { imageUrl: row.image_url, artistName: row.artist_name, groupName: row.group_name, - mediaTitle: row.media_title, + mediaTitle: row.title, mediaType: row.media_type, context: row.context, viewCount: row.view_count, diff --git a/packages/web/lib/supabase/queries/posts.ts b/packages/web/lib/supabase/queries/posts.ts index 812dff44..338f7b21 100644 --- a/packages/web/lib/supabase/queries/posts.ts +++ b/packages/web/lib/supabase/queries/posts.ts @@ -156,7 +156,7 @@ export async function fetchPostWithImagesAndItems( post: { id: result.post.id, account: result.post.artist_name || result.post.group_name || "", - article: result.post.media_title, + article: result.post.title, created_at: result.post.created_at, item_ids: result.solutions.map((s) => s.id), metadata: null, diff --git a/packages/web/lib/supabase/queries/warehouse-entities.server.ts b/packages/web/lib/supabase/queries/warehouse-entities.server.ts index 6a7d2d81..22964a17 100644 --- a/packages/web/lib/supabase/queries/warehouse-entities.server.ts +++ b/packages/web/lib/supabase/queries/warehouse-entities.server.ts @@ -7,7 +7,7 @@ */ import { createWarehouseServerClient } from "@/lib/supabase/warehouse"; -import type { ArtistRow, GroupRow } from "@/lib/supabase/warehouse-types"; +import type { ArtistRow, GroupRow, BrandRow } from "@/lib/supabase/warehouse-types"; /** * Fetches artists from warehouse.artists with profile images. @@ -15,7 +15,7 @@ import type { ArtistRow, GroupRow } from "@/lib/supabase/warehouse-types"; * @param limit - Maximum number of artists to fetch (default: 50) * @returns Array of ArtistRow, empty on error */ -export async function fetchWarehouseArtists(limit = 50): Promise { +export async function fetchWarehouseArtists(limit = 500): Promise { try { const wh = await createWarehouseServerClient(); const { data, error } = await wh @@ -42,7 +42,7 @@ export async function fetchWarehouseArtists(limit = 50): Promise { * @param limit - Maximum number of groups to fetch (default: 50) * @returns Array of GroupRow, empty on error */ -export async function fetchWarehouseGroups(limit = 50): Promise { +export async function fetchWarehouseGroups(limit = 500): Promise { try { const wh = await createWarehouseServerClient(); const { data, error } = await wh @@ -86,8 +86,8 @@ export async function buildArtistProfileMap(): Promise { + try { + const wh = await createWarehouseServerClient(); + const { data, error } = await wh + .from("brands") + .select("id, name_ko, name_en, logo_image_url, primary_instagram_account_id, metadata, created_at, updated_at") + .order("created_at", { ascending: false }) + .limit(limit); + + if (error) { + console.error("[warehouse-entities] fetchWarehouseBrands error:", error); + return []; + } + + return (data ?? []) as BrandRow[]; + } catch (err) { + console.error("[warehouse-entities] fetchWarehouseBrands unexpected error:", err); + return []; + } +} + +/** Brand profile entry for lookup by brand name or ID */ +export interface BrandProfileEntry { + name: string; + profileImageUrl: string | null; +} + +/** + * Builds a lookup map from brand names (lowercased) to profile data. + * + * Fetches brands and indexes each by name_ko, name_en (lowercased), + * and by ID so callers can look up by raw text or brand_id. + * + * @returns Map keyed by lowercased name or brand ID, empty Map on error + */ +export async function buildBrandProfileMap(): Promise> { + const map = new Map(); + + try { + const brands = await fetchWarehouseBrands(); + + for (const brand of brands) { + const displayName = brand.name_en || brand.name_ko || ""; + if (!displayName) continue; + + const entry: BrandProfileEntry = { + name: displayName, + profileImageUrl: brand.logo_image_url, + }; + + if (brand.name_ko) map.set(brand.name_ko.toLowerCase(), entry); + if (brand.name_en) map.set(brand.name_en.toLowerCase(), entry); + // Also key by ID for brand_id lookup + if (brand.id) map.set(brand.id, entry); + } + } catch (err) { + console.error("[warehouse-entities] buildBrandProfileMap error:", err); + } + + return map; +} diff --git a/packages/web/lib/supabase/types.ts b/packages/web/lib/supabase/types.ts index d7b12ae3..db8533af 100644 --- a/packages/web/lib/supabase/types.ts +++ b/packages/web/lib/supabase/types.ts @@ -1,23 +1,12 @@ /** - * Supabase Database Types + * Supabase Database Types (public schema) * - * Auto-generated from actual database schema inspection. - * Last updated: 2026-03-18 + * Auto-generated via `supabase gen types typescript`. + * Last updated: 2026-04-04 * - * Tables: - * - posts - Main content posts with images - * - users - User profiles and stats - * - categories - Item categories (i18n) - * - subcategories - Item subcategories (i18n) - * - badges - Achievement badges - * - user_badges - User-badge assignments - * - spots - Item locations in images - * - solutions - Product matches for spots - * - comments - Post comments - * - user_events - Behavioral event tracking (30-day TTL) - * - user_tryon_history - VTON history - * - user_social_accounts - OAuth SNS connections - * - decoded_picks - Editor/AI curated daily picks for homepage + * Regenerate: npx supabase gen types typescript --project-id fvxchskblyhuswzlcmql --schema public + * + * DO NOT manually edit the Database type below. Edit custom aliases at the bottom of this file. */ export type Json = @@ -26,761 +15,1980 @@ export type Json = | boolean | null | { [key: string]: Json | undefined } - | Json[]; - -/** - * Internationalized text (Korean/English) - */ -export interface I18nText { - ko: string; - en: string; -} + | Json[] export type Database = { + // Allows to automatically instantiate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: "14.1" + } public: { Tables: { - // ================================================================= - // CORE CONTENT - // ================================================================= - - /** - * Decoded Picks - Editor/AI curated daily picks for homepage - */ + agent_sessions: { + Row: { + created_at: string + id: string + keywords: Json | null + last_message_at: string | null + magazine_id: string | null + message_count: number | null + metadata: Json | null + status: string + thread_id: string + updated_at: string + user_id: string + } + Insert: { + created_at?: string + id?: string + keywords?: Json | null + last_message_at?: string | null + magazine_id?: string | null + message_count?: number | null + metadata?: Json | null + status?: string + thread_id: string + updated_at?: string + user_id: string + } + Update: { + created_at?: string + id?: string + keywords?: Json | null + last_message_at?: string | null + magazine_id?: string | null + message_count?: number | null + metadata?: Json | null + status?: string + thread_id?: string + updated_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "agent_sessions_magazine_id_fkey" + columns: ["magazine_id"] + isOneToOne: false + referencedRelation: "magazines" + referencedColumns: ["id"] + }, + { + foreignKeyName: "agent_sessions_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + badges: { + Row: { + created_at: string + criteria: Json + description: string | null + icon_url: string | null + id: string + name: string + rarity: string + type: string + updated_at: string + } + Insert: { + created_at?: string + criteria: Json + description?: string | null + icon_url?: string | null + id: string + name: string + rarity?: string + type: string + updated_at?: string + } + Update: { + created_at?: string + criteria?: Json + description?: string | null + icon_url?: string | null + id?: string + name?: string + rarity?: string + type?: string + updated_at?: string + } + Relationships: [] + } + categories: { + Row: { + code: string + color_hex: string | null + created_at: string + description: Json | null + display_order: number + icon_url: string | null + id: string + is_active: boolean + name: Json + updated_at: string + } + Insert: { + code: string + color_hex?: string | null + created_at?: string + description?: Json | null + display_order?: number + icon_url?: string | null + id: string + is_active?: boolean + name: Json + updated_at?: string + } + Update: { + code?: string + color_hex?: string | null + created_at?: string + description?: Json | null + display_order?: number + icon_url?: string | null + id?: string + is_active?: boolean + name?: Json + updated_at?: string + } + Relationships: [] + } + checkpoint_blobs: { + Row: { + blob: string | null + channel: string + checkpoint_ns: string + thread_id: string + type: string + version: string + } + Insert: { + blob?: string | null + channel: string + checkpoint_ns?: string + thread_id: string + type: string + version: string + } + Update: { + blob?: string | null + channel?: string + checkpoint_ns?: string + thread_id?: string + type?: string + version?: string + } + Relationships: [] + } + checkpoint_migrations: { + Row: { + v: number + } + Insert: { + v: number + } + Update: { + v?: number + } + Relationships: [] + } + checkpoint_writes: { + Row: { + blob: string + channel: string + checkpoint_id: string + checkpoint_ns: string + idx: number + task_id: string + task_path: string + thread_id: string + type: string | null + } + Insert: { + blob: string + channel: string + checkpoint_id: string + checkpoint_ns?: string + idx: number + task_id: string + task_path?: string + thread_id: string + type?: string | null + } + Update: { + blob?: string + channel?: string + checkpoint_id?: string + checkpoint_ns?: string + idx?: number + task_id?: string + task_path?: string + thread_id?: string + type?: string | null + } + Relationships: [] + } + checkpoints: { + Row: { + checkpoint: Json + checkpoint_id: string + checkpoint_ns: string + metadata: Json + parent_checkpoint_id: string | null + thread_id: string + type: string | null + } + Insert: { + checkpoint: Json + checkpoint_id: string + checkpoint_ns?: string + metadata?: Json + parent_checkpoint_id?: string | null + thread_id: string + type?: string | null + } + Update: { + checkpoint?: Json + checkpoint_id?: string + checkpoint_ns?: string + metadata?: Json + parent_checkpoint_id?: string | null + thread_id?: string + type?: string | null + } + Relationships: [] + } + click_logs: { + Row: { + created_at: string + id: string + ip_address: string + referrer: string | null + solution_id: string + user_agent: string | null + user_id: string | null + } + Insert: { + created_at?: string + id: string + ip_address: string + referrer?: string | null + solution_id: string + user_agent?: string | null + user_id?: string | null + } + Update: { + created_at?: string + id?: string + ip_address?: string + referrer?: string | null + solution_id?: string + user_agent?: string | null + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "fk_click_logs_solution_id" + columns: ["solution_id"] + isOneToOne: false + referencedRelation: "solutions" + referencedColumns: ["id"] + }, + { + foreignKeyName: "fk_click_logs_user_id" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + comments: { + Row: { + content: string + created_at: string + id: string + parent_id: string | null + post_id: string + updated_at: string + user_id: string + } + Insert: { + content: string + created_at?: string + id: string + parent_id?: string | null + post_id: string + updated_at?: string + user_id: string + } + Update: { + content?: string + created_at?: string + id?: string + parent_id?: string | null + post_id?: string + updated_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "fk_comments_parent_id" + columns: ["parent_id"] + isOneToOne: false + referencedRelation: "comments" + referencedColumns: ["id"] + }, + { + foreignKeyName: "fk_comments_post_id" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "explore_posts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "fk_comments_post_id" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "posts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "fk_comments_user_id" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + content_reports: { + Row: { + created_at: string + details: string | null + id: string + reason: string + reporter_id: string + resolution: string | null + reviewed_by: string | null + status: string + target_id: string + target_type: string + updated_at: string + } + Insert: { + created_at?: string + details?: string | null + id?: string + reason: string + reporter_id: string + resolution?: string | null + reviewed_by?: string | null + status?: string + target_id: string + target_type?: string + updated_at?: string + } + Update: { + created_at?: string + details?: string | null + id?: string + reason?: string + reporter_id?: string + resolution?: string | null + reviewed_by?: string | null + status?: string + target_id?: string + target_type?: string + updated_at?: string + } + Relationships: [] + } + credit_transactions: { + Row: { + action_type: string + amount: number + created_at: string + id: string + reference_id: string | null + status: string + user_id: string + } + Insert: { + action_type: string + amount: number + created_at?: string + id: string + reference_id?: string | null + status?: string + user_id: string + } + Update: { + action_type?: string + amount?: number + created_at?: string + id?: string + reference_id?: string | null + status?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "credit_transactions_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + curation_posts: { + Row: { + curation_id: string + display_order: number + post_id: string + } + Insert: { + curation_id: string + display_order?: number + post_id: string + } + Update: { + curation_id?: string + display_order?: number + post_id?: string + } + Relationships: [ + { + foreignKeyName: "fk_curation_posts_curation_id" + columns: ["curation_id"] + isOneToOne: false + referencedRelation: "curations" + referencedColumns: ["id"] + }, + { + foreignKeyName: "fk_curation_posts_post_id" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "explore_posts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "fk_curation_posts_post_id" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "posts" + referencedColumns: ["id"] + }, + ] + } + curations: { + Row: { + cover_image_url: string | null + created_at: string + description: string | null + display_order: number + id: string + is_active: boolean + title: string + updated_at: string + } + Insert: { + cover_image_url?: string | null + created_at: string + description?: string | null + display_order?: number + id: string + is_active?: boolean + title: string + updated_at: string + } + Update: { + cover_image_url?: string | null + created_at?: string + description?: string | null + display_order?: number + id?: string + is_active?: boolean + title?: string + updated_at?: string + } + Relationships: [] + } decoded_picks: { Row: { - id: string; - post_id: string; - pick_date: string; - note: string | null; - curated_by: string; - is_active: boolean; - created_at: string; - }; - Insert: { - id?: string; - post_id: string; - pick_date?: string; - note?: string | null; - curated_by?: string; - is_active?: boolean; - created_at?: string; - }; - Update: { - id?: string; - post_id?: string; - pick_date?: string; - note?: string | null; - curated_by?: string; - is_active?: boolean; - created_at?: string; - }; + created_at: string + curated_by: string + id: string + is_active: boolean + note: string | null + pick_date: string + post_id: string + } + Insert: { + created_at?: string + curated_by?: string + id?: string + is_active?: boolean + note?: string | null + pick_date?: string + post_id: string + } + Update: { + created_at?: string + curated_by?: string + id?: string + is_active?: boolean + note?: string | null + pick_date?: string + post_id?: string + } Relationships: [ { - foreignKeyName: "decoded_picks_post_id_fkey"; - columns: ["post_id"]; - referencedRelation: "posts"; - referencedColumns: ["id"]; + foreignKeyName: "decoded_picks_post_id_fkey" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "explore_posts" + referencedColumns: ["id"] }, - ]; - }; - - /** - * Posts - Main content with images - * 591 records - */ - posts: { + { + foreignKeyName: "decoded_picks_post_id_fkey" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "posts" + referencedColumns: ["id"] + }, + ] + } + earnings: { Row: { - id: string; - user_id: string; - image_url: string | null; - media_type: string | null; // 'event', 'paparazzi', etc. - media_title: string | null; - media_metadata: Json; - group_name: string | null; - artist_name: string | null; - context: string | null; // 'street style', 'street', etc. - view_count: number; - status: string; // 'active', 'inactive', etc. - created_at: string; - updated_at: string; - trending_score: number | null; - post_magazine_id: string | null; - ai_summary: string | null; - }; - Insert: { - id?: string; - user_id: string; - image_url?: string | null; - media_type?: string | null; - media_title?: string | null; - media_metadata?: Json; - group_name?: string | null; - artist_name?: string | null; - context?: string | null; - view_count?: number; - status?: string; - created_at?: string; - updated_at?: string; - trending_score?: number | null; - post_magazine_id?: string | null; - ai_summary?: string | null; - }; - Update: { - id?: string; - user_id?: string; - image_url?: string | null; - media_type?: string | null; - media_title?: string | null; - media_metadata?: Json; - group_name?: string | null; - artist_name?: string | null; - context?: string | null; - view_count?: number; - status?: string; - created_at?: string; - updated_at?: string; - trending_score?: number | null; - post_magazine_id?: string | null; - ai_summary?: string | null; - }; + affiliate_platform: string | null + amount: number + created_at: string + currency: string + id: string + solution_id: string + status: string + user_id: string + } + Insert: { + affiliate_platform?: string | null + amount: number + created_at?: string + currency?: string + id: string + solution_id: string + status?: string + user_id: string + } + Update: { + affiliate_platform?: string | null + amount?: number + created_at?: string + currency?: string + id?: string + solution_id?: string + status?: string + user_id?: string + } Relationships: [ { - foreignKeyName: "posts_user_id_fkey"; - columns: ["user_id"]; - referencedRelation: "users"; - referencedColumns: ["id"]; + foreignKeyName: "fk_earnings_solution_id" + columns: ["solution_id"] + isOneToOne: false + referencedRelation: "solutions" + referencedColumns: ["id"] }, { - foreignKeyName: "fk_posts_post_magazine_id"; - columns: ["post_magazine_id"]; - referencedRelation: "post_magazines"; - referencedColumns: ["id"]; + foreignKeyName: "fk_earnings_user_id" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] }, - ]; - }; - - /** - * Post Magazines - Editorial magazine content - */ + ] + } + embeddings: { + Row: { + content_text: string + created_at: string | null + embedding: string + entity_id: string + entity_type: string + id: string + } + Insert: { + content_text: string + created_at?: string | null + embedding: string + entity_id: string + entity_type: string + id?: string + } + Update: { + content_text?: string + created_at?: string | null + embedding?: string + entity_id?: string + entity_type?: string + id?: string + } + Relationships: [] + } + failed_batch_items: { + Row: { + batch_id: string + created_at: string + error_message: string | null + id: string + item_id: string + next_retry_at: string + retry_count: number + status: string + updated_at: string + url: string + } + Insert: { + batch_id: string + created_at?: string + error_message?: string | null + id: string + item_id: string + next_retry_at: string + retry_count?: number + status: string + updated_at?: string + url: string + } + Update: { + batch_id?: string + created_at?: string + error_message?: string | null + id?: string + item_id?: string + next_retry_at?: string + retry_count?: number + status?: string + updated_at?: string + url?: string + } + Relationships: [] + } + magazine_posts: { + Row: { + magazine_id: string + post_id: string + section_index: number + } + Insert: { + magazine_id: string + post_id: string + section_index?: number + } + Update: { + magazine_id?: string + post_id?: string + section_index?: number + } + Relationships: [ + { + foreignKeyName: "magazine_posts_magazine_id_fkey" + columns: ["magazine_id"] + isOneToOne: false + referencedRelation: "magazines" + referencedColumns: ["id"] + }, + { + foreignKeyName: "magazine_posts_post_id_fkey" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "explore_posts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "magazine_posts_post_id_fkey" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "posts" + referencedColumns: ["id"] + }, + ] + } + magazines: { + Row: { + agent_version: string | null + artists: Json | null + cover_image_url: string | null + created_at: string + generation_log: Json | null + id: string + keywords: Json + published_at: string | null + published_by: string | null + review_notes: string | null + reviewed_at: string | null + reviewed_by: string | null + spec: Json + status: string + subtitle: string | null + theme: string | null + title: string + updated_at: string + } + Insert: { + agent_version?: string | null + artists?: Json | null + cover_image_url?: string | null + created_at?: string + generation_log?: Json | null + id?: string + keywords?: Json + published_at?: string | null + published_by?: string | null + review_notes?: string | null + reviewed_at?: string | null + reviewed_by?: string | null + spec?: Json + status?: string + subtitle?: string | null + theme?: string | null + title: string + updated_at?: string + } + Update: { + agent_version?: string | null + artists?: Json | null + cover_image_url?: string | null + created_at?: string + generation_log?: Json | null + id?: string + keywords?: Json + published_at?: string | null + published_by?: string | null + review_notes?: string | null + reviewed_at?: string | null + reviewed_by?: string | null + spec?: Json + status?: string + subtitle?: string | null + theme?: string | null + title?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "magazines_published_by_fkey" + columns: ["published_by"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + { + foreignKeyName: "magazines_reviewed_by_fkey" + columns: ["reviewed_by"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + point_logs: { + Row: { + activity_type: string + created_at: string + description: string | null + id: string + points: number + ref_id: string | null + ref_type: string | null + user_id: string + } + Insert: { + activity_type: string + created_at?: string + description?: string | null + id: string + points: number + ref_id?: string | null + ref_type?: string | null + user_id: string + } + Update: { + activity_type?: string + created_at?: string + description?: string | null + id?: string + points?: number + ref_id?: string | null + ref_type?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "fk_point_logs_user" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + post_likes: { + Row: { + created_at: string + id: string + post_id: string + user_id: string + } + Insert: { + created_at?: string + id: string + post_id: string + user_id: string + } + Update: { + created_at?: string + id?: string + post_id?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "fk_post_likes_post_id" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "explore_posts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "fk_post_likes_post_id" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "posts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "fk_post_likes_user_id" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } post_magazines: { Row: { - id: string; - title: string; - subtitle: string | null; - keyword: string | null; - layout_json: Json | null; - status: string; - review_summary: string | null; - thread_id: string | null; - error_log: Json | null; - created_at: string; - updated_at: string; - published_at: string | null; - }; - Insert: { - id?: string; - title: string; - subtitle?: string | null; - keyword?: string | null; - layout_json?: Json | null; - status?: string; - review_summary?: string | null; - thread_id?: string | null; - error_log?: Json | null; - created_at?: string; - updated_at?: string; - published_at?: string | null; - }; - Update: { - title?: string; - subtitle?: string | null; - keyword?: string | null; - layout_json?: Json | null; - status?: string; - review_summary?: string | null; - thread_id?: string | null; - error_log?: Json | null; - updated_at?: string; - published_at?: string | null; - }; - Relationships: []; - }; - - /** - * Comments - Post comments - * 0 records (empty) - */ - comments: { + created_at: string + error_log: Json | null + id: string + keyword: string | null + layout_json: Json | null + published_at: string | null + review_summary: string | null + status: string + subtitle: string | null + thread_id: string | null + title: string + updated_at: string + } + Insert: { + created_at?: string + error_log?: Json | null + id?: string + keyword?: string | null + layout_json?: Json | null + published_at?: string | null + review_summary?: string | null + status?: string + subtitle?: string | null + thread_id?: string | null + title?: string + updated_at?: string + } + Update: { + created_at?: string + error_log?: Json | null + id?: string + keyword?: string | null + layout_json?: Json | null + published_at?: string | null + review_summary?: string | null + status?: string + subtitle?: string | null + thread_id?: string | null + title?: string + updated_at?: string + } + Relationships: [] + } + posts: { Row: { - id: string; - post_id: string; - user_id: string; - content: string; - created_at: string; - updated_at: string; - }; - Insert: { - id?: string; - post_id: string; - user_id: string; - content: string; - created_at?: string; - updated_at?: string; - }; - Update: { - id?: string; - post_id?: string; - user_id?: string; - content?: string; - created_at?: string; - updated_at?: string; - }; + ai_summary: string | null + artist_id: string | null + artist_name: string | null + context: string | null + created_at: string + created_with_solutions: boolean | null + group_id: string | null + group_name: string | null + id: string + image_url: string + media_metadata: Json | null + media_type: string + post_magazine_id: string | null + status: string + title: string | null + trending_score: number | null + updated_at: string + user_id: string + view_count: number + } + Insert: { + ai_summary?: string | null + artist_id?: string | null + artist_name?: string | null + context?: string | null + created_at?: string + created_with_solutions?: boolean | null + group_id?: string | null + group_name?: string | null + id: string + image_url: string + media_metadata?: Json | null + media_type: string + post_magazine_id?: string | null + status?: string + title?: string | null + trending_score?: number | null + updated_at?: string + user_id: string + view_count?: number + } + Update: { + ai_summary?: string | null + artist_id?: string | null + artist_name?: string | null + context?: string | null + created_at?: string + created_with_solutions?: boolean | null + group_id?: string | null + group_name?: string | null + id?: string + image_url?: string + media_metadata?: Json | null + media_type?: string + post_magazine_id?: string | null + status?: string + title?: string | null + trending_score?: number | null + updated_at?: string + user_id?: string + view_count?: number + } Relationships: [ { - foreignKeyName: "comments_post_id_fkey"; - columns: ["post_id"]; - referencedRelation: "posts"; - referencedColumns: ["id"]; + foreignKeyName: "fk_posts_post_magazine_id" + columns: ["post_magazine_id"] + isOneToOne: false + referencedRelation: "post_magazines" + referencedColumns: ["id"] }, { - foreignKeyName: "comments_user_id_fkey"; - columns: ["user_id"]; - referencedRelation: "users"; - referencedColumns: ["id"]; + foreignKeyName: "fk_posts_user_id" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] }, - ]; - }; - - // ================================================================= - // USER MANAGEMENT - // ================================================================= - - /** - * Users - User profiles and stats - * 3 records - */ - users: { + ] + } + processed_batches: { Row: { - id: string; - email: string; - username: string | null; - display_name: string | null; - avatar_url: string | null; - bio: string | null; - rank: string | null; // 'Member', etc. - total_points: number; - is_admin: boolean; - ink_credits: number; - style_dna: Record | null; - studio_config: Record | null; - created_at: string; - updated_at: string; - }; - Insert: { - id?: string; - email: string; - username?: string | null; - display_name?: string | null; - avatar_url?: string | null; - bio?: string | null; - rank?: string | null; - total_points?: number; - is_admin?: boolean; - ink_credits?: number; - style_dna?: Record | null; - studio_config?: Record | null; - created_at?: string; - updated_at?: string; - }; - Update: { - id?: string; - email?: string; - username?: string | null; - display_name?: string | null; - avatar_url?: string | null; - bio?: string | null; - rank?: string | null; - total_points?: number; - is_admin?: boolean; - ink_credits?: number; - style_dna?: Record | null; - studio_config?: Record | null; - created_at?: string; - updated_at?: string; - }; - Relationships: []; - }; - - /** - * User Social Accounts - OAuth SNS connections - */ - user_social_accounts: { + batch_id: string + created_at: string + failed_count: number + partial_count: number + processing_time_ms: number + processing_timestamp: string + success_count: number + total_count: number + } + Insert: { + batch_id: string + created_at?: string + failed_count: number + partial_count: number + processing_time_ms: number + processing_timestamp: string + success_count: number + total_count: number + } + Update: { + batch_id?: string + created_at?: string + failed_count?: number + partial_count?: number + processing_time_ms?: number + processing_timestamp?: string + success_count?: number + total_count?: number + } + Relationships: [] + } + saved_posts: { Row: { - id: string; - user_id: string; - provider: string; - provider_user_id: string; - access_token: string; - refresh_token: string | null; - last_synced_at: string | null; - created_at: string; - updated_at: string; - }; - Insert: { - id?: string; - user_id: string; - provider: string; - provider_user_id: string; - access_token: string; - refresh_token?: string | null; - last_synced_at?: string | null; - created_at?: string; - updated_at?: string; - }; - Update: { - provider_user_id?: string; - access_token?: string; - refresh_token?: string | null; - last_synced_at?: string | null; - updated_at?: string; - }; - Relationships: []; - }; - - /** - * User Try-on History - VTON history - */ - user_tryon_history: { + created_at: string + id: string + post_id: string + user_id: string + } + Insert: { + created_at?: string + id: string + post_id: string + user_id: string + } + Update: { + created_at?: string + id?: string + post_id?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "fk_saved_posts_post_id" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "explore_posts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "fk_saved_posts_post_id" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "posts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "fk_saved_posts_user_id" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + seaql_migrations: { Row: { - id: string; - user_id: string; - image_url: string; - style_combination: Record | null; - created_at: string; - }; - Insert: { - id?: string; - user_id: string; - image_url: string; - style_combination?: Record | null; - created_at?: string; - }; - Update: { - image_url?: string; - style_combination?: Record | null; - }; - Relationships: []; - }; - - /** - * User Events - Behavioral event tracking - * Immutable (insert-only), 30-day TTL via pg_cron - */ - user_events: { + applied_at: number + version: string + } + Insert: { + applied_at: number + version: string + } + Update: { + applied_at?: number + version?: string + } + Relationships: [] + } + search_logs: { Row: { - id: string; - user_id: string; - event_type: string; // post_click, post_view, spot_click, search_query, category_filter, dwell_time, scroll_depth - entity_id: string | null; - session_id: string; - page_path: string; - metadata: Json | null; - created_at: string; - }; - Insert: { - id?: string; - user_id: string; - event_type: string; - entity_id?: string | null; - session_id: string; - page_path: string; - metadata?: Json | null; - created_at?: string; - }; - Update: { - id?: string; - user_id?: string; - event_type?: string; - entity_id?: string | null; - session_id?: string; - page_path?: string; - metadata?: Json | null; - created_at?: string; - }; + created_at: string + filters: Json | null + id: string + query: string + user_id: string | null + } + Insert: { + created_at?: string + filters?: Json | null + id: string + query: string + user_id?: string | null + } + Update: { + created_at?: string + filters?: Json | null + id?: string + query?: string + user_id?: string | null + } Relationships: [ { - foreignKeyName: "user_events_user_id_fkey"; - columns: ["user_id"]; - referencedRelation: "users"; - referencedColumns: ["id"]; + foreignKeyName: "fk_search_logs_user_id" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] }, - ]; - }; - - /** - * User Badges - Badge assignments - */ - user_badges: { + ] + } + settlements: { Row: { - user_id: string; - badge_id: string; - earned_at: string; - }; + amount: number + bank_info: Json | null + created_at: string + currency: string + id: string + processed_at: string | null + status: string + user_id: string + } Insert: { - user_id: string; - badge_id: string; - earned_at?: string; - }; + amount: number + bank_info?: Json | null + created_at?: string + currency?: string + id: string + processed_at?: string | null + status?: string + user_id: string + } Update: { - user_id?: string; - badge_id?: string; - earned_at?: string; - }; + amount?: number + bank_info?: Json | null + created_at?: string + currency?: string + id?: string + processed_at?: string | null + status?: string + user_id?: string + } Relationships: [ { - foreignKeyName: "user_badges_user_id_fkey"; - columns: ["user_id"]; - referencedRelation: "users"; - referencedColumns: ["id"]; + foreignKeyName: "fk_settlements_user_id" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] }, + ] + } + solutions: { + Row: { + accurate_count: number + adopted_at: string | null + affiliate_url: string | null + brand_id: string | null + click_count: number + comment: string | null + created_at: string + description: string | null + different_count: number + id: string + is_adopted: boolean + is_verified: boolean + keywords: Json | null + link_type: string | null + match_type: string | null + metadata: Json | null + original_url: string | null + price_amount: number | null + price_currency: string | null + purchase_count: number + qna: Json | null + spot_id: string + status: string + thumbnail_url: string | null + title: string + updated_at: string + user_id: string + } + Insert: { + accurate_count?: number + adopted_at?: string | null + affiliate_url?: string | null + brand_id?: string | null + click_count?: number + comment?: string | null + created_at?: string + description?: string | null + different_count?: number + id: string + is_adopted?: boolean + is_verified?: boolean + keywords?: Json | null + link_type?: string | null + match_type?: string | null + metadata?: Json | null + original_url?: string | null + price_amount?: number | null + price_currency?: string | null + purchase_count?: number + qna?: Json | null + spot_id: string + status?: string + thumbnail_url?: string | null + title: string + updated_at?: string + user_id: string + } + Update: { + accurate_count?: number + adopted_at?: string | null + affiliate_url?: string | null + brand_id?: string | null + click_count?: number + comment?: string | null + created_at?: string + description?: string | null + different_count?: number + id?: string + is_adopted?: boolean + is_verified?: boolean + keywords?: Json | null + link_type?: string | null + match_type?: string | null + metadata?: Json | null + original_url?: string | null + price_amount?: number | null + price_currency?: string | null + purchase_count?: number + qna?: Json | null + spot_id?: string + status?: string + thumbnail_url?: string | null + title?: string + updated_at?: string + user_id?: string + } + Relationships: [ { - foreignKeyName: "user_badges_badge_id_fkey"; - columns: ["badge_id"]; - referencedRelation: "badges"; - referencedColumns: ["id"]; + foreignKeyName: "fk_solutions_spot_id" + columns: ["spot_id"] + isOneToOne: false + referencedRelation: "spots" + referencedColumns: ["id"] }, - ]; - }; - - // ================================================================= - // CATEGORIES & CLASSIFICATION - // ================================================================= - - /** - * Categories - Item categories (i18n) - * 5 records: wearables, accessories, beauty, lifestyle, other - */ - categories: { + { + foreignKeyName: "fk_solutions_user_id" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + spots: { Row: { - id: string; - code: string; // 'wearables', 'accessories', etc. - name: I18nText; // { ko: '패션 아이템', en: 'Wearables' } - icon_url: string | null; - color_hex: string | null; - description: string | null; - display_order: number; - is_active: boolean; - created_at: string; - updated_at: string; - }; - Insert: { - id?: string; - code: string; - name: I18nText; - icon_url?: string | null; - color_hex?: string | null; - description?: string | null; - display_order?: number; - is_active?: boolean; - created_at?: string; - updated_at?: string; - }; - Update: { - id?: string; - code?: string; - name?: I18nText; - icon_url?: string | null; - color_hex?: string | null; - description?: string | null; - display_order?: number; - is_active?: boolean; - created_at?: string; - updated_at?: string; - }; - Relationships: []; - }; - - /** - * Subcategories - Item subcategories (i18n) - * 23 records: headwear, eyewear, tops, bottoms, etc. - */ + created_at: string + id: string + position_left: string + position_top: string + post_id: string + status: string + subcategory_id: string | null + updated_at: string + user_id: string + } + Insert: { + created_at?: string + id: string + position_left: string + position_top: string + post_id: string + status?: string + subcategory_id?: string | null + updated_at?: string + user_id: string + } + Update: { + created_at?: string + id?: string + position_left?: string + position_top?: string + post_id?: string + status?: string + subcategory_id?: string | null + updated_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "fk_spots_post_id" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "explore_posts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "fk_spots_post_id" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "posts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "fk_spots_subcategory_id" + columns: ["subcategory_id"] + isOneToOne: false + referencedRelation: "subcategories" + referencedColumns: ["id"] + }, + { + foreignKeyName: "fk_spots_user_id" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } subcategories: { Row: { - id: string; - category_id: string; - code: string; // 'headwear', 'eyewear', etc. - name: I18nText; // { ko: '모자', en: 'Headwear' } - description: string | null; - display_order: number; - is_active: boolean; - created_at: string; - updated_at: string; - }; - Insert: { - id?: string; - category_id: string; - code: string; - name: I18nText; - description?: string | null; - display_order?: number; - is_active?: boolean; - created_at?: string; - updated_at?: string; - }; - Update: { - id?: string; - category_id?: string; - code?: string; - name?: I18nText; - description?: string | null; - display_order?: number; - is_active?: boolean; - created_at?: string; - updated_at?: string; - }; + category_id: string + code: string + created_at: string + description: Json | null + display_order: number + id: string + is_active: boolean + name: Json + updated_at: string + } + Insert: { + category_id: string + code: string + created_at?: string + description?: Json | null + display_order?: number + id: string + is_active?: boolean + name: Json + updated_at?: string + } + Update: { + category_id?: string + code?: string + created_at?: string + description?: Json | null + display_order?: number + id?: string + is_active?: boolean + name?: Json + updated_at?: string + } Relationships: [ { - foreignKeyName: "subcategories_category_id_fkey"; - columns: ["category_id"]; - referencedRelation: "categories"; - referencedColumns: ["id"]; + foreignKeyName: "fk_subcategories_category_id" + columns: ["category_id"] + isOneToOne: false + referencedRelation: "categories" + referencedColumns: ["id"] }, - ]; - }; - - /** - * Badges - Achievement badges - * 20 records - */ - badges: { + ] + } + synonyms: { Row: { - id: string; - type: string; // 'achievement', etc. - name: string; - description: string; - icon_url: string | null; - criteria: Json; // { type: 'count', threshold: 1 } - rarity: string; // 'common', 'rare', 'epic', 'legendary' - created_at: string; - updated_at: string; - }; - Insert: { - id?: string; - type: string; - name: string; - description: string; - icon_url?: string | null; - criteria?: Json; - rarity?: string; - created_at?: string; - updated_at?: string; - }; - Update: { - id?: string; - type?: string; - name?: string; - description?: string; - icon_url?: string | null; - criteria?: Json; - rarity?: string; - created_at?: string; - updated_at?: string; - }; - Relationships: []; - }; - - // ================================================================= - // SPOTS & SOLUTIONS (Item Detection/Matching) - // ================================================================= - - /** - * Spots - Item locations in images - * 2 records - */ - spots: { + canonical: string + created_at: string + id: string + is_active: boolean + synonyms: string[] + type: string + updated_at: string + } + Insert: { + canonical: string + created_at?: string + id: string + is_active?: boolean + synonyms: string[] + type: string + updated_at?: string + } + Update: { + canonical?: string + created_at?: string + id?: string + is_active?: boolean + synonyms?: string[] + type?: string + updated_at?: string + } + Relationships: [] + } + user_badges: { Row: { - id: string; - post_id: string; - user_id: string; - position_left: string; // Percentage (e.g., "26.26788036410923") - position_top: string; // Percentage (e.g., "30.54806828391734") - subcategory_id: string; - status: string; // 'open', 'solved', etc. - created_at: string; - updated_at: string; - }; - Insert: { - id?: string; - post_id: string; - user_id: string; - position_left: string; - position_top: string; - subcategory_id: string; - status?: string; - created_at?: string; - updated_at?: string; - }; - Update: { - id?: string; - post_id?: string; - user_id?: string; - position_left?: string; - position_top?: string; - subcategory_id?: string; - status?: string; - created_at?: string; - updated_at?: string; - }; + badge_id: string + earned_at: string + user_id: string + } + Insert: { + badge_id: string + earned_at?: string + user_id: string + } + Update: { + badge_id?: string + earned_at?: string + user_id?: string + } Relationships: [ { - foreignKeyName: "spots_post_id_fkey"; - columns: ["post_id"]; - referencedRelation: "posts"; - referencedColumns: ["id"]; + foreignKeyName: "fk_user_badges_badge_id" + columns: ["badge_id"] + isOneToOne: false + referencedRelation: "badges" + referencedColumns: ["id"] }, { - foreignKeyName: "spots_user_id_fkey"; - columns: ["user_id"]; - referencedRelation: "users"; - referencedColumns: ["id"]; + foreignKeyName: "fk_user_badges_user_id" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] }, + ] + } + user_collections: { + Row: { + created_at: string + id: string + is_pinned: boolean + magazine_id: string + user_id: string + } + Insert: { + created_at?: string + id: string + is_pinned?: boolean + magazine_id: string + user_id: string + } + Update: { + created_at?: string + id?: string + is_pinned?: boolean + magazine_id?: string + user_id?: string + } + Relationships: [ { - foreignKeyName: "spots_subcategory_id_fkey"; - columns: ["subcategory_id"]; - referencedRelation: "subcategories"; - referencedColumns: ["id"]; + foreignKeyName: "user_collections_magazine_id_fkey" + columns: ["magazine_id"] + isOneToOne: false + referencedRelation: "user_magazines" + referencedColumns: ["id"] }, - ]; - }; - - /** - * Solutions - Product matches for spots - * 12 records - */ - solutions: { + { + foreignKeyName: "user_collections_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + user_events: { Row: { - id: string; - spot_id: string; - user_id: string; - match_type: string | null; - title: string; - - price_amount: number | null; - price_currency: string; // 'KRW', 'USD', etc. - original_url: string; - affiliate_url: string | null; - thumbnail_url: string | null; - description: string; - accurate_count: number; - different_count: number; - is_verified: boolean; - is_adopted: boolean; - adopted_at: string | null; - click_count: number; - purchase_count: number; - status: string; // 'active', 'inactive', etc. - created_at: string; - updated_at: string; - metadata: Json | null; - comment: string | null; - qna: Json | null; - keywords: string[] | null; - }; - Insert: { - id?: string; - spot_id: string; - user_id: string; - match_type?: string | null; - title: string; - - price_amount?: number | null; - price_currency?: string; - original_url: string; - affiliate_url?: string | null; - thumbnail_url?: string | null; - description?: string; - accurate_count?: number; - different_count?: number; - is_verified?: boolean; - is_adopted?: boolean; - adopted_at?: string | null; - click_count?: number; - purchase_count?: number; - status?: string; - created_at?: string; - updated_at?: string; - metadata?: Json | null; - comment?: string | null; - qna?: Json | null; - keywords?: string[] | null; - }; - Update: { - id?: string; - spot_id?: string; - user_id?: string; - match_type?: string | null; - title?: string; - - price_amount?: number | null; - price_currency?: string; - original_url?: string; - affiliate_url?: string | null; - thumbnail_url?: string | null; - description?: string; - accurate_count?: number; - different_count?: number; - is_verified?: boolean; - is_adopted?: boolean; - adopted_at?: string | null; - click_count?: number; - purchase_count?: number; - status?: string; - created_at?: string; - updated_at?: string; - metadata?: Json | null; - comment?: string | null; - qna?: Json | null; - keywords?: string[] | null; - }; + created_at: string + entity_id: string | null + event_type: string + id: string + metadata: Json | null + page_path: string + session_id: string + user_id: string + } + Insert: { + created_at?: string + entity_id?: string | null + event_type: string + id?: string + metadata?: Json | null + page_path: string + session_id: string + user_id: string + } + Update: { + created_at?: string + entity_id?: string | null + event_type?: string + id?: string + metadata?: Json | null + page_path?: string + session_id?: string + user_id?: string + } Relationships: [ { - foreignKeyName: "solutions_spot_id_fkey"; - columns: ["spot_id"]; - referencedRelation: "spots"; - referencedColumns: ["id"]; + foreignKeyName: "user_events_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] }, + ] + } + user_follows: { + Row: { + created_at: string + follower_id: string + following_id: string + } + Insert: { + created_at?: string + follower_id: string + following_id: string + } + Update: { + created_at?: string + follower_id?: string + following_id?: string + } + Relationships: [] + } + user_magazines: { + Row: { + created_at: string + created_by: string + id: string + layout_json: Json | null + magazine_type: string + theme_palette: Json | null + title: string + updated_at: string + } + Insert: { + created_at?: string + created_by: string + id: string + layout_json?: Json | null + magazine_type: string + theme_palette?: Json | null + title: string + updated_at?: string + } + Update: { + created_at?: string + created_by?: string + id?: string + layout_json?: Json | null + magazine_type?: string + theme_palette?: Json | null + title?: string + updated_at?: string + } + Relationships: [ { - foreignKeyName: "solutions_user_id_fkey"; - columns: ["user_id"]; - referencedRelation: "users"; - referencedColumns: ["id"]; + foreignKeyName: "user_magazines_created_by_fkey" + columns: ["created_by"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] }, - ]; - }; - }; + ] + } + user_social_accounts: { + Row: { + access_token: string + created_at: string + id: string + last_synced_at: string | null + provider: string + provider_user_id: string + refresh_token: string | null + updated_at: string + user_id: string + } + Insert: { + access_token: string + created_at?: string + id: string + last_synced_at?: string | null + provider: string + provider_user_id: string + refresh_token?: string | null + updated_at?: string + user_id: string + } + Update: { + access_token?: string + created_at?: string + id?: string + last_synced_at?: string | null + provider?: string + provider_user_id?: string + refresh_token?: string | null + updated_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "user_social_accounts_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + user_tryon_history: { + Row: { + created_at: string + id: string + image_url: string + style_combination: Json | null + user_id: string + } + Insert: { + created_at?: string + id?: string + image_url: string + style_combination?: Json | null + user_id: string + } + Update: { + created_at?: string + id?: string + image_url?: string + style_combination?: Json | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "user_tryon_history_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + users: { + Row: { + avatar_url: string | null + bio: string | null + created_at: string + display_name: string | null + email: string + id: string + ink_credits: number + is_admin: boolean + rank: string + studio_config: Json | null + style_dna: Json | null + total_points: number + updated_at: string + username: string + } + Insert: { + avatar_url?: string | null + bio?: string | null + created_at?: string + display_name?: string | null + email: string + id: string + ink_credits?: number + is_admin?: boolean + rank?: string + studio_config?: Json | null + style_dna?: Json | null + total_points?: number + updated_at?: string + username: string + } + Update: { + avatar_url?: string | null + bio?: string | null + created_at?: string + display_name?: string | null + email?: string + id?: string + ink_credits?: number + is_admin?: boolean + rank?: string + studio_config?: Json | null + style_dna?: Json | null + total_points?: number + updated_at?: string + username?: string + } + Relationships: [] + } + view_logs: { + Row: { + created_at: string + id: string + reference_id: string + reference_type: string + user_id: string | null + } + Insert: { + created_at?: string + id: string + reference_id: string + reference_type: string + user_id?: string | null + } + Update: { + created_at?: string + id?: string + reference_id?: string + reference_type?: string + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "fk_view_logs_user_id" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + votes: { + Row: { + created_at: string + id: string + solution_id: string + user_id: string + vote_type: string + } + Insert: { + created_at?: string + id: string + solution_id: string + user_id: string + vote_type: string + } + Update: { + created_at?: string + id?: string + solution_id?: string + user_id?: string + vote_type?: string + } + Relationships: [ + { + foreignKeyName: "fk_votes_solution_id" + columns: ["solution_id"] + isOneToOne: false + referencedRelation: "solutions" + referencedColumns: ["id"] + }, + { + foreignKeyName: "fk_votes_user_id" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + } Views: { - [_ in never]: never; - }; + explore_posts: { + Row: { + ai_summary: string | null + artist_id: string | null + artist_name: string | null + context: string | null + created_at: string | null + created_with_solutions: boolean | null + group_id: string | null + group_name: string | null + id: string | null + image_url: string | null + media_metadata: Json | null + media_type: string | null + post_magazine_id: string | null + post_magazine_title: string | null + status: string | null + title: string | null + trending_score: number | null + updated_at: string | null + user_id: string | null + view_count: number | null + } + Relationships: [ + { + foreignKeyName: "fk_posts_post_magazine_id" + columns: ["post_magazine_id"] + isOneToOne: false + referencedRelation: "post_magazines" + referencedColumns: ["id"] + }, + { + foreignKeyName: "fk_posts_user_id" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + } Functions: { - [_ in never]: never; - }; + search_similar: { + Args: { + filter_type?: string + match_count?: number + query_embedding: string + } + Returns: { + content_text: string + entity_id: string + entity_type: string + similarity: number + }[] + } + show_limit: { Args: never; Returns: number } + show_trgm: { Args: { "": string }; Returns: string[] } + unaccent: { Args: { "": string }; Returns: string } + } Enums: { - // Note: These are inferred from data, not from actual DB enums - post_status: "active" | "inactive" | "pending" | "deleted"; - spot_status: "open" | "solved" | "closed"; - solution_status: "active" | "inactive" | "pending" | "deleted"; - badge_rarity: "common" | "rare" | "epic" | "legendary"; - }; + [_ in never]: never + } CompositeTypes: { - [_ in never]: never; - }; - }; -}; + [_ in never]: never + } + } +} + +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + public: { + Enums: {}, + }, +} as const // ============================================================================= -// TYPE ALIASES +// CUSTOM TYPE ALIASES (manually maintained) // ============================================================================= +/** + * Internationalized text (Korean/English) + */ +export interface I18nText { + ko: string; + en: string; +} + // Core content export type PostRow = Database["public"]["Tables"]["posts"]["Row"]; export type PostInsert = Database["public"]["Tables"]["posts"]["Insert"]; @@ -813,7 +2021,8 @@ export type UserTryonHistoryRow = Database["public"]["Tables"]["user_tryon_history"]["Row"]; // Post Magazines -export type PostMagazineRow = Database["public"]["Tables"]["post_magazines"]["Row"]; +export type PostMagazineRow = + Database["public"]["Tables"]["post_magazines"]["Row"]; // Spots & Solutions export type SpotRow = Database["public"]["Tables"]["spots"]["Row"]; @@ -826,34 +2035,6 @@ export type SolutionInsert = export type SolutionUpdate = Database["public"]["Tables"]["solutions"]["Update"]; -// ============================================================================= -// UTILITY TYPES -// ============================================================================= - -/** - * Generic table row type extractor - */ -export type Tables = - Database["public"]["Tables"][T]["Row"]; - -/** - * Generic table insert type extractor - */ -export type TablesInsert = - Database["public"]["Tables"][T]["Insert"]; - -/** - * Generic table update type extractor - */ -export type TablesUpdate = - Database["public"]["Tables"][T]["Update"]; - -/** - * Enum type extractor - */ -export type Enums = - Database["public"]["Enums"][T]; - // ============================================================================= // LEGACY COMPATIBILITY (deprecated, will be removed) // ============================================================================= diff --git a/packages/web/proxy.ts b/packages/web/proxy.ts index 34d50cfa..0c665da5 100644 --- a/packages/web/proxy.ts +++ b/packages/web/proxy.ts @@ -35,15 +35,20 @@ export async function proxy(req: NextRequest) { return res; } - // Admin: require session + admin role — silently redirect to home on failure + // Allow /admin/login through without auth + if (pathname === "/admin/login") { + return res; + } + + // Admin: require session + admin role — redirect to login on failure if (!session) { - return NextResponse.redirect(new URL("/", req.url)); + return NextResponse.redirect(new URL("/admin/login", req.url)); } const isAdmin = await checkIsAdmin(supabase, session.user.id); if (!isAdmin) { - return NextResponse.redirect(new URL("/", req.url)); + return NextResponse.redirect(new URL("/admin/login", req.url)); } // Admin confirmed — allow request through with refreshed session cookies diff --git a/specs/flows/FLW-08-my-try.md b/specs/flows/FLW-08-my-try.md index b1e135f8..b0a1111c 100644 --- a/specs/flows/FLW-08-my-try.md +++ b/specs/flows/FLW-08-my-try.md @@ -58,7 +58,7 @@ COMMENT ON COLUMN posts.post_type IS 'original: 일반, try: 사용자 시도 "image_url": "https://...", "post_type": "try", "parent_post_id": "uuid", - "media_title": "나도 입어봤어요!", + "title": "나도 입어봤어요!", "created_at": "2026-03-12T...", "user": { "display_name": "fashionista_kim", @@ -79,7 +79,7 @@ COMMENT ON COLUMN posts.post_type IS 'original: 일반, try: 사용자 시도 "image": "(file)", "parent_post_id": "원본-post-uuid", "post_type": "try", - "media_title": "한줄 코멘트 (선택)", + "title": "한줄 코멘트 (선택)", "media_type": "try" } ``` @@ -103,7 +103,7 @@ COMMENT ON COLUMN posts.post_type IS 'original: 일반, try: 사용자 시도 - 하단: 한줄 코멘트 (선택, 100자 제한) - CTA: "Try 공유하기" - **State change:** `tryStore.image`, `tryStore.comment` -- **Data:** POST `/api/v1/posts` with `{ image, parent_post_id, post_type: 'try', media_title }` +- **Data:** POST `/api/v1/posts` with `{ image, parent_post_id, post_type: 'try', title }` - **Next:** -> Step 3 (on success) | -> Error toast (on failure) ### Step 3: 완료