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] =?UTF-8?q?fix:=20code=20review=20batch=20=E2=80=94=20CRIT?= =?UTF-8?q?ICAL+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) => ( + + ))}