import { notFound } from "next/navigation"; import { headers } from "next/headers"; import Link from "next/link"; import Image from "next/image"; import { Lock } from "lucide-react"; import { auth } from "@/lib/auth/server"; import { db, users, recipes, userFollows, userBlocks, cookingHistory, ratings, eq, and, desc, count, isNotNull, } from "@epicure/db"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { FollowButton } from "@/components/social/follow-button"; import { BlockButton } from "@/components/social/block-button"; import { MessageButton } from "@/components/social/message-button"; import { getPublicUrl } from "@/lib/storage"; import { RecipeCoverPlaceholder } from "@/components/recipe/recipe-cover-placeholder"; import { ProfileTabs, type HistoryData, type PhotosData } from "@/components/profile/profile-tabs"; const PAGE_SIZE = 24; type Params = { params: Promise<{ username: string }>; searchParams: Promise<{ page?: string; tab?: string; ppage?: string }>; }; /** Consecutive-day streak (UTC calendar days) ending today or yesterday. */ function computeStreak(dates: Date[]): number { const daySet = new Set(dates.map((d) => d.toISOString().slice(0, 10))); const cursor = new Date(); cursor.setUTCHours(0, 0, 0, 0); if (!daySet.has(cursor.toISOString().slice(0, 10))) { cursor.setUTCDate(cursor.getUTCDate() - 1); if (!daySet.has(cursor.toISOString().slice(0, 10))) return 0; } let streak = 0; while (daySet.has(cursor.toISOString().slice(0, 10))) { streak++; cursor.setUTCDate(cursor.getUTCDate() - 1); } return streak; } export async function generateMetadata({ params }: Params) { const { username } = await params; const user = await db.query.users.findFirst({ where: eq(users.username, username) }); return { title: user ? `${user.name} (@${user.username})` : "Profile" }; } export default async function UserProfilePage({ params, searchParams }: Params) { const { username } = await params; const { page: pageParam, tab: tabParam, ppage: ppageParam } = await searchParams; const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1); const offset = (page - 1) * PAGE_SIZE; const photoPage = Math.max(1, parseInt(ppageParam ?? "1", 10) || 1); const photoOffset = (photoPage - 1) * PAGE_SIZE; const activeTab = tabParam === "history" || tabParam === "photos" ? tabParam : "recipes"; const session = await auth.api.getSession({ headers: await headers() }); const user = await db.query.users.findFirst({ where: eq(users.username, username) }); if (!user) notFound(); const [followerCountRow, followingCountRow, recipeCountRow, publicRecipes] = await Promise.all([ db .select({ count: count() }) .from(userFollows) .where(eq(userFollows.followingId, user.id)), db .select({ count: count() }) .from(userFollows) .where(eq(userFollows.followerId, user.id)), db .select({ count: count() }) .from(recipes) .where(and(eq(recipes.authorId, user.id), eq(recipes.visibility, "public"))), db.query.recipes.findMany({ where: and(eq(recipes.authorId, user.id), eq(recipes.visibility, "public")), orderBy: desc(recipes.createdAt), limit: PAGE_SIZE, offset, with: { photos: { orderBy: (t, { asc }) => asc(t.order), limit: 1 }, }, }), ]); const followerCount = followerCountRow[0]?.count ?? 0; const followingCount = followingCountRow[0]?.count ?? 0; const recipeCount = recipeCountRow[0]?.count ?? 0; const totalPages = Math.max(1, Math.ceil(recipeCount / PAGE_SIZE)); let isFollowing = false; let isBlocked = false; const isOwnProfile = session?.user.id === user.id; if (session && !isOwnProfile) { const [followRow, blockRow] = await Promise.all([ db.query.userFollows.findFirst({ where: and( eq(userFollows.followerId, session.user.id), eq(userFollows.followingId, user.id), ), }), db.query.userBlocks.findFirst({ where: and( eq(userBlocks.blockerId, session.user.id), eq(userBlocks.blockedId, user.id), ), }), ]); isFollowing = !!followRow; isBlocked = !!blockRow; } // Private accounts hide their recipe grid from anyone who isn't the owner or an // existing follower. This only affects what's rendered on this direct-link profile // page — it does not change access to a specific recipe's own direct URL, and it // does not gate new follow requests (following stays immediate, no approval step). const isPrivateHidden = user.isPrivate && !isOwnProfile && !isFollowing; // Cooking history and "cooked it" photo gallery are only meaningful (and visible) to the // profile owner: history reveals private behavioral/timing patterns, and photos may be // attached to reviews of recipes that aren't visible to other viewers (private recipes), // so surfacing them to non-owners could leak the existence of content they can't access. let historyData: HistoryData | null = null; let photosData: PhotosData | null = null; if (isOwnProfile) { const [totalCookedRow, cookedDatesRows, lastCookedRow, recentEntries, totalPhotosRow, photoRows] = await Promise.all([ db.select({ count: count() }).from(cookingHistory).where(eq(cookingHistory.userId, user.id)), db.query.cookingHistory.findMany({ where: eq(cookingHistory.userId, user.id), columns: { cookedAt: true }, orderBy: desc(cookingHistory.cookedAt), limit: 3650, }), db.query.cookingHistory.findFirst({ where: eq(cookingHistory.userId, user.id), orderBy: desc(cookingHistory.cookedAt), with: { recipe: { columns: { id: true, title: true } } }, }), db.query.cookingHistory.findMany({ where: eq(cookingHistory.userId, user.id), orderBy: desc(cookingHistory.cookedAt), limit: 20, with: { recipe: { columns: { id: true, title: true } } }, }), db .select({ count: count() }) .from(ratings) .where(and(eq(ratings.userId, user.id), isNotNull(ratings.photoKey))), db.query.ratings.findMany({ where: and(eq(ratings.userId, user.id), isNotNull(ratings.photoKey)), orderBy: desc(ratings.createdAt), limit: PAGE_SIZE, offset: photoOffset, with: { recipe: { columns: { id: true, title: true } } }, }), ]); const totalCooked = totalCookedRow[0]?.count ?? 0; const streak = computeStreak(cookedDatesRows.map((r) => r.cookedAt)); const totalPhotos = totalPhotosRow[0]?.count ?? 0; historyData = { totalCooked, streak, lastCooked: lastCookedRow && lastCookedRow.recipe ? { recipeId: lastCookedRow.recipe.id, recipeTitle: lastCookedRow.recipe.title, cookedAt: lastCookedRow.cookedAt.toISOString(), } : null, recentEntries: recentEntries .filter((e) => e.recipe) .map((e) => ({ id: e.id, recipeId: e.recipe!.id, recipeTitle: e.recipe!.title, cookedAt: e.cookedAt.toISOString(), servings: e.servings, })), }; photosData = { items: photoRows .filter((r) => r.recipe && r.photoKey) .map((r) => ({ id: r.id, recipeId: r.recipe!.id, recipeTitle: r.recipe!.title, photoKey: r.photoKey!, createdAt: r.createdAt.toISOString(), })), page: photoPage, totalPages: Math.max(1, Math.ceil(totalPhotos / PAGE_SIZE)), total: totalPhotos, }; } const initials = user.name .split(" ") .slice(0, 2) .map((w) => w[0]) .join("") .toUpperCase(); const recipesSection = isPrivateHidden ? (

This account is private

Follow @{user.username} to see their recipes.

) : ( publicRecipes.length > 0 ? (

Recipes

{publicRecipes.map((recipe) => { const cover = recipe.photos[0]; return (
{cover ? ( {recipe.title} ) : ( )}

{recipe.title}

); })}
{totalPages > 1 && (
{page > 1 && ( 1 ? `?page=${page - 1}` : ""}`} className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent" > Previous )} Page {page} of {totalPages} {page < totalPages && ( Next )}
)}
) : (

No public recipes yet.

) ); return (
{/* Profile header */}
{user.avatarUrl && } {initials}

{user.name} {isOwnProfile && user.isPrivate && ( Private )}

@{user.username}

{!isOwnProfile && session && (
{!isBlocked && }
)}
{user.bio && !isPrivateHidden && (

{user.bio}

)}
{recipeCount} recipes {followerCount} followers {followingCount} following
{/* Recipes / cooking history / photos */} {isOwnProfile && historyData && photosData ? ( ) : ( recipesSection )}
); }