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
232 changes: 18 additions & 214 deletions packages/web/lib/hooks/useImages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import {
useQuery,
useInfiniteQuery,
useQueryClient,
keepPreviousData,
} from "@tanstack/react-query";
import {
Expand All @@ -20,8 +19,6 @@ import {
} from "@decoded/shared/supabase/queries/images";
import { getPost, listPosts } from "@/lib/api/generated/posts/posts";
import { postDetailToImageDetail } from "@/lib/api/adapters/postDetailToImageDetail";
import { fetchPostWithSpotsAndSolutions } from "@/lib/supabase/queries/posts";
import { spotToItemRow } from "@/lib/components/detail/types";
import type {
CategoryFilter,
ImagePage,
Expand Down Expand Up @@ -208,97 +205,20 @@ export function useInfinitePosts(params: {
queryFn: async ({ pageParam }) => {
const page = (pageParam as number) ?? 1;

// hasMagazine=true → REST API 사용 (Supabase posts 뷰에 magazine 컬럼 없음)
if (hasMagazine) {
const response = await listPosts({
page,
per_page: limit,
sort,
has_magazine: true,
artist_name: mediaName ?? artistName,
group_name: castName ? undefined : groupName,
context:
contextType ??
(category && category !== "all" ? category : undefined),
});

const items: PostGridItem[] = response.data.map((post) => ({
id: post.id,
imageUrl: post.image_url,
postId: post.id,
postSource: "post" as const,
postAccount: post.artist_name ?? post.group_name ?? "",
postCreatedAt: post.created_at,
spotCount: post.spot_count ?? 0,
viewCount: post.view_count,
title: post.post_magazine_title ?? post.title ?? null,
}));

const totalPages = response.pagination.total_pages;
const hasMore = page < totalPages;
return { items, nextPage: hasMore ? page + 1 : null, hasMore };
}

// 일반 모드 → Supabase 직접 쿼리
const from = (page - 1) * limit;
const to = from + limit - 1;

// Query posts directly with same filters as the old explore_posts view
let query = supabaseBrowserClient
.from("posts")
.select("*, post_magazines!inner(title)", { count: "exact" })
.eq("status", "active")
.not("image_url", "is", null)
.eq("created_with_solutions", true)
.eq("post_magazines.status", "published");

// category filter (flat) — skip if contextType is set (hierarchical takes precedence)
if (category && category !== "all" && !contextType) {
query = query.eq("context", category);
}
if (artistName) {
query = query.ilike("artist_name", `%${artistName}%`);
}
if (groupName) {
query = query.ilike("group_name", `%${groupName}%`);
}

// mediaName from hierarchical filter — matches group_name column
if (mediaName) {
query = query.ilike("group_name", `%${mediaName}%`);
}
// castName from hierarchical filter — matches artist_name column
if (castName) {
query = query.ilike("artist_name", `%${castName}%`);
}
// contextType from hierarchical filter — matches context column exactly
if (contextType) {
query = query.eq("context", contextType);
}

// Sort
if (sort === "popular") {
query = query.order("view_count", { ascending: false });
} else if (sort === "trending") {
query = query.order("trending_score", { ascending: false });
} else {
query = query.order("created_at", { ascending: false });
}

query = query.range(from, to);

const { data, count, error } = await query;

if (error) {
throw new Error(error.message);
}

const totalItems = count ?? 0;
const totalPages = Math.ceil(totalItems / limit);
const hasMore = page < totalPages;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const items: PostGridItem[] = (data ?? []).map((post: any) => ({
// REST API
const response = await listPosts({
page,
per_page: limit,
sort,
has_magazine: true,
artist_name: mediaName ?? artistName,
group_name: castName ? undefined : groupName,
context:
contextType ??
(category && category !== "all" ? category : undefined),
});

const items: PostGridItem[] = response.data.map((post) => ({
id: post.id,
imageUrl: post.image_url,
postId: post.id,
Expand All @@ -310,6 +230,8 @@ export function useInfinitePosts(params: {
title: post.post_magazine_title ?? post.title ?? null,
}));

const totalPages = response.pagination.total_pages;
const hasMore = page < totalPages;
return { items, nextPage: hasMore ? page + 1 : null, hasMore };
},
getNextPageParam: (lastPage) => lastPage.nextPage,
Expand All @@ -325,129 +247,11 @@ export function useInfinitePosts(params: {
* Tries REST API first (production), falls back to Supabase direct (dev without backend).
*/
export function usePostDetailForImage(postId: string) {
const queryClient = useQueryClient();

return useQuery<ImageDetail | null>({
queryKey: ["posts", "detail", "image", postId],
queryFn: async () => {
// Helper: eagerly prefetch magazine data once we have a magazine_id
const prefetchMagazine = (magazineId: string | null | undefined) => {
if (!magazineId) return;
queryClient.prefetchQuery({
queryKey: ["post-magazines", magazineId],
queryFn: async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error } = await (supabaseBrowserClient as any)
.from("post_magazines")
.select("*")
.eq("id", magazineId)
.single();
if (error || !data) return null;
return data as unknown as PostMagazineResponse;
},
staleTime: 1000 * 60 * 5,
});
};

// 1. Try REST API (works when backend is running)
try {
const response = await getPost(postId);
const detail = postDetailToImageDetail(response, postId);
// Start magazine fetch immediately (no waterfall)
prefetchMagazine((detail as any)?.post_magazine_id);
return detail;
} catch {
// Backend unavailable — fall through to Supabase
}

// 2. Fallback: Supabase direct query
try {
const result = await fetchPostWithSpotsAndSolutions(postId);
if (!result) return null;

const { post, spots, solutions } = result;
// DB has columns not in PostRow type (post_magazine_id, ai_summary, etc.)
const postAny = post as Record<string, unknown>;

// Eagerly prefetch magazine in Supabase fallback path too
prefetchMagazine(postAny.post_magazine_id as string | null);

const items = spots.map((spot) => {
const topSolution = solutions.find((s) => s.spot_id === spot.id);
return spotToItemRow(spot, topSolution);
});

return {
id: post.id,
image_hash: "",
image_url: post.image_url,
status: (post.status ?? "pending") as
| "pending"
| "extracted"
| "skipped"
| "extracted_metadata",
with_items: items.length > 0,
created_at: post.created_at,
items,
posts: [
{
id: post.id,
account: post.artist_name ?? post.group_name ?? "",
article: post.title ?? null,
created_at: post.created_at,
item_ids: null,
metadata: [],
ts: post.created_at,
} as any,
],
postImages: [
{
post: {
id: post.id,
account: post.artist_name ?? post.group_name ?? "",
article: post.title ?? null,
created_at: post.created_at,
} as any,
created_at: post.created_at,
item_locations: spots.map((s, idx) => ({
item_id: idx + 1,
center: [
Math.max(
0,
Math.min(
1,
parseFloat(s.position_left) > 1
? parseFloat(s.position_left) / 100
: parseFloat(s.position_left)
)
),
Math.max(
0,
Math.min(
1,
parseFloat(s.position_top) > 1
? parseFloat(s.position_top) / 100
: parseFloat(s.position_top)
)
),
],
})),
item_locations_updated_at: post.updated_at,
} as any,
],
// Extended fields (exist in DB but not in PostRow type)
post_owner_id: post.user_id ?? null,
post_magazine_id: (postAny.post_magazine_id as string) ?? null,
ai_summary: (postAny.ai_summary as string) ?? null,
artist_name: post.artist_name ?? null,
group_name: post.group_name ?? null,
created_with_solutions:
(postAny.created_with_solutions as boolean) ?? null,
like_count: (postAny.like_count as number) ?? 0,
} as ImageDetail;
} catch {
return null;
}
const response = await getPost(postId);
return postDetailToImageDetail(response, postId);
},
enabled: !!postId,
staleTime: 1000 * 60,
Expand Down
32 changes: 6 additions & 26 deletions packages/web/lib/hooks/usePosts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,7 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import {
fetchPostWithSpotsAndSolutions,
fetchPostWithImagesAndItems,
type PostDetail,
type LegacyPostDetail,
} from "@/lib/supabase/queries/posts";
import {
getPost,
listPosts,
updatePost as updatePostGenerated,
deletePost as deletePostGenerated,
Expand All @@ -20,7 +15,7 @@ import type {
PostsListResponse,
PostsListParams,
} from "@/lib/api/mutation-types";
import type { UpdatePostDto, PostResponse } from "@/lib/api/generated/models";
import type { UpdatePostDto, PostDetailResponse } from "@/lib/api/generated/models";

// ============================================================
// Query Keys
Expand All @@ -41,29 +36,15 @@ export const postKeys = {

/**
* React Query hook for fetching a single post with its spots and solutions
* Uses backend REST API instead of direct Supabase queries
*
* @param id - Post ID to fetch
* @returns React Query result with data, loading, error states
*/
export function usePostById(id: string) {
return useQuery<PostDetail | null>({
return useQuery<PostDetailResponse>({
queryKey: postKeys.detail(id),
queryFn: () => fetchPostWithSpotsAndSolutions(id),
enabled: !!id,
});
}

/**
* React Query hook for fetching a single post in legacy format
* @deprecated Use usePostById instead
*
* @param id - Post ID to fetch
* @returns React Query result with legacy data format
*/
export function usePostByIdLegacy(id: string) {
return useQuery<LegacyPostDetail | null>({
queryKey: ["posts", "detail", "legacy", id],
queryFn: () => fetchPostWithImagesAndItems(id),
queryFn: () => getPost(id),
enabled: !!id,
});
}
Expand Down Expand Up @@ -193,6 +174,5 @@ export type {
Post,
PostsListResponse,
PostsListParams,
PostDetail,
LegacyPostDetail,
PostDetailResponse,
};
39 changes: 2 additions & 37 deletions packages/web/lib/hooks/useSolutions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,13 @@ import type {
MetadataResponse,
AffiliateLinkResponse,
} from "@/lib/api/generated/models";
import { supabaseBrowserClient } from "@/lib/supabase/client";

/**
* Cache Invalidation Boundaries (MIG-09)
* Cache Invalidation Boundaries
*
* REST API cache (Orval generated hooks):
* Keys: solutionKeys, spotKeys, commentKeys, postKeys.lists()
* Invalidated by: REST mutations below
*
* Supabase direct query cache:
* Keys: postKeys.detail(id) via fetchPostWithSpotsAndSolutions
* Cross-boundary: useAdoptSolution/useUnadoptSolution explicitly
* invalidate ["posts", "detail"] — this is intentional.
*
* Server-side fetches (fetchPostsServer):
* No React Query cache — uses Next.js revalidate header only.
*/
Expand Down Expand Up @@ -82,39 +75,11 @@ export function useSolutions(
// useAllSolutionsForSpots - Fetch solutions for multiple spots
// ============================================================

/** Solutions grouped by spot ID */
/** Fetch solutions for a spot via Supabase (no Rust backend needed) */
async function fetchSolutionsFromSupabase(spotId: string): Promise<GeneratedSolutionListItem[]> {
const { data, error } = await supabaseBrowserClient
.from("solutions")
.select("*, profiles:user_id(id, username, avatar_url)")
.eq("spot_id", spotId)
.order("created_at", { ascending: false });

if (error) throw new Error(error.message);
return (data ?? []).map((row: any) => ({
id: row.id,
title: row.title ?? "",
thumbnail_url: row.thumbnail_url,
original_url: row.original_url,
affiliate_url: row.affiliate_url,
link_type: row.link_type,
metadata: row.metadata,
brand_id: row.brand_id,
match_type: row.match_type,
is_verified: row.is_verified ?? false,
is_adopted: row.is_adopted ?? false,
created_at: row.created_at,
vote_stats: { accurate: 0, different: 0 },
user: { id: row.user_id, username: "", email: "", rank: "Member", total_points: 0, followers_count: 0, following_count: 0, is_admin: false },
} as GeneratedSolutionListItem));
}

export function useAllSolutionsForSpots(spotIds: string[]) {
const results = useQueries({
queries: spotIds.map((spotId) => ({
queryKey: solutionKeys.list(spotId),
queryFn: () => fetchSolutionsFromSupabase(spotId),
queryFn: () => listSolutions(spotId),
enabled: !!spotId,
staleTime: 1000 * 60,
})),
Expand Down
Loading
Loading