diff --git a/.claude/commands/improve-codebase-architecture.md b/.claude/commands/improve-codebase-architecture.md new file mode 100644 index 00000000..cfb69c76 --- /dev/null +++ b/.claude/commands/improve-codebase-architecture.md @@ -0,0 +1,11 @@ +--- +description: Matt Pocock의 improve-codebase-architecture — 코드베이스 deepening opportunities 발견, 도메인 언어·ADR 기반 refactoring 제안 +--- + +Read `~/.agents/skills/improve-codebase-architecture/SKILL.md` and follow its instructions exactly. Treat the loaded SKILL.md content as executable instructions, not reference material. + +Before exploring, read the domain docs as listed in [`docs/agents/domain.md`](docs/agents/domain.md) and any relevant ADRs in `docs/adr/`. Use `subagent_type=Explore` for codebase walks. + +The user's target area to improve: $ARGUMENTS + +If `$ARGUMENTS` is empty, ask which area/package to scope (e.g. `packages/web/lib/supabase`, `packages/api-server`, design-system) before exploring. Don't run on the entire monorepo without scope. diff --git a/.gitignore b/.gitignore index f971d556..bd281c2b 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,15 @@ packages/ai-server/searxng/settings.yml.new !.omc/ !.omc/project-memory.json +# OMC per-package artifacts (root .omc 정책은 위에서 처리) +packages/*/.omc/ + +# Next.js build backups +packages/*/.next.bak/ + +# Local scratch directory +.scratch/ + # Hybrid harness local artifacts packages/web/.tasks/ packages/web/.handoff/ diff --git a/docs/superpowers/plans/2026-05-07-profile-tries-detail-modal.md b/docs/superpowers/plans/2026-05-07-profile-tries-detail-modal.md new file mode 100644 index 00000000..5ec91693 --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-profile-tries-detail-modal.md @@ -0,0 +1,1293 @@ +# Profile Tries Detail Modal 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:** Clicking a saved try-on in `/profile` -> `Tries` opens an in-place detail modal backed by save-time provenance snapshots and safe legacy fallbacks. + +**Architecture:** Keep the existing `user_tryon_history` table shape. Expand the POST save payload into `style_combination`, normalize that JSON in the profile tries route, regenerate the generated web API model from the OpenAPI source, and keep the detail UI local to the profile grid. + +**Tech Stack:** Next.js App Router route handlers, Supabase JS, React 19, TanStack Query, Vitest, Testing Library, Orval generated API models. + +--- + +## Spec Review Gate + +Verdict: proceed to implementation planning. + +Required planning adjustment: the spec's file list must include the OpenAPI source path because `TriesGrid` imports `TryItem` from `packages/web/lib/api/generated/models`. Do not hand-edit `packages/web/lib/api/generated/*`; update the source schema in `packages/api-server/src/domains/users/dto.rs`, regenerate `packages/api-server/openapi.json` through the repo's API-server flow if needed, then run `bun run generate:api` from `packages/web`. + +No migration is part of this pass. Storage migration remains a follow-up. + +## File Structure + +- Modify `packages/web/app/api/v1/tries/route.ts` + - Validate expanded save payload fields. + - Store `person_original_image`, `source_post_snapshot`, and `selected_items_snapshot` inside `style_combination`. +- Modify `packages/web/app/api/v1/tries/__tests__/route.test.ts` + - Cover snapshot save and legacy ID-only save. +- Modify `packages/web/app/api/v1/users/me/tries/route.ts` + - Select `style_combination`. + - Normalize malformed, null, legacy, and snapshot rows. + - Resolve current post and solution rows only for legacy rows with IDs and no snapshots. +- Modify `packages/web/app/api/v1/users/me/tries/__tests__/route.test.ts` + - Cover snapshot response and legacy fallback response. +- Modify `packages/api-server/src/domains/users/dto.rs` + - Expand `TryItem` schema with nullable source post, selected item, and original image fields. +- Regenerate `packages/api-server/openapi.json` if this repo's API-server command is available. +- Regenerate `packages/web/lib/api/generated/models/tryItem.ts` and related generated files via `bun run generate:api`. +- Modify `packages/web/lib/hooks/useVtonTryOn.ts` + - Accept source post snapshot and include snapshots in save request. +- Modify `packages/web/lib/components/vton/VtonModal.tsx` + - Derive `sourcePostSnapshot` from `posts` and pass it to the hook. +- Create `packages/web/lib/components/profile/TryDetailModal.tsx` + - Present detail-ready try item content. +- Modify `packages/web/lib/components/profile/TriesGrid.tsx` + - Own selected try state and render `TryDetailModal`. +- Create `packages/web/lib/components/profile/__tests__/TryDetailModal.test.tsx` + - Verify full metadata and fallback rendering. +- Create `packages/web/lib/components/profile/__tests__/TriesGrid.test.tsx` + - Verify card click opens the modal. + +## Task 1: Save Expanded Snapshot Contract + +**Files:** +- Modify: `packages/web/app/api/v1/tries/route.ts` +- Modify: `packages/web/app/api/v1/tries/__tests__/route.test.ts` + +- [ ] **Step 1: Add failing POST snapshot test** + +Add this test case to `describe("POST /api/v1/tries", ...)` in `packages/web/app/api/v1/tries/__tests__/route.test.ts`. + +```ts +it("stores detail snapshots in style_combination", async () => { + getUserMock.mockResolvedValue({ data: { user: { id: "user-1" } } }); + singleMock.mockResolvedValue({ + data: { + id: "try-1", + image_url: "data:image/png;base64,result", + created_at: "2026-05-07T00:00:00Z", + }, + error: null, + }); + + const { POST } = await import("../route"); + const res = await POST( + makeRequest({ + result_image: "data:image/png;base64,result", + person_original_image: "data:image/png;base64,person", + source_post_id: "post-1", + selected_item_ids: ["item-1", "item-2"], + source_post_snapshot: { + id: "post-1", + title: "Source Look", + image_url: "https://example.com/post.jpg", + artist_name: "Artist", + group_name: null, + context: "Airport look", + }, + selected_items_snapshot: [ + { + id: "item-1", + title: "Jacket", + thumbnail_url: "https://example.com/jacket.jpg", + description: "Black jacket", + keywords: ["outerwear"], + }, + ], + }) + ); + + expect(res.status).toBe(201); + expect(insertMock).toHaveBeenCalledWith("user_tryon_history", { + user_id: "user-1", + image_url: "data:image/png;base64,result", + style_combination: { + source_post_id: "post-1", + selected_item_ids: ["item-1", "item-2"], + person_original_image: "data:image/png;base64,person", + source_post_snapshot: { + id: "post-1", + title: "Source Look", + image_url: "https://example.com/post.jpg", + artist_name: "Artist", + group_name: null, + context: "Airport look", + }, + selected_items_snapshot: [ + { + id: "item-1", + title: "Jacket", + thumbnail_url: "https://example.com/jacket.jpg", + description: "Black jacket", + keywords: ["outerwear"], + }, + ], + }, + }); +}); +``` + +- [ ] **Step 2: Run the failing POST test** + +Run: + +```bash +cd packages/web +bun run test:unit app/api/v1/tries/__tests__/route.test.ts +``` + +Expected: the new test fails because `person_original_image`, `source_post_snapshot`, and `selected_items_snapshot` are not stored. + +- [ ] **Step 3: Implement payload normalization** + +In `packages/web/app/api/v1/tries/route.ts`, replace `SaveTryOnBody` and add the helpers near `toStringArray`. + +```ts +interface SourcePostSnapshot { + id: string; + title: string | null; + image_url: string | null; + artist_name?: string | null; + group_name?: string | null; + context?: string | null; +} + +interface SelectedItemSnapshot { + id: string; + title: string; + thumbnail_url: string; + description?: string | null; + keywords?: string[] | null; +} + +interface SaveTryOnBody { + result_image?: unknown; + image_url?: unknown; + person_original_image?: unknown; + source_post_id?: unknown; + selected_item_ids?: unknown; + source_post_snapshot?: unknown; + selected_items_snapshot?: unknown; +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function nullableString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function toSourcePostSnapshot(value: unknown): SourcePostSnapshot | null { + if (!value || typeof value !== "object") return null; + const input = value as Record; + const id = optionalString(input.id); + if (!id) return null; + return { + id, + title: nullableString(input.title), + image_url: nullableString(input.image_url), + artist_name: nullableString(input.artist_name), + group_name: nullableString(input.group_name), + context: nullableString(input.context), + }; +} + +function toSelectedItemSnapshots(value: unknown): SelectedItemSnapshot[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (!entry || typeof entry !== "object") return []; + const input = entry as Record; + const id = optionalString(input.id); + const title = optionalString(input.title); + const thumbnailUrl = optionalString(input.thumbnail_url); + if (!id || !title || !thumbnailUrl) return []; + return [ + { + id, + title, + thumbnail_url: thumbnailUrl, + description: nullableString(input.description), + keywords: Array.isArray(input.keywords) + ? input.keywords.filter( + (keyword): keyword is string => typeof keyword === "string" + ) + : null, + }, + ]; + }); +} +``` + +Then update the `style_combination` insert object. + +```ts +const styleCombination = { + source_post_id: + typeof body.source_post_id === "string" ? body.source_post_id : null, + selected_item_ids: toStringArray(body.selected_item_ids), + person_original_image: optionalString(body.person_original_image), + source_post_snapshot: toSourcePostSnapshot(body.source_post_snapshot), + selected_items_snapshot: toSelectedItemSnapshots( + body.selected_items_snapshot + ), +}; +``` + +Use `style_combination: styleCombination` in the insert. + +- [ ] **Step 4: Keep legacy test green** + +Update existing expectations in `route.test.ts` for legacy payloads to include the newly persisted nullable fields. + +```ts +style_combination: { + source_post_id: "post-1", + selected_item_ids: ["item-1", "item-2"], + person_original_image: null, + source_post_snapshot: null, + selected_items_snapshot: [], +}, +``` + +- [ ] **Step 5: Verify POST route tests** + +Run: + +```bash +cd packages/web +bun run test:unit app/api/v1/tries/__tests__/route.test.ts +``` + +Expected: all tests in `route.test.ts` pass. + +## Task 2: Return Detail-Ready Try Items + +**Files:** +- Modify: `packages/web/app/api/v1/users/me/tries/route.ts` +- Modify: `packages/web/app/api/v1/users/me/tries/__tests__/route.test.ts` + +- [ ] **Step 1: Add failing snapshot response test** + +Extend the mock chain in `route.test.ts` so `.from("user_tryon_history")` can return the existing chain, then add this test. + +```ts +it("returns expanded fields from saved snapshots", async () => { + getUserMock.mockResolvedValue({ data: { user: { id: "user-1" } } }); + rangeMock.mockResolvedValue({ + data: [ + { + id: "try-1", + image_url: "https://example.com/result.png", + created_at: "2026-05-07T00:00:00Z", + style_combination: { + source_post_id: "post-1", + selected_item_ids: ["item-1"], + person_original_image: "https://example.com/person.png", + source_post_snapshot: { + id: "post-1", + title: "Source Look", + image_url: "https://example.com/post.png", + artist_name: "Artist", + group_name: null, + context: "Stage look", + }, + selected_items_snapshot: [ + { + id: "item-1", + title: "Jacket", + thumbnail_url: "https://example.com/jacket.png", + description: "Black jacket", + keywords: ["outerwear"], + }, + ], + }, + }, + ], + error: null, + count: 1, + }); + + const { GET } = await import("../route"); + const res = await GET(makeRequest()); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(selectMock).toHaveBeenCalledWith( + "user_tryon_history", + "id, image_url, created_at, style_combination", + { count: "exact" } + ); + expect(json.data[0]).toEqual({ + id: "try-1", + image_url: "https://example.com/result.png", + created_at: "2026-05-07T00:00:00Z", + source_post_id: "post-1", + selected_item_ids: ["item-1"], + person_original_image: "https://example.com/person.png", + source_post: { + id: "post-1", + title: "Source Look", + image_url: "https://example.com/post.png", + artist_name: "Artist", + group_name: null, + context: "Stage look", + }, + selected_items: [ + { + id: "item-1", + title: "Jacket", + thumbnail_url: "https://example.com/jacket.png", + description: "Black jacket", + keywords: ["outerwear"], + }, + ], + }); +}); +``` + +- [ ] **Step 2: Add failing legacy fallback test** + +Add a test where `style_combination` is null. It should return detail fields with nulls and empty arrays. + +```ts +it("returns fallback detail fields for null style_combination", async () => { + getUserMock.mockResolvedValue({ data: { user: { id: "user-1" } } }); + rangeMock.mockResolvedValue({ + data: [ + { + id: "try-legacy", + image_url: "https://example.com/legacy.png", + created_at: "2026-05-07T00:00:00Z", + style_combination: null, + }, + ], + error: null, + count: 1, + }); + + const { GET } = await import("../route"); + const res = await GET(makeRequest()); + const json = await res.json(); + + expect(json.data[0]).toMatchObject({ + id: "try-legacy", + source_post_id: null, + selected_item_ids: [], + person_original_image: null, + source_post: null, + selected_items: [], + }); +}); +``` + +- [ ] **Step 3: Run the failing GET route tests** + +Run: + +```bash +cd packages/web +bun run test:unit app/api/v1/users/me/tries/__tests__/route.test.ts +``` + +Expected: tests fail because the route does not select or map `style_combination`. + +- [ ] **Step 4: Implement normalizers** + +Add these types and helpers in `packages/web/app/api/v1/users/me/tries/route.ts`. + +```ts +type TrySourcePost = { + id: string; + title: string | null; + image_url: string | null; + artist_name?: string | null; + group_name?: string | null; + context?: string | null; +}; + +type TrySelectedItem = { + id: string; + title: string; + thumbnail_url: string; + description?: string | null; + keywords?: string[] | null; +}; + +type StyleCombination = { + source_post_id: string | null; + selected_item_ids: string[]; + person_original_image: string | null; + source_post_snapshot: TrySourcePost | null; + selected_items_snapshot: TrySelectedItem[]; +}; + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function stringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); +} + +function normalizeSourcePost(value: unknown): TrySourcePost | null { + if (!value || typeof value !== "object") return null; + const input = value as Record; + const id = stringOrNull(input.id); + if (!id) return null; + return { + id, + title: stringOrNull(input.title), + image_url: stringOrNull(input.image_url), + artist_name: stringOrNull(input.artist_name), + group_name: stringOrNull(input.group_name), + context: stringOrNull(input.context), + }; +} + +function normalizeSelectedItems(value: unknown): TrySelectedItem[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (!entry || typeof entry !== "object") return []; + const input = entry as Record; + const id = stringOrNull(input.id); + const title = stringOrNull(input.title); + const thumbnailUrl = stringOrNull(input.thumbnail_url); + if (!id || !title || !thumbnailUrl) return []; + return [ + { + id, + title, + thumbnail_url: thumbnailUrl, + description: stringOrNull(input.description), + keywords: Array.isArray(input.keywords) + ? input.keywords.filter( + (keyword): keyword is string => typeof keyword === "string" + ) + : null, + }, + ]; + }); +} + +function normalizeStyleCombination(value: unknown): StyleCombination { + if (!value || typeof value !== "object") { + return { + source_post_id: null, + selected_item_ids: [], + person_original_image: null, + source_post_snapshot: null, + selected_items_snapshot: [], + }; + } + const input = value as Record; + return { + source_post_id: stringOrNull(input.source_post_id), + selected_item_ids: stringArray(input.selected_item_ids), + person_original_image: stringOrNull(input.person_original_image), + source_post_snapshot: normalizeSourcePost(input.source_post_snapshot), + selected_items_snapshot: normalizeSelectedItems( + input.selected_items_snapshot + ), + }; +} +``` + +- [ ] **Step 5: Select and map detail fields** + +Change the Supabase select and response map. + +```ts +.select("id, image_url, created_at, style_combination", { count: "exact" }) +``` + +Then map rows before returning. + +```ts +const tries = (data ?? []).map((row) => { + const style = normalizeStyleCombination( + (row as { style_combination?: unknown }).style_combination + ); + return { + id: row.id, + image_url: row.image_url, + created_at: row.created_at, + source_post_id: style.source_post_id, + selected_item_ids: style.selected_item_ids, + person_original_image: style.person_original_image, + source_post: style.source_post_snapshot, + selected_items: style.selected_items_snapshot, + }; +}); +``` + +Use `data: tries` in `NextResponse.json`. + +- [ ] **Step 6: Add best-effort ID lookup for legacy rows** + +After the snapshot-only tests pass, add one more test for a legacy row with `source_post_id` and `selected_item_ids` but no snapshots. Mock additional `from("posts")` and `from("solutions")` chains that return current rows. Implement lookup only for missing snapshots, using these select shapes: + +```ts +const { data: postRows } = await supabase + .from("posts") + .select("id, title, image_url, artist_name, group_name, context") + .in("id", sourcePostIds); + +const { data: solutionRows } = await supabase + .from("solutions") + .select("id, title, thumbnail_url, description, keywords") + .in("id", selectedItemIds); +``` + +Use saved snapshots first, then maps from current rows, then null or `[]`. + +- [ ] **Step 7: Verify GET route tests** + +Run: + +```bash +cd packages/web +bun run test:unit app/api/v1/users/me/tries/__tests__/route.test.ts +``` + +Expected: all tests pass. + +## Task 3: Regenerate TryItem API Model + +**Files:** +- Modify: `packages/api-server/src/domains/users/dto.rs` +- Regenerate: `packages/api-server/openapi.json` +- Regenerate: `packages/web/lib/api/generated/models/tryItem.ts` +- Regenerate: related generated files under `packages/web/lib/api/generated/` + +- [ ] **Step 1: Expand OpenAPI DTO source** + +In `packages/api-server/src/domains/users/dto.rs`, add nested DTOs near `TryItem` and replace `TryItem`. + +```rust +/// VTON 히스토리 원본 포스트 스냅샷 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct TrySourcePost { + pub id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub artist_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub group_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, +} + +/// VTON 히스토리 선택 아이템 스냅샷 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct TrySelectedItem { + pub id: Uuid, + pub title: String, + pub thumbnail_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub keywords: Option>, +} + +/// VTON 히스토리 아이템 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct TryItem { + pub id: Uuid, + pub image_url: String, + pub created_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_post_id: Option, + pub selected_item_ids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub person_original_image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_post: Option, + pub selected_items: Vec, +} +``` + +- [ ] **Step 2: Keep API-server compile errors explicit** + +Run from repo root: + +```bash +cargo check -p api-server +``` + +Expected: if `users/service.rs` still constructs the old three-field `TryItem`, Rust reports missing fields. + +- [ ] **Step 3: Update API-server service construction** + +In `packages/api-server/src/domains/users/service.rs`, update the existing `TryItem` construction to compile with empty detail fields. The Next route owns the detail implementation for this pass. + +```rust +TryItem { + id: r.id, + image_url: r.image_url, + created_at: r.created_at.into(), + source_post_id: None, + selected_item_ids: Vec::new(), + person_original_image: None, + source_post: None, + selected_items: Vec::new(), +} +``` + +- [ ] **Step 4: Regenerate OpenAPI and web client** + +Use the repo's existing OpenAPI dump command if available. If no package script exists, run the local binary command used by this repository. + +```bash +cd packages/api-server +cargo run --bin dump_openapi +cd ../web +bun run generate:api +``` + +Expected: `packages/web/lib/api/generated/models/tryItem.ts` includes `source_post_id`, `selected_item_ids`, `person_original_image`, `source_post`, and `selected_items`. + +- [ ] **Step 5: Verify generated model shape** + +Run: + +```bash +rg -n "source_post_id|selected_items|person_original_image" packages/web/lib/api/generated/models/tryItem.ts +``` + +Expected: all three fields are present. + +## Task 4: Send Save-Time Snapshots from VTON + +**Files:** +- Modify: `packages/web/lib/hooks/useVtonTryOn.ts` +- Modify: `packages/web/lib/components/vton/VtonModal.tsx` + +- [ ] **Step 1: Extend hook options** + +In `useVtonTryOn.ts`, import `VtonPostData` and add snapshot fields. + +```ts +import type { VtonPostData } from "@/lib/hooks/useVtonPostFetch"; + +interface UseVtonTryOnOptions { + personImage: string | null; + personPreview: string | null; + selectedItems: ItemData[]; + sourcePostId: string | null; + sourcePostSnapshot: Omit | null; + displayResultImage: string | null; + abortControllerRef: React.RefObject; + onTryOnStart: () => void; + onTryOnComplete: (resultDataUrl: string, latencyMs: number | null) => void; + onTryOnError: (message: string) => void; + onTryOnFinally: () => void; + startBackgroundJob: ( + personPreview: string, + personImageBase64: string, + selectedItems: ItemData[] + ) => string; +} +``` + +- [ ] **Step 2: Add snapshot payload to save request** + +Include these fields in the `JSON.stringify` body inside `handleSaveToProfile`. + +```ts +body: JSON.stringify({ + result_image: displayResultImage, + person_original_image: personPreview, + source_post_id: sourcePostId, + selected_item_ids: selectedItems.map((i) => i.id), + source_post_snapshot: sourcePostSnapshot, + selected_items_snapshot: selectedItems.map((item) => ({ + id: item.id, + title: item.title, + thumbnail_url: item.thumbnail_url, + description: item.description, + keywords: item.keywords, + })), +}), +``` + +Add `personPreview` and `sourcePostSnapshot` to the callback dependency list. + +- [ ] **Step 3: Derive source post snapshot in VtonModal** + +In `VtonModal.tsx`, derive a snapshot after `posts` and `sourcePostId` are available. + +```ts +const sourcePostSnapshot = useMemo(() => { + if (!sourcePostId) return null; + const post = posts.find((entry) => entry.id === sourcePostId); + if (!post) return null; + return { + id: post.id, + title: post.title, + image_url: post.image_url, + artist_name: post.artist_name, + group_name: post.group_name, + context: post.context, + }; +}, [posts, sourcePostId]); +``` + +Pass it to `useVtonTryOn`. + +```ts +sourcePostSnapshot, +``` + +- [ ] **Step 4: Verify TypeScript for VTON files** + +Run: + +```bash +cd packages/web +bun run typecheck +``` + +Expected: no type errors from `useVtonTryOn.ts` or `VtonModal.tsx`. + +## Task 5: Build Try Detail Modal + +**Files:** +- Create: `packages/web/lib/components/profile/TryDetailModal.tsx` +- Create: `packages/web/lib/components/profile/__tests__/TryDetailModal.test.tsx` + +- [ ] **Step 1: Write full metadata modal test** + +Create `TryDetailModal.test.tsx`. + +```tsx +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { TryDetailModal } from "../TryDetailModal"; +import type { TryItem } from "@/lib/api/generated/models"; + +vi.mock("next/image", () => ({ + default: (props: React.ImgHTMLAttributes) => ( + + ), +})); + +const fullTry: TryItem = { + id: "try-1", + image_url: "https://example.com/result.png", + created_at: "2026-05-07T00:00:00Z", + source_post_id: "post-1", + selected_item_ids: ["item-1"], + person_original_image: "https://example.com/person.png", + source_post: { + id: "post-1", + title: "Source Look", + image_url: "https://example.com/post.png", + artist_name: "Artist", + group_name: null, + context: "Stage look", + }, + selected_items: [ + { + id: "item-1", + title: "Jacket", + thumbnail_url: "https://example.com/jacket.png", + description: "Black jacket", + keywords: ["outerwear"], + }, + ], +}; + +describe("TryDetailModal", () => { + it("renders result, original, source post, and selected items", () => { + render( {}} />); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByAltText("Try-on result")).toBeInTheDocument(); + expect(screen.getByAltText("Uploaded original")).toBeInTheDocument(); + expect(screen.getByText("Source Look")).toBeInTheDocument(); + expect(screen.getByText("Jacket")).toBeInTheDocument(); + expect(screen.getByText("outerwear")).toBeInTheDocument(); + }); + + it("calls onClose from the close button", () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /close/i })); + expect(onClose).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Add fallback modal test** + +Add this case to the same test file. + +```tsx +it("renders fallback text when metadata is missing", () => { + render( + {}} + /> + ); + + expect(screen.getByText("Original image unavailable")).toBeInTheDocument(); + expect(screen.getByText("Source post unavailable")).toBeInTheDocument(); + expect(screen.getByText("Items unavailable")).toBeInTheDocument(); +}); +``` + +- [ ] **Step 3: Run failing modal tests** + +Run: + +```bash +cd packages/web +bun run test:unit lib/components/profile/__tests__/TryDetailModal.test.tsx +``` + +Expected: module not found until `TryDetailModal.tsx` is created. + +- [ ] **Step 4: Implement TryDetailModal** + +Create `packages/web/lib/components/profile/TryDetailModal.tsx`. + +```tsx +"use client"; + +import { useEffect } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { X } from "lucide-react"; +import type { TryItem } from "@/lib/api/generated/models"; + +interface TryDetailModalProps { + tryItem: TryItem; + onClose: () => void; +} + +export function TryDetailModal({ tryItem, onClose }: TryDetailModalProps) { + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + const savedDate = new Date(tryItem.created_at).toLocaleDateString("ko-KR", { + year: "numeric", + month: "short", + day: "numeric", + }); + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+ + +
+
+
+ Try-on result +
+
+ Result +
+
+ + {tryItem.person_original_image ? ( +
+
+ Uploaded original +
+
+ Original +
+
+ ) : ( +
+ Original image unavailable +
+ )} + + {tryItem.source_post?.image_url ? ( +
+
+ Source post +
+
+ Source post +
+
+ ) : ( +
+ Source post unavailable +
+ )} +
+ + +
+
+ ); +} +``` + +- [ ] **Step 5: Verify modal tests** + +Run: + +```bash +cd packages/web +bun run test:unit lib/components/profile/__tests__/TryDetailModal.test.tsx +``` + +Expected: all modal tests pass. + +## Task 6: Wire Modal into TriesGrid + +**Files:** +- Modify: `packages/web/lib/components/profile/TriesGrid.tsx` +- Create: `packages/web/lib/components/profile/__tests__/TriesGrid.test.tsx` + +- [ ] **Step 1: Add failing grid interaction test** + +Create `TriesGrid.test.tsx`. + +```tsx +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { TriesGrid } from "../TriesGrid"; + +vi.mock("next/image", () => ({ + default: (props: React.ImgHTMLAttributes) => ( + + ), +})); + +vi.mock("@/lib/api/generated/users/users", () => ({ + getMyTries: vi.fn(async () => ({ + data: [ + { + id: "try-1", + image_url: "https://example.com/result.png", + created_at: "2026-05-07T00:00:00Z", + source_post_id: null, + selected_item_ids: [], + person_original_image: null, + source_post: null, + selected_items: [], + }, + ], + pagination: { + current_page: 1, + per_page: 20, + total: 1, + total_pages: 1, + }, + })), +})); + +function renderGrid() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + ); +} + +describe("TriesGrid", () => { + it("opens the detail modal when a try card is clicked", async () => { + renderGrid(); + + await waitFor(() => + expect(screen.getByAltText("Try-on result")).toBeInTheDocument() + ); + fireEvent.click(screen.getByRole("button", { name: /open try-on/i })); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Try-on details")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run failing grid test** + +Run: + +```bash +cd packages/web +bun run test:unit lib/components/profile/__tests__/TriesGrid.test.tsx +``` + +Expected: the test fails because cards have no accessible name and no modal state. + +- [ ] **Step 3: Implement selected try state** + +In `TriesGrid.tsx`, import `useState` and `TryDetailModal`. + +```ts +import { useRef, useEffect, useState } from "react"; +import { TryDetailModal } from "./TryDetailModal"; +``` + +Inside `TriesGrid`, add: + +```ts +const [selectedTry, setSelectedTry] = useState(null); +``` + +Update the card button. + +```tsx + + + +
+ {loading ? ( +
+ +
+ ) : results.length === 0 ? ( +
+ No posts found +
+ ) : ( +
+ {results.map((post) => ( + + ))} +
+ )} +
+ +
+ {showManualInput ? ( +
{ + e.preventDefault(); + if (manualId.trim()) { + onSelect(manualId.trim()); + onClose(); + } + }} + className="flex gap-2" + > + setManualId(e.target.value)} + placeholder="Enter post ID or UUID" + className="flex-1 rounded-md border border-input bg-background px-3 py-1.5 text-sm outline-none" + /> + +
+ ) : ( + + )} +
+ + + ); +} +``` + +**주의**: `search()` 함수의 실제 파라미터 형식과 응답 타입은 `packages/web/lib/api/generated/search/search.ts`와 `SearchParams`/`SearchResponse` 타입을 확인하세요. 위 코드의 `{ q, limit }` 파라미터와 `res.data` 응답 매핑은 실제 API 스펙에 맞게 조정이 필요할 수 있습니다. `createBrowserClient`는 `@/lib/supabase/client`에서 import합니다 — 실제 경로를 확인하세요. + +- [ ] **Step 2: 타입 체크** + +Run: `cd packages/web && npx tsc --noEmit --pretty 2>&1 | grep "PostPickerModal"` +Expected: 0 errors (import 전이므로 에러 없음) + +- [ ] **Step 3: 커밋** + +```bash +git add packages/web/app/admin/content-studio/PostPickerModal.tsx +git commit -m "feat(content-studio): add PostPickerModal component + +Meilisearch search with 300ms debounce + Supabase popular posts fallback +(view_count descending, top 20). Shows thumbnail, title, artist/group, +view/like counts. Manual ID input via fallback link at bottom." +``` + +--- + +## Task 7: page.tsx 통합 — PostPickerModal + Research 제거 + 썸네일 UI + +**Files:** + +- Modify: `packages/web/app/admin/content-studio/page.tsx` + +- [ ] **Step 1: page.tsx에서 research 상태 및 ResearchPanel 제거** + +`packages/web/app/admin/content-studio/page.tsx`에서: + +1. import에서 `ResearchPanel`, `ResearchRun` 제거 +2. import에 `PostPickerModal` 추가 +3. state에서 `researchRun`, `researchWarning`, `useResearchInCopy` 삭제 +4. `handleCreatePacket`에서 research state 초기화 제거 (`setResearchRun(null)`, `setResearchWarning(null)`, `setUseResearchInCopy(false)`) +5. `handleOpenPacket`에서 동일 research state 제거 +6. `handleGenerateVariants`에서 `researchContext`, `useResearchInCopy` API body 제거 +7. JSX에서 `` 전체 제거 +8. ``에서 `researchRun={researchRun}`, `useResearchInCopy={useResearchInCopy}` props 제거 +9. ``에서 동일 props 제거 + +- [ ] **Step 2: PostPickerModal 통합 + post ID 텍스트 입력 교체** + +```tsx +// 추가 state: +const [pickerOpen, setPickerOpen] = useState(false); +const [keywords, setKeywords] = useState([]); +const [imagePrompts, setImagePrompts] = useState<{ + youtube: string; + instagram_feed: string; + instagram_story: string; +} | null>(null); +const [thumbnails, setThumbnails] = useState>({}); +const [thumbnailLoading, setThumbnailLoading] = useState(false); + +// handleSelectPost: PostPickerModal에서 선택 시 자동 packet 생성 +async function handleSelectPost(selectedPostId: string) { + setPostId(selectedPostId); + setState("loading"); + setError(null); + setGovernance(null); + setGenerationWarning(null); + setVariants([]); + setKeywords([]); + setImagePrompts(null); + setThumbnails({}); + + try { + const data = await postJson<{ packet: ContentPacket }>( + "/api/v1/content/packets", + { postId: selectedPostId }, + ); + setPacket(data.packet); + await loadRecentPackets(); + setState("idle"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create packet"); + setState("error"); + } +} +``` + +기존 form을 교체: + +```tsx +
+
+
+ + Post + + +
+
+ + +
+
+ + {error && ( +
+ {error} +
+ )} + {generationWarning && ( +
+ {generationWarning} +
+ )} +
+ + setPickerOpen(false)} + onSelect={handleSelectPost} +/> +``` + +- [ ] **Step 3: 썸네일 생성 UI 추가** + +variants 아래, GovernancePanel 위에 썸네일 섹션 추가: + +```tsx +{ + imagePrompts && ( +
+

+ Thumbnail Generation +

+
+ {(["youtube", "instagram_feed", "instagram_story"] as const).map( + (channel) => ( +