Skip to content
Open
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
276 changes: 39 additions & 237 deletions packages/web/app/[locale]/(shell)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,20 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { HomeShell } from "@/lib/components/home/HomeShell";
import { HomeDiscovery } from "@/lib/components/home/HomeDiscovery";
import { DiscoverySkeleton } from "@/lib/components/home/DiscoverySkeleton";
import type {
HomeData,
HomeLookCard,
HeroDecodeBandData,
MoodRail,
CuratedRail,
HeroSlide,
} from "@/lib/components/home/types";
import type { PaginatedResponsePostListItem } from "@/lib/api/generated/models";
import type { PaginatedResponsePostListItemDataItem } from "@/lib/api/generated/models";
import type { WarehouseProfilesResponse } from "@/lib/api/generated/models";
import type {
OnboardingPoolResponse,
OnboardingPoolItem,
} from "@/lib/api/generated/models";
import type {
CurationListResponse,
FeedItem,
PaginatedResponsePostListItem,
StyleSampleItem,
} from "@/lib/api/generated/models";
import { setRequestLocale } from "next-intl/server";
import { serverApiGet } from "@/lib/api/server-instance";
import { isLocale, type Locale } from "@/lib/i18n/locale";
import { getPostMatch } from "@/lib/api/adapters/postMatch";
import { colorNameToHex } from "@/lib/components/home/styleColors";
import type { HeroSlide } from "@/lib/components/home/types";
import type { StyleSampleItem } from "@/lib/api/generated/models";
import { buildLocaleAlternates } from "@/lib/seo/locale-alternates";

// 이전엔 이 페이지에 metadata export가 전혀 없어서 root layout의 canonical(항상
Expand All @@ -44,116 +34,9 @@ export async function generateMetadata({
};
}

/** "Browse by mood" 표시 순서 + 라벨 — onboarding pool 의 mood_key 기준. 데이터에
* 없는 무드는 렌더 안 됨(정직). 라벨은 홈의 다른 하드코딩 영어 섹션명과 일관되게
* 매력적인 표현으로(예: retro_vintage → "Y2K & Retro"). */
const MOOD_RAILS: { key: string; title: string }[] = [
{ key: "street", title: "Street" },
{ key: "minimal", title: "Minimal" },
{ key: "casual_daily", title: "Casual" },
{ key: "chic_edgy", title: "Chic & Edgy" },
{ key: "retro_vintage", title: "Y2K & Retro" },
{ key: "preppy", title: "Preppy" },
{ key: "sporty", title: "Sporty" },
{ key: "classic_elegant", title: "Classic Elegant" },
{ key: "lovely_feminine", title: "Lovely Feminine" },
];

/** Shorthand for post list item from REST API */
type ApiPost = PaginatedResponsePostListItemDataItem & {
style_tags?: unknown;
};

interface ArtistProfileEntry {
name: string;
profileImageUrl: string | null;
}

const proxyImg = (url: string) =>
`/api/v1/image-proxy?url=${encodeURIComponent(url)}`;

/** REST API fetch — no direct DB fallback */
async function fetchPosts(params: string, locale?: Locale): Promise<ApiPost[]> {
try {
const res = await serverApiGet<PaginatedResponsePostListItem>(
`/api/v1/posts?${params}`,
{ locale }
);
return res.data ?? [];
} catch {
return [];
}
}

