diff --git a/packages/api-server/migration/src/m20260409_add_image_dimensions.rs b/packages/api-server/migration/src/m20260409_add_image_dimensions.rs index c75a1e27..e16ed63a 100644 --- a/packages/api-server/migration/src/m20260409_add_image_dimensions.rs +++ b/packages/api-server/migration/src/m20260409_add_image_dimensions.rs @@ -6,15 +6,11 @@ pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .alter_table( - Table::alter() - .table(Alias::new("posts")) - .add_column(ColumnDef::new(Alias::new("image_width")).integer().null()) - .add_column(ColumnDef::new(Alias::new("image_height")).integer().null()) - .to_owned(), - ) - .await + let db = manager.get_connection(); + db.execute_unprepared( + "ALTER TABLE posts ADD COLUMN IF NOT EXISTS image_width integer, ADD COLUMN IF NOT EXISTS image_height integer" + ).await?; + Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { diff --git a/packages/shared/supabase/client.ts b/packages/shared/supabase/client.ts index 3aac4a8c..8491225a 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: { + detectSessionInUrl: true, + }, + }); } return supabaseClient; } 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", + }, + ], + }; +} 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/MobileNavBar.tsx b/packages/web/lib/components/MobileNavBar.tsx index 778e50c7..e22c79b5 100644 --- a/packages/web/lib/components/MobileNavBar.tsx +++ b/packages/web/lib/components/MobileNavBar.tsx @@ -2,7 +2,7 @@ import { memo } from "react"; import { usePathname } from "next/navigation"; -import { Home, Search, Compass } from "lucide-react"; +import { Home, Search, Compass, Upload } from "lucide-react"; import { NavBar, NavItem } from "@/lib/design-system"; interface NavItemConfig { @@ -21,6 +21,7 @@ interface NavItemConfig { const navItems: NavItemConfig[] = [ { id: "home", href: "/", icon: Home, label: "Home" }, { id: "search", href: "/search", icon: Search, label: "Search" }, + { id: "upload", href: "/request/upload", icon: Upload, label: "Upload" }, { id: "explore", href: "/explore", icon: Compass, label: "Explore" }, ]; diff --git a/packages/web/lib/components/main-renewal/SmartNav.tsx b/packages/web/lib/components/main-renewal/SmartNav.tsx index c00b11be..31396d8a 100644 --- a/packages/web/lib/components/main-renewal/SmartNav.tsx +++ b/packages/web/lib/components/main-renewal/SmartNav.tsx @@ -1,17 +1,20 @@ "use client"; -import { useRef, useEffect, useState } from "react"; +import { useRef, useEffect, useState, useCallback } from "react"; import Image from "next/image"; import gsap from "gsap"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useRouter } from "next/navigation"; import dynamic from "next/dynamic"; import { useAuthStore, selectIsLoggedIn, selectUser, selectProfile, + selectLogout, } from "@/lib/stores/authStore"; +import { RequestModal } from "@/lib/components/request/RequestModal"; const DecodedLogo = dynamic(() => import("@/lib/components/DecodedLogo"), { ssr: false, @@ -21,10 +24,6 @@ const DecodedLogo = dynamic(() => import("@/lib/components/DecodedLogo"), { const NAV_ITEMS = [ { href: "/", label: "Home" }, { href: "/explore", label: "Explore" }, - // 1st release: Upload hidden (GH #35) - // { href: "/request/upload", label: "Upload", isUpload: true }, - // 1st release: Lab hidden (GH #35) - // { href: "/lab", label: "Lab" }, ] as const; interface SmartNavProps { @@ -46,9 +45,37 @@ export function SmartNav({ className }: SmartNavProps) { const [isAtTop, setIsAtTop] = useState(true); const pathname = usePathname(); + const router = useRouter(); const isLoggedIn = useAuthStore(selectIsLoggedIn); const user = useAuthStore(selectUser); 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 + useEffect(() => { + if (!isProfileOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (profileRef.current && !profileRef.current.contains(e.target as Node)) { + setIsProfileOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isProfileOpen]); + + // Close dropdown on route change + useEffect(() => { + setIsProfileOpen(false); + }, [pathname]); + + const handleLogout = useCallback(async () => { + setIsProfileOpen(false); + await logout(); + router.push("/"); + }, [logout, router]); // Scroll-responsive hide/show useEffect(() => { @@ -96,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 ? ( - - {user?.avatarUrl ? ( - {profile?.display_name - ) : ( -
- {(user?.name?.[0] || "U").toUpperCase()} +
+ + + {isProfileOpen && ( +
+
+

+ {profile?.display_name || user?.name} +

+

+ @{profile?.username || user?.email} +

+
+
+ setIsProfileOpen(false)} + > + 프로필 보기 + + + +
)} - +
) : (
+ + {/* Upload Modal — rendered outside header to avoid z-index issues */} + setIsUploadModalOpen(false)} + /> + ); } 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 (