Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions docs/agent/api-v1-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,23 @@ 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) |

## Solutions & spots

| 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 |
Expand Down
2 changes: 2 additions & 0 deletions docs/agent/web-hooks-and-stores.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
10 changes: 10 additions & 0 deletions docs/agent/web-routes-and-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions packages/api-server/src/domains/posts/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,12 @@ pub struct CreatePostDto {

/// 이미지 가로 크기 (픽셀, 옵션)
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(range(min = 1, max = 20000))]
pub image_width: Option<i32>,

/// 이미지 세로 크기 (픽셀, 옵션)
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(range(min = 1, max = 20000))]
pub image_height: Option<i32>,

/// Spots (최소 1개 이상 필요, 유저가 직접 지정)
Expand Down
20 changes: 12 additions & 8 deletions packages/api-server/src/domains/posts/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1540,7 +1544,7 @@ pub async fn create_try_post(
) -> AppResult<PostResponse> {
// 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(),
));
Expand Down Expand Up @@ -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),
Expand All @@ -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()
};

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -1736,7 +1740,7 @@ pub async fn count_tries(
) -> AppResult<TryCountResponse> {
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
Expand Down
40 changes: 22 additions & 18 deletions packages/web/app/explore/ExploreClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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")) && (
<div className="absolute inset-0 z-0 flex items-center justify-center">
<div className="flex flex-col items-center justify-center px-4 py-12 text-center max-w-md">
<div className="mb-4 text-4xl">
Expand All @@ -394,18 +404,16 @@ export function ExploreClient({
{debouncedQuery.trim().length > 0 && (
<>
<div className="flex flex-wrap justify-center gap-2 mb-4">
{["BLACKPINK", "NewJeans", "Lisa", "Jennie", "Minji", "Hanni"].map(
(tag) => (
<button
key={tag}
type="button"
onClick={() => handleSuggestionSelect(tag)}
className="rounded-full border border-border bg-card/80 px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent hover:border-primary/30"
>
{tag}
</button>
)
)}
{POPULAR_SEARCH_TAGS.map((tag) => (
<button
key={tag}
type="button"
onClick={() => handleSuggestionSelect(tag)}
className="rounded-full border border-border bg-card/80 px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent hover:border-primary/30"
>
{tag}
</button>
))}
</div>
<button
type="button"
Expand All @@ -425,11 +433,7 @@ export function ExploreClient({
<div className="absolute inset-0 z-0">
<ThiingsGrid
gridSize={gridSize}
renderItem={(config) => (
<ExploreCardCell
{...config}
/>
)}
renderItem={(config) => <ExploreCardCell {...config} />}
initialPosition={{ x: 0, y: 0 }}
items={gridItems}
onReachEnd={() => {
Expand Down
28 changes: 19 additions & 9 deletions packages/web/app/images/ImageCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import type { Post } from "@/lib/api/mutation-types";
import { formatRelativeTime } from "@/lib/utils";
Expand Down Expand Up @@ -37,15 +38,24 @@ export function ImageCard({ post }: Props) {
}
>
{post.image_url && !imageError ? (
<img
src={post.image_url}
alt={`Post by @${displayName}`}
className="w-full h-full object-cover"
loading="lazy"
width={post.image_width ?? undefined}
height={post.image_height ?? undefined}
onError={() => setImageError(true)}
/>
post.image_width && post.image_height ? (
<Image
src={post.image_url}
alt={`Post by @${displayName}`}
className="w-full h-full object-cover"
width={post.image_width}
height={post.image_height}
onError={() => setImageError(true)}
/>
) : (
<Image
src={post.image_url}
alt={`Post by @${displayName}`}
className="object-cover"
fill
onError={() => setImageError(true)}
/>
)
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-muted-foreground bg-muted">
<div className="text-2xl mb-1">📷</div>
Expand Down
42 changes: 35 additions & 7 deletions packages/web/app/request/try/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
"use client";

import { useCallback, useState } from "react";
import { Suspense, useCallback, useEffect, 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";
Expand All @@ -17,17 +16,24 @@ 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() {
function TryUploadContent() {
const router = useRouter();
const searchParams = useSearchParams();
const parentId = searchParams.get("parent") ?? "";

const [comment, setComment] = useState("");
const [selectedSpotIds, setSelectedSpotIds] = useState<string[]>([]);

// Validate parent param
const isValidParent = UUID_REGEX.test(parentId);

// Invalid parent → side effect를 useEffect로 처리
useEffect(() => {
if (!isValidParent) {
toast.error("포스트를 찾을 수 없습니다.");
router.push("/");
}
}, [isValidParent, router]);

// Fetch parent post
const { data: parentPost, isLoading: isLoadingParent } = useGetPost(
parentId,
Expand Down Expand Up @@ -87,10 +93,8 @@ export default function TryUploadPage() {
router,
]);

// Invalid parent
// Invalid parent일 때는 useEffect redirect가 처리하므로 아무것도 렌더하지 않음
if (!isValidParent) {
toast.error("포스트를 찾을 수 없습니다.");
router.push("/");
return null;
}

Expand Down Expand Up @@ -214,3 +218,27 @@ export default function TryUploadPage() {
</div>
);
}

function TryUploadFallback() {
return (
<div className="mx-auto flex min-h-dvh max-w-lg flex-col">
<header className="flex items-center justify-between border-b px-4 py-3">
<div className="h-7 w-7 rounded bg-muted" />
<div className="h-4 w-24 rounded bg-muted" />
<div className="h-7 w-7 rounded bg-muted" />
</header>
<div className="flex-1 animate-pulse space-y-4 p-4">
<div className="h-18 rounded-lg bg-muted" />
<div className="h-40 rounded-lg bg-muted" />
</div>
</div>
);
}

export default function TryUploadPage() {
return (
<Suspense fallback={<TryUploadFallback />}>
<TryUploadContent />
</Suspense>
);
}
Loading
Loading