/** Build artist/group name → profile lookup from Rust API warehouse/profiles */
async function fetchArtistProfileMap(
locale?: Locale
): Promise<Map<string, ArtistProfileEntry>> {
const map = new Map<string, ArtistProfileEntry>();
try {
const res = await serverApiGet<WarehouseProfilesResponse>(
"/api/v1/warehouse/profiles",
{ locale }
);
const addEntity = (
name_ko: string | null | undefined,
name_en: string | null | undefined,
profile_image_url: string | null | undefined
) => {
const displayName = name_en || name_ko || "";
if (!displayName) return;
const entry: ArtistProfileEntry = {
name: displayName,
profileImageUrl: profile_image_url ?? null,
};
if (name_ko) map.set(name_ko.toLowerCase(), entry);
if (name_en) map.set(name_en.toLowerCase(), entry);
};
for (const a of res.artists ?? [])
addEntity(a.name_ko, a.name_en, a.profile_image_url);
for (const g of res.groups ?? [])
addEntity(g.name_ko, g.name_en, g.profile_image_url);
} catch {
/* warehouse profiles unavailable — use raw names */
}
return map;
}

/** 무드별 룩 풀 — Style DNA 온보딩 picker 와 같은 public 풀(무드별 균형 버킷).
* 로그아웃 Home 의 "Browse by mood" 밀도 소스(무드당 ~10룩, 정직하게 채워짐). */
async function fetchMoodPool(
limit = 120,
locale?: Locale
): Promise<OnboardingPoolItem[]> {
try {
const res = await serverApiGet<OnboardingPoolResponse>(
`/api/v1/onboarding/pool?limit=${limit}`,
{ locale }
);
return res.items ?? [];
} catch {
return [];
}
}

/** decoded 큐레이션 에이전트(#791) — 주 1회 배치가 `curations`/`curation_posts`
* 를 채운다(Fully Decoded/Weekly Edit/Seasonal Edit). 하드코딩 필터/카피 없음 —
* 여기서는 배치가 이미 쓴 것을 그대로 읽어 렌더한다. 룩 0인 큐레이션은 배치가
* 아예 안 쓰거나(표본 부족 self-hide) posts 가 빈 배열이라 자연히 제외된다. */
async function fetchCurations(
locale?: Locale
): Promise<CurationListResponse["data"]> {
try {
const res = await serverApiGet<CurationListResponse>(
"/api/v1/feed/curations?include_posts=true",
{ locale }
);
return res.data ?? [];
} catch {
return [];
}
}

/**
* Hero carousel slides with a real per-post style read (mood/color), via the
* public `style-samples` endpoint (derived server-side from cody_analysis). No
Expand Down Expand Up @@ -187,6 +70,22 @@ async function fetchHeroSlides(
}
}

/** style-samples 가 비면 hero 가 스캔-온리 단일 슬라이드로라도 뜨도록 인기 포스트
* 이미지 1장만 가져온다(정상 케이스에선 style-samples 가 채워져 호출되지 않음).
* discovery 의 popularPosts 는 이제 HomeDiscovery(스트리밍) 소관이라 hero fallback
* 은 여기서 독립적으로, 필요할 때만 조회한다. */
async function fetchHeroFallbackImage(locale?: Locale): Promise<string | null> {
try {
const res = await serverApiGet<PaginatedResponsePostListItem>(
"/api/v1/posts?sort=popular&per_page=1",
{ locale }
);
return res.data?.[0]?.image_url ?? null;
} catch {
return null;
}
}

