From 953fd46a2e90402d42013b75d35512666235afda Mon Sep 17 00:00:00 2001 From: cocoyoon Date: Thu, 2 Apr 2026 16:10:08 +0900 Subject: [PATCH 01/12] docs(spec): add spot tagging to Try post flow (#24, #25, #29) Integrate #29 (spot reviews) into Try posts. Add spot_ids optional tagging, try_spot_tags schema, GET /spots/:spotId/tries endpoint, and SpotTagSelector UI to FLW-08 and SCR-CREA-TRY-01 specs. Co-Authored-By: Claude Opus 4.6 (1M context) --- specs/flows/FLW-08-my-try.md | 40 ++++++++++++++++--- .../creation/SCR-CREA-TRY-01-try-upload.md | 16 +++++++- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/specs/flows/FLW-08-my-try.md b/specs/flows/FLW-08-my-try.md index b1e135f8..5a95262d 100644 --- a/specs/flows/FLW-08-my-try.md +++ b/specs/flows/FLW-08-my-try.md @@ -11,6 +11,7 @@ User discovers a post with fashion items, wants to share their own attempt (outf - **Try = Post with `parent_post_id`**: No new table. Extends `posts` with 2 columns. - **Lightweight upload**: Simplified version of FLW-03 (skip AI detect, minimal metadata). - **Bidirectional link**: Original post shows Try count + gallery; Try post links back to original. +- **Spot tagging (optional)**: Try 생성 시 원본 포스트의 스팟(아이템)을 태깅할 수 있음. 이를 통해 "이 아이템 갖고 있어요" 리뷰 역할도 겸함. → #29 (spot_reviews) 기능을 통합. ## Screen Sequence @@ -36,6 +37,18 @@ CREATE INDEX idx_posts_post_type ON posts(post_type); COMMENT ON COLUMN posts.parent_post_id IS 'Try 포스트의 원본 포스트 ID (null이면 일반 포스트)'; COMMENT ON COLUMN posts.post_type IS 'original: 일반, try: 사용자 시도 공유'; + +-- Try ↔ Spot 태깅 (optional, N:M) +CREATE TABLE try_spot_tags ( + try_post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + spot_id UUID NOT NULL REFERENCES spots(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (try_post_id, spot_id) +); + +CREATE INDEX idx_try_spot_tags_spot_id ON try_spot_tags(spot_id); + +COMMENT ON TABLE try_spot_tags IS 'Try 포스트가 태깅한 원본 포스트의 스팟(아이템). 스팟별 리뷰 집계에 활용.'; ``` ## API Endpoints @@ -44,7 +57,8 @@ COMMENT ON COLUMN posts.post_type IS 'original: 일반, try: 사용자 시도 |--------|----------|-------------| | GET | `/api/v1/posts/:postId/tries` | 원본 포스트의 Try 목록 (pagination) | | GET | `/api/v1/posts/:postId/tries/count` | Try 개수 | -| POST | `/api/v1/posts` | 기존 엔드포인트 확장 — `parent_post_id` 필드 추가 | +| POST | `/api/v1/posts` | 기존 엔드포인트 확장 — `parent_post_id` + `spot_ids` 필드 추가 | +| GET | `/api/v1/spots/:spotId/tries` | 특정 스팟을 태깅한 Try 목록 (스팟별 리뷰 조회) | ### GET /api/v1/posts/:postId/tries @@ -74,13 +88,14 @@ COMMENT ON COLUMN posts.post_type IS 'original: 일반, try: 사용자 시도 ### POST /api/v1/posts (Try 생성) ```json -// Request — 기존 포스트 생성에 parent_post_id만 추가 +// Request — 기존 포스트 생성에 parent_post_id + spot_ids 추가 { "image": "(file)", "parent_post_id": "원본-post-uuid", "post_type": "try", "media_title": "한줄 코멘트 (선택)", - "media_type": "try" + "media_type": "try", + "spot_ids": ["spot-uuid-1", "spot-uuid-2"] // optional — 태깅할 스팟 ID 배열 } ``` @@ -100,10 +115,11 @@ COMMENT ON COLUMN posts.post_type IS 'original: 일반, try: 사용자 시도 - **UI 구성:** - 상단: 원본 포스트 썸네일 + 제목 (컨텍스트) - 중앙: 이미지 업로드 (DropZone, 단일 이미지) + - 스팟 태깅: 원본 포스트의 아이템(스팟) 목록에서 선택 (optional, 복수 가능) — "이 아이템 갖고 있어요" - 하단: 한줄 코멘트 (선택, 100자 제한) - CTA: "Try 공유하기" -- **State change:** `tryStore.image`, `tryStore.comment` -- **Data:** POST `/api/v1/posts` with `{ image, parent_post_id, post_type: 'try', media_title }` +- **State change:** `tryStore.image`, `tryStore.comment`, `tryStore.spotIds` +- **Data:** POST `/api/v1/posts` with `{ image, parent_post_id, post_type: 'try', media_title, spot_ids }` - **Next:** -> Step 3 (on success) | -> Error toast (on failure) ### Step 3: 완료 @@ -178,9 +194,21 @@ COMMENT ON COLUMN posts.post_type IS 'original: 일반, try: 사용자 시도 | Try creation fails | Retry with confirm | Preserve comment text | | Auth expired mid-flow | Re-auth silently | Return to post detail | +## #29 통합 — 스팟 리뷰를 Try로 흡수 + +기존 #29 (feat: 스팟 소유 자가신고 + 사진 리뷰)는 별도 `spot_reviews` 테이블 대신 **Try 포스트 + 스팟 태깅**으로 통합한다. + +| #29 요구사항 | Try 포스트에서의 해결 | +|---|---| +| 스팟별 사진 리뷰 | Try 생성 시 `spot_ids` 태깅 → `try_spot_tags`로 스팟별 역조회 | +| "이 아이템 있어요" 자가 신고 | 스팟 태깅 자체가 소유 신고 역할 | +| 유저당 스팟당 1리뷰 | 제약 완화 — 같은 스팟을 여러 Try에서 태깅 가능 (더 자연스러운 UGC) | +| 리뷰 텍스트 | Try의 `media_title` (한줄 코멘트) | +| 스팟별 리뷰 목록 | `GET /api/v1/spots/:spotId/tries` | + ## Future Extensions -- **Try에 Spots 추가**: 실제 구매 링크 태깅 (Phase 2) +- **스팟별 리뷰 집계**: 태깅 수 기반 "N명이 소유" 배지 (Phase 2) - **Try 비교 뷰**: 원본 vs Try 나란히 비교 (Phase 2) - **Try 투표**: "잘 소화했어요" 리액션 (Phase 3) - **VTON 연동**: VTON 결과를 Try로 자동 공유 (Phase 3) diff --git a/specs/screens/creation/SCR-CREA-TRY-01-try-upload.md b/specs/screens/creation/SCR-CREA-TRY-01-try-upload.md index ad22dad9..8256ff71 100644 --- a/specs/screens/creation/SCR-CREA-TRY-01-try-upload.md +++ b/specs/screens/creation/SCR-CREA-TRY-01-try-upload.md @@ -25,6 +25,7 @@ User uploads a single photo of their own attempt (outfit, purchase, styling) lin | DropZone | `packages/web/lib/components/request/DropZone.tsx` | Image upload (reuse) | | MobileUploadOptions | `packages/web/lib/components/request/MobileUploadOptions.tsx` | Camera/Gallery (reuse) | | TryCommentInput | `packages/web/lib/components/request/TryCommentInput.tsx` | Single-line comment (new) | +| SpotTagSelector | `packages/web/lib/components/request/SpotTagSelector.tsx` | Optional spot tagging chips (new) | | DS Button | `packages/web/lib/design-system/` | CTA button (reuse) | --- @@ -48,6 +49,9 @@ User uploads a single photo of their own attempt (outfit, purchase, styling) lin │ │ 착용샷, 구매 인증 등 │ │ │ └─────────────────────────┘ │ │ │ +│ 🏷️ 이 아이템 갖고 있어요 │ SpotTagSelector (optional) +│ [아이템A ✓] [아이템B] [아이템C] │ 원본 포스트 스팟 목록, 탭 토글 +│ │ │ [ 한줄 코멘트 (선택) _______ ] │ TryCommentInput (100자) │ │ │ [Try 공유하기] │ disabled until image selected @@ -70,6 +74,9 @@ User uploads a single photo of their own attempt (outfit, purchase, styling) lin │ └───────────────────────┘ │ │ [🔄 다시 선택] │ Replace button │ │ +│ 🏷️ 이 아이템 갖고 있어요 │ SpotTagSelector +│ [아이템A ✓] [아이템B ✓] │ selected spots highlighted +│ │ │ [ 잘 어울리네요! ____________ ] │ TryCommentInput (filled) │ │ │ [Try 공유하기] │ enabled, primary color @@ -116,6 +123,7 @@ User uploads a single photo of their own attempt (outfit, purchase, styling) lin | `parentPostId` | `string \| null` | Original post reference | | `postType` | `'original' \| 'try'` | Defaults to 'original' | | `tryComment` | `string` | Max 100 chars | +| `taggedSpotIds` | `string[]` | Selected spot IDs (optional) | Reuse existing `images`, `addImage`, `clearImages`, `resetRequestFlow`. @@ -127,6 +135,7 @@ Reuse existing `images`, `addImage`, `clearImages`, `resetRequestFlow`. | `image` | `File \| null` | Single image | | `previewUrl` | `string \| null` | Object URL | | `comment` | `string` | Max 100 chars | +| `taggedSpotIds` | `string[]` | Selected spot IDs (optional) | | `isSubmitting` | `boolean` | Submit state | --- @@ -139,7 +148,8 @@ Reuse existing `images`, `addImage`, `clearImages`, `resetRequestFlow`. | R2 | When user selects an image, the system shall validate (JPG/PNG/WebP, ≤10MB), compress if needed, and show preview | Draft | | R3 | When user taps the uploaded image or "다시 선택", the system shall allow replacing the image | Draft | | R4 | When user types a comment, the system shall enforce 100-character limit with counter | Draft | -| R5 | When user taps "Try 공유하기" with an image, the system shall POST to `/api/v1/posts` with `parent_post_id` and `post_type: 'try'` | Draft | +| R5 | When user taps "Try 공유하기" with an image, the system shall POST to `/api/v1/posts` with `parent_post_id`, `post_type: 'try'`, and optional `spot_ids` | Draft | +| R10 | When the original post has spots, the system shall display SpotTagSelector with item chips; user may toggle 0 or more spots | Draft | | R6 | When the POST succeeds, the system shall navigate to `/posts/:parentId`, show success toast, and reset state | Draft | | R7 | When the POST fails, the system shall show error toast and preserve image + comment state | Draft | | R8 | When user taps close (✕), the system shall confirm if image is selected, then reset and navigate to `/posts/:parentId` | Draft | @@ -157,6 +167,8 @@ formData.append('parent_post_id', parentPostId); formData.append('post_type', 'try'); formData.append('media_type', 'try'); formData.append('media_title', comment || ''); // 한줄 코멘트 +// optional — 태깅할 스팟 ID들 +taggedSpotIds.forEach(id => formData.append('spot_ids', id)); ``` --- @@ -200,7 +212,7 @@ formData.append('media_title', comment || ''); // 한줄 코멘트 |--------|---------------------|----------------------| | Steps | 3 steps (Upload → Detect → Details) | 1 step (Image + Comment) | | AI Detection | Required (spots mandatory) | None | -| Spots/Solutions | Manual placement + form | None (Phase 2) | +| Spots/Solutions | Manual placement + form | Optional spot tagging (select from parent's spots) | | Metadata | media_type, source, artist, group | comment only | | Context | Standalone creation | Linked to parent post | | CTA text | "Post" | "Try 공유하기" | From 0a2964b9d9a5c310b3ddaaab595486b1788b73ac Mon Sep 17 00:00:00 2001 From: cocoyoon Date: Thu, 2 Apr 2026 16:16:23 +0900 Subject: [PATCH 02/12] feat(45): complete request & solution user flow Implements the full TODO list for issue #45 across 5 phases: Phase 1 - Upload UX & Error Handling: - Weighted upload progress (compression 0-30%, upload 30-95%) - Spot placement guide overlay when no spots placed - Submit error retry UI with inline error message - Image dimension validation (200-8000px) and corruption detection - Offline draft save/restore via localStorage Phase 2 - Voting & Tracking: - Votes proxy API route (GET/POST/DELETE /solutions/{id}/votes) - useVoting hook with optimistic updates - VotingButtons wired to backend (replaces hardcoded values) - Affiliate click tracking via existing user_events system Phase 3 - Auth: - AuthGate component for login-required actions (BottomSheet) - Route protection for /request/* in proxy.ts Phase 4 - Solution Browsing: - SpotSolutionTabs for spot-by-spot solution comparison - Voting UX: verified badge, fill-on-vote, animated accuracy bar Phase 5 - Image Editing: - ImageEditor with react-advanced-cropper (crop/rotate/aspect ratios) - Integrated into upload flow with edit button overlay Closes #45 Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 7 + package.json | 1 + .../v1/solutions/[solutionId]/votes/route.ts | 145 +++++++++++++++ packages/web/app/request/upload/page.tsx | 127 +++++++++++++- .../components/detail/ImageDetailContent.tsx | 22 +++ .../lib/components/detail/ItemDetailCard.tsx | 21 +++ .../web/lib/components/detail/ItemVoting.tsx | 13 +- .../components/detail/OtherSolutionsList.tsx | 4 + .../components/detail/SpotSolutionTabs.tsx | 157 +++++++++++++++++ .../lib/components/detail/TopSolutionCard.tsx | 4 + .../lib/components/request/DetectionView.tsx | 32 +++- .../lib/components/request/ImageEditor.tsx | 143 +++++++++++++++ .../web/lib/components/shared/AuthGate.tsx | 105 +++++++++++ .../lib/components/shared/VotingButtons.tsx | 72 ++++---- packages/web/lib/hooks/useAffiliateClick.ts | 46 +++++ packages/web/lib/hooks/useImageUpload.ts | 38 ++-- packages/web/lib/hooks/useVoting.ts | 165 ++++++++++++++++++ packages/web/lib/stores/behaviorStore.ts | 3 +- packages/web/lib/utils/imageCompression.ts | 13 +- packages/web/lib/utils/offlineDraft.ts | 137 +++++++++++++++ packages/web/lib/utils/validation.ts | 54 ++++++ packages/web/proxy.ts | 10 +- 22 files changed, 1251 insertions(+), 68 deletions(-) create mode 100644 packages/web/app/api/v1/solutions/[solutionId]/votes/route.ts create mode 100644 packages/web/lib/components/detail/SpotSolutionTabs.tsx create mode 100644 packages/web/lib/components/request/ImageEditor.tsx create mode 100644 packages/web/lib/components/shared/AuthGate.tsx create mode 100644 packages/web/lib/hooks/useAffiliateClick.ts create mode 100644 packages/web/lib/hooks/useVoting.ts create mode 100644 packages/web/lib/utils/offlineDraft.ts diff --git a/bun.lock b/bun.lock index d0c627ef..6f451034 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "decoded-monorepo", "dependencies": { "react": "^19.2.0", + "react-advanced-cropper": "^0.20.1", "react-dom": "^19.2.0", }, "devDependencies": { @@ -1298,6 +1299,8 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "advanced-cropper": ["advanced-cropper@0.17.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Z1P0sYOXa2tqZjeY742QtNERofXh1AuOa27LEurO9rbx3IfzLrGQlzy7sWEc5VN9hRg+J/qCiMmnB6tUDLb1TA=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], @@ -1482,6 +1485,8 @@ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], @@ -2612,6 +2617,8 @@ "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "react-advanced-cropper": ["react-advanced-cropper@0.20.1", "", { "dependencies": { "advanced-cropper": "~0.17.1", "classnames": "^2.2.6", "tslib": "^2.4.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-Pcmkv0xQMpig6+LkM+zLbEuqBbYG3+CwXvIfYU+LDNn9l8t91Jm0fp9MSTNW0pjIvT6frAGTfmlnvnZW4PEs7Q=="], + "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], diff --git a/package.json b/package.json index 9ca376f8..d71adc17 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ }, "dependencies": { "react": "^19.2.0", + "react-advanced-cropper": "^0.20.1", "react-dom": "^19.2.0" } } diff --git a/packages/web/app/api/v1/solutions/[solutionId]/votes/route.ts b/packages/web/app/api/v1/solutions/[solutionId]/votes/route.ts new file mode 100644 index 00000000..5811aafe --- /dev/null +++ b/packages/web/app/api/v1/solutions/[solutionId]/votes/route.ts @@ -0,0 +1,145 @@ +/** + * Solution Votes Proxy API Route + * GET /api/v1/solutions/[solutionId]/votes - Get vote stats (public) + * POST /api/v1/solutions/[solutionId]/votes - Create/update vote (auth required) + * DELETE /api/v1/solutions/[solutionId]/votes - Remove vote (auth required) + */ + +import { NextRequest, NextResponse } from "next/server"; +import { API_BASE_URL } from "@/lib/server-env"; + +type RouteParams = { + params: Promise<{ solutionId: string }>; +}; + +export async function GET(request: NextRequest, { params }: RouteParams) { + const { solutionId } = await params; + const authHeader = request.headers.get("Authorization"); + + try { + const headers: Record = { + "Content-Type": "application/json", + }; + if (authHeader) headers.Authorization = authHeader; + + const response = await fetch( + `${API_BASE_URL}/api/v1/solutions/${solutionId}/votes`, + { method: "GET", headers } + ); + + const responseText = await response.text(); + let data; + try { + data = JSON.parse(responseText); + } catch { + data = { + message: `Backend error: ${response.status} ${response.statusText}`, + }; + } + return NextResponse.json(data, { status: response.status }); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.error("Votes GET proxy error:", error); + } + return NextResponse.json( + { message: error instanceof Error ? error.message : "Proxy error" }, + { status: 502 } + ); + } +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + const authHeader = request.headers.get("Authorization"); + if (!authHeader) { + return NextResponse.json( + { message: "Authentication required" }, + { status: 401 } + ); + } + + const { solutionId } = await params; + + try { + const body = await request.json(); + + const response = await fetch( + `${API_BASE_URL}/api/v1/solutions/${solutionId}/votes`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + }, + body: JSON.stringify(body), + } + ); + + if (response.status === 204) { + return new NextResponse(null, { status: 204 }); + } + + const responseText = await response.text(); + let data; + try { + data = JSON.parse(responseText); + } catch { + data = { + message: `Backend error: ${response.status} ${response.statusText}`, + }; + } + return NextResponse.json(data, { status: response.status }); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.error("Votes POST proxy error:", error); + } + return NextResponse.json( + { message: error instanceof Error ? error.message : "Proxy error" }, + { status: 502 } + ); + } +} + +export async function DELETE(request: NextRequest, { params }: RouteParams) { + const authHeader = request.headers.get("Authorization"); + if (!authHeader) { + return NextResponse.json( + { message: "Authentication required" }, + { status: 401 } + ); + } + + const { solutionId } = await params; + + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/solutions/${solutionId}/votes`, + { + method: "DELETE", + headers: { Authorization: authHeader }, + } + ); + + if (response.status === 204) { + return new NextResponse(null, { status: 204 }); + } + + const responseText = await response.text(); + let data; + try { + data = JSON.parse(responseText); + } catch { + data = { + message: `Backend error: ${response.status} ${response.statusText}`, + }; + } + return NextResponse.json(data, { status: response.status }); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.error("Votes DELETE proxy error:", error); + } + return NextResponse.json( + { message: error instanceof Error ? error.message : "Proxy error" }, + { status: 502 } + ); + } +} diff --git a/packages/web/app/request/upload/page.tsx b/packages/web/app/request/upload/page.tsx index 28f35716..0289dece 100644 --- a/packages/web/app/request/upload/page.tsx +++ b/packages/web/app/request/upload/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { @@ -34,11 +34,20 @@ import { MetadataInputForm, type MetadataFormValues, } from "@/lib/components/request/MetadataInputForm"; -import { Trash2, Plus, Loader2 } from "lucide-react"; +import { ImageEditor } from "@/lib/components/request/ImageEditor"; +import { Trash2, Plus, Loader2, RefreshCw, Crop } from "lucide-react"; +import { + saveDraft, + saveDraftThumbnail, + loadDraft, + clearDraft, +} from "@/lib/utils/offlineDraft"; export default function RequestUploadPage() { const router = useRouter(); const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [showEditor, setShowEditor] = useState(false); // 상태만 구독 (렌더링에 필요한 것만) const currentStep = useRequestStore(selectCurrentStep); @@ -52,9 +61,66 @@ export default function RequestUploadPage() { const context = useRequestStore(selectContext); // autoUpload: false, autoAnalyze: false - 자동 업로드/분석 비활성화 - const { images, isMaxImages, handleFilesSelected, removeImage } = + const { images, isMaxImages, handleFilesSelected, removeImage, retryUpload } = useImageUpload({ autoUpload: false, autoAnalyze: false }); + // Draft 복원 (마운트 시 1회) + useEffect(() => { + const draft = loadDraft(); + if (!draft || draft.spots.length === 0) return; + + toast("이전에 작성 중이던 요청이 있습니다.", { + action: { + label: "복원하기", + onClick: () => { + const actions = getRequestActions(); + if (draft.userKnowsItems !== null) { + actions.setUserKnowsItems(draft.userKnowsItems); + } + for (const spot of draft.spots) { + actions.addSpot(spot.center.x, spot.center.y, spot.categoryCode); + if (spot.solution) { + actions.setSpotSolution(spot.id, spot.solution); + } + } + if (draft.mediaSource) actions.setMediaSource(draft.mediaSource); + if (draft.artistName) actions.setArtistName(draft.artistName); + if (draft.groupName) actions.setGroupName(draft.groupName); + if (draft.context) actions.setContext(draft.context); + toast.success("복원되었습니다. 이미지를 다시 선택해주세요."); + }, + }, + duration: 10000, + }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Auto-save draft (spots/metadata 변경 시) + useEffect(() => { + if (detectedSpots.length === 0 && userKnowsItems === null) return; + + saveDraft({ + userKnowsItems, + spots: detectedSpots.map((s) => ({ + id: s.id, + index: s.index, + center: s.center, + label: s.label, + categoryCode: s.categoryCode, + title: s.title, + description: s.description, + solution: s.solution, + })), + mediaSource: mediaSource ?? null, + artistName: artistName ?? "", + groupName: groupName ?? "", + context: context ?? null, + }); + + // 이미지 썸네일도 저장 + const file = images[0]?.file; + if (file) saveDraftThumbnail(file); + }, [detectedSpots, userKnowsItems, mediaSource, artistName, groupName, context, images]); + // canProceed: 모르는 유저는 spots만, 알고 있는 유저는 spots + 모든 스팟에 솔루션(링크) const canProceed = detectedSpots.length > 0 && @@ -110,6 +176,7 @@ export default function RequestUploadPage() { } setIsSubmitting(true); + setSubmitError(null); try { // 1. 이미지 압축 @@ -169,7 +236,8 @@ export default function RequestUploadPage() { toast.success("포스트가 생성되었습니다!"); - // 4. 완료 후 리다이렉트 + // 4. 완료 후 draft 삭제 + 리다이렉트 + clearDraft(); getRequestActions().resetRequestFlow(); router.push(`/posts/${response.id}`); } catch (error) { @@ -177,6 +245,7 @@ export default function RequestUploadPage() { toast.dismiss("create"); const message = error instanceof Error ? error.message : "포스트 생성에 실패했습니다."; + setSubmitError(message); toast.error(message); } finally { setIsSubmitting(false); @@ -212,6 +281,21 @@ export default function RequestUploadPage() { getRequestActions().selectSpot(null); }, []); + // Image editor save handler + const handleEditorSave = useCallback( + (editedFile: File) => { + const currentImage = images[0]; + if (!currentImage) return; + + // Replace the image in the store + const actions = getRequestActions(); + actions.removeImage(currentImage.id); + actions.addImage(editedFile); + setShowEditor(false); + }, + [images] + ); + // 로컬 프리뷰 이미지 사용 (previewUrl) const localImage = images[0]; @@ -276,7 +360,15 @@ export default function RequestUploadPage() {
{/* Image with spot markers */} -
+
+
-
+
+ {submitError && ( +
+

{submitError}

+ +
+ )}
); } diff --git a/packages/web/lib/components/detail/ImageDetailContent.tsx b/packages/web/lib/components/detail/ImageDetailContent.tsx index a8065bf5..1f6e68af 100644 --- a/packages/web/lib/components/detail/ImageDetailContent.tsx +++ b/packages/web/lib/components/detail/ImageDetailContent.tsx @@ -33,6 +33,7 @@ import { } from "./magazine"; import { MagazineTitleSection } from "./magazine/MagazineTitleSection"; import { SpotDot } from "./SpotDot"; +import { SpotSolutionTabs } from "./SpotSolutionTabs"; type Props = { image: ImageDetail & { ai_summary?: string | null }; @@ -355,6 +356,27 @@ export function ImageDetailContent({ ) : ( <> + {/* Spot-by-spot Solution Comparison (when 2+ spots) */} + {spotIds.length >= 2 && ( +
+
+ Solutions by Spot +
+ i.spot_id) + .map((item, idx) => ({ + spotId: item.spot_id!, + label: item.product_name ?? undefined, + index: idx + 1, + }))} + isPostOwner={false} + postId={image.id} + onAddSolution={(spotId) => setSpotIdToAddSolution(spotId)} + /> +
+ )} + {/* Shop Grid (show if any items exist, even without coordinates) */} {hasItems && (
diff --git a/packages/web/lib/components/detail/ItemDetailCard.tsx b/packages/web/lib/components/detail/ItemDetailCard.tsx index 7aa2283c..dbaa953a 100644 --- a/packages/web/lib/components/detail/ItemDetailCard.tsx +++ b/packages/web/lib/components/detail/ItemDetailCard.tsx @@ -10,6 +10,7 @@ import { useAuthStore } from "@/lib/stores/authStore"; import { useSolutions } from "@/lib/hooks/useSolutions"; import { useAdoptDropdown } from "@/lib/hooks/useAdoptDropdown"; import { useItemCardGSAP } from "@/lib/hooks/useItemCardGSAP"; +import { useAffiliateClick } from "@/lib/hooks/useAffiliateClick"; import { TopSolutionCard } from "./TopSolutionCard"; import { OtherSolutionsList } from "./OtherSolutionsList"; @@ -25,6 +26,8 @@ type Props = { onAddSolution?: (spotId: string) => void; /** 포스트 작성자 ID - 채택 UI 표시 여부 */ postOwnerId?: string | null; + /** 포스트 ID - 어필리에이트 클릭 트래킹용 */ + postId?: string | null; }; /** @@ -39,6 +42,7 @@ export function ItemDetailCard({ spotId, onAddSolution, postOwnerId = null, + postId = null, }: Props) { const cardRef = useRef(null); const contentRef = useRef(null); @@ -64,6 +68,7 @@ export function ItemDetailCard({ const adoptDropdown = useAdoptDropdown(spotId); useItemCardGSAP(cardRef, contentRef, isModal); + const trackAffiliateClick = useAffiliateClick(); // Parse multi-language fields const displayBrand = item.brand @@ -269,6 +274,14 @@ export function ItemDetailCard({ topSolution={topSolution} isPostOwner={isPostOwner} spotId={spotId} + onLinkClick={(url) => + trackAffiliateClick({ + solutionId: topSolution.id, + spotId: spotId ?? undefined, + postId: postId ?? undefined, + url, + }) + } {...adoptDropdown} /> )} @@ -277,6 +290,14 @@ export function ItemDetailCard({ spotId={spotId} onAddSolution={onAddSolution} isPostOwner={isPostOwner} + onLinkClick={(solutionId, url) => + trackAffiliateClick({ + solutionId, + spotId: spotId ?? undefined, + postId: postId ?? undefined, + url, + }) + } {...adoptDropdown} />
diff --git a/packages/web/lib/components/detail/ItemVoting.tsx b/packages/web/lib/components/detail/ItemVoting.tsx index 0d56577e..df526c60 100644 --- a/packages/web/lib/components/detail/ItemVoting.tsx +++ b/packages/web/lib/components/detail/ItemVoting.tsx @@ -4,22 +4,15 @@ import { VotingButtons } from "@/lib/components/shared/VotingButtons"; import { cn } from "@/lib/utils"; interface ItemVotingProps { - itemId: string; - upvotes?: number; - downvotes?: number; + solutionId: string; className?: string; } -export function ItemVoting({ - itemId: _itemId, - upvotes = 24, - downvotes = 3, - className, -}: ItemVotingProps) { +export function ItemVoting({ solutionId, className }: ItemVotingProps) { return (

Accuracy

- +
); } diff --git a/packages/web/lib/components/detail/OtherSolutionsList.tsx b/packages/web/lib/components/detail/OtherSolutionsList.tsx index bd881ab0..8bb9075c 100644 --- a/packages/web/lib/components/detail/OtherSolutionsList.tsx +++ b/packages/web/lib/components/detail/OtherSolutionsList.tsx @@ -31,6 +31,7 @@ interface OtherSolutionsListProps { adoptDropdownRef: UseAdoptDropdownReturn["adoptDropdownRef"]; adoptMutation: UseAdoptDropdownReturn["adoptMutation"]; unadoptMutation: UseAdoptDropdownReturn["unadoptMutation"]; + onLinkClick?: (solutionId: string, url: string) => void; } /** @@ -48,6 +49,7 @@ export function OtherSolutionsList({ adoptDropdownRef, adoptMutation, unadoptMutation, + onLinkClick, }: OtherSolutionsListProps) { const [othersExpanded, setOthersExpanded] = useState(false); @@ -96,6 +98,7 @@ export function OtherSolutionsList({ href={linkUrl} target="_blank" rel="noopener noreferrer" + onClick={() => onLinkClick?.(sol.id, linkUrl)} className="shrink-0 w-10 h-10 rounded overflow-hidden bg-muted/30 border border-border/20" > onLinkClick?.(sol.id, linkUrl)} className="group/link flex items-center gap-1 text-[11px] text-muted-foreground/70 hover:text-primary" > {(() => { diff --git a/packages/web/lib/components/detail/SpotSolutionTabs.tsx b/packages/web/lib/components/detail/SpotSolutionTabs.tsx new file mode 100644 index 00000000..413d33ff --- /dev/null +++ b/packages/web/lib/components/detail/SpotSolutionTabs.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useAllSolutionsForSpots } from "@/lib/hooks/useSolutions"; +import { TopSolutionCard, type SolutionItem } from "./TopSolutionCard"; +import { OtherSolutionsList } from "./OtherSolutionsList"; +import { useAdoptDropdown } from "@/lib/hooks/useAdoptDropdown"; +import { useAffiliateClick } from "@/lib/hooks/useAffiliateClick"; +import { Plus } from "lucide-react"; + +interface SpotInfo { + spotId: string; + label?: string; + index: number; +} + +interface SpotSolutionTabsProps { + spots: SpotInfo[]; + isPostOwner: boolean; + postId?: string | null; + onAddSolution?: (spotId: string) => void; + onSpotSelect?: (spotId: string) => void; + className?: string; +} + +/** + * SpotSolutionTabs - 스팟별 솔루션 비교 탭 UI + * + * 스팟이 2개 이상일 때 탭으로 전환하며 각 스팟의 솔루션을 비교합니다. + */ +export function SpotSolutionTabs({ + spots, + isPostOwner, + postId, + onAddSolution, + onSpotSelect, + className, +}: SpotSolutionTabsProps) { + const [activeSpotIndex, setActiveSpotIndex] = useState(0); + const spotIds = useMemo(() => spots.map((s) => s.spotId), [spots]); + const { spotSolutionsMap, isLoading } = useAllSolutionsForSpots(spotIds); + const trackAffiliateClick = useAffiliateClick(); + + const activeSpot = spots[activeSpotIndex]; + const activeSpotId = activeSpot?.spotId; + const adoptDropdown = useAdoptDropdown(activeSpotId); + + const activeSolutions = useMemo( + () => + (activeSpotId ? spotSolutionsMap.get(activeSpotId) : undefined) ?? [], + [activeSpotId, spotSolutionsMap] + ) as SolutionItem[]; + + const topSolution = activeSolutions[0]; + const otherSolutions = activeSolutions.slice(1); + + const handleTabClick = (index: number) => { + setActiveSpotIndex(index); + onSpotSelect?.(spots[index].spotId); + }; + + if (spots.length === 0) return null; + + return ( +
+ {/* Spot Tabs */} + {spots.length > 1 && ( +
+ {spots.map((spot, i) => { + const solutions = spotSolutionsMap.get(spot.spotId); + const count = solutions?.length ?? 0; + + return ( + + ); + })} +
+ )} + + {/* Active Spot Solutions */} +
+ {isLoading ? ( +

로딩 중...

+ ) : activeSolutions.length === 0 ? ( +
+

+ 등록된 솔루션이 없습니다. +

+ {onAddSolution && activeSpotId && ( + + )} +
+ ) : ( +
+ {topSolution && ( + + trackAffiliateClick({ + solutionId: topSolution.id, + spotId: activeSpotId ?? undefined, + postId: postId ?? undefined, + url, + }) + } + {...adoptDropdown} + /> + )} + + trackAffiliateClick({ + solutionId, + spotId: activeSpotId ?? undefined, + postId: postId ?? undefined, + url, + }) + } + {...adoptDropdown} + /> +
+ )} +
+
+ ); +} diff --git a/packages/web/lib/components/detail/TopSolutionCard.tsx b/packages/web/lib/components/detail/TopSolutionCard.tsx index 0e475578..60c4cf43 100644 --- a/packages/web/lib/components/detail/TopSolutionCard.tsx +++ b/packages/web/lib/components/detail/TopSolutionCard.tsx @@ -40,6 +40,7 @@ interface TopSolutionCardProps { adoptDropdownRef: UseAdoptDropdownReturn["adoptDropdownRef"]; adoptMutation: UseAdoptDropdownReturn["adoptMutation"]; unadoptMutation: UseAdoptDropdownReturn["unadoptMutation"]; + onLinkClick?: (url: string) => void; } /** @@ -56,6 +57,7 @@ export function TopSolutionCard({ adoptDropdownRef, adoptMutation, unadoptMutation, + onLinkClick, }: TopSolutionCardProps) { const linkUrl = topSolution.affiliate_url ?? topSolution.original_url ?? null; @@ -71,6 +73,7 @@ export function TopSolutionCard({ href={linkUrl} target="_blank" rel="noopener noreferrer" + onClick={() => linkUrl && onLinkClick?.(linkUrl)} className="shrink-0 w-14 h-14 rounded overflow-hidden bg-muted/30 border border-border/20" > linkUrl && onLinkClick?.(linkUrl)} className="group/link flex items-center gap-1.5 text-xs text-muted-foreground/80 hover:text-primary" > {(() => { diff --git a/packages/web/lib/components/request/DetectionView.tsx b/packages/web/lib/components/request/DetectionView.tsx index fdc589e2..3fc11a5e 100644 --- a/packages/web/lib/components/request/DetectionView.tsx +++ b/packages/web/lib/components/request/DetectionView.tsx @@ -184,12 +184,42 @@ export const DetectionView = memo( /> ))} + {/* 스팟 배치 가이드 오버레이 */} + {!isDetecting && spots.length === 0 && ( +
+
+
+ + + +
+

+ 아이템 위치를 탭하세요 +

+

+ 최소 1개 이상 표시해주세요 +

+
+
+ )} + {/* 안내 메시지 */} {!isDetecting && (

{spots.length > 0 - ? `${spots.length}개의 스팟이 추가됨` + ? `${spots.length}개의 스팟 추가됨 · 더 추가하거나 다음으로 진행하세요` : "이미지를 탭하여 아이템 위치를 표시하세요"}

diff --git a/packages/web/lib/components/request/ImageEditor.tsx b/packages/web/lib/components/request/ImageEditor.tsx new file mode 100644 index 00000000..e51cdd39 --- /dev/null +++ b/packages/web/lib/components/request/ImageEditor.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useRef, useState, useCallback } from "react"; +import { Cropper, CropperRef } from "react-advanced-cropper"; +import "react-advanced-cropper/dist/style.css"; +import { RotateCw, Check, X, RectangleHorizontal } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface ImageEditorProps { + imageUrl: string; + onSave: (file: File) => void; + onCancel: () => void; +} + +type AspectRatio = "free" | "1:1" | "3:4" | "4:3"; + +const ASPECT_RATIOS: { label: string; value: AspectRatio; ratio?: number }[] = [ + { label: "Free", value: "free" }, + { label: "1:1", value: "1:1", ratio: 1 }, + { label: "3:4", value: "3:4", ratio: 3 / 4 }, + { label: "4:3", value: "4:3", ratio: 4 / 3 }, +]; + +/** + * ImageEditor - 이미지 크롭/회전 에디터 + * + * 풀스크린 모달로 표시되며, 크롭 핸들 + 회전 버튼 + 비율 프리셋을 제공합니다. + */ +export function ImageEditor({ imageUrl, onSave, onCancel }: ImageEditorProps) { + const cropperRef = useRef(null); + const [rotation, setRotation] = useState(0); + const [aspectRatio, setAspectRatio] = useState("free"); + const [isSaving, setIsSaving] = useState(false); + + const handleRotate = useCallback(() => { + if (cropperRef.current) { + const newRotation = rotation + 90; + setRotation(newRotation); + cropperRef.current.rotateImage(90); + } + }, [rotation]); + + const handleSave = useCallback(async () => { + const cropper = cropperRef.current; + if (!cropper) return; + + setIsSaving(true); + try { + const canvas = cropper.getCanvas(); + if (!canvas) return; + + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, "image/jpeg", 0.9) + ); + if (!blob) return; + + const file = new File([blob], "edited-image.jpg", { + type: "image/jpeg", + }); + onSave(file); + } finally { + setIsSaving(false); + } + }, [onSave]); + + const selectedRatio = ASPECT_RATIOS.find((r) => r.value === aspectRatio); + + return ( +
+ {/* Header */} +
+ + Edit Image + +
+ + {/* Cropper */} +
+ +
+ + {/* Controls */} +
+ {/* Aspect Ratio */} +
+ {ASPECT_RATIOS.map((ratio) => ( + + ))} +
+ + {/* Rotate */} +
+ +
+
+
+ ); +} diff --git a/packages/web/lib/components/shared/AuthGate.tsx b/packages/web/lib/components/shared/AuthGate.tsx new file mode 100644 index 00000000..44ef3511 --- /dev/null +++ b/packages/web/lib/components/shared/AuthGate.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useState, useCallback, type ReactNode } from "react"; +import { useAuthStore, type OAuthProvider } from "@/lib/stores/authStore"; +import { BottomSheet } from "@/lib/design-system"; +import { OAuthButton } from "@/lib/components/auth/OAuthButton"; + +interface AuthGateProps { + children: (handleAction: () => void) => ReactNode; + /** 로그인 유도 시 표시할 메시지 */ + message?: string; +} + +/** + * AuthGate - 인증이 필요한 액션을 감싸는 컴포넌트 + * + * 로그인 상태면 children의 action을 그대로 실행하고, + * 비로그인 상태면 로그인 유도 BottomSheet를 표시합니다. + * + * @example + * + * {(handleAction) => ( + * + * )} + * + */ +export function AuthGate({ + children, + message = "이 기능을 사용하려면 로그인이 필요합니다.", +}: AuthGateProps) { + const [isOpen, setIsOpen] = useState(false); + const isAuthenticated = useAuthStore((s) => !!s.user); + const { signInWithOAuth, loadingProvider, isLoading } = useAuthStore(); + + const [pendingAction, setPendingAction] = useState<(() => void) | null>(null); + + const handleAction = useCallback( + (action?: () => void) => { + if (isAuthenticated) { + action?.(); + return; + } + if (action) { + setPendingAction(() => action); + } + setIsOpen(true); + }, + [isAuthenticated] + ); + + const handleLogin = async (provider: OAuthProvider) => { + // 로그인 후 현재 페이지로 돌아오도록 설정 + sessionStorage.setItem( + "post_login_redirect", + window.location.pathname + window.location.search + ); + await signInWithOAuth(provider); + }; + + const handleClose = useCallback(() => { + setIsOpen(false); + setPendingAction(null); + }, []); + + return ( + <> + {children(() => handleAction(pendingAction ?? undefined))} + + +
+
+

+ 로그인이 필요합니다 +

+

{message}

+
+ +
+ handleLogin("google")} + isLoading={loadingProvider === "google"} + disabled={isLoading} + /> +
+ + +
+
+ + ); +} diff --git a/packages/web/lib/components/shared/VotingButtons.tsx b/packages/web/lib/components/shared/VotingButtons.tsx index 7e814ce2..58ab2e4b 100644 --- a/packages/web/lib/components/shared/VotingButtons.tsx +++ b/packages/web/lib/components/shared/VotingButtons.tsx @@ -1,42 +1,36 @@ "use client"; -import { useState } from "react"; -import { ThumbsUp, ThumbsDown } from "lucide-react"; +import { ThumbsUp, ThumbsDown, BadgeCheck } from "lucide-react"; import { cn } from "@/lib/utils"; +import { + useVoteStats, + useCreateVote, + useDeleteVote, +} from "@/lib/hooks/useVoting"; export interface VotingButtonsProps { - upvotes?: number; - downvotes?: number; - userVote?: "up" | "down" | null; - onVote?: (vote: "up" | "down" | null) => void; + solutionId: string; className?: string; } -export function VotingButtons({ - upvotes = 0, - downvotes = 0, - userVote: initialVote = null, - onVote, - className, -}: VotingButtonsProps) { - const [vote, setVote] = useState(initialVote); - const [ups, setUps] = useState(upvotes); - const [downs, setDowns] = useState(downvotes); +export function VotingButtons({ solutionId, className }: VotingButtonsProps) { + const { data: stats } = useVoteStats(solutionId); + const createVote = useCreateVote(); + const deleteVote = useDeleteVote(); + const ups = stats?.upvotes ?? 0; + const downs = stats?.downvotes ?? 0; + const userVote = stats?.user_vote ?? null; + const isVerified = stats?.is_verified ?? false; const total = ups + downs; const percentage = total > 0 ? Math.round((ups / total) * 100) : 50; - const handleVote = (newVote: "up" | "down") => { - const next = vote === newVote ? null : newVote; - - // Adjust counts - if (vote === "up") setUps((v) => v - 1); - if (vote === "down") setDowns((v) => v - 1); - if (next === "up") setUps((v) => v + 1); - if (next === "down") setDowns((v) => v + 1); - - setVote(next); - onVote?.(next); + const handleVote = (voteType: "up" | "down") => { + if (userVote === voteType) { + deleteVote.mutate({ solutionId }); + } else { + createVote.mutate({ solutionId, voteType }); + } }; return ( @@ -46,13 +40,13 @@ export function VotingButtons({ onClick={() => handleVote("up")} className={cn( "flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors", - vote === "up" - ? "bg-green-500/10 text-green-500" + userVote === "up" + ? "bg-green-500/10 text-green-500 ring-1 ring-green-500/30" : "text-muted-foreground hover:bg-accent" )} aria-label="Accurate" > - + {ups} @@ -60,22 +54,32 @@ export function VotingButtons({ onClick={() => handleVote("down")} className={cn( "flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors", - vote === "down" - ? "bg-red-500/10 text-red-500" + userVote === "down" + ? "bg-red-500/10 text-red-500 ring-1 ring-red-500/30" : "text-muted-foreground hover:bg-accent" )} aria-label="Inaccurate" > - + {downs} + + {isVerified && ( + + + Verified + + )}
{/* Accuracy bar */}
diff --git a/packages/web/lib/hooks/useAffiliateClick.ts b/packages/web/lib/hooks/useAffiliateClick.ts new file mode 100644 index 00000000..9064be37 --- /dev/null +++ b/packages/web/lib/hooks/useAffiliateClick.ts @@ -0,0 +1,46 @@ +/** + * useAffiliateClick - 어필리에이트 링크 클릭 추적 훅 + * + * 기존 behaviorStore의 이벤트 큐를 활용하여 + * affiliate_click 이벤트를 기록합니다. + */ + +import { useCallback } from "react"; +import { useTrackEvent } from "./useTrackEvent"; + +interface AffiliateClickParams { + solutionId: string; + spotId?: string; + postId?: string; + url: string; +} + +export function useAffiliateClick() { + const trackEvent = useTrackEvent(); + + const trackAffiliateClick = useCallback( + ({ solutionId, spotId, postId, url }: AffiliateClickParams) => { + trackEvent({ + event_type: "affiliate_click", + entity_id: solutionId, + metadata: { + url, + spot_id: spotId, + post_id: postId, + domain: extractDomain(url), + }, + }); + }, + [trackEvent] + ); + + return trackAffiliateClick; +} + +function extractDomain(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} diff --git a/packages/web/lib/hooks/useImageUpload.ts b/packages/web/lib/hooks/useImageUpload.ts index 5951d9c7..19f8726b 100644 --- a/packages/web/lib/hooks/useImageUpload.ts +++ b/packages/web/lib/hooks/useImageUpload.ts @@ -15,6 +15,7 @@ import { } from "@/lib/stores/requestStore"; import { validateImageFile, + validateImageDimensions, validateAddingImages, extractImageFromClipboard, UPLOAD_CONFIG, @@ -59,18 +60,26 @@ export function useImageUpload(options: UseImageUploadOptions = {}) { updateImageStatus(id, "uploading", 0); try { - // 1. 이미지 압축 - updateImageStatus(id, "uploading", 10); - const { file: compressedFile, wasCompressed } = await compressImage(file); + // 1. 이미지 압축 (0-30% 구간) + const { file: compressedFile, wasCompressed } = await compressImage( + file, + (compressionPercent) => { + const weighted = Math.round(compressionPercent * 0.3); + updateImageStatus(id, "uploading", weighted); + } + ); if (wasCompressed) { console.log(`Image compressed: ${file.name}`); } - // 2. API를 통해 백엔드에 업로드 + // 2. API를 통해 백엔드에 업로드 (30-95% 구간) const { image_url } = await uploadImage({ file: compressedFile, - onProgress: (progress) => updateImageStatus(id, "uploading", progress), + onProgress: (uploadPercent) => { + const weighted = 30 + Math.round(uploadPercent * 0.65); + updateImageStatus(id, "uploading", Math.min(weighted, 95)); + }, }); // 3. 업로드 완료 @@ -124,14 +133,21 @@ export function useImageUpload(options: UseImageUploadOptions = {}) { const validFiles: File[] = []; const errors: string[] = []; - // 각 파일 검증 + // 각 파일 검증 (동기 + 비동기) for (const file of files) { - const validation = validateImageFile(file); - if (validation.valid) { - validFiles.push(file); - } else { - errors.push(`${file.name}: ${validation.error}`); + const syncValidation = validateImageFile(file); + if (!syncValidation.valid) { + errors.push(`${file.name}: ${syncValidation.error}`); + continue; + } + + const dimValidation = await validateImageDimensions(file); + if (!dimValidation.valid) { + errors.push(`${file.name}: ${dimValidation.error}`); + continue; } + + validFiles.push(file); } // 에러가 있으면 토스트 표시 diff --git a/packages/web/lib/hooks/useVoting.ts b/packages/web/lib/hooks/useVoting.ts new file mode 100644 index 00000000..a045cab1 --- /dev/null +++ b/packages/web/lib/hooks/useVoting.ts @@ -0,0 +1,165 @@ +/** + * Solution Voting Hooks + * React Query hooks for solution vote CRUD with optimistic updates + */ + +import { + useQuery, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; +import { apiClient } from "@/lib/api/client"; + +// ============================================================ +// Types +// ============================================================ + +export interface VoteStats { + solution_id: string; + upvotes: number; + downvotes: number; + user_vote: "up" | "down" | null; + is_verified: boolean; +} + +export type VoteType = "up" | "down"; + +// ============================================================ +// Query Keys +// ============================================================ + +export const voteKeys = { + all: ["votes"] as const, + stats: (solutionId: string) => [...voteKeys.all, "stats", solutionId] as const, +}; + +// ============================================================ +// useVoteStats - Fetch vote stats for a solution +// ============================================================ + +export function useVoteStats(solutionId: string) { + return useQuery({ + queryKey: voteKeys.stats(solutionId), + queryFn: () => + apiClient({ + path: `/api/v1/solutions/${solutionId}/votes`, + method: "GET", + }), + enabled: !!solutionId, + staleTime: 1000 * 60, // 1 minute + }); +} + +// ============================================================ +// useCreateVote - Create or change vote (optimistic) +// ============================================================ + +interface CreateVoteVariables { + solutionId: string; + voteType: VoteType; +} + +export function useCreateVote() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ solutionId, voteType }: CreateVoteVariables) => + apiClient({ + path: `/api/v1/solutions/${solutionId}/votes`, + method: "POST", + body: { vote_type: voteType }, + requiresAuth: true, + }), + onMutate: async ({ solutionId, voteType }) => { + await queryClient.cancelQueries({ + queryKey: voteKeys.stats(solutionId), + }); + + const previous = queryClient.getQueryData( + voteKeys.stats(solutionId) + ); + + if (previous) { + const updated = { ...previous }; + // Remove previous vote + if (previous.user_vote === "up") updated.upvotes--; + if (previous.user_vote === "down") updated.downvotes--; + // Add new vote + if (voteType === "up") updated.upvotes++; + if (voteType === "down") updated.downvotes++; + updated.user_vote = voteType; + + queryClient.setQueryData(voteKeys.stats(solutionId), updated); + } + + return { previous }; + }, + onError: (_err, { solutionId }, context) => { + if (context?.previous) { + queryClient.setQueryData( + voteKeys.stats(solutionId), + context.previous + ); + } + }, + onSettled: (_, __, { solutionId }) => { + queryClient.invalidateQueries({ + queryKey: voteKeys.stats(solutionId), + }); + }, + }); +} + +// ============================================================ +// useDeleteVote - Remove vote (optimistic) +// ============================================================ + +interface DeleteVoteVariables { + solutionId: string; +} + +export function useDeleteVote() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ solutionId }: DeleteVoteVariables) => + apiClient({ + path: `/api/v1/solutions/${solutionId}/votes`, + method: "DELETE", + requiresAuth: true, + }), + onMutate: async ({ solutionId }) => { + await queryClient.cancelQueries({ + queryKey: voteKeys.stats(solutionId), + }); + + const previous = queryClient.getQueryData( + voteKeys.stats(solutionId) + ); + + if (previous) { + const updated = { ...previous }; + if (previous.user_vote === "up") updated.upvotes--; + if (previous.user_vote === "down") updated.downvotes--; + updated.user_vote = null; + + queryClient.setQueryData(voteKeys.stats(solutionId), updated); + } + + return { previous }; + }, + onError: (_err, { solutionId }, context) => { + if (context?.previous) { + queryClient.setQueryData( + voteKeys.stats(solutionId), + context.previous + ); + } + }, + onSettled: (_, __, { solutionId }) => { + queryClient.invalidateQueries({ + queryKey: voteKeys.stats(solutionId), + }); + }, + }); +} diff --git a/packages/web/lib/stores/behaviorStore.ts b/packages/web/lib/stores/behaviorStore.ts index 5ac70837..aa08c680 100644 --- a/packages/web/lib/stores/behaviorStore.ts +++ b/packages/web/lib/stores/behaviorStore.ts @@ -15,7 +15,8 @@ export type EventType = | "search_query" | "category_filter" | "dwell_time" - | "scroll_depth"; + | "scroll_depth" + | "affiliate_click"; export interface TrackEventPayload { event_type: EventType; diff --git a/packages/web/lib/utils/imageCompression.ts b/packages/web/lib/utils/imageCompression.ts index 3df0c394..a198a538 100644 --- a/packages/web/lib/utils/imageCompression.ts +++ b/packages/web/lib/utils/imageCompression.ts @@ -25,12 +25,16 @@ export interface CompressionResult { * - 2MB 이상인 경우 압축 * - 1920px 이상인 경우 리사이즈 */ -export async function compressImage(file: File): Promise { +export async function compressImage( + file: File, + onProgress?: (progress: number) => void +): Promise { const originalSize = file.size; const needsCompression = file.size > COMPRESSION_CONFIG.maxSizeMB * 1024 * 1024; if (!needsCompression) { + onProgress?.(100); return { file, originalSize, @@ -44,10 +48,14 @@ export async function compressImage(file: File): Promise { maxWidthOrHeight: COMPRESSION_CONFIG.maxWidthOrHeight, useWebWorker: COMPRESSION_CONFIG.useWebWorker, initialQuality: COMPRESSION_CONFIG.initialQuality, + onProgress: (percent: number) => { + onProgress?.(percent); + }, }; try { const compressedFile = await imageCompression(file, options); + onProgress?.(100); return { file: compressedFile, originalSize, @@ -57,6 +65,7 @@ export async function compressImage(file: File): Promise { } catch { // 압축 실패 시 원본 반환 console.warn("이미지 압축 실패, 원본 파일 사용:", file.name); + onProgress?.(100); return { file, originalSize, @@ -72,7 +81,7 @@ export async function compressImage(file: File): Promise { export async function compressImages( files: File[] ): Promise { - return Promise.all(files.map(compressImage)); + return Promise.all(files.map((file) => compressImage(file))); } /** diff --git a/packages/web/lib/utils/offlineDraft.ts b/packages/web/lib/utils/offlineDraft.ts new file mode 100644 index 00000000..92074352 --- /dev/null +++ b/packages/web/lib/utils/offlineDraft.ts @@ -0,0 +1,137 @@ +/** + * 오프라인 임시 저장 유틸리티 + * Request flow 진행 중 데이터를 localStorage에 저장하여 + * 페이지 이탈/네트워크 오류 시 복원할 수 있게 합니다. + */ + +import type { MediaSource, ContextType } from "@/lib/api/mutation-types"; + +const DRAFT_KEY = "decoded_request_draft"; +const DRAFT_THUMBNAIL_KEY = "decoded_request_draft_thumb"; +const MAX_THUMBNAIL_SIZE = 100 * 1024; // 100KB + +export interface RequestDraft { + savedAt: number; + userKnowsItems: boolean | null; + spots: Array<{ + id: string; + index: number; + center: { x: number; y: number }; + label?: string; + categoryCode?: string; + title: string; + description: string; + solution?: { + title: string; + originalUrl: string; + thumbnailUrl?: string; + priceAmount?: number; + priceCurrency?: string; + description?: string; + }; + }>; + mediaSource: MediaSource | null; + artistName: string; + groupName: string; + context: ContextType | null; +} + +/** + * Request flow 상태를 localStorage에 저장 + */ +export function saveDraft(draft: Omit): void { + try { + const data: RequestDraft = { ...draft, savedAt: Date.now() }; + localStorage.setItem(DRAFT_KEY, JSON.stringify(data)); + } catch { + // localStorage가 가득 찬 경우 등 무시 + } +} + +/** + * 이미지 썸네일을 base64로 저장 (복원 시 시각적 확인용) + */ +export async function saveDraftThumbnail(file: File): Promise { + try { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const url = URL.createObjectURL(file); + const img = await new Promise((resolve, reject) => { + const i = new Image(); + i.onload = () => resolve(i); + i.onerror = reject; + i.src = url; + }); + URL.revokeObjectURL(url); + + // 최대 200px로 리사이즈 + const scale = Math.min(200 / img.naturalWidth, 200 / img.naturalHeight, 1); + canvas.width = Math.round(img.naturalWidth * scale); + canvas.height = Math.round(img.naturalHeight * scale); + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + + const dataUrl = canvas.toDataURL("image/jpeg", 0.6); + if (dataUrl.length <= MAX_THUMBNAIL_SIZE) { + localStorage.setItem(DRAFT_THUMBNAIL_KEY, dataUrl); + } + } catch { + // 썸네일 저장 실패는 무시 + } +} + +/** + * 저장된 draft 불러오기 + * 24시간 이상 경과한 draft는 무효 처리 + */ +export function loadDraft(): RequestDraft | null { + try { + const raw = localStorage.getItem(DRAFT_KEY); + if (!raw) return null; + + const draft: RequestDraft = JSON.parse(raw); + const ageMs = Date.now() - draft.savedAt; + const MAX_AGE = 24 * 60 * 60 * 1000; // 24시간 + + if (ageMs > MAX_AGE) { + clearDraft(); + return null; + } + + return draft; + } catch { + clearDraft(); + return null; + } +} + +/** + * 저장된 썸네일 불러오기 + */ +export function loadDraftThumbnail(): string | null { + try { + return localStorage.getItem(DRAFT_THUMBNAIL_KEY); + } catch { + return null; + } +} + +/** + * Draft 삭제 (포스트 생성 성공 시 호출) + */ +export function clearDraft(): void { + try { + localStorage.removeItem(DRAFT_KEY); + localStorage.removeItem(DRAFT_THUMBNAIL_KEY); + } catch { + // 무시 + } +} + +/** + * Draft가 존재하는지 확인 + */ +export function hasDraft(): boolean { + return loadDraft() !== null; +} diff --git a/packages/web/lib/utils/validation.ts b/packages/web/lib/utils/validation.ts index 74252e41..65df8eed 100644 --- a/packages/web/lib/utils/validation.ts +++ b/packages/web/lib/utils/validation.ts @@ -8,6 +8,8 @@ export const UPLOAD_CONFIG = { maxImages: 1, // 단일 이미지만 허용 (AI 감지용) supportedFormats: ["image/jpeg", "image/png", "image/webp"] as const, supportedExtensions: [".jpg", ".jpeg", ".png", ".webp"] as const, + minDimension: 200, // 최소 200x200px + maxDimension: 8000, // 최대 8000x8000px } as const; export type SupportedFormat = (typeof UPLOAD_CONFIG.supportedFormats)[number]; @@ -99,6 +101,58 @@ export function validateAddingImages( return { valid: true }; } +/** + * 이미지 해상도 검증 (비동기) + * 이미지를 로드하여 dimensions과 손상 여부를 확인합니다. + */ +export function validateImageDimensions( + file: File +): Promise { + return new Promise((resolve) => { + const url = URL.createObjectURL(file); + const img = new window.Image(); + + img.onload = () => { + URL.revokeObjectURL(url); + const { naturalWidth: w, naturalHeight: h } = img; + + if ( + w < UPLOAD_CONFIG.minDimension || + h < UPLOAD_CONFIG.minDimension + ) { + resolve({ + valid: false, + error: `이미지가 너무 작습니다. 최소 ${UPLOAD_CONFIG.minDimension}x${UPLOAD_CONFIG.minDimension}px 이상이어야 합니다. (현재: ${w}x${h}px)`, + }); + return; + } + + if ( + w > UPLOAD_CONFIG.maxDimension || + h > UPLOAD_CONFIG.maxDimension + ) { + resolve({ + valid: false, + error: `이미지가 너무 큽니다. 최대 ${UPLOAD_CONFIG.maxDimension}x${UPLOAD_CONFIG.maxDimension}px 이하여야 합니다. (현재: ${w}x${h}px)`, + }); + return; + } + + resolve({ valid: true }); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + resolve({ + valid: false, + error: "손상된 이미지 파일입니다. 다른 파일을 선택해주세요.", + }); + }; + + img.src = url; + }); +} + /** * 파일이 이미지인지 확인 */ diff --git a/packages/web/proxy.ts b/packages/web/proxy.ts index 34d50cfa..8b69ca16 100644 --- a/packages/web/proxy.ts +++ b/packages/web/proxy.ts @@ -25,8 +25,12 @@ export async function proxy(req: NextRequest) { const pathname = req.nextUrl.pathname; - // Profile: require session, redirect to login with return URL - if (pathname === "/profile" || pathname.startsWith("/profile/")) { + // Protected pages: require session, redirect to login with return URL + if ( + pathname === "/profile" || + pathname.startsWith("/profile/") || + pathname.startsWith("/request/") + ) { if (!session) { const loginUrl = new URL("/login", req.url); loginUrl.searchParams.set("redirect", pathname); @@ -51,5 +55,5 @@ export async function proxy(req: NextRequest) { } export const config = { - matcher: ["/admin/:path*", "/profile"], + matcher: ["/admin/:path*", "/profile", "/request/:path*"], }; From 5db5a000dd124763fe9e01fbe943695b444a32fb Mon Sep 17 00:00:00 2001 From: cocoyoon Date: Thu, 2 Apr 2026 16:26:51 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat(24,25):=20implement=20Try=20posts=20?= =?UTF-8?q?with=20spot=20tagging=20=E2=80=94=20DB,=20API,=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add "Try post" feature: users share their own attempt at a look, optionally tagging which items (spots) they own from the original post. Integrates #29 (spot reviews) into the Try flow. Backend (Rust): - Migration: parent_post_id + post_type on posts, try_spot_tags table - Entity: posts.rs updated, try_spot_tags.rs created - API: POST /posts/try, GET /posts/{id}/tries, GET /tries/count, GET /spots/{id}/tries - Feed filter: tries excluded from main post list - PostDetailResponse includes try_count Frontend (Next.js): - useTries hook: real API calls replacing stub - /request/try page with DropZone, SpotTagSelector, comment - createTryPost API function + proxy route - SpotTagSelector chip-toggle component Closes #24, closes #25, closes #29 Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/agent/api-v1-routes.md | 4 + packages/api-server/README.md | 2 +- packages/api-server/migration/src/lib.rs | 4 + ...20260402_000001_add_try_fields_to_posts.rs | 104 +++++ .../m20260402_000002_create_try_spot_tags.rs | 109 +++++ .../src/domains/admin/editorial_candidates.rs | 11 +- packages/api-server/src/domains/posts/dto.rs | 94 +++++ .../api-server/src/domains/posts/handlers.rs | 138 ++++++- .../api-server/src/domains/posts/service.rs | 373 +++++++++++++++++- .../api-server/src/domains/spots/handlers.rs | 4 + packages/api-server/src/entities/mod.rs | 5 + packages/api-server/src/entities/posts.rs | 13 + .../api-server/src/entities/try_spot_tags.rs | 46 +++ packages/api-server/src/openapi.rs | 9 + packages/web/app/api/v1/posts/try/route.ts | 53 +++ packages/web/app/request/try/page.tsx | 216 ++++++++++ packages/web/lib/api/mutation-types.ts | 37 ++ packages/web/lib/api/posts.ts | 51 +++ .../components/request/SpotTagSelector.tsx | 62 +++ packages/web/lib/hooks/useTries.ts | 102 +++-- 20 files changed, 1395 insertions(+), 42 deletions(-) create mode 100644 packages/api-server/migration/src/m20260402_000001_add_try_fields_to_posts.rs create mode 100644 packages/api-server/migration/src/m20260402_000002_create_try_spot_tags.rs create mode 100644 packages/api-server/src/entities/try_spot_tags.rs create mode 100644 packages/web/app/api/v1/posts/try/route.ts create mode 100644 packages/web/app/request/try/page.tsx create mode 100644 packages/web/lib/components/request/SpotTagSelector.tsx diff --git a/docs/agent/api-v1-routes.md b/docs/agent/api-v1-routes.md index dc9e08d5..115122c3 100644 --- a/docs/agent/api-v1-routes.md +++ b/docs/agent/api-v1-routes.md @@ -16,6 +16,9 @@ | `/api/v1/posts/[postId]/spots` | GET/POST | Spots for a post | | `/api/v1/posts/[postId]/likes` | POST | Like/unlike a post | | `/api/v1/posts/[postId]/saved` | POST | Save/unsave a post | +| `/api/v1/posts/try` | POST | Create a try post | +| `/api/v1/posts/[postId]/tries` | GET | List tries for a post | +| `/api/v1/posts/[postId]/tries/count` | GET | Try count for a post | | `/api/v1/post-magazines/[id]` | GET | Post magazine data | ## Solutions & spots @@ -27,6 +30,7 @@ | `/api/v1/solutions/[solutionId]/adopt` | POST | Adopt a solution | | `/api/v1/solutions/extract-metadata` | POST | Solution metadata extraction | | `/api/v1/spots/[spotId]` | GET/PATCH | Spot CRUD | +| `/api/v1/spots/[spotId]/tries` | GET | Tries tagged with spot | | `/api/v1/spots/[spotId]/solutions` | GET/POST | Solutions for spot | ## Users & profile diff --git a/packages/api-server/README.md b/packages/api-server/README.md index e53f5d4a..b62dd124 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 ## 프로젝트 통계 -- **총 라인 수**: ~26,968 lines (Rust) +- **총 라인 수**: ~28,420 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/migration/src/lib.rs b/packages/api-server/migration/src/lib.rs index 7fad9001..740b9006 100644 --- a/packages/api-server/migration/src/lib.rs +++ b/packages/api-server/migration/src/lib.rs @@ -42,6 +42,8 @@ mod m20260317_000001_add_ai_summary_to_posts; mod m20260318_000001_create_post_likes; mod m20260318_000002_create_saved_posts; mod m20260320_000001_add_system_uncategorized_subcategory; +mod m20260402_000001_add_try_fields_to_posts; +mod m20260402_000002_create_try_spot_tags; pub struct Migrator; @@ -91,6 +93,8 @@ impl MigratorTrait for Migrator { Box::new(m20260318_000001_create_post_likes::Migration), Box::new(m20260318_000002_create_saved_posts::Migration), Box::new(m20260320_000001_add_system_uncategorized_subcategory::Migration), + Box::new(m20260402_000001_add_try_fields_to_posts::Migration), + Box::new(m20260402_000002_create_try_spot_tags::Migration), ] } } diff --git a/packages/api-server/migration/src/m20260402_000001_add_try_fields_to_posts.rs b/packages/api-server/migration/src/m20260402_000001_add_try_fields_to_posts.rs new file mode 100644 index 00000000..6d7bb81b --- /dev/null +++ b/packages/api-server/migration/src/m20260402_000001_add_try_fields_to_posts.rs @@ -0,0 +1,104 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // parent_post_id 컬럼 추가 + manager + .alter_table( + Table::alter() + .table(Posts::Table) + .add_column( + ColumnDef::new(Posts::ParentPostId) + .uuid() + .null() + .comment("Try 포스트의 원본 포스트 ID (null이면 일반 포스트)"), + ) + .to_owned(), + ) + .await?; + + // post_type 컬럼 추가 + manager + .alter_table( + Table::alter() + .table(Posts::Table) + .add_column( + ColumnDef::new(Posts::PostType) + .string_len(20) + .null() + .comment("post: 일반, try: 사용자 시도 공유"), + ) + .to_owned(), + ) + .await?; + + // 자기참조 FK + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_posts_parent_post_id") + .from(Posts::Table, Posts::ParentPostId) + .to(Posts::Table, Posts::Id) + .on_delete(ForeignKeyAction::SetNull) + .to_owned(), + ) + .await?; + + // parent_post_id 인덱스 + manager + .create_index( + Index::create() + .name("idx_posts_parent_post_id") + .table(Posts::Table) + .col(Posts::ParentPostId) + .to_owned(), + ) + .await?; + + // post_type 인덱스 + manager + .create_index( + Index::create() + .name("idx_posts_post_type") + .table(Posts::Table) + .col(Posts::PostType) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_foreign_key( + ForeignKey::drop() + .table(Posts::Table) + .name("fk_posts_parent_post_id") + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Posts::Table) + .drop_column(Posts::ParentPostId) + .drop_column(Posts::PostType) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum Posts { + Table, + Id, + ParentPostId, + PostType, +} diff --git a/packages/api-server/migration/src/m20260402_000002_create_try_spot_tags.rs b/packages/api-server/migration/src/m20260402_000002_create_try_spot_tags.rs new file mode 100644 index 00000000..de6d47b5 --- /dev/null +++ b/packages/api-server/migration/src/m20260402_000002_create_try_spot_tags.rs @@ -0,0 +1,109 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(TrySpotTags::Table) + .if_not_exists() + .col( + ColumnDef::new(TrySpotTags::Id) + .uuid() + .not_null() + .primary_key() + .comment("Try-Spot tag ID"), + ) + .col( + ColumnDef::new(TrySpotTags::TryPostId) + .uuid() + .not_null() + .comment("Try post ID (references posts)"), + ) + .col( + ColumnDef::new(TrySpotTags::SpotId) + .uuid() + .not_null() + .comment("Spot ID (references spots)"), + ) + .col( + timestamp_with_time_zone(TrySpotTags::CreatedAt) + .default(Expr::current_timestamp()) + .comment("Tag creation timestamp"), + ) + .foreign_key( + ForeignKey::create() + .name("fk_try_spot_tags_try_post_id") + .from(TrySpotTags::Table, TrySpotTags::TryPostId) + .to(Posts::Table, Posts::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk_try_spot_tags_spot_id") + .from(TrySpotTags::Table, TrySpotTags::SpotId) + .to(Spots::Table, Spots::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + // (try_post_id, spot_id) 유니크 인덱스 + manager + .create_index( + Index::create() + .name("idx_try_spot_tags_try_post_spot_unique") + .table(TrySpotTags::Table) + .col(TrySpotTags::TryPostId) + .col(TrySpotTags::SpotId) + .unique() + .to_owned(), + ) + .await?; + + // spot_id 인덱스 (스팟별 Try 조회용) + manager + .create_index( + Index::create() + .name("idx_try_spot_tags_spot_id") + .table(TrySpotTags::Table) + .col(TrySpotTags::SpotId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(TrySpotTags::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum TrySpotTags { + Table, + Id, + TryPostId, + SpotId, + CreatedAt, +} + +#[derive(DeriveIden)] +enum Posts { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Spots { + Table, + Id, +} 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/posts/dto.rs b/packages/api-server/src/domains/posts/dto.rs index 05a91d3f..1420c3ac 100644 --- a/packages/api-server/src/domains/posts/dto.rs +++ b/packages/api-server/src/domains/posts/dto.rs @@ -108,6 +108,75 @@ pub struct CreatePostDto { pub spots: Vec, } +/// Try Post 생성 요청 (multipart의 data 필드) +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)] +pub struct CreateTryPostDto { + /// 원본 포스트 ID + pub parent_post_id: Uuid, + + /// 한줄 코멘트 (선택, 100자) + #[serde(skip_serializing_if = "Option::is_none")] + #[validate(length(max = 100))] + pub media_title: Option, + + /// 태깅할 스팟 ID 배열 (선택) + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub spot_ids: Vec, +} + +/// Try Post 목록 아이템 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct TryPostListItem { + /// Try Post ID + pub id: Uuid, + + /// 사용자 정보 + pub user: PostUserInfo, + + /// 이미지 URL + pub image_url: String, + + /// 한줄 코멘트 + #[serde(skip_serializing_if = "Option::is_none")] + pub media_title: Option, + + /// 태깅된 스팟 ID 목록 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tagged_spot_ids: Vec, + + /// 생성일시 + pub created_at: DateTime, +} + +/// Try 목록 응답 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct TryListResponse { + /// Try 목록 + pub tries: Vec, + + /// 전체 개수 + pub total: i64, +} + +/// Try 개수 응답 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct TryCountResponse { + /// 개수 + pub count: i64, +} + +/// Try 목록 조회 쿼리 +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct TryListQuery { + /// 페이지 번호 + #[serde(default = "default_page")] + pub page: u64, + + /// 페이지당 개수 + #[serde(default = "default_per_page")] + pub per_page: u64, +} + /// Post 수정 요청 #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)] pub struct UpdatePostDto { @@ -238,6 +307,14 @@ pub struct PostResponse { /// 상태 pub status: String, + /// Try 포스트의 원본 포스트 ID + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_post_id: Option, + + /// 포스트 타입 (post | try) + #[serde(skip_serializing_if = "Option::is_none")] + pub post_type: Option, + /// 생성일시 pub created_at: DateTime, } @@ -441,6 +518,18 @@ pub struct PostDetailResponse { /// AI가 생성한 포스트 요약 (1-2문장) #[serde(skip_serializing_if = "Option::is_none")] pub ai_summary: Option, + + /// Try 포스트의 원본 포스트 ID + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_post_id: Option, + + /// 포스트 타입 (post | try) + #[serde(skip_serializing_if = "Option::is_none")] + pub post_type: Option, + + /// Try 개수 (원본 포스트일 때만) + #[serde(skip_serializing_if = "Option::is_none")] + pub try_count: Option, } impl PostDetailResponse { @@ -470,6 +559,9 @@ impl PostDetailResponse { created_with_solutions: post.created_with_solutions, post_magazine_id: post.post_magazine_id, ai_summary: post.ai_summary.clone(), + parent_post_id: post.parent_post_id, + post_type: post.post_type.clone(), + try_count: None, user: PostUserInfo { id: user.id, username: user.username, @@ -522,6 +614,8 @@ impl From for PostResponse { context: model.context, view_count: model.view_count, status: model.status, + parent_post_id: model.parent_post_id, + post_type: model.post_type, created_at: model.created_at.with_timezone(&chrono::Utc), } } diff --git a/packages/api-server/src/domains/posts/handlers.rs b/packages/api-server/src/domains/posts/handlers.rs index cfeed746..e3134022 100644 --- a/packages/api-server/src/domains/posts/handlers.rs +++ b/packages/api-server/src/domains/posts/handlers.rs @@ -20,8 +20,9 @@ use crate::{ use super::{ dto::{ - CreatePostDto, CreatePostWithSolutionsResponse, ImageAnalyzeResponse, ImageUploadResponse, - PostDetailResponse, PostListQuery, PostResponse, UpdatePostDto, + CreatePostDto, CreatePostWithSolutionsResponse, CreateTryPostDto, ImageAnalyzeResponse, + ImageUploadResponse, PostDetailResponse, PostListQuery, PostResponse, TryCountResponse, + TryListQuery, TryListResponse, UpdatePostDto, }, service, }; @@ -395,11 +396,142 @@ pub async fn analyze_image( Ok(Json(response)) } +/// POST /api/v1/posts/try - Try Post 생성 +#[utoipa::path( + post, + path = "/api/v1/posts/try", + tag = "posts", + security( + ("bearer_auth" = []) + ), + request_body(content = String, content_type = "multipart/form-data"), + responses( + (status = 201, description = "Try Post 생성 성공", body = PostResponse), + (status = 401, description = "인증 필요"), + (status = 400, description = "잘못된 요청") + ) +)] +pub async fn create_try_post( + State(state): State, + Extension(user): Extension, + mut multipart: Multipart, +) -> AppResult> { + let mut image_file: Option> = None; + let mut image_content_type: Option = None; + let mut post_data_str: Option = None; + + while let Some(field) = multipart.next_field().await.map_err(|e| { + crate::error::AppError::BadRequest(format!("Failed to parse multipart: {}", e)) + })? { + let name = field.name().unwrap_or(""); + + if name == "image" { + let content_type = field.content_type().unwrap_or("image/jpeg").to_string(); + let data = field.bytes().await.map_err(|e| { + crate::error::AppError::BadRequest(format!("Failed to read image data: {}", e)) + })?; + image_file = Some(data.to_vec()); + image_content_type = Some(content_type); + } else if name == "data" { + let data = field.text().await.map_err(|e| { + crate::error::AppError::BadRequest(format!("Failed to read data field: {}", e)) + })?; + post_data_str = Some(data); + } + } + + let image_file = image_file.ok_or_else(|| { + crate::error::AppError::BadRequest("No image file found in multipart form".to_string()) + })?; + + let image_content_type = image_content_type.unwrap_or_else(|| "image/jpeg".to_string()); + + let post_data_str = post_data_str.ok_or_else(|| { + crate::error::AppError::BadRequest("No data field found in multipart form".to_string()) + })?; + + let dto: CreateTryPostDto = serde_json::from_str(&post_data_str).map_err(|e| { + crate::error::AppError::BadRequest(format!("Failed to parse JSON data: {}", e)) + })?; + + let post = + service::create_try_post(&state, user.id, image_file, &image_content_type, dto).await?; + Ok(Json(post)) +} + +/// GET /api/v1/posts/{post_id}/tries - Try 목록 조회 +#[utoipa::path( + get, + path = "/api/v1/posts/{post_id}/tries", + tag = "posts", + params( + ("post_id" = Uuid, Path, description = "원본 Post ID"), + ("page" = Option, Query, description = "페이지 번호"), + ("per_page" = Option, Query, description = "페이지당 개수"), + ), + responses( + (status = 200, description = "Try 목록 조회 성공", body = TryListResponse), + (status = 404, description = "Post를 찾을 수 없음") + ) +)] +pub async fn list_tries( + State(state): State, + Path(post_id): Path, + Query(query): Query, +) -> AppResult> { + let response = service::list_tries(&state.db, post_id, query).await?; + Ok(Json(response)) +} + +/// GET /api/v1/posts/{post_id}/tries/count - Try 개수 조회 +#[utoipa::path( + get, + path = "/api/v1/posts/{post_id}/tries/count", + tag = "posts", + params( + ("post_id" = Uuid, Path, description = "원본 Post ID"), + ), + responses( + (status = 200, description = "Try 개수 조회 성공", body = TryCountResponse), + ) +)] +pub async fn count_tries( + State(state): State, + Path(post_id): Path, +) -> AppResult> { + let response = service::count_tries(&state.db, post_id).await?; + Ok(Json(response)) +} + +/// GET /api/v1/spots/{spot_id}/tries - 스팟별 Try 목록 조회 +#[utoipa::path( + get, + path = "/api/v1/spots/{spot_id}/tries", + tag = "spots", + params( + ("spot_id" = Uuid, Path, description = "Spot ID"), + ("page" = Option, Query, description = "페이지 번호"), + ("per_page" = Option, Query, description = "페이지당 개수"), + ), + responses( + (status = 200, description = "스팟별 Try 목록 조회 성공", body = TryListResponse), + ) +)] +pub async fn list_tries_by_spot( + State(state): State, + Path(spot_id): Path, + Query(query): Query, +) -> AppResult> { + let response = service::list_tries_by_spot(&state.db, spot_id, query).await?; + Ok(Json(response)) +} + /// Posts 도메인 라우터 pub fn router(app_config: AppConfig) -> Router { let protected_routes = Router::new() .route("/", post(create_post_without_solutions)) .route("/with-solutions", post(create_post_with_solutions)) + .route("/try", post(create_try_post)) .route("/upload", post(upload_image)) .route("/{post_id}", patch(update_post).delete(delete_post)) .route_layer(from_fn_with_state(app_config.clone(), auth_middleware)); @@ -422,6 +554,8 @@ pub fn router(app_config: AppConfig) -> Router { Router::new() .route("/", get(list_posts)) + .route("/{post_id}/tries", get(list_tries)) + .route("/{post_id}/tries/count", get(count_tries)) .merge(rate_limited_routes) .nest("/{post_id}/spots", spots_router) .merge(optional_auth_routes) diff --git a/packages/api-server/src/domains/posts/service.rs b/packages/api-server/src/domains/posts/service.rs index 062168f4..85b2e53f 100644 --- a/packages/api-server/src/domains/posts/service.rs +++ b/packages/api-server/src/domains/posts/service.rs @@ -16,9 +16,10 @@ use crate::{ }; use super::dto::{ - CreatePostDto, CreatePostWithSolutionsResponse, ImageAnalyzeResponse, ImageUploadResponse, - MediaSourceDto, PostDetailResponse, PostListItem, PostListQuery, PostResponse, PostUserInfo, - SpotWithTopSolution, TopSolutionSummary, UpdatePostDto, + CreatePostDto, CreatePostWithSolutionsResponse, CreateTryPostDto, ImageAnalyzeResponse, + ImageUploadResponse, MediaSourceDto, PostDetailResponse, PostListItem, PostListQuery, + PostResponse, PostUserInfo, SpotWithTopSolution, TopSolutionSummary, TryCountResponse, + TryListQuery, TryListResponse, TryPostListItem, UpdatePostDto, }; /// Solution 정보 (AI 분석 트리거용) @@ -518,7 +519,17 @@ pub async fn get_post_detail( // 3. Spots이 없으면 빈 응답 반환 if related_data.spots.is_empty() { let media_source = build_media_source_from_post(&post); - return Ok(PostDetailResponse::from_post_model( + let try_count = if post.post_type.as_deref() != Some("try") { + let c = count_tries(db, post_id).await?; + if c.count > 0 { + Some(c.count) + } else { + None + } + } else { + None + }; + let mut response = PostDetailResponse::from_post_model( post, user, media_source, @@ -527,7 +538,9 @@ pub async fn get_post_detail( like_stats.like_count as i64, Some(like_stats.user_has_liked), user_id.map(|_| user_has_saved), - )); + ); + response.try_count = try_count; + return Ok(response); } // 4. Spot 목록 생성 (배치 로드된 데이터로부터) @@ -537,8 +550,20 @@ pub async fn get_post_detail( let comment_count = count_comments_by_post_id(db, post_id).await? as i64; let media_source = build_media_source_from_post(&post); - // 6. PostDetailResponse 반환 - Ok(PostDetailResponse::from_post_model( + // 6. Try 개수 조회 (원본 포스트인 경우만) + let try_count = if post.post_type.as_deref() != Some("try") { + let count = count_tries(db, post_id).await?; + if count.count > 0 { + Some(count.count) + } else { + None + } + } else { + None + }; + + // 7. PostDetailResponse 반환 + let mut response = PostDetailResponse::from_post_model( post, user, media_source, @@ -547,7 +572,10 @@ pub async fn get_post_detail( like_stats.like_count as i64, Some(like_stats.user_has_liked), user_id.map(|_| user_has_saved), - )) + ); + response.try_count = try_count; + + Ok(response) } /// 대표 Solution 선택 (우선순위: is_adopted > is_verified > vote score) @@ -603,6 +631,9 @@ pub async fn list_posts( ) -> AppResult> { let mut select = Posts::find().filter(Column::Status.eq(crate::constants::post_status::ACTIVE)); + // Try 포스트는 일반 피드에서 제외 + select = select.filter(Column::PostType.is_null().or(Column::PostType.ne("try"))); + // 필터 적용 if let Some(ref artist_name) = query.artist_name { select = select.filter(Column::ArtistName.eq(artist_name)); @@ -1352,6 +1383,332 @@ fn convert_category_model_to_response( }) } +// ============================================================ +// Try Post Service Functions +// ============================================================ + +/// Try Post 생성 (이미지 업로드 포함, 경량 경로) +pub async fn create_try_post( + state: &AppState, + user_id: Uuid, + image_data: Vec, + content_type: &str, + dto: CreateTryPostDto, +) -> AppResult { + // 1. parent_post_id 검증 + let parent = get_post_by_id(&state.db, dto.parent_post_id).await?; + if parent.post_type.as_deref() == Some("try") { + return Err(AppError::BadRequest( + "Cannot create a try on another try post".to_string(), + )); + } + + // 2. spot_ids 검증 (parent post 소속인지) + if !dto.spot_ids.is_empty() { + use crate::entities::spots::{Column as SpotColumn, Entity as Spots}; + let valid_spots = Spots::find() + .filter(SpotColumn::PostId.eq(dto.parent_post_id)) + .filter(SpotColumn::Id.is_in(dto.spot_ids.clone())) + .count(&state.db) + .await + .map_err(AppError::DatabaseError)?; + if valid_spots != dto.spot_ids.len() as u64 { + return Err(AppError::BadRequest( + "Some spot_ids do not belong to the parent post".to_string(), + )); + } + } + + // 3. 이미지 업로드 + let upload_response = upload_image(state, image_data, content_type, user_id).await?; + let image_key = extract_key_from_url(&upload_response.image_url); + + // 4. Post + try_spot_tags 생성 (트랜잭션) + let result = state + .db + .transaction::<_, PostResponse, AppError>(|txn| { + let image_url = upload_response.image_url.clone(); + let spot_ids = dto.spot_ids.clone(); + let media_title = dto.media_title.clone(); + let parent_post_id = dto.parent_post_id; + + Box::pin(async move { + let post = ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user_id), + image_url: Set(image_url), + media_type: Set("try".to_string()), + title: Set(media_title), + media_metadata: Set(None), + group_name: Set(None), + artist_name: Set(None), + context: Set(None), + view_count: Set(0), + status: Set(crate::constants::post_status::ACTIVE.to_string()), + created_with_solutions: Set(None), + parent_post_id: Set(Some(parent_post_id)), + post_type: Set(Some("try".to_string())), + ..Default::default() + }; + + let created_post = post.insert(txn).await.map_err(AppError::DatabaseError)?; + + // try_spot_tags 생성 + if !spot_ids.is_empty() { + use crate::entities::try_spot_tags::ActiveModel as TagActiveModel; + + for spot_id in &spot_ids { + let tag = TagActiveModel { + id: Set(Uuid::new_v4()), + try_post_id: Set(created_post.id), + spot_id: Set(*spot_id), + ..Default::default() + }; + tag.insert(txn).await.map_err(AppError::DatabaseError)?; + } + } + + Ok(created_post.into()) + }) + }) + .await + .map_err(|e| match e { + sea_orm::TransactionError::Transaction(err) => err, + sea_orm::TransactionError::Connection(err) => AppError::DatabaseError(err), + }) + .inspect_err(|_| { + // 실패 시 업로드된 이미지 삭제 + let image_key_clone = image_key.clone(); + let storage_client = state.storage_client.clone(); + tokio::spawn(async move { + if let Err(e) = storage_client.delete(&image_key_clone).await { + tracing::warn!( + "Failed to delete orphaned try image {}: {}", + image_key_clone, + e + ); + } + }); + })?; + + // Meilisearch 인덱싱 생략 (Try는 검색에 포함하지 않음) + + Ok(result) +} + +/// Try 목록 조회 (원본 포스트의 Try 포스트들) +pub async fn list_tries( + db: &DatabaseConnection, + parent_post_id: Uuid, + query: TryListQuery, +) -> AppResult { + use crate::entities::users::{Column as UserColumn, Entity as Users}; + + let paginator = Posts::find() + .filter(Column::ParentPostId.eq(parent_post_id)) + .filter(Column::PostType.eq("try")) + .filter(Column::Status.eq(crate::constants::post_status::ACTIVE)) + .order_by_desc(Column::CreatedAt); + + let total = paginator + .clone() + .count(db) + .await + .map_err(AppError::DatabaseError)? as i64; + + let offset = (query.page.saturating_sub(1)) * query.per_page; + let posts = paginator + .offset(offset) + .limit(query.per_page) + .all(db) + .await + .map_err(AppError::DatabaseError)?; + + // 유저 정보 배치 로드 + let user_ids: Vec = posts.iter().map(|p| p.user_id).collect(); + let users: Vec = if user_ids.is_empty() { + vec![] + } else { + Users::find() + .filter(UserColumn::Id.is_in(user_ids)) + .all(db) + .await + .map_err(AppError::DatabaseError)? + }; + + // try_spot_tags 배치 로드 + let post_ids: Vec = posts.iter().map(|p| p.id).collect(); + let tags = if post_ids.is_empty() { + vec![] + } else { + use crate::entities::try_spot_tags::{Column as TagColumn, Entity as TrySpotTags}; + TrySpotTags::find() + .filter(TagColumn::TryPostId.is_in(post_ids)) + .all(db) + .await + .map_err(AppError::DatabaseError)? + }; + + let tries = posts + .into_iter() + .map(|post| { + let user = users.iter().find(|u| u.id == post.user_id); + let tagged_spot_ids: Vec = tags + .iter() + .filter(|t| t.try_post_id == post.id) + .map(|t| t.spot_id) + .collect(); + + TryPostListItem { + id: post.id, + user: user.map_or_else( + || PostUserInfo { + id: post.user_id, + username: "unknown".to_string(), + avatar_url: None, + rank: "bronze".to_string(), + }, + |u| PostUserInfo { + id: u.id, + username: u.username.clone(), + avatar_url: u.avatar_url.clone(), + rank: u.rank.clone(), + }, + ), + image_url: post.image_url, + media_title: post.title, + tagged_spot_ids, + created_at: post.created_at.with_timezone(&chrono::Utc), + } + }) + .collect(); + + Ok(TryListResponse { tries, total }) +} + +/// Try 개수 조회 +pub async fn count_tries( + db: &DatabaseConnection, + parent_post_id: Uuid, +) -> AppResult { + let count = Posts::find() + .filter(Column::ParentPostId.eq(parent_post_id)) + .filter(Column::PostType.eq("try")) + .filter(Column::Status.eq(crate::constants::post_status::ACTIVE)) + .count(db) + .await + .map_err(AppError::DatabaseError)? as i64; + + Ok(TryCountResponse { count }) +} + +/// 특정 스팟을 태깅한 Try 목록 조회 +pub async fn list_tries_by_spot( + db: &DatabaseConnection, + spot_id: Uuid, + query: TryListQuery, +) -> AppResult { + use crate::entities::try_spot_tags::{Column as TagColumn, Entity as TrySpotTags}; + use crate::entities::users::{Column as UserColumn, Entity as Users}; + + // 1. 해당 spot을 태깅한 try_post_id 목록 + let tag_post_ids: Vec = TrySpotTags::find() + .filter(TagColumn::SpotId.eq(spot_id)) + .select_only() + .column(TagColumn::TryPostId) + .into_tuple::() + .all(db) + .await + .map_err(AppError::DatabaseError)?; + + if tag_post_ids.is_empty() { + return Ok(TryListResponse { + tries: vec![], + total: 0, + }); + } + + // 2. 해당 포스트 조회 + let paginator = Posts::find() + .filter(Column::Id.is_in(tag_post_ids.clone())) + .filter(Column::Status.eq(crate::constants::post_status::ACTIVE)) + .order_by_desc(Column::CreatedAt); + + let total = paginator + .clone() + .count(db) + .await + .map_err(AppError::DatabaseError)? as i64; + + let offset = (query.page.saturating_sub(1)) * query.per_page; + let posts = paginator + .offset(offset) + .limit(query.per_page) + .all(db) + .await + .map_err(AppError::DatabaseError)?; + + // 유저 정보 배치 로드 + let user_ids: Vec = posts.iter().map(|p| p.user_id).collect(); + let users: Vec = if user_ids.is_empty() { + vec![] + } else { + Users::find() + .filter(UserColumn::Id.is_in(user_ids)) + .all(db) + .await + .map_err(AppError::DatabaseError)? + }; + + // 모든 tags 로드 + let post_ids: Vec = posts.iter().map(|p| p.id).collect(); + let all_tags = if post_ids.is_empty() { + vec![] + } else { + TrySpotTags::find() + .filter(TagColumn::TryPostId.is_in(post_ids)) + .all(db) + .await + .map_err(AppError::DatabaseError)? + }; + + let tries = posts + .into_iter() + .map(|post| { + let user = users.iter().find(|u| u.id == post.user_id); + let tagged_spot_ids: Vec = all_tags + .iter() + .filter(|t| t.try_post_id == post.id) + .map(|t| t.spot_id) + .collect(); + + TryPostListItem { + id: post.id, + user: user.map_or_else( + || PostUserInfo { + id: post.user_id, + username: "unknown".to_string(), + avatar_url: None, + rank: "bronze".to_string(), + }, + |u| PostUserInfo { + id: u.id, + username: u.username.clone(), + avatar_url: u.avatar_url.clone(), + rank: u.rank.clone(), + }, + ), + image_url: post.image_url, + media_title: post.title, + tagged_spot_ids, + created_at: post.created_at.with_timezone(&chrono::Utc), + } + }) + .collect(); + + Ok(TryListResponse { tries, total }) +} + #[cfg(test)] #[allow(clippy::disallowed_methods)] mod tests { diff --git a/packages/api-server/src/domains/spots/handlers.rs b/packages/api-server/src/domains/spots/handlers.rs index 24953a23..f6f406fa 100644 --- a/packages/api-server/src/domains/spots/handlers.rs +++ b/packages/api-server/src/domains/spots/handlers.rs @@ -196,6 +196,10 @@ pub fn router(app_config: AppConfig) -> Router { Router::new() .route("/", get(list_spots)) + .route( + "/{spot_id}/tries", + get(crate::domains::posts::handlers::list_tries_by_spot), + ) .merge(optional_auth_routes) .merge(protected_routes) } diff --git a/packages/api-server/src/entities/mod.rs b/packages/api-server/src/entities/mod.rs index 6583c2ee..554dcadf 100644 --- a/packages/api-server/src/entities/mod.rs +++ b/packages/api-server/src/entities/mod.rs @@ -27,6 +27,7 @@ pub mod solutions; pub mod spots; pub mod subcategories; pub mod synonyms; +pub mod try_spot_tags; pub mod user_badges; pub mod user_tryon_history; pub mod users; @@ -133,6 +134,10 @@ pub use agent_sessions::ActiveModel as AgentSessionsActiveModel; pub use agent_sessions::Entity as AgentSessions; pub use agent_sessions::Model as AgentSessionsModel; +pub use try_spot_tags::ActiveModel as TrySpotTagsActiveModel; +pub use try_spot_tags::Entity as TrySpotTags; +pub use try_spot_tags::Model as TrySpotTagsModel; + pub use user_tryon_history::ActiveModel as UserTryonHistoryActiveModel; pub use user_tryon_history::Entity as UserTryonHistory; pub use user_tryon_history::Model as UserTryonHistoryModel; diff --git a/packages/api-server/src/entities/posts.rs b/packages/api-server/src/entities/posts.rs index b2e9a886..e2fc911f 100644 --- a/packages/api-server/src/entities/posts.rs +++ b/packages/api-server/src/entities/posts.rs @@ -42,6 +42,12 @@ pub struct Model { #[sea_orm(column_type = "Text", nullable)] pub ai_summary: Option, + #[sea_orm(nullable)] + pub parent_post_id: Option, + + #[sea_orm(nullable)] + pub post_type: Option, + pub created_at: DateTimeWithTimeZone, pub updated_at: DateTimeWithTimeZone, @@ -62,6 +68,13 @@ pub enum Relation { to = "super::post_magazines::Column::Id" )] PostMagazine, + + #[sea_orm( + belongs_to = "Entity", + from = "Column::ParentPostId", + to = "Column::Id" + )] + ParentPost, } impl Related for Entity { diff --git a/packages/api-server/src/entities/try_spot_tags.rs b/packages/api-server/src/entities/try_spot_tags.rs new file mode 100644 index 00000000..d763b2d3 --- /dev/null +++ b/packages/api-server/src/entities/try_spot_tags.rs @@ -0,0 +1,46 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "try_spot_tags")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub try_post_id: Uuid, + pub spot_id: Uuid, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::posts::Entity", + from = "Column::TryPostId", + to = "super::posts::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Posts, + #[sea_orm( + belongs_to = "super::spots::Entity", + from = "Column::SpotId", + to = "super::spots::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Spots, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Posts.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Spots.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/packages/api-server/src/openapi.rs b/packages/api-server/src/openapi.rs index 5ac79489..52ea6646 100644 --- a/packages/api-server/src/openapi.rs +++ b/packages/api-server/src/openapi.rs @@ -36,6 +36,10 @@ use utoipa::OpenApi; crate::domains::posts::handlers::delete_post, crate::domains::posts::handlers::upload_image, crate::domains::posts::handlers::analyze_image, + crate::domains::posts::handlers::create_try_post, + crate::domains::posts::handlers::list_tries, + crate::domains::posts::handlers::count_tries, + crate::domains::posts::handlers::list_tries_by_spot, crate::domains::spots::handlers::create_spot, crate::domains::spots::handlers::list_spots, crate::domains::spots::handlers::get_spot, @@ -151,6 +155,11 @@ use utoipa::OpenApi; crate::domains::posts::dto::ImageUploadResponse, crate::domains::posts::dto::ImageAnalyzeResponse, crate::domains::posts::dto::ImageAnalysisMetadata, + crate::domains::posts::dto::CreateTryPostDto, + crate::domains::posts::dto::TryPostListItem, + crate::domains::posts::dto::TryListResponse, + crate::domains::posts::dto::TryCountResponse, + crate::domains::posts::dto::TryListQuery, crate::domains::posts::dto::CreateSpotDto, crate::domains::spots::dto::CreateSpotDto, crate::domains::spots::dto::UpdateSpotDto, diff --git a/packages/web/app/api/v1/posts/try/route.ts b/packages/web/app/api/v1/posts/try/route.ts new file mode 100644 index 00000000..00fba324 --- /dev/null +++ b/packages/web/app/api/v1/posts/try/route.ts @@ -0,0 +1,53 @@ +/** + * Try Post Proxy API Route + * POST /api/v1/posts/try - Create a try post (auth required) + */ + +import { NextRequest, NextResponse } from "next/server"; +import { API_BASE_URL } from "@/lib/server-env"; + +export async function POST(request: NextRequest) { + const authHeader = request.headers.get("Authorization"); + if (!authHeader) { + return NextResponse.json( + { message: "Authentication required" }, + { status: 401 } + ); + } + + try { + const incomingFormData = await request.formData(); + + const formData = new FormData(); + for (const [key, value] of incomingFormData.entries()) { + formData.append(key, value); + } + + const response = await fetch(`${API_BASE_URL}/api/v1/posts/try`, { + method: "POST", + headers: { + Authorization: authHeader, + }, + body: formData, + }); + + const responseText = await response.text(); + + let data; + try { + data = JSON.parse(responseText); + } catch { + data = { message: responseText || "Unknown backend error" }; + } + + return NextResponse.json(data, { status: response.status }); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.error("Try POST proxy error:", error); + } + return NextResponse.json( + { message: error instanceof Error ? error.message : "Proxy error" }, + { status: 502 } + ); + } +} diff --git a/packages/web/app/request/try/page.tsx b/packages/web/app/request/try/page.tsx new file mode 100644 index 00000000..2c66a9ff --- /dev/null +++ b/packages/web/app/request/try/page.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { toast } from "sonner"; +import Image from "next/image"; +import { useImageUpload } from "@/lib/hooks/useImageUpload"; +import { useCreateTryPost } from "@/lib/hooks/useTries"; +import { useSpots } from "@/lib/hooks/useSpots"; +import { compressImage } from "@/lib/utils/imageCompression"; +import { DropZone } from "@/lib/components/request/DropZone"; +import { MobileUploadOptions } from "@/lib/components/request/MobileUploadOptions"; +import { SpotTagSelector } from "@/lib/components/request/SpotTagSelector"; +import { ArrowLeft, X, RefreshCw, Loader2 } from "lucide-react"; +import { useGetPost } from "@/lib/api/generated/posts/posts"; + +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export default function TryUploadPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const parentId = searchParams.get("parent") ?? ""; + + const [comment, setComment] = useState(""); + const [selectedSpotIds, setSelectedSpotIds] = useState([]); + + // Validate parent param + const isValidParent = UUID_REGEX.test(parentId); + + // Fetch parent post + const { data: parentPost, isLoading: isLoadingParent } = useGetPost( + parentId, + { query: { enabled: isValidParent } } + ); + + // Image upload + const { images, handleFilesSelected, removeImage } = useImageUpload({ + autoUpload: false, + autoAnalyze: false, + }); + + const selectedImage = images[0] ?? null; + const hasImage = !!selectedImage; + + // Try creation mutation + const createTry = useCreateTryPost(); + + const handleClose = useCallback(() => { + if (isValidParent) { + router.push(`/posts/${parentId}`); + } else { + router.push("/"); + } + }, [router, parentId, isValidParent]); + + const handleSubmit = useCallback(async () => { + if (!selectedImage?.file || !isValidParent) return; + + try { + const compressed = await compressImage(selectedImage.file); + + await createTry.mutateAsync({ + file: compressed, + parent_post_id: parentId, + spot_ids: + selectedSpotIds.length > 0 ? selectedSpotIds : undefined, + media_title: comment.trim() || undefined, + }); + + toast.success("Try가 공유되었습니다!"); + router.push(`/posts/${parentId}`); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "업로드에 실패했습니다. 다시 시도해주세요." + ); + } + }, [ + selectedImage, + isValidParent, + parentId, + selectedSpotIds, + comment, + createTry, + router, + ]); + + // Invalid parent + if (!isValidParent) { + toast.error("포스트를 찾을 수 없습니다."); + router.push("/"); + return null; + } + + return ( +
+ {/* Header */} +
+ +

나도 해봤어

+ +
+ +
+ {/* Original Post Preview */} + {isLoadingParent ? ( +
+
+
+
+
+
+
+ ) : parentPost ? ( +
+
+ 원본 포스트 +
+
+

+ {parentPost.title || "원본 포스트"} +

+

+ {parentPost.artist_name && `@${parentPost.artist_name}`} + {parentPost.context && ` · ${parentPost.context}`} +

+
+
+ ) : null} + + {/* Image Upload */} + {!hasImage ? ( + <> + + + + ) : ( +
+
+ Try 이미지 +
+ +
+ )} + + {/* Spot Tag Selector */} + + + {/* Comment */} +
+ setComment(e.target.value.slice(0, 100))} + placeholder="한줄 코멘트 (선택)" + className="w-full rounded-lg border bg-background px-3 py-2.5 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary" + maxLength={100} + /> +

+ {comment.length}/100 +

+
+
+ + {/* CTA */} +
+ +
+
+ ); +} diff --git a/packages/web/lib/api/mutation-types.ts b/packages/web/lib/api/mutation-types.ts index 7a46d168..96fa9bac 100644 --- a/packages/web/lib/api/mutation-types.ts +++ b/packages/web/lib/api/mutation-types.ts @@ -316,6 +316,43 @@ export interface PostMagazineResponse { // Coordinate Conversion Utilities // ============================================================ +// ============================================================ +// Try Post API +// POST /api/v1/posts/try +// ============================================================ + +export interface CreateTryPostRequest { + file: File; + parent_post_id: string; + spot_ids?: string[]; + media_title?: string; +} + +export interface TryPostUser { + id: string; + username: string; + avatar_url: string | null; + rank: string; +} + +export interface TryPostListItem { + id: string; + user: TryPostUser; + image_url: string; + media_title: string | null; + tagged_spot_ids: string[]; + created_at: string; +} + +export interface TryListResponse { + tries: TryPostListItem[]; + total: number; +} + +export interface TryCountResponse { + count: number; +} + /** * API 좌표 (백분율 숫자) → Store 좌표 (0-1 비율) */ diff --git a/packages/web/lib/api/posts.ts b/packages/web/lib/api/posts.ts index 683fe616..bf33010f 100644 --- a/packages/web/lib/api/posts.ts +++ b/packages/web/lib/api/posts.ts @@ -18,6 +18,7 @@ import type { PostsListResponse, PostsListParams, PostMagazineResponse, + CreateTryPostRequest, } from "./mutation-types"; import { ApiError } from "./mutation-types"; @@ -450,6 +451,56 @@ export async function fetchPostsServer( return response.json(); } +// ============================================================ +// Create Try Post +// POST /api/v1/posts/try +// ============================================================ + +export async function createTryPost( + request: CreateTryPostRequest +): Promise { + const token = await getAuthToken(); + + if (!token) { + throw new Error("로그인이 필요합니다."); + } + + const formData = new FormData(); + formData.append("image", request.file); + + const data: Record = { + parent_post_id: request.parent_post_id, + media_title: request.media_title || "", + }; + if (request.spot_ids && request.spot_ids.length > 0) { + data.spot_ids = request.spot_ids; + } + formData.append("data", JSON.stringify(data)); + + const response = await fetch("/api/v1/posts/try", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }); + + if (!response.ok) { + const responseText = await response.text(); + let errorMessage = "Try 포스트 생성에 실패했습니다."; + try { + const errorJson = JSON.parse(responseText); + errorMessage = + errorJson.message || errorJson.error?.message || errorMessage; + } catch { + errorMessage = responseText || errorMessage; + } + throw new Error(errorMessage); + } + + return response.json(); +} + // ============================================================ // Fetch Post Magazine // GET /api/v1/post-magazines/{magazineId} diff --git a/packages/web/lib/components/request/SpotTagSelector.tsx b/packages/web/lib/components/request/SpotTagSelector.tsx new file mode 100644 index 00000000..779ee4f1 --- /dev/null +++ b/packages/web/lib/components/request/SpotTagSelector.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useSpots } from "@/lib/hooks/useSpots"; + +interface SpotTagSelectorProps { + parentPostId: string; + selectedSpotIds: string[]; + onSelectionChange: (spotIds: string[]) => void; +} + +export function SpotTagSelector({ + parentPostId, + selectedSpotIds, + onSelectionChange, +}: SpotTagSelectorProps) { + const { data: spots, isLoading } = useSpots(parentPostId); + + if (isLoading || !spots || spots.length === 0) { + return null; + } + + const toggleSpot = (spotId: string) => { + if (selectedSpotIds.includes(spotId)) { + onSelectionChange(selectedSpotIds.filter((id) => id !== spotId)); + } else { + onSelectionChange([...selectedSpotIds, spotId]); + } + }; + + return ( +
+

+ 이 아이템 갖고 있어요 (선택) +

+
+ {spots.map((spot) => { + const isSelected = selectedSpotIds.includes(spot.id); + const label = + spot.category?.name?.ko || + spot.category?.name?.en || + spot.category?.code || + "아이템"; + return ( + + ); + })} +
+
+ ); +} diff --git a/packages/web/lib/hooks/useTries.ts b/packages/web/lib/hooks/useTries.ts index e669f3f4..e506a31a 100644 --- a/packages/web/lib/hooks/useTries.ts +++ b/packages/web/lib/hooks/useTries.ts @@ -1,21 +1,18 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiClient } from "@/lib/api/client"; +import { createTryPost } from "@/lib/api/posts"; +import type { + CreateTryPostRequest, + TryListResponse, + TryCountResponse, + TryPostListItem, +} from "@/lib/api/mutation-types"; // ============================================================ -// Types +// Types (re-export for consumers) // ============================================================ -export interface TryPost { - id: string; - user_id: string; - image_url: string; - media_title: string | null; - created_at: string; - user: { - display_name: string; - avatar_url: string | null; - username: string | null; - }; -} +export type TryPost = TryPostListItem; export interface TriesResponse { tries: TryPost[]; @@ -23,36 +20,83 @@ export interface TriesResponse { } // ============================================================ -// Fetch Function +// Query Keys +// ============================================================ + +export const tryKeys = { + all: ["tries"] as const, + list: (postId: string, limit?: number) => + [...tryKeys.all, "list", postId, limit] as const, + count: (postId: string) => [...tryKeys.all, "count", postId] as const, + bySpot: (spotId: string) => [...tryKeys.all, "spot", spotId] as const, +}; + +// ============================================================ +// Fetch Functions // ============================================================ -/** - * Fetch tries for a given post. - * TODO: Replace placeholder with actual API call when backend is ready. - * Will be: GET /api/v1/posts/:postId/tries?limit=N - */ async function fetchTries( - _postId: string, - _limit: number + postId: string, + limit: number ): Promise { - return { tries: [], total: 0 }; + const data = await apiClient({ + path: `/api/v1/posts/${postId}/tries?per_page=${limit}`, + method: "GET", + }); + return { tries: data.tries, total: data.total }; +} + +async function fetchTryCount(postId: string): Promise { + const data = await apiClient({ + path: `/api/v1/posts/${postId}/tries/count`, + method: "GET", + }); + return data.count; } // ============================================================ -// Hook +// Hooks // ============================================================ /** * React Query hook for fetching user "Try" posts linked to an original post. - * - * @param postId - The parent post ID - * @param limit - Max number of tries to fetch (default 6) */ export function useTries(postId: string, limit = 6) { return useQuery({ - queryKey: ["tries", postId, limit], + queryKey: tryKeys.list(postId, limit), queryFn: () => fetchTries(postId, limit), enabled: !!postId, - staleTime: 1000 * 60, // 1 minute + staleTime: 1000 * 60, + }); +} + +/** + * React Query hook for fetching try count for a post. + */ +export function useTryCount(postId: string) { + return useQuery({ + queryKey: tryKeys.count(postId), + queryFn: () => fetchTryCount(postId), + enabled: !!postId, + staleTime: 1000 * 60, + }); +} + +/** + * Mutation hook for creating a try post. + */ +export function useCreateTryPost() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (request: CreateTryPostRequest) => createTryPost(request), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: tryKeys.list(variables.parent_post_id), + }); + queryClient.invalidateQueries({ + queryKey: tryKeys.count(variables.parent_post_id), + }); + }, }); } From a0eaf3c3ed8975b10a6e40e8d88ef1d0dee13715 Mon Sep 17 00:00:00 2001 From: CIOI Date: Sat, 4 Apr 2026 15:01:26 +0900 Subject: [PATCH 04/12] fix(api-server): Validate on report DTOs; register admin_update_post in OpenAPI - CreateReportDto / UpdateReportStatusDto: derive Validate + length rules - openapi paths: admin posts PATCH /{id} (admin_update_post) Made-with: Cursor --- packages/api-server/src/domains/reports/dto.rs | 10 ++++++++-- packages/api-server/src/openapi.rs | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/api-server/src/domains/reports/dto.rs b/packages/api-server/src/domains/reports/dto.rs index cb3098d6..6b1a737f 100644 --- a/packages/api-server/src/domains/reports/dto.rs +++ b/packages/api-server/src/domains/reports/dto.rs @@ -4,28 +4,34 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; +use validator::Validate; /// 신고 생성 요청 -#[derive(Debug, Clone, Deserialize, ToSchema)] +#[derive(Debug, Clone, Deserialize, Validate, ToSchema)] pub struct CreateReportDto { /// 대상 타입 (post, comment, solution) + #[validate(length(min = 1, max = 64))] pub target_type: String, /// 대상 ID pub target_id: Uuid, /// 신고 사유 (spam, inappropriate, copyright, incorrect, other) + #[validate(length(min = 1, max = 64))] pub reason: String, /// 상세 설명 (선택) #[serde(skip_serializing_if = "Option::is_none")] + #[validate(length(max = 5000))] pub details: Option, } /// 신고 상태 업데이트 요청 (admin) -#[derive(Debug, Clone, Deserialize, ToSchema)] +#[derive(Debug, Clone, Deserialize, Validate, ToSchema)] pub struct UpdateReportStatusDto { /// 새 상태 (pending, reviewed, dismissed, actioned) + #[validate(length(min = 1, max = 64))] pub status: String, /// 처리 결과 메모 (선택) #[serde(skip_serializing_if = "Option::is_none")] + #[validate(length(max = 5000))] pub resolution: Option, } diff --git a/packages/api-server/src/openapi.rs b/packages/api-server/src/openapi.rs index 5ac79489..d284a54e 100644 --- a/packages/api-server/src/openapi.rs +++ b/packages/api-server/src/openapi.rs @@ -96,6 +96,7 @@ use utoipa::OpenApi; crate::domains::admin::categories::update_category_status, crate::domains::admin::posts::list_posts, crate::domains::admin::posts::update_post_status, + crate::domains::admin::posts::admin_update_post, crate::domains::admin::spots::list_spots, crate::domains::admin::spots::update_spot_subcategory, crate::domains::admin::solutions::list_solutions, From 1bcf64430021b5a7c159a19ebf739937f20575a5 Mon Sep 17 00:00:00 2001 From: CIOI Date: Sat, 4 Apr 2026 15:01:26 +0900 Subject: [PATCH 05/12] feat(api-server): optional warehouse FK ids on post create (issue #77) - CreatePostDto: group_id, artist_id (optional UUID) - CreateSolutionInlineDto / CreateSolutionDto: brand_id - create_post_transaction + into_active_model mapping - dto_tests.inc + solution DTO tests Made-with: Cursor --- packages/api-server/src/domains/posts/dto.rs | 12 +++++++++ .../src/domains/posts/dto_tests.inc | 10 +++++++ .../api-server/src/domains/posts/service.rs | 19 ++++++------- .../api-server/src/domains/solutions/dto.rs | 27 +++++++++++++++++++ .../src/domains/solutions/handlers.rs | 1 + 5 files changed, 58 insertions(+), 11 deletions(-) diff --git a/packages/api-server/src/domains/posts/dto.rs b/packages/api-server/src/domains/posts/dto.rs index d7d37c93..dc612b62 100644 --- a/packages/api-server/src/domains/posts/dto.rs +++ b/packages/api-server/src/domains/posts/dto.rs @@ -54,6 +54,10 @@ pub struct CreateSolutionInlineDto { /// og metadata image #[serde(skip_serializing_if = "Option::is_none")] pub thumbnail_url: Option, + + /// `warehouse.brands.id` (옵션). 없으면 NULL — 스케줄러/대시보드에서 후속 백필 가능 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub brand_id: Option, } /// Spot 생성 요청 (Post 생성 시 포함) @@ -95,10 +99,18 @@ pub struct CreatePostDto { #[serde(skip_serializing_if = "Option::is_none")] pub group_name: Option, + /// `warehouse.groups.id` (옵션). 없으면 NULL — 스케줄러/대시보드에서 후속 백필 가능 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_id: Option, + /// 아티스트명 (옵션) #[serde(skip_serializing_if = "Option::is_none")] pub artist_name: Option, + /// `warehouse.artists.id` (옵션). 없으면 NULL — 스케줄러/대시보드에서 후속 백필 가능 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artist_id: Option, + /// 상황 정보 (옵션) #[serde(skip_serializing_if = "Option::is_none")] pub context: Option, diff --git a/packages/api-server/src/domains/posts/dto_tests.inc b/packages/api-server/src/domains/posts/dto_tests.inc index 9ceb9d74..23ccc54c 100644 --- a/packages/api-server/src/domains/posts/dto_tests.inc +++ b/packages/api-server/src/domains/posts/dto_tests.inc @@ -18,6 +18,8 @@ fn sample_post(m: &str) -> PostModel { media_metadata: None, group_name: None, artist_name: None, + artist_id: None, + group_id: None, context: None, view_count: 3, status: "active".into(), @@ -73,6 +75,7 @@ fn posts_dto_validation_serde_and_entity_helpers() { description: None, comment: None, thumbnail_url: None, + brand_id: None, } .validate() .is_err()); @@ -83,6 +86,7 @@ fn posts_dto_validation_serde_and_entity_helpers() { description: None, comment: None, thumbnail_url: None, + brand_id: None, }; assert!(sol.validate().is_ok()); let sj = serde_json::to_value(&sol).unwrap(); @@ -118,7 +122,9 @@ fn posts_dto_validation_serde_and_entity_helpers() { }, metadata: None, group_name: None, + group_id: None, artist_name: None, + artist_id: None, context: None, spots: vec![spot_ok()], } @@ -129,7 +135,9 @@ fn posts_dto_validation_serde_and_entity_helpers() { media_source: ms_ok(), metadata: None, group_name: None, + group_id: None, artist_name: None, + artist_id: None, context: None, spots: vec![], } @@ -140,7 +148,9 @@ fn posts_dto_validation_serde_and_entity_helpers() { media_source: ms_ok(), metadata: None, group_name: None, + group_id: None, artist_name: None, + artist_id: None, context: None, spots: vec![spot_ok()], } diff --git a/packages/api-server/src/domains/posts/service.rs b/packages/api-server/src/domains/posts/service.rs index 6653fdb9..74659946 100644 --- a/packages/api-server/src/domains/posts/service.rs +++ b/packages/api-server/src/domains/posts/service.rs @@ -45,9 +45,11 @@ async fn create_post_transaction( let mut solution_infos = Vec::new(); let mut spot_ids = Vec::new(); - // dto에서 직접 전달된 group_name, artist_name, context 사용 + // dto에서 직접 전달된 group_name, artist_name, context 및 optional warehouse FK 사용 let group_name = dto.group_name; + let group_id = dto.group_id; let artist_name = dto.artist_name; + let artist_id = dto.artist_id; let context = dto.context; // ActiveModel 생성 @@ -59,7 +61,9 @@ async fn create_post_transaction( title: Set(None), // AI가 description에서 추출할 예정 media_metadata: Set(None), // AI가 description에서 추출할 예정 group_name: Set(group_name), + group_id: Set(group_id), artist_name: Set(artist_name), + artist_id: Set(artist_id), context: Set(context), view_count: Set(0), status: Set(crate::constants::post_status::ACTIVE.to_string()), @@ -108,6 +112,7 @@ async fn create_post_transaction( description: solution_dto.description.clone(), comment: solution_dto.comment.clone(), thumbnail_url: solution_dto.thumbnail_url.clone(), + brand_id: solution_dto.brand_id, }; let solution = create_solution_dto.into_active_model(created_spot.id, user_id); @@ -1149,11 +1154,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 +1166,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/solutions/dto.rs b/packages/api-server/src/domains/solutions/dto.rs index 2fdc4f6a..372eaf55 100644 --- a/packages/api-server/src/domains/solutions/dto.rs +++ b/packages/api-server/src/domains/solutions/dto.rs @@ -73,6 +73,10 @@ pub struct CreateSolutionDto { /// og metadata image #[serde(skip_serializing_if = "Option::is_none")] pub thumbnail_url: Option, + + /// `warehouse.brands.id` (옵션). 없으면 NULL — 스케줄러/대시보드에서 후속 백필 가능 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub brand_id: Option, } impl CreateSolutionDto { @@ -88,6 +92,7 @@ impl CreateSolutionDto { thumbnail_url: Set(self.thumbnail_url), description: Set(self.description), comment: Set(self.comment), + brand_id: Set(self.brand_id), accurate_count: Set(0), different_count: Set(0), is_verified: Set(false), @@ -344,6 +349,7 @@ mod tests { description: None, comment: None, thumbnail_url: None, + brand_id: None, }; assert!(dto.validate().is_err()); @@ -361,6 +367,7 @@ mod tests { description: Some("desc".to_string()), comment: None, thumbnail_url: None, + brand_id: None, }; let am = dto.into_active_model(spot_id, user_id); @@ -372,6 +379,7 @@ mod tests { am.original_url, ActiveValue::Set(Some("https://shop.example/p/1".to_string())) ); + assert_eq!(am.brand_id, ActiveValue::Set(None)); assert_eq!(am.is_verified, ActiveValue::Set(false)); assert_eq!(am.status, ActiveValue::Set("active".to_string())); } @@ -386,12 +394,31 @@ mod tests { description: None, comment: None, thumbnail_url: None, + brand_id: None, } .into_active_model(Uuid::new_v4(), Uuid::new_v4()); assert_eq!(am.title, ActiveValue::Set("Custom".to_string())); } + #[test] + fn create_solution_dto_into_active_model_preserves_brand_id() { + let bid = Uuid::new_v4(); + let am = CreateSolutionDto { + original_url: "https://a.com".to_string(), + affiliate_url: None, + title: None, + metadata: None, + description: None, + comment: None, + thumbnail_url: None, + brand_id: Some(bid), + } + .into_active_model(Uuid::new_v4(), Uuid::new_v4()); + + assert_eq!(am.brand_id, ActiveValue::Set(Some(bid))); + } + #[test] fn update_solution_dto_validation_rejects_oversized_title() { let dto = UpdateSolutionDto { diff --git a/packages/api-server/src/domains/solutions/handlers.rs b/packages/api-server/src/domains/solutions/handlers.rs index 86738a84..60e8db71 100644 --- a/packages/api-server/src/domains/solutions/handlers.rs +++ b/packages/api-server/src/domains/solutions/handlers.rs @@ -404,6 +404,7 @@ pub async fn test_full_integration_flow( affiliate_url: None, thumbnail_url: og_data.image.clone(), comment: None, + brand_id: None, }; let solution_id = service::create_solution(&state.db, spot.id, user.id, create_dto).await?; From 83215085426aa7671044d18ac08ed6c87aef99e0 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:10:51 +0900 Subject: [PATCH 06/12] chore: remove worktree dirs from tracking --- .claude/worktrees/feat-44-auth | 1 - .claude/worktrees/feat-58-image-dims | 1 - .claude/worktrees/feat-75-search | 1 - 3 files changed, 3 deletions(-) delete mode 160000 .claude/worktrees/feat-44-auth delete mode 160000 .claude/worktrees/feat-58-image-dims delete mode 160000 .claude/worktrees/feat-75-search diff --git a/.claude/worktrees/feat-44-auth b/.claude/worktrees/feat-44-auth deleted file mode 160000 index d0f2d53c..00000000 --- a/.claude/worktrees/feat-44-auth +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d0f2d53c78f47fb1b2037eb254e2a1e784a779a8 diff --git a/.claude/worktrees/feat-58-image-dims b/.claude/worktrees/feat-58-image-dims deleted file mode 160000 index 4f55d8b4..00000000 --- a/.claude/worktrees/feat-58-image-dims +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4f55d8b4d4411b417618193425f150f1f061b491 diff --git a/.claude/worktrees/feat-75-search b/.claude/worktrees/feat-75-search deleted file mode 160000 index 5623f143..00000000 --- a/.claude/worktrees/feat-75-search +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5623f1435a38b66ec5b78f498c688e412a6c3602 From 652e77d0070f9465c84eb00d3bc4e595043bdd82 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:11:09 +0900 Subject: [PATCH 07/12] chore: add .claude/worktrees/ to .gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 52a19950..e5dc1603 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,6 @@ packages/backend/target/ .agents/skills/gstack*/node_modules/ .dmux/ .gstack/ + +# Worktrees +.claude/worktrees/ From 2de5dd80d12817b65449fadb5380c22cace9eb8f Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:26:29 +0900 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20code=20review=20batch=20=E2=80=94?= =?UTF-8?q?=20CRITICAL+WARNING+docs=20(#134,=20#135,=20#136,=20#137)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL: - TryUploadPage: render-time side effects → useEffect, Suspense 경계 추가 - ImageCard/SmartNav/mobile-header: native img → next/image 전환 WARNING: - AuthProvider: setInterval 미인증 유저 제외 - SpotSolutionTabs: role="tablist/tab/tabpanel" 접근성 추가 - ExploreClient: 추천 태그 상수 추출 - Rust dto: image dimensions validate(range) 추가 - Rust service: post_type 매직 스트링 → 상수 DOCS: - api-v1-routes: votes, tries 엔드포인트 추가 - web-hooks-and-stores: useVoting, useCreateTryPost 추가 - web-routes-and-features: /request/try, proxy 라우트 추가 Closes #134, #135, #136, #137 Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/agent/api-v1-routes.md | 17 ++++---- docs/agent/web-hooks-and-stores.md | 2 + docs/agent/web-routes-and-features.md | 10 +++++ packages/api-server/src/domains/posts/dto.rs | 2 + .../api-server/src/domains/posts/service.rs | 20 +++++---- packages/web/app/explore/ExploreClient.tsx | 40 ++++++++++-------- packages/web/app/images/ImageCard.tsx | 28 +++++++++---- packages/web/app/request/try/page.tsx | 42 +++++++++++++++---- .../web/lib/components/auth/AuthProvider.tsx | 39 ++++++++++------- .../components/detail/SpotSolutionTabs.tsx | 15 ++++--- .../lib/components/main-renewal/SmartNav.tsx | 5 ++- .../web/lib/design-system/mobile-header.tsx | 5 ++- 12 files changed, 153 insertions(+), 72 deletions(-) diff --git a/docs/agent/api-v1-routes.md b/docs/agent/api-v1-routes.md index fb508d20..a8752d6e 100644 --- a/docs/agent/api-v1-routes.md +++ b/docs/agent/api-v1-routes.md @@ -24,9 +24,9 @@ Params: `q`, `context`, `media_type`, `sort`, `page`, `limit`. | `/api/v1/posts/[postId]/spots` | GET/POST | Spots for a post | | `/api/v1/posts/[postId]/likes` | POST | Like/unlike a post | | `/api/v1/posts/[postId]/saved` | POST | Save/unsave a post | -| `/api/v1/posts/try` | POST | Create a try post | -| `/api/v1/posts/[postId]/tries` | GET | List tries for a post | -| `/api/v1/posts/[postId]/tries/count` | GET | Try count for a post | +| `/api/v1/posts/try` | POST | Try 포스트 생성 (인증 필요) | +| `/api/v1/posts/[postId]/tries` | GET | Try 포스트 목록 | +| `/api/v1/posts/[postId]/tries/count` | GET | Try 개수 | | `/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) | @@ -34,10 +34,13 @@ Params: `q`, `context`, `media_type`, `sort`, `page`, `limit`. | Route | Methods | Description | | -------------------------------------- | --------- | ---------------------------- | -| `/api/v1/solutions/convert-affiliate` | POST | Convert affiliate links | -| `/api/v1/solutions/[solutionId]` | GET/PATCH | Solution CRUD | -| `/api/v1/solutions/[solutionId]/adopt` | POST | Adopt a solution | -| `/api/v1/solutions/extract-metadata` | POST | Solution metadata extraction | +| `/api/v1/solutions/convert-affiliate` | POST | Convert affiliate links | +| `/api/v1/solutions/[solutionId]` | GET/PATCH | Solution CRUD | +| `/api/v1/solutions/[solutionId]/adopt` | POST | Adopt a solution | +| `/api/v1/solutions/extract-metadata` | POST | Solution metadata extraction | +| `/api/v1/solutions/[solutionId]/votes` | GET | 솔루션 투표 조회 | +| `/api/v1/solutions/[solutionId]/votes` | POST | 투표 생성 (인증 필요) | +| `/api/v1/solutions/[solutionId]/votes` | DELETE | 투표 삭제 (인증 필요) | | `/api/v1/spots/[spotId]` | GET/PATCH | Spot CRUD | | `/api/v1/spots/[spotId]/tries` | GET | Tries tagged with spot | | `/api/v1/spots/[spotId]/solutions` | GET/POST | Solutions for spot | diff --git a/docs/agent/web-hooks-and-stores.md b/docs/agent/web-hooks-and-stores.md index 6323d129..ca39ad6f 100644 --- a/docs/agent/web-hooks-and-stores.md +++ b/docs/agent/web-hooks-and-stores.md @@ -120,6 +120,7 @@ import type { PaginatedResponsePostListItem } from "@/lib/api/generated/models"; - `useSpots()` - Fetch spot data for images - `useComments()` - Fetch and manage comments - `useTries()` - Fetch try-on results +- `useCreateTryPost()` - Try 포스트 생성 (`lib/hooks/useTries.ts`) - `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 @@ -129,6 +130,7 @@ import type { PaginatedResponsePostListItem } from "@/lib/api/generated/models"; - `useSavedPost()` - Save/unsave posts - `useReport()` - Submit content reports - `useAdoptDropdown()` - Adopt a solution from dropdown +- `useVoting()` (`useVoteStats`, `useCreateVote`, `useDeleteVote`) — 솔루션 투표 조회·생성·삭제 (`lib/hooks/useVoting.ts`) ### Behavioral tracking diff --git a/docs/agent/web-routes-and-features.md b/docs/agent/web-routes-and-features.md index 14242913..7359d5ef 100644 --- a/docs/agent/web-routes-and-features.md +++ b/docs/agent/web-routes-and-features.md @@ -31,6 +31,7 @@ App Router 기준 (`packages/web/app/`). 작업 시 이 표와 실제 `app/` 트 | `/admin/entities/group-members` | 그룹 멤버 관리 | | `/request/upload` | Image upload with DropZone | | `/request/detect` | AI detection results with item spotting | +| `/request/try` | Try 포스트 업로드 페이지 | | `/login` | OAuth authentication (Kakao, Google, Apple) | | `/debug/supabase` | Supabase debug tools | | `/lab/*` | Experimental (ascii-text, fashion-scan) | @@ -70,6 +71,15 @@ Sections rendered in order: | `app/robots.ts` | robots.txt 규칙 | | `app/api/og/route.tsx` | OG image 동적 생성 | +## API proxy routes (Try & Votes) + +| Route | Methods | Description | +| ----- | ------- | ----------- | +| `/api/v1/posts/try` | POST | Try 생성 API (인증 필요) | +| `/api/v1/posts/[postId]/tries` | GET | Try 목록 | +| `/api/v1/posts/[postId]/tries/count` | GET | Try 개수 | +| `/api/v1/solutions/[solutionId]/votes` | GET/POST/DELETE | 솔루션 투표 조회·생성·삭제 (POST·DELETE 인증 필요) | + ## Auth | File | Description | diff --git a/packages/api-server/src/domains/posts/dto.rs b/packages/api-server/src/domains/posts/dto.rs index daa79f55..9771ef4d 100644 --- a/packages/api-server/src/domains/posts/dto.rs +++ b/packages/api-server/src/domains/posts/dto.rs @@ -117,10 +117,12 @@ pub struct CreatePostDto { /// 이미지 가로 크기 (픽셀, 옵션) #[serde(skip_serializing_if = "Option::is_none")] + #[validate(range(min = 1, max = 20000))] pub image_width: Option, /// 이미지 세로 크기 (픽셀, 옵션) #[serde(skip_serializing_if = "Option::is_none")] + #[validate(range(min = 1, max = 20000))] pub image_height: Option, /// Spots (최소 1개 이상 필요, 유저가 직접 지정) diff --git a/packages/api-server/src/domains/posts/service.rs b/packages/api-server/src/domains/posts/service.rs index 83e57ec7..82afd783 100644 --- a/packages/api-server/src/domains/posts/service.rs +++ b/packages/api-server/src/domains/posts/service.rs @@ -22,6 +22,10 @@ use super::dto::{ TryListQuery, TryListResponse, TryPostListItem, UpdatePostDto, }; +#[allow(dead_code)] +const POST_TYPE_POST: &str = "post"; +const POST_TYPE_TRY: &str = "try"; + /// Solution 정보 (AI 분석 트리거용) struct SolutionInfo { id: Uuid, @@ -557,7 +561,7 @@ pub async fn get_post_detail( // 3. Spots이 없으면 빈 응답 반환 if related_data.spots.is_empty() { let media_source = build_media_source_from_post(&post); - let try_count = if post.post_type.as_deref() != Some("try") { + let try_count = if post.post_type.as_deref() != Some(POST_TYPE_TRY) { let c = count_tries(db, post_id).await?; if c.count > 0 { Some(c.count) @@ -589,7 +593,7 @@ pub async fn get_post_detail( let media_source = build_media_source_from_post(&post); // 6. Try 개수 조회 (원본 포스트인 경우만) - let try_count = if post.post_type.as_deref() != Some("try") { + let try_count = if post.post_type.as_deref() != Some(POST_TYPE_TRY) { let count = count_tries(db, post_id).await?; if count.count > 0 { Some(count.count) @@ -670,7 +674,7 @@ pub async fn list_posts( let mut select = Posts::find().filter(Column::Status.eq(crate::constants::post_status::ACTIVE)); // Try 포스트는 일반 피드에서 제외 - select = select.filter(Column::PostType.is_null().or(Column::PostType.ne("try"))); + select = select.filter(Column::PostType.is_null().or(Column::PostType.ne(POST_TYPE_TRY))); // 필터 적용 if let Some(ref artist_name) = query.artist_name { @@ -1540,7 +1544,7 @@ pub async fn create_try_post( ) -> AppResult { // 1. parent_post_id 검증 let parent = get_post_by_id(&state.db, dto.parent_post_id).await?; - if parent.post_type.as_deref() == Some("try") { + if parent.post_type.as_deref() == Some(POST_TYPE_TRY) { return Err(AppError::BadRequest( "Cannot create a try on another try post".to_string(), )); @@ -1580,7 +1584,7 @@ pub async fn create_try_post( id: Set(Uuid::new_v4()), user_id: Set(user_id), image_url: Set(image_url), - media_type: Set("try".to_string()), + media_type: Set(POST_TYPE_TRY.to_string()), title: Set(media_title), media_metadata: Set(None), group_name: Set(None), @@ -1590,7 +1594,7 @@ pub async fn create_try_post( status: Set(crate::constants::post_status::ACTIVE.to_string()), created_with_solutions: Set(None), parent_post_id: Set(Some(parent_post_id)), - post_type: Set(Some("try".to_string())), + post_type: Set(Some(POST_TYPE_TRY.to_string())), ..Default::default() }; @@ -1649,7 +1653,7 @@ pub async fn list_tries( let paginator = Posts::find() .filter(Column::ParentPostId.eq(parent_post_id)) - .filter(Column::PostType.eq("try")) + .filter(Column::PostType.eq(POST_TYPE_TRY)) .filter(Column::Status.eq(crate::constants::post_status::ACTIVE)) .order_by_desc(Column::CreatedAt); @@ -1736,7 +1740,7 @@ pub async fn count_tries( ) -> AppResult { let count = Posts::find() .filter(Column::ParentPostId.eq(parent_post_id)) - .filter(Column::PostType.eq("try")) + .filter(Column::PostType.eq(POST_TYPE_TRY)) .filter(Column::Status.eq(crate::constants::post_status::ACTIVE)) .count(db) .await diff --git a/packages/web/app/explore/ExploreClient.tsx b/packages/web/app/explore/ExploreClient.tsx index 0756b8e9..3f35a6c3 100644 --- a/packages/web/app/explore/ExploreClient.tsx +++ b/packages/web/app/explore/ExploreClient.tsx @@ -14,6 +14,15 @@ import { LoadingSpinner } from "@/lib/design-system/loading-spinner"; import { SearchSuggestions } from "@/lib/components/search/SearchSuggestions"; import { cn } from "@/lib/utils"; +const POPULAR_SEARCH_TAGS = [ + "BLACKPINK", + "NewJeans", + "Lisa", + "Jennie", + "Minji", + "Hanni", +] as const; + const SORT_OPTIONS = [ { value: "relevant", label: "Relevant" }, { value: "recent", label: "Recent" }, @@ -375,7 +384,8 @@ export function ExploreClient({ )} {/* Empty/error state with search suggestions */} - {((!isError && !isLoading && items.length === 0) || (isError && mode === "search")) && ( + {((!isError && !isLoading && items.length === 0) || + (isError && mode === "search")) && (
@@ -394,18 +404,16 @@ export function ExploreClient({ {debouncedQuery.trim().length > 0 && ( <>
- {["BLACKPINK", "NewJeans", "Lisa", "Jennie", "Minji", "Hanni"].map( - (tag) => ( - - ) - )} + {POPULAR_SEARCH_TAGS.map((tag) => ( + + ))}