From 67f3b20255f3eb1f2b99886f7a7bbbbe9a0b44b4 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:21:48 +0900 Subject: [PATCH 01/15] fix(web): add brand assets and fix lang attribute (#82, #83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SVG favicon with decoded "D" logo (brand colors) - Add PWA manifest.ts with app metadata - Fix html lang attribute: en → ko (consistent with global-error.tsx) - Add favicon metadata to layout.tsx Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/web/app/icon.svg | 4 ++++ packages/web/app/layout.tsx | 6 +++++- packages/web/app/manifest.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 packages/web/app/icon.svg create mode 100644 packages/web/app/manifest.ts diff --git a/packages/web/app/icon.svg b/packages/web/app/icon.svg new file mode 100644 index 00000000..b051a6f9 --- /dev/null +++ b/packages/web/app/icon.svg @@ -0,0 +1,4 @@ + + + D + diff --git a/packages/web/app/layout.tsx b/packages/web/app/layout.tsx index 9178faae..eb5339d2 100644 --- a/packages/web/app/layout.tsx +++ b/packages/web/app/layout.tsx @@ -55,6 +55,10 @@ export const metadata: Metadata = { card: "summary_large_image", images: [`${SITE_URL}/api/og`], }, + icons: { + icon: "/icon.svg", + apple: "/icon.svg", + }, robots: { index: true, follow: true, @@ -69,7 +73,7 @@ export default function RootLayout({ modal: React.ReactNode; }) { return ( - + diff --git a/packages/web/app/manifest.ts b/packages/web/app/manifest.ts new file mode 100644 index 00000000..e1d2466b --- /dev/null +++ b/packages/web/app/manifest.ts @@ -0,0 +1,27 @@ +import type { MetadataRoute } from "next"; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "decoded", + short_name: "decoded", + description: "Discover and decode fashion items from any image", + theme_color: "#050505", + background_color: "#050505", + display: "standalone", + start_url: "/", + icons: [ + { + src: "/icon.svg", + sizes: "any", + type: "image/svg+xml", + purpose: "maskable", + }, + { + src: "/icon.svg", + sizes: "any", + type: "image/svg+xml", + purpose: "any", + }, + ], + }; +} From 7b0780e694440bc8d4dcdc9c51710aa1daebe29f Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:22:14 +0900 Subject: [PATCH 02/15] feat(web): enhance profile with real data integration (#46) - PublicProfileClient: replace placeholder sections with actual activity tabs (PostsGrid, SpotsList, SolutionsList) using Supabase queries - ProfileClient: switch from API-based ActivityItemCard to Supabase-based grid components (works without backend API server) - ActivityTabs: add configurable tabs prop with PUBLIC_TABS preset - Profile OG metadata: add avatar image, username, Korean description - Mobile header: show user display name instead of generic "Profile" Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/web/app/profile/ProfileClient.tsx | 78 ++------- .../profile/[userId]/PublicProfileClient.tsx | 149 +++++++----------- packages/web/app/profile/[userId]/page.tsx | 16 +- .../lib/components/profile/ActivityTabs.tsx | 14 +- packages/web/lib/components/profile/index.ts | 2 +- 5 files changed, 98 insertions(+), 161 deletions(-) diff --git a/packages/web/app/profile/ProfileClient.tsx b/packages/web/app/profile/ProfileClient.tsx index 1e4b6dd7..e174a6ee 100644 --- a/packages/web/app/profile/ProfileClient.tsx +++ b/packages/web/app/profile/ProfileClient.tsx @@ -16,11 +16,12 @@ import { ProfileDesktopLayout, ActivityTabs, ActivityContent, - EmptyState, - ActivityItemCard, type ActivityTab, ProfileBio, FollowStats, + PostsGrid, + SpotsList, + SolutionsList, SavedGrid, TriesGrid, StyleDNACard, @@ -32,7 +33,6 @@ import { useUserStats, useMyBadges, useMyRanking, - useUserActivities, useProfileExtras, useTryOnCount, } from "@/lib/hooks/useProfile"; @@ -200,30 +200,6 @@ export function ProfileClient() { const { data: profileExtras } = useProfileExtras(userId); const { data: tryOnCount } = useTryOnCount(userId); - // Activities from API (saved 탭은 미구현) - 반드시 early return 전에 호출 - const activitiesTypeMap: Record< - ActivityTab, - "post" | "spot" | "solution" | undefined - > = { - posts: "post", - spots: "spot", - solutions: "solution", - tries: undefined, - saved: undefined, - }; - const activitiesType = activitiesTypeMap[activeTab]; - const { - data: activitiesData, - isLoading: isActivitiesLoading, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - } = useUserActivities({ - type: activitiesType ?? undefined, - perPage: 20, - enabled: activeTab !== "saved" && activeTab !== "tries", - }); - // Sync API data to store const setUserFromApi = useProfileStore((state) => state.setUserFromApi); const setStatsFromApi = useProfileStore((state) => state.setStatsFromApi); @@ -295,43 +271,21 @@ export function ProfileClient() { return ; } - const activityItems = activitiesData?.pages.flatMap((p) => p.data) ?? []; - const hasActivityContent = activityItems.length > 0; - const renderTabContent = () => { - if (activeTab === "tries") { - return ; - } - if (activeTab === "saved") { - return ; - } - if (isActivitiesLoading && !activitiesData) { - return ( -
-
-
- ); - } - if (!hasActivityContent) { - return ; + switch (activeTab) { + case "posts": + return ; + case "spots": + return ; + case "solutions": + return ; + case "tries": + return ; + case "saved": + return ; + default: + return null; } - - return ( -
- {activityItems.map((item) => ( - - ))} - {hasNextPage && ( - - )} -
- ); }; return ( diff --git a/packages/web/app/profile/[userId]/PublicProfileClient.tsx b/packages/web/app/profile/[userId]/PublicProfileClient.tsx index 60946bb6..20d4231f 100644 --- a/packages/web/app/profile/[userId]/PublicProfileClient.tsx +++ b/packages/web/app/profile/[userId]/PublicProfileClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect } from "react"; +import { useState, useEffect } from "react"; import { ArrowLeft, RefreshCw, Share2 } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -12,26 +12,27 @@ import { FollowStats, StyleDNACard, ProfileDesktopLayout, + ActivityTabs, + ActivityContent, + PUBLIC_TABS, + PostsGrid, + SpotsList, + SolutionsList, + type ActivityTab, } from "@/lib/components/profile"; import { useMe, useUser, useProfileExtras } from "@/lib/hooks/useProfile"; -// Local skeleton — simpler than ProfileSkeleton since fewer sections function PublicProfileSkeleton() { return (
- {/* Mobile Header Skeleton */}
- - {/* Desktop Header */}
- - {/* Mobile Layout Skeleton */}
@@ -58,11 +59,9 @@ function PublicProfileSkeleton() { ); } -// Inline not-found UI for 404 case function UserNotFound() { return (
- {/* Mobile Header */}
@@ -70,16 +69,12 @@ function UserNotFound() {

Profile

- - {/* Desktop Header */}
- - {/* Not Found Content */}
-
👤
+
👤

이 유저를 찾을 수 없습니다

@@ -98,7 +93,6 @@ function UserNotFound() { ); } -// Inline general error UI with retry function PublicProfileError({ error, onRetry, @@ -108,7 +102,6 @@ function PublicProfileError({ }) { return (
- {/* Mobile Header */}
@@ -116,16 +109,12 @@ function PublicProfileError({

Profile

- - {/* Desktop Header */}
- - {/* Error Content */}
-
⚠️
+
⚠️

프로필을 불러올 수 없습니다

@@ -147,6 +136,7 @@ function PublicProfileError({ export function PublicProfileClient({ userId }: { userId: string }) { const router = useRouter(); + const [activeTab, setActiveTab] = useState("posts"); // Fetch the target user's public data const { @@ -170,27 +160,18 @@ export function PublicProfileClient({ userId }: { userId: string }) { } }, [me?.id, userId, router]); - // Loading state - if (isLoading) { - return ; - } + if (isLoading) return ; - // 404 — user does not exist if (isError && (error as AxiosError)?.response?.status === 404) { return ; } - // General error state if (isError && error) { return ; } - // Guard: should not happen after loading + no error, but satisfies TS - if (!userData) { - return ; - } + if (!userData) return ; - // Derived stats for ProfileHeaderCard const formattedStats = [ { label: "Points", value: userData.total_points }, { label: "Rank", value: userData.rank }, @@ -204,14 +185,29 @@ export function PublicProfileClient({ userId }: { userId: string }) { } }; + const renderTabContent = () => { + switch (activeTab) { + case "posts": + return ; + case "spots": + return ; + case "solutions": + return ; + default: + return null; + } + }; + return (
- {/* Mobile Header — no Settings gear, no logout; just back + title + share */} + {/* Mobile Header */}
-

Profile

+

+ {userData.display_name ?? userData.username} +

- {/* Mobile Layout — stacked, private sections omitted */} + {/* Mobile Layout */}
- {/* ProfileHeaderCard with explicit data — NOT ProfileHeader (avoids Zustand store) */} - {/* Inline stats grid — total_points and rank from UserResponse */} -
-
-

- {userData.total_points.toLocaleString()} -

-

Points

-
-
-

{userData.rank}

-

Rank

-
-
- - {/* BadgeGrid placeholder — BadgeGrid reads from profileStore, not suitable for public view */} -
-

No badges yet

-
- - {/* RankingList placeholder — no API for other users' rankings */} -
-

Not ranked yet

-
- + + {/* Activity Tabs — posts/spots/solutions only */} +
+ + + {renderTabContent()} + +
{/* Desktop Layout — 2-column */} @@ -288,19 +270,12 @@ export function PublicProfileClient({ userId }: { userId: string }) { stats={formattedStats} /> - {/* Inline stats grid */} -
-
-

- {userData.total_points.toLocaleString()} -

-

Points

-
-
-

{userData.rank}

-

Rank

-
-
+ + + } activitySection={ - <> - - - + - - {/* BadgeGrid placeholder */} -
-

No badges yet

-
- - {/* RankingList placeholder */} -
-

Not ranked yet

-
- + + {renderTabContent()} + +
} />
diff --git a/packages/web/app/profile/[userId]/page.tsx b/packages/web/app/profile/[userId]/page.tsx index 1ca78a7b..b0c70faa 100644 --- a/packages/web/app/profile/[userId]/page.tsx +++ b/packages/web/app/profile/[userId]/page.tsx @@ -14,20 +14,26 @@ export async function generateMetadata({ params }: Props): Promise { const { data: user } = await supabase .from("users") - .select("display_name, bio") + .select("display_name, bio, avatar_url, username") .eq("id", userId) .single(); - const displayName = user?.display_name || "User"; - const title = `${displayName}'s Profile`; + const displayName = user?.display_name || user?.username || "User"; + const title = `${displayName} | DECODED`; const description = - user?.bio || `View ${displayName}'s style collection on Decoded.`; + user?.bio || `${displayName}의 스타일 컬렉션을 확인하세요.`; return { title, description, alternates: { canonical: `${SITE_URL}/profile/${userId}` }, - openGraph: { title, description }, + openGraph: { + title, + description, + ...(user?.avatar_url && { + images: [{ url: user.avatar_url, width: 200, height: 200 }], + }), + }, robots: { index: true, follow: true }, }; } diff --git a/packages/web/lib/components/profile/ActivityTabs.tsx b/packages/web/lib/components/profile/ActivityTabs.tsx index 7a4c74ab..47f894d3 100644 --- a/packages/web/lib/components/profile/ActivityTabs.tsx +++ b/packages/web/lib/components/profile/ActivityTabs.tsx @@ -7,10 +7,12 @@ export type ActivityTab = "posts" | "spots" | "solutions" | "tries" | "saved"; interface ActivityTabsProps { activeTab: ActivityTab; onTabChange: (tab: ActivityTab) => void; + /** Override which tabs to show (default: all) */ + tabs?: { id: ActivityTab; label: string }[]; className?: string; } -const TABS: { id: ActivityTab; label: string }[] = [ +const ALL_TABS: { id: ActivityTab; label: string }[] = [ { id: "posts", label: "Posts" }, { id: "spots", label: "Spots" }, { id: "solutions", label: "Solutions" }, @@ -18,15 +20,23 @@ const TABS: { id: ActivityTab; label: string }[] = [ { id: "saved", label: "Saved" }, ]; +/** Public profile shows only posts/spots/solutions */ +export const PUBLIC_TABS: { id: ActivityTab; label: string }[] = [ + { id: "posts", label: "Posts" }, + { id: "spots", label: "Spots" }, + { id: "solutions", label: "Solutions" }, +]; + export function ActivityTabs({ activeTab, onTabChange, + tabs, className, }: ActivityTabsProps) { return (
) : ( Date: Thu, 9 Apr 2026 18:58:36 +0900 Subject: [PATCH 06/15] fix(web): eliminate upload modal flash on open - Add bg-black/60 to container for instant backdrop - Use fromTo with immediateRender to prevent opacity:0 flash - Faster, smoother animation (0.2s backdrop, 0.25s modal) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/request/RequestFlowModal.tsx | 32 +++++-------------- 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/packages/web/lib/components/request/RequestFlowModal.tsx b/packages/web/lib/components/request/RequestFlowModal.tsx index 4970c967..afda0cf3 100644 --- a/packages/web/lib/components/request/RequestFlowModal.tsx +++ b/packages/web/lib/components/request/RequestFlowModal.tsx @@ -73,34 +73,18 @@ export function RequestFlowModal({ children }: RequestFlowModalProps) { document.body.style.overflow = "hidden"; document.documentElement.style.overflow = "hidden"; - // Initialize GSAP context + // Initialize GSAP context — start visible to avoid flash ctxRef.current = gsap.context(() => { - // Initial States - gsap.set(backdropRef.current, { opacity: 0 }); - gsap.set(modalRef.current, { opacity: 0, scale: 0.95 }); - - // Animate in - const tl = gsap.timeline(); - - tl.to( + gsap.fromTo( backdropRef.current, - { - opacity: 1, - duration: 0.3, - ease: "power2.out", - }, - 0 + { opacity: 0 }, + { opacity: 1, duration: 0.2, ease: "power2.out", immediateRender: true } ); - tl.to( + gsap.fromTo( modalRef.current, - { - opacity: 1, - scale: 1, - duration: 0.3, - ease: "power2.out", - }, - 0.1 + { opacity: 0, scale: 0.97, y: 8 }, + { opacity: 1, scale: 1, y: 0, duration: 0.25, ease: "power2.out", delay: 0.05, immediateRender: true } ); }, containerRef); @@ -125,7 +109,7 @@ export function RequestFlowModal({ children }: RequestFlowModalProps) { return (
From ac21a4d8656662010ae9ecfdaf6f1acbe0c2bc0c Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:03:54 +0900 Subject: [PATCH 07/15] fix(web): use state-based modal for Upload nav to prevent hero re-render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upload button now opens RequestModal via state instead of navigating to /request/upload (intercepting route). This prevents the hero section from re-rendering and flickering when the modal opens. - Desktop nav Upload: button → RequestModal (no URL change) - Profile dropdown Upload: same modal approach - Direct URL /request/upload still works as full page Co-Authored-By: Claude Opus 4.6 (1M context) --- .../lib/components/main-renewal/SmartNav.tsx | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/web/lib/components/main-renewal/SmartNav.tsx b/packages/web/lib/components/main-renewal/SmartNav.tsx index 963056bd..31396d8a 100644 --- a/packages/web/lib/components/main-renewal/SmartNav.tsx +++ b/packages/web/lib/components/main-renewal/SmartNav.tsx @@ -14,6 +14,7 @@ import { selectProfile, selectLogout, } from "@/lib/stores/authStore"; +import { RequestModal } from "@/lib/components/request/RequestModal"; const DecodedLogo = dynamic(() => import("@/lib/components/DecodedLogo"), { ssr: false, @@ -23,7 +24,6 @@ const DecodedLogo = dynamic(() => import("@/lib/components/DecodedLogo"), { const NAV_ITEMS = [ { href: "/", label: "Home" }, { href: "/explore", label: "Explore" }, - { href: "/request/upload", label: "Upload" }, ] as const; interface SmartNavProps { @@ -51,6 +51,7 @@ export function SmartNav({ className }: SmartNavProps) { const profile = useAuthStore(selectProfile); const logout = useAuthStore(selectLogout); const [isProfileOpen, setIsProfileOpen] = useState(false); + const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); const profileRef = useRef(null); // Close dropdown on outside click @@ -122,6 +123,7 @@ export function SmartNav({ className }: SmartNavProps) { : "bg-[#050505] border-b border-white/5"; return ( + <>
setIsUploadModalOpen(true)} + className={[ + "text-xs tracking-[0.2em] uppercase transition-colors", + isUploadModalOpen ? "text-white" : "text-white/60 hover:text-white", + ].join(" ")} + > + Upload + + {/* Auth: Login / Profile Dropdown */} {isLoggedIn ? (
@@ -211,13 +224,15 @@ export function SmartNav({ className }: SmartNavProps) { > 프로필 보기 - setIsProfileOpen(false)} +
+ + {/* Upload Modal — rendered outside header to avoid z-index issues */} + setIsUploadModalOpen(false)} + /> + ); } From f14ad8930be51bab0b0ddafdc6723a6b999e8a89 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:08:10 +0900 Subject: [PATCH 08/15] fix(shared): enable PKCE auth flow to fix OAuth callback error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supabase defaulted to implicit grant (hash-based tokens) but the callback route expects a code parameter for exchangeCodeForSession. Setting flowType: 'pkce' ensures the OAuth redirect sends a code instead of hash tokens, fixing "인증 코드가 없습니다" error. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/shared/supabase/client.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/shared/supabase/client.ts b/packages/shared/supabase/client.ts index 3aac4a8c..7c9bfbe0 100644 --- a/packages/shared/supabase/client.ts +++ b/packages/shared/supabase/client.ts @@ -12,7 +12,11 @@ export function initSupabase( anonKey: string ): SupabaseClient { if (!supabaseClient) { - supabaseClient = createClient(url, anonKey); + supabaseClient = createClient(url, anonKey, { + auth: { + flowType: "pkce", + }, + }); } return supabaseClient; } From 060708d5357ade2fc5ebb3e6e5eceecfcbfed17d Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:10:36 +0900 Subject: [PATCH 09/15] fix(auth): switch OAuth to implicit flow with client-side session detection The server callback route (/api/auth/callback) requires PKCE code exchange but @supabase/auth-helpers-nextjs v0.15 doesn't handle code verifier cookies. Fix: redirect OAuth back to the client page instead of server callback. Supabase JS client with detectSessionInUrl: true auto-detects the hash tokens and sets the session via onAuthStateChange. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/shared/supabase/client.ts | 2 +- packages/web/lib/stores/authStore.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/supabase/client.ts b/packages/shared/supabase/client.ts index 7c9bfbe0..8491225a 100644 --- a/packages/shared/supabase/client.ts +++ b/packages/shared/supabase/client.ts @@ -14,7 +14,7 @@ export function initSupabase( if (!supabaseClient) { supabaseClient = createClient(url, anonKey, { auth: { - flowType: "pkce", + detectSessionInUrl: true, }, }); } diff --git a/packages/web/lib/stores/authStore.ts b/packages/web/lib/stores/authStore.ts index a6f30cce..9d49b15f 100644 --- a/packages/web/lib/stores/authStore.ts +++ b/packages/web/lib/stores/authStore.ts @@ -146,7 +146,7 @@ export const useAuthStore = create((set, get) => ({ const { error } = await supabaseBrowserClient.auth.signInWithOAuth({ provider, options: { - redirectTo: `${window.location.origin}/api/auth/callback?next=${encodeURIComponent(sessionStorage.getItem("post_login_redirect") || "/")}`, + redirectTo: `${window.location.origin}/${sessionStorage.getItem("post_login_redirect")?.replace(/^\//, "") || ""}`, }, }); From f035e0d1c79079f8199b4cd4dabe30bb00300b58 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:47:34 +0900 Subject: [PATCH 10/15] =?UTF-8?q?feat(profile):=20complete=20#46=20?= =?UTF-8?q?=E2=80=94=20follow=20API,=20avatar=20upload,=20Style=20DNA,=20r?= =?UTF-8?q?ankings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (Rust): - Fix get_user_stats: real COUNT queries for posts/comments/likes - Fix list_user_activities: UNION ALL across posts/spots/solutions - Add follow/unfollow/follow-status endpoints - Add FollowStatusResponse DTO Frontend (Next.js): - Add follow proxy routes and hooks (useFollowUser/useUnfollowUser/useFollowStatus) - Add FollowButton component with Follow/Following/Unfollow states - Integrate FollowButton into PublicProfileClient - Avatar upload via Supabase Storage (replace URL text input) - StyleDNAEditModal with keyword tags + color picker - Profile OG metadata: twitter card, JSON-LD Person schema - Rankings page with period filter (weekly/monthly/all_time) - Rankings proxy API routes Closes #46 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/api-server/src/domains/users/dto.rs | 7 + .../api-server/src/domains/users/handlers.rs | 77 +++++- .../api-server/src/domains/users/service.rs | 259 +++++++++++++++++- packages/web/app/api/v1/rankings/me/route.ts | 25 +- packages/web/app/api/v1/rankings/route.ts | 50 ++-- .../v1/users/[userId]/follow-status/route.ts | 48 ++++ .../app/api/v1/users/[userId]/follow/route.ts | 94 +++++++ packages/web/app/profile/ProfileClient.tsx | 13 + .../profile/[userId]/PublicProfileClient.tsx | 5 + packages/web/app/profile/[userId]/page.tsx | 39 ++- packages/web/app/rankings/RankingsClient.tsx | 182 ++++++++++++ packages/web/app/rankings/page.tsx | 11 + .../lib/components/profile/FollowButton.tsx | 72 +++++ .../components/profile/ProfileEditModal.tsx | 88 ++++-- .../lib/components/profile/StyleDNACard.tsx | 20 +- .../components/profile/StyleDNAEditModal.tsx | 210 ++++++++++++++ packages/web/lib/components/profile/index.ts | 2 + packages/web/lib/hooks/useProfile.ts | 59 ++++ 18 files changed, 1173 insertions(+), 88 deletions(-) create mode 100644 packages/web/app/api/v1/users/[userId]/follow-status/route.ts create mode 100644 packages/web/app/api/v1/users/[userId]/follow/route.ts create mode 100644 packages/web/app/rankings/RankingsClient.tsx create mode 100644 packages/web/app/rankings/page.tsx create mode 100644 packages/web/lib/components/profile/FollowButton.tsx create mode 100644 packages/web/lib/components/profile/StyleDNAEditModal.tsx diff --git a/packages/api-server/src/domains/users/dto.rs b/packages/api-server/src/domains/users/dto.rs index c3cfb2c2..00c02d10 100644 --- a/packages/api-server/src/domains/users/dto.rs +++ b/packages/api-server/src/domains/users/dto.rs @@ -87,6 +87,13 @@ pub struct UpdateUserDto { pub bio: Option, } +/// 팔로우 상태 응답 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct FollowStatusResponse { + /// 팔로우 여부 + pub is_following: bool, +} + /// 사용자 통계 응답 #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UserStatsResponse { diff --git a/packages/api-server/src/domains/users/handlers.rs b/packages/api-server/src/domains/users/handlers.rs index 5a59dce5..b9c948ce 100644 --- a/packages/api-server/src/domains/users/handlers.rs +++ b/packages/api-server/src/domains/users/handlers.rs @@ -4,8 +4,9 @@ use axum::{ extract::{Path, Query, State}, + http::StatusCode, middleware::from_fn_with_state, - routing::get, + routing::{get, post}, Extension, Json, Router, }; use uuid::Uuid; @@ -19,8 +20,8 @@ use crate::{ use super::{ dto::{ - SavedItem, TryItem, UpdateUserDto, UserActivitiesQuery, UserActivityItem, UserActivityType, - UserResponse, UserStatsResponse, + FollowStatusResponse, SavedItem, TryItem, UpdateUserDto, UserActivitiesQuery, + UserActivityItem, UserActivityType, UserResponse, UserStatsResponse, }, service, }; @@ -199,6 +200,71 @@ pub async fn get_my_saved( Ok(Json(result)) } +/// POST /api/v1/users/{user_id}/follow - 팔로우 +#[utoipa::path( + post, + path = "/api/v1/users/{user_id}/follow", + tag = "users", + security(("bearer_auth" = [])), + params(("user_id" = Uuid, Path, description = "팔로우할 사용자 ID")), + responses( + (status = 204, description = "팔로우 성공"), + (status = 400, description = "자기 자신을 팔로우할 수 없음"), + (status = 401, description = "인증 필요"), + (status = 404, description = "사용자를 찾을 수 없음") + ) +)] +pub async fn follow_user_handler( + State(state): State, + Extension(user): Extension, + Path(target_id): Path, +) -> AppResult { + service::follow_user(&state.db, user.id, target_id).await?; + Ok(StatusCode::NO_CONTENT) +} + +/// DELETE /api/v1/users/{user_id}/follow - 언팔로우 +#[utoipa::path( + delete, + path = "/api/v1/users/{user_id}/follow", + tag = "users", + security(("bearer_auth" = [])), + params(("user_id" = Uuid, Path, description = "언팔로우할 사용자 ID")), + responses( + (status = 204, description = "언팔로우 성공"), + (status = 401, description = "인증 필요") + ) +)] +pub async fn unfollow_user_handler( + State(state): State, + Extension(user): Extension, + Path(target_id): Path, +) -> AppResult { + service::unfollow_user(&state.db, user.id, target_id).await?; + Ok(StatusCode::NO_CONTENT) +} + +/// GET /api/v1/users/{user_id}/follow-status - 팔로우 여부 확인 +#[utoipa::path( + get, + path = "/api/v1/users/{user_id}/follow-status", + tag = "users", + security(("bearer_auth" = [])), + params(("user_id" = Uuid, Path, description = "대상 사용자 ID")), + responses( + (status = 200, description = "팔로우 상태 조회 성공", body = FollowStatusResponse), + (status = 401, description = "인증 필요") + ) +)] +pub async fn get_follow_status( + State(state): State, + Extension(user): Extension, + Path(target_id): Path, +) -> AppResult> { + let is_following = service::check_is_following(&state.db, user.id, target_id).await?; + Ok(Json(FollowStatusResponse { is_following })) +} + /// Users 도메인 라우터 pub fn router(app_config: AppConfig) -> Router { let protected_routes = Router::new() @@ -207,6 +273,11 @@ pub fn router(app_config: AppConfig) -> Router { .route("/me/stats", get(get_my_stats)) .route("/me/tries", get(get_my_tries)) .route("/me/saved", get(get_my_saved)) + .route( + "/{user_id}/follow", + post(follow_user_handler).delete(unfollow_user_handler), + ) + .route("/{user_id}/follow-status", get(get_follow_status)) .route_layer(from_fn_with_state(app_config, auth_middleware)); Router::new() diff --git a/packages/api-server/src/domains/users/service.rs b/packages/api-server/src/domains/users/service.rs index d033f610..15d9e407 100644 --- a/packages/api-server/src/domains/users/service.rs +++ b/packages/api-server/src/domains/users/service.rs @@ -14,8 +14,8 @@ use crate::{ }; use super::dto::{ - SavedItem, TryItem, UpdateUserDto, UserActivityItem, UserActivityType, UserResponse, - UserStatsResponse, + SavedItem, TryItem, UpdateUserDto, UserActivityItem, UserActivityPostMeta, + UserActivitySpotMeta, UserActivityType, UserResponse, UserStatsResponse, }; /// 사용자 ID로 프로필 조회 @@ -58,32 +58,208 @@ pub async fn update_user_profile( .map_err(AppError::DatabaseError) } +/// 사용자의 활성 포스트 수 +async fn count_user_posts(db: &DatabaseConnection, user_id: Uuid) -> AppResult { + let result = db + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT COUNT(*)::BIGINT AS cnt FROM public.posts WHERE user_id = $1 AND status = 'active'", + [user_id.into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + Ok(result + .map(|row| row.try_get::("", "cnt").unwrap_or(0)) + .unwrap_or(0)) +} + +/// 사용자의 댓글 수 +async fn count_user_comments(db: &DatabaseConnection, user_id: Uuid) -> AppResult { + let result = db + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT COUNT(*)::BIGINT AS cnt FROM public.comments WHERE user_id = $1", + [user_id.into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + Ok(result + .map(|row| row.try_get::("", "cnt").unwrap_or(0)) + .unwrap_or(0)) +} + +/// 사용자가 받은 좋아요 수 +async fn count_user_likes_received(db: &DatabaseConnection, user_id: Uuid) -> AppResult { + let result = db + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT COUNT(*)::BIGINT AS cnt FROM public.post_likes pl JOIN public.posts p ON pl.post_id = p.id WHERE p.user_id = $1", + [user_id.into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + Ok(result + .map(|row| row.try_get::("", "cnt").unwrap_or(0)) + .unwrap_or(0)) +} + /// 내 활동 통계 조회 pub async fn get_user_stats( db: &DatabaseConnection, user_id: Uuid, ) -> AppResult { let user = get_user_by_id(db, user_id).await?; + let total_posts = count_user_posts(db, user_id).await?; + let total_comments = count_user_comments(db, user_id).await?; + let total_likes_received = count_user_likes_received(db, user_id).await?; Ok(UserStatsResponse { user_id, - total_posts: 0, - total_comments: 0, - total_likes_received: 0, + total_posts, + total_comments, + total_likes_received, total_points: user.total_points, rank: user.rank, }) } -/// 활동 내역 조회 (향후 Post/Spot/Solution 데이터 연동 예정) +/// 활동 내역 조회 (posts, spots, solutions UNION) pub async fn list_user_activities( - _db: &DatabaseConnection, - _user_id: Uuid, - _activity_type: Option, + db: &DatabaseConnection, + user_id: Uuid, + activity_type: Option, pagination: Pagination, ) -> AppResult> { - // TODO(Phase 6+): 실제 Post/Spot/Solution 데이터와 조인 - Ok(PaginatedResponse::new(Vec::new(), pagination, 0)) + let per_page = pagination.per_page.min(100); + let offset = (pagination.page.max(1) - 1) * per_page; + + // Build type filter + let type_filter = match &activity_type { + Some(UserActivityType::Post) => "WHERE a.activity_type = 'post'", + Some(UserActivityType::Spot) => "WHERE a.activity_type = 'spot'", + Some(UserActivityType::Solution) => "WHERE a.activity_type = 'solution'", + None => "", + }; + + let count_sql = format!( + r#"SELECT COUNT(*)::BIGINT AS cnt FROM ( + SELECT id, 'post' AS activity_type FROM public.posts WHERE user_id = $1 AND status = 'active' + UNION ALL + SELECT id, 'spot' AS activity_type FROM public.spots WHERE user_id = $1 + UNION ALL + SELECT id, 'solution' AS activity_type FROM public.solutions WHERE user_id = $1 AND status = 'active' + ) a {type_filter}"# + ); + + let total_row = db + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + &count_sql, + [user_id.into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + let total_items = total_row + .map(|r| r.try_get::("", "cnt").unwrap_or(0)) + .unwrap_or(0) as u64; + + let data_sql = format!( + r#"SELECT * FROM ( + SELECT p.id, 'post' AS activity_type, p.title, NULL::text AS product_name, + NULL::boolean AS is_adopted, NULL::boolean AS is_verified, + NULL::uuid AS spot_id, NULL::uuid AS spot_post_id, + p.image_url AS spot_post_image_url, + p.artist_name AS spot_post_artist_name, p.group_name AS spot_post_group_name, + p.created_at + FROM public.posts p WHERE p.user_id = $1 AND p.status = 'active' + UNION ALL + SELECT s.id, 'spot' AS activity_type, NULL AS title, NULL AS product_name, + NULL AS is_adopted, NULL AS is_verified, + s.id AS spot_id, s.post_id AS spot_post_id, + sp.image_url AS spot_post_image_url, + sp.artist_name AS spot_post_artist_name, sp.group_name AS spot_post_group_name, + s.created_at + FROM public.spots s + LEFT JOIN public.posts sp ON s.post_id = sp.id + WHERE s.user_id = $1 + UNION ALL + SELECT sol.id, 'solution' AS activity_type, sol.title, sol.product_name, + sol.is_adopted, sol.is_verified, + sol.spot_id AS spot_id, sp2.post_id AS spot_post_id, + sp3.image_url AS spot_post_image_url, + sp3.artist_name AS spot_post_artist_name, sp3.group_name AS spot_post_group_name, + sol.created_at + FROM public.solutions sol + LEFT JOIN public.spots sp2 ON sol.spot_id = sp2.id + LEFT JOIN public.posts sp3 ON sp2.post_id = sp3.id + WHERE sol.user_id = $1 AND sol.status = 'active' + ) a {type_filter} + ORDER BY a.created_at DESC + LIMIT $2 OFFSET $3"# + ); + + let rows = db + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + &data_sql, + [ + user_id.into(), + (per_page as i64).into(), + (offset as i64).into(), + ], + )) + .await + .map_err(AppError::DatabaseError)?; + + let items: Vec = rows + .iter() + .map(|row| { + let activity_type_str: String = row.try_get("", "activity_type").unwrap_or_default(); + let activity_type = match activity_type_str.as_str() { + "spot" => UserActivityType::Spot, + "solution" => UserActivityType::Solution, + _ => UserActivityType::Post, + }; + + let spot = row + .try_get::("", "spot_id") + .ok() + .map(|spot_id| UserActivitySpotMeta { + id: spot_id, + post: row.try_get::("", "spot_post_id").ok().map(|pid| { + UserActivityPostMeta { + id: pid, + image_url: row.try_get("", "spot_post_image_url").ok(), + artist_name: row.try_get("", "spot_post_artist_name").ok(), + group_name: row.try_get("", "spot_post_group_name").ok(), + } + }), + }); + + UserActivityItem { + id: row.try_get("", "id").unwrap_or_default(), + activity_type, + spot, + product_name: row.try_get("", "product_name").ok(), + title: row.try_get("", "title").ok(), + is_adopted: row.try_get("", "is_adopted").ok(), + is_verified: row.try_get("", "is_verified").ok(), + created_at: row + .try_get::>("", "created_at") + .unwrap_or_default(), + } + }) + .collect(); + + Ok(PaginatedResponse::new( + items, + Pagination::new(pagination.page, per_page), + total_items, + )) } async fn count_followers(db: &DatabaseConnection, user_id: Uuid) -> AppResult { @@ -234,3 +410,64 @@ pub async fn get_user_with_follow_counts( ..UserResponse::from(user) }) } + +/// 팔로우 +pub async fn follow_user( + db: &DatabaseConnection, + follower_id: Uuid, + following_id: Uuid, +) -> AppResult<()> { + if follower_id == following_id { + return Err(AppError::BadRequest("자기 자신을 팔로우할 수 없습니다".to_string())); + } + + // 대상 유저 존재 확인 + get_user_by_id(db, following_id).await?; + + db.execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO public.user_follows (follower_id, following_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", + [follower_id.into(), following_id.into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + Ok(()) +} + +/// 언팔로우 +pub async fn unfollow_user( + db: &DatabaseConnection, + follower_id: Uuid, + following_id: Uuid, +) -> AppResult<()> { + db.execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "DELETE FROM public.user_follows WHERE follower_id = $1 AND following_id = $2", + [follower_id.into(), following_id.into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + Ok(()) +} + +/// 팔로우 여부 확인 +pub async fn check_is_following( + db: &DatabaseConnection, + follower_id: Uuid, + following_id: Uuid, +) -> AppResult { + let result = db + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT EXISTS(SELECT 1 FROM public.user_follows WHERE follower_id = $1 AND following_id = $2) AS is_following", + [follower_id.into(), following_id.into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + Ok(result + .map(|row| row.try_get::("", "is_following").unwrap_or(false)) + .unwrap_or(false)) +} diff --git a/packages/web/app/api/v1/rankings/me/route.ts b/packages/web/app/api/v1/rankings/me/route.ts index 6a315f8e..90efd8de 100644 --- a/packages/web/app/api/v1/rankings/me/route.ts +++ b/packages/web/app/api/v1/rankings/me/route.ts @@ -1,8 +1,6 @@ /** - * Rankings Me Proxy API Route - * GET /api/v1/rankings/me - 내 랭킹 상세 (인증 필요) - * - * Proxies requests to the backend API. + * My Ranking Proxy API Route + * GET /api/v1/rankings/me - Get my ranking detail (auth required) */ import { NextRequest, NextResponse } from "next/server"; @@ -26,24 +24,13 @@ export async function GET(request: NextRequest) { }, }); - const responseText = await response.text(); - let data; - try { - data = JSON.parse(responseText); - } catch { - data = { - message: `Backend error: ${response.status} ${response.statusText}`, - }; - } + const data = await response.json().catch(() => ({ + message: `Backend error: ${response.status}`, + })); return NextResponse.json(data, { status: response.status }); } catch (error) { - if (process.env.NODE_ENV === "development") { - console.error("Rankings/me GET proxy error:", error); - } return NextResponse.json( - { - message: error instanceof Error ? error.message : "Proxy error", - }, + { message: error instanceof Error ? error.message : "Proxy error" }, { status: 502 } ); } diff --git a/packages/web/app/api/v1/rankings/route.ts b/packages/web/app/api/v1/rankings/route.ts index 65d89950..cac412d9 100644 --- a/packages/web/app/api/v1/rankings/route.ts +++ b/packages/web/app/api/v1/rankings/route.ts @@ -1,47 +1,35 @@ /** * Rankings Proxy API Route - * GET /api/v1/rankings - 전체 랭킹 (선택적 인증) - * - * Proxies requests to the backend API. + * GET /api/v1/rankings - List rankings (optional auth for my_ranking) */ import { NextRequest, NextResponse } from "next/server"; import { API_BASE_URL } from "@/lib/server-env"; export async function GET(request: NextRequest) { - try { - const authHeader = request.headers.get("Authorization"); - const { searchParams } = new URL(request.url); - const queryString = searchParams.toString(); - const url = queryString - ? `${API_BASE_URL}/api/v1/rankings?${queryString}` - : `${API_BASE_URL}/api/v1/rankings`; - - const headers: Record = { - "Content-Type": "application/json", - }; - if (authHeader) headers.Authorization = authHeader; + const url = new URL(request.url); + const queryString = url.searchParams.toString(); + const authHeader = request.headers.get("Authorization"); - const response = await fetch(url, { method: "GET", headers }); + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/rankings${queryString ? `?${queryString}` : ""}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + ...(authHeader ? { Authorization: authHeader } : {}), + }, + } + ); - const responseText = await response.text(); - let data; - try { - data = JSON.parse(responseText); - } catch { - data = { - message: `Backend error: ${response.status} ${response.statusText}`, - }; - } + const data = await response.json().catch(() => ({ + message: `Backend error: ${response.status}`, + })); return NextResponse.json(data, { status: response.status }); } catch (error) { - if (process.env.NODE_ENV === "development") { - console.error("Rankings GET proxy error:", error); - } return NextResponse.json( - { - message: error instanceof Error ? error.message : "Proxy error", - }, + { message: error instanceof Error ? error.message : "Proxy error" }, { status: 502 } ); } diff --git a/packages/web/app/api/v1/users/[userId]/follow-status/route.ts b/packages/web/app/api/v1/users/[userId]/follow-status/route.ts new file mode 100644 index 00000000..43cba021 --- /dev/null +++ b/packages/web/app/api/v1/users/[userId]/follow-status/route.ts @@ -0,0 +1,48 @@ +/** + * Follow Status Proxy API Route + * GET /api/v1/users/[userId]/follow-status - Check if current user follows target (auth required) + */ + +import { NextRequest, NextResponse } from "next/server"; +import { API_BASE_URL } from "@/lib/server-env"; + +interface RouteParams { + params: Promise<{ userId: string }>; +} + +export async function GET(request: NextRequest, { params }: RouteParams) { + const { userId } = await params; + const authHeader = request.headers.get("Authorization"); + if (!authHeader) { + return NextResponse.json( + { message: "Authentication required" }, + { status: 401 } + ); + } + + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/users/${userId}/follow-status`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + }, + } + ); + + const data = await response.json().catch(() => ({ + message: `Backend error: ${response.status}`, + })); + return NextResponse.json(data, { status: response.status }); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.error("Follow-status GET proxy error:", error); + } + return NextResponse.json( + { message: error instanceof Error ? error.message : "Proxy error" }, + { status: 502 } + ); + } +} diff --git a/packages/web/app/api/v1/users/[userId]/follow/route.ts b/packages/web/app/api/v1/users/[userId]/follow/route.ts new file mode 100644 index 00000000..68b0c574 --- /dev/null +++ b/packages/web/app/api/v1/users/[userId]/follow/route.ts @@ -0,0 +1,94 @@ +/** + * Follow/Unfollow Proxy API Route + * POST /api/v1/users/[userId]/follow - Follow a user (auth required) + * DELETE /api/v1/users/[userId]/follow - Unfollow a user (auth required) + */ + +import { NextRequest, NextResponse } from "next/server"; +import { API_BASE_URL } from "@/lib/server-env"; + +interface RouteParams { + params: Promise<{ userId: string }>; +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + const { userId } = await params; + const authHeader = request.headers.get("Authorization"); + if (!authHeader) { + return NextResponse.json( + { message: "Authentication required" }, + { status: 401 } + ); + } + + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/users/${userId}/follow`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + }, + } + ); + + if (response.status === 204) { + return new NextResponse(null, { status: 204 }); + } + + const data = await response.json().catch(() => ({ + message: `Backend error: ${response.status}`, + })); + return NextResponse.json(data, { status: response.status }); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.error("Follow 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 { userId } = await params; + const authHeader = request.headers.get("Authorization"); + if (!authHeader) { + return NextResponse.json( + { message: "Authentication required" }, + { status: 401 } + ); + } + + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/users/${userId}/follow`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + }, + } + ); + + if (response.status === 204) { + return new NextResponse(null, { status: 204 }); + } + + const data = await response.json().catch(() => ({ + message: `Backend error: ${response.status}`, + })); + return NextResponse.json(data, { status: response.status }); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.error("Follow DELETE proxy error:", error); + } + return NextResponse.json( + { message: error instanceof Error ? error.message : "Proxy error" }, + { status: 502 } + ); + } +} diff --git a/packages/web/app/profile/ProfileClient.tsx b/packages/web/app/profile/ProfileClient.tsx index e174a6ee..5ae7b402 100644 --- a/packages/web/app/profile/ProfileClient.tsx +++ b/packages/web/app/profile/ProfileClient.tsx @@ -28,6 +28,7 @@ import { ArchiveStats, InkEconomyCard, } from "@/lib/components/profile"; +import { StyleDNAEditModal } from "@/lib/components/profile/StyleDNAEditModal"; import { useMe, useUserStats, @@ -171,6 +172,7 @@ function ProfileError({ export function ProfileClient() { const router = useRouter(); const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isStyleDNAModalOpen, setIsStyleDNAModalOpen] = useState(false); const [activeTab, setActiveTab] = useState("posts"); // Fetch user data from API @@ -337,6 +339,8 @@ export function ProfileClient() { keywords={profileExtras?.style_dna?.keywords} colors={profileExtras?.style_dna?.colors} progress={profileExtras?.style_dna?.progress} + editable + onEditClick={() => setIsStyleDNAModalOpen(true)} /> @@ -390,6 +394,15 @@ export function ProfileClient() { isOpen={isEditModalOpen} onClose={() => setIsEditModalOpen(false)} /> + {userId && ( + setIsStyleDNAModalOpen(false)} + userId={userId} + initialKeywords={profileExtras?.style_dna?.keywords} + initialColors={profileExtras?.style_dna?.colors} + /> + )}
); } diff --git a/packages/web/app/profile/[userId]/PublicProfileClient.tsx b/packages/web/app/profile/[userId]/PublicProfileClient.tsx index 20d4231f..7baaa05e 100644 --- a/packages/web/app/profile/[userId]/PublicProfileClient.tsx +++ b/packages/web/app/profile/[userId]/PublicProfileClient.tsx @@ -10,6 +10,7 @@ import { ProfileHeaderCard } from "@/lib/design-system"; import { ProfileBio, FollowStats, + FollowButton, StyleDNACard, ProfileDesktopLayout, ActivityTabs, @@ -231,6 +232,8 @@ export function PublicProfileClient({ userId }: { userId: string }) { stats={formattedStats} /> + {me && } + + {me && } + { openGraph: { title, description, + url: `${SITE_URL}/profile/${userId}`, + type: "profile", ...(user?.avatar_url && { images: [{ url: user.avatar_url, width: 200, height: 200 }], }), }, + twitter: { + card: user?.avatar_url ? "summary" : "summary", + title, + description, + ...(user?.avatar_url && { images: [user.avatar_url] }), + }, robots: { index: true, follow: true }, + other: { + "profile:username": user?.username || "", + }, }; } export default async function PublicProfilePage({ params }: Props) { const { userId } = await params; - return ; + const supabase = await createSupabaseServerClient(); + + const { data: user } = await supabase + .from("users") + .select("display_name, username, avatar_url, bio") + .eq("id", userId) + .single(); + + const displayName = user?.display_name || user?.username || "User"; + const jsonLd = { + "@context": "https://schema.org", + "@type": "Person", + name: displayName, + url: `${SITE_URL}/profile/${userId}`, + ...(user?.avatar_url && { image: user.avatar_url }), + ...(user?.bio && { description: user.bio }), + }; + + return ( + <> +