export default async function Home({
params,
}: {
Expand All @@ -202,123 +101,26 @@ export default async function Home({
? rawLocale
: undefined;

const [
popularPosts,
recentPosts,
moodPool,
artistProfileMap,
curations,
slides,
] = await Promise.all([
fetchPosts("sort=popular&per_page=30", locale),
fetchPosts("sort=recent&per_page=24", locale),
fetchMoodPool(120, locale),
fetchArtistProfileMap(locale),
fetchCurations(locale),
// Hero decode carousel: posts with a real style read (mood/color), via
// the public style-samples endpoint (no direct DB access — #661). Fetched
// alongside the rest so the above-the-fold hero doesn't wait on a
// separate round-trip after the other 5 calls resolve.
fetchHeroSlides(6, locale),
]);

function enrichArtistName(name: string | null | undefined): string {
if (!name) return "";
return artistProfileMap.get(name.toLowerCase())?.name ?? name;
}

function toLookCard(post: ApiPost): HomeLookCard {
const artistName = enrichArtistName(post.artist_name || post.group_name);
return {
id: post.id,
imageUrl: proxyImg(post.image_url),
title: post.title || artistName || "Untitled look",
href: `/posts/${post.id}`,
artistName: artistName || undefined,
// List model carries no detected-item count yet — omit (no fake counts).
match: getPostMatch(post), // null until #646 lands
};
}

// Fallback when no style reads exist yet — single scan-only slide.
// above-the-fold hero(style-samples)만 await 한다 → 아래 디스커버리(popular/
// recent/mood/warehouse/curations 등 느린 fetch)를 기다리지 않고 즉시 페인트.
// 디스커버리는 <Suspense> 안의 HomeDiscovery 로 독립 스트리밍된다(#848 AC4).
const slides = await fetchHeroSlides(6, locale);
if (slides.length === 0) {
const fallback = popularPosts[0];
if (fallback) {
slides.push({ imageUrl: proxyImg(fallback.image_url), styleRead: null });
const fallbackImg = await fetchHeroFallbackImage(locale);
if (fallbackImg) {
slides.push({ imageUrl: proxyImg(fallbackImg), styleRead: null });
}
}

const hero: HeroDecodeBandData = { slides };

// 로드 불가능한 이미지(로컬 시드의 mock 상대경로 등) 카드는 거른다 — 깨진
// placeholder 대신 미노출(정직). prod 이미지는 전부 절대 URL 이라 무해.
const isRenderable = (p: { image_url: string }) =>
typeof p.image_url === "string" && p.image_url.startsWith("http");

function feedItemToLookCard(item: FeedItem): HomeLookCard {
const artistName = enrichArtistName(item.artist_name || item.group_name);
return {
id: item.id,
imageUrl: proxyImg(item.image_url),
title: artistName || "Look",
href: `/posts/${item.id}`,
artistName: artistName || undefined,
match: getPostMatch(item),
};
}

// Trending(인기) + Fresh(최신) — 작은 코퍼스에서 두 레일이 같은 룩으로 겹치지
// 않게 Fresh 는 Trending 에 이미 뜬 id 를 제외한다(중복 나열 방지).
const trending = popularPosts
.filter(isRenderable)
.slice(0, 12)
.map(toLookCard);
const trendingIds = new Set(trending.map((c) => c.id));
const fresh = recentPosts
.filter(isRenderable)
.filter((p) => !trendingIds.has(p.id))
.slice(0, 12)
.map(toLookCard);

// Browse by mood — onboarding pool 을 mood_key 로 버킷팅. 카드는 이미지-온리라
// post_id/image_url 만 있으면 충분. 데이터 있는 무드만(MOOD_RAILS 순서 유지).
const poolToCard = (item: OnboardingPoolItem): HomeLookCard => ({
id: item.post_id,
imageUrl: proxyImg(item.image_url),
title: item.title || "Look",
href: `/posts/${item.post_id}`,
});
const byMood = new Map<string, HomeLookCard[]>();
for (const item of moodPool) {
if (!item.image_url?.startsWith("http")) continue;
const arr = byMood.get(item.mood_key) ?? [];
arr.push(poolToCard(item));
byMood.set(item.mood_key, arr);
}
const moodRails: MoodRail[] = MOOD_RAILS.filter(
(m) => (byMood.get(m.key)?.length ?? 0) > 0
).map((m) => ({
moodKey: m.key,
title: m.title,
looks: byMood.get(m.key)!,
}));

// decoded 큐레이션 레일(#791) — 배치가 curations/curation_posts 에 이미 써둔
// 걸 그대로 카드로 변환. 룩 0인 레일은 미포함(정직 self-hide, 표본 부족 시
// 배치가 애초에 안 쓰거나 posts 가 빈 배열).
const curated: CuratedRail[] = curations
.map((c) => ({
key: c.slug ?? c.id,
title: c.title,
description: c.description ?? "",
looks: (c.posts ?? [])
.filter(isRenderable)
.slice(0, 12)
.map(feedItemToLookCard),
}))
.filter((rail) => rail.looks.length > 0);

const home: HomeData = { hero, trending, fresh, moodRails, curated };

return <HomeShell home={home} />;
return (
<HomeShell
hero={hero}
discoverySlot={
<Suspense fallback={<DiscoverySkeleton />}>
<HomeDiscovery locale={locale} />
</Suspense>
}
/>
);
}
74 changes: 74 additions & 0 deletions packages/web/lib/components/home/DiscoveryBrowse.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { SectionCarousel } from "./SectionCarousel";
import { FeaturedCuratedGrid } from "./FeaturedCuratedGrid";
import { WeeklyEditSection } from "./WeeklyEditSection";
import { RecommendedLookCard } from "./LookCards";
import { BrowseByMoodSection } from "./BrowseByMoodSection";
import { TrendingRail } from "./TrendingRail";
import type { HomeLookCard, MoodRail, CuratedRail } from "./types";

interface DiscoveryBrowseProps {
/** 글로벌 트렌딩 seed(sort=popular) — TrendingRail 이 로그인 유저에겐 DNA 블렌드로
* 스왑, 게스트에겐 이 값을 그대로 노출한다. */
trending: HomeLookCard[];
fresh: HomeLookCard[];
moodRails: MoodRail[];
curated: CuratedRail[];
}

/**
* 로그아웃/무신호 Home 의 디스커버리 밀도(#786 후속, raf: "돈키호테식으로 풍부하게").
* 섹션마다 배치(캐러셀 vs 그리드, 균등 vs 피처드)를 다르게 줘 리듬을 만든다(raf:
* "너무 다 같은 레이아웃으로 되어있어서 단조롭게 느껴진다"): Trending Style(캐러셀)
* → Complete Looks(Look Set 완성도, 피처드 그리드) → Fresh Decodes(캐러셀) → Weekly
* Edit(#791 큐레이션 에이전트가 자율 선정, 1~2 레일 스플릿/피처드) → Seasonal
* Edit(캐러셀) → Browse by mood(칩 필터+그리드). 전부 public 데이터 + 정직한
* self-hide(빈 레일 미렌더).
*
* server 컴포넌트 — 클라이언트 leaf(TrendingRail/SectionCarousel 등)를 조합만 한다.
* HomeDiscovery 가 Suspense 안에서 이 트리를 스트리밍한다(#848 AC4).
*/
export function DiscoveryBrowse({
trending,
fresh,
moodRails,
curated,
}: DiscoveryBrowseProps) {
const completeLooks = curated.find((r) => r.key === "complete-looks");
const weeklyEditRails = curated.filter((r) =>
r.key.startsWith("weekly-edit-")
);
const seasonal = curated.find((r) => r.key === "seasonal-edit");

return (
<>
{/* Trending — 로그인 유저는 /feed/trending(DNA 블렌드)으로 스왑되고, 게스트는
* server seed(글로벌 popular)를 본다. 빈/무신호 처리는 TrendingRail 내부. */}
<TrendingRail initialLooks={trending} />

{completeLooks && <FeaturedCuratedGrid rail={completeLooks} />}

{fresh.length > 0 && (
<SectionCarousel title="Fresh Decodes" href="/explore">
{fresh.map((card) => (
<RecommendedLookCard key={card.id} card={card} />
))}
</SectionCarousel>
)}

<WeeklyEditSection rails={weeklyEditRails} />

{seasonal && (
<SectionCarousel
title={seasonal.title}
description={seasonal.description}
>
{seasonal.looks.map((card) => (
<RecommendedLookCard key={card.id} card={card} />
))}
</SectionCarousel>
)}

{moodRails.length > 0 && <BrowseByMoodSection moodRails={moodRails} />}
</>
);
}
Loading
Loading