import type { Metadata } from "next"; import Image from "next/image"; import { notFound } from "next/navigation"; import { headers } from "next/headers"; import Link from "next/link"; import { Clock, Users, Globe, Lock, Link2, Pencil, ChefHat, ExternalLink, Play, UserCheck } from "lucide-react"; import { VariationsButton } from "@/components/recipe/variations-button"; import { TranslateButton } from "@/components/recipe/translate-button"; import { AddToShoppingListButton } from "@/components/recipe/add-to-shopping-list-button"; import { MealPairingButton } from "@/components/recipe/meal-pairing-button"; import { DrinkPairingButton } from "@/components/recipe/drink-pairing-button"; import { AdaptRecipeButton } from "@/components/recipe/adapt-recipe-button"; import { PrintButton } from "@/components/recipe/print-button"; import { ShareRecipeButton } from "@/components/recipe/share-recipe-button"; import { VersionHistoryButton } from "@/components/recipe/version-history-button"; import { DeleteRecipeButton } from "@/components/recipe/delete-recipe-button"; import { ForkRecipeButton } from "@/components/recipe/fork-recipe-button"; import { NutritionPanel } from "@/components/recipe/nutrition-panel"; import { GenerateContentButton } from "@/components/recipe/generate-content-button"; import { auth } from "@/lib/auth/server"; import { db, recipes, ratings, favorites, recipeVariations, recipeNotes, cookingHistory, avg } from "@epicure/db"; import { and, eq, count, desc } from "@epicure/db"; import { visibleToViewer } from "@/lib/visibility"; import { Badge } from "@/components/ui/badge"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { buttonVariants } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { ServingScaler } from "@/components/recipe/serving-scaler"; import { FavoriteButton } from "@/components/social/favorite-button"; import { RatingStars } from "@/components/social/rating-stars"; import { CookedItReview } from "@/components/social/cooked-it-review"; import { RecipeNotes } from "@/components/recipe/recipe-notes"; import { CommentsSection } from "@/components/social/comments-section"; import { getPublicUrl } from "@/lib/storage"; import { cn, stripMarkdown } from "@/lib/utils"; import { RecipeChatPanel } from "@/components/recipe/recipe-chat-panel"; import { BatchCookSteps } from "@/components/recipe/batch-cook-steps"; import { BatchCookDishes } from "@/components/recipe/batch-cook-dishes"; import { KeepScreenAwake } from "@/components/recipe/keep-screen-awake"; import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; import { recipeToMarkdown } from "@/lib/markdown/recipe"; import { getMessages, formatMessage } from "@/lib/i18n/server"; import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags"; type Params = { params: Promise<{ id: string }> }; export async function generateMetadata({ params }: Params): Promise { const { id } = await params; const session = await auth.api.getSession({ headers: await headers() }); if (!session) return { title: "Recipe" }; const recipe = await db.query.recipes.findFirst({ where: and( eq(recipes.id, id), visibleToViewer(session.user.id) ), }); return { title: recipe?.title ?? "Recipe" }; } const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe, followers: UserCheck }; const DIFFICULTY_COLOR = { easy: "default", medium: "secondary", hard: "destructive" } as const; export default async function RecipePage({ params }: Params) { const { id } = await params; const session = await auth.api.getSession({ headers: await headers() }); if (!session) return null; const m = getMessages((session.user as { locale?: string }).locale); const VISIBILITY_LABEL = m.recipe.visibility; const DIETARY_LABELS = m.recipe.dietary; const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric"; const [recipe, ratingData, favoriteData, myRating, forkedFrom, myNote, dishCookLog, featureFlags] = await Promise.all([ db.query.recipes.findFirst({ where: and( eq(recipes.id, id), visibleToViewer(session.user.id) ), with: { ingredients: { orderBy: (t, { asc }) => asc(t.order) }, steps: { orderBy: (t, { asc }) => asc(t.order) }, photos: { orderBy: (t, { asc }) => asc(t.order) }, author: { columns: { id: true, name: true, username: true, avatarUrl: true } }, batchDishes: { orderBy: (t, { asc }) => asc(t.order) }, }, }), db.select({ avgScore: avg(ratings.score), total: count() }).from(ratings).where(eq(ratings.recipeId, id)), db.query.favorites.findFirst({ where: and(eq(favorites.userId, session.user.id), eq(favorites.recipeId, id)) }), db.query.ratings.findFirst({ where: and(eq(ratings.recipeId, id), eq(ratings.userId, session.user.id)) }), db.query.recipeVariations.findFirst({ where: eq(recipeVariations.childRecipeId, id), with: { parent: { columns: { id: true, title: true } } }, }), db.query.recipeNotes.findFirst({ where: and(eq(recipeNotes.recipeId, id), eq(recipeNotes.userId, session.user.id)), columns: { content: true }, }), db.query.cookingHistory.findMany({ where: and(eq(cookingHistory.recipeId, id), eq(cookingHistory.userId, session.user.id)), orderBy: desc(cookingHistory.cookedAt), columns: { batchDishId: true, cookedAt: true }, }), getFeatureFlagMatrix(), ]); if (!recipe) notFound(); const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free"; const locked = { variations: !featureFlags.recipe_variations[viewerTier], drinkPairing: !featureFlags.drink_pairing[viewerTier], mealPairing: !featureFlags.meal_pairing[viewerTier], }; const isOwner = recipe.authorId === session.user.id; const dishCookedAtMap = new Map(); for (const log of dishCookLog) { if (log.batchDishId && !dishCookedAtMap.has(log.batchDishId)) { dishCookedAtMap.set(log.batchDishId, log.cookedAt.toISOString()); } } const avgScore = ratingData[0]?.avgScore ? parseFloat(ratingData[0].avgScore) : null; const ratingCount = ratingData[0]?.total ?? 0; const isFavorited = !!favoriteData; const myScore = myRating?.score ?? 0; const cover = recipe.photos.find((p) => p.isCover) ?? recipe.photos[0]; const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0); const activeDietaryTags = Object.entries(recipe.dietaryTags ?? {}) .filter(([, v]) => v) .map(([k]) => DIETARY_LABELS[k as keyof typeof DIETARY_LABELS]) .filter(Boolean); const VisibilityIcon = VISIBILITY_ICON[recipe.visibility]; return (
{/* pb-20 keeps the last section (private notes' Save button) clear of the fixed recipe-chat button, which otherwise overlaps it on short pages — same fix as recipe-form.tsx's floating save bar. */} {/* Header */}

{recipe.title}

{recipe.isBatchCook && ( {m.recipe.batchCookBadge} )}
{!isOwner && recipe.author.username && ( {recipe.author.avatarUrl && } {recipe.author.name.slice(0, 2).toUpperCase()} {formatMessage(m.recipe.byAuthor, { name: recipe.author.name })} )} {forkedFrom && ( {formatMessage(m.recipe.forkedFrom, { title: forkedFrom.parent.title })} )}
{recipe.steps.length > 0 && ( } /> {m.recipe.cookAction} )} {!recipe.isBatchCook && recipe.recipeType !== "drink" && ( <> )} {recipe.visibility === "public" && ( } /> {m.recipe.viewPublicly} )} {recipe.ingredients.length > 0 && ( ({ rawName: ing.rawName, quantity: ing.quantity, unit: ing.unit, }))} /> )} {isOwner && (!recipe.language || recipe.language !== (session.user as { locale?: string }).locale) && ( )} {recipe.ingredients.length > 0 && ( ({ rawName: ing.rawName }))} /> )} ({ rawName: ing.rawName, quantity: ing.quantity, unit: ing.unit, note: ing.note, order: ing.order, }))} steps={recipe.steps.map((s) => ({ instruction: s.instruction, timerSeconds: s.timerSeconds, order: s.order, }))} locked={locked.variations} /> {isOwner && ( <> ({ rawName: ing.rawName, quantity: ing.quantity, unit: ing.unit, note: ing.note, })), steps: recipe.steps.map((s) => ({ instruction: s.instruction, timerSeconds: s.timerSeconds, })), }} /> } /> {m.recipe.edit} )}
{avgScore !== null && (
{avgScore.toFixed(1)} ({ratingCount})
)} {recipe.description && (

{recipe.isBatchCook ? stripMarkdown(recipe.description) : recipe.description}

)} {recipe.sourceUrl && ( {m.recipe.source}: {new URL(recipe.sourceUrl).hostname.replace(/^www\./, "")} )}
{recipe.difficulty && ( {m.recipe.difficulty[recipe.difficulty]} )} {formatMessage(m.recipe.servings, { count: recipe.baseServings })} {recipe.prepMins && ( {formatMessage(m.recipe.prep, { mins: recipe.prepMins })} )} {recipe.cookMins && ( {formatMessage(m.recipe.cook, { mins: recipe.cookMins })} )} {totalMins > 0 && recipe.prepMins && recipe.cookMins && ( ({formatMessage(m.recipe.total, { mins: totalMins })}) )} {VISIBILITY_LABEL[recipe.visibility]}
{activeDietaryTags.length > 0 && (
{activeDietaryTags.map((tag) => ( {tag} ))}
)}
{/* Cover photo */} {cover && (
{recipe.title}
)} {/* Empty state */} {recipe.ingredients.length === 0 && recipe.steps.length === 0 && (

No ingredients or steps yet.

{isOwner && (
Edit manually
)}
)} {/* Serving scaler + ingredients */} {recipe.ingredients.length > 0 && (

{m.recipe.ingredients}

({ id: ing.id, rawName: ing.rawName, quantity: ing.quantity, unit: ing.unit, note: ing.note, order: ing.order, }))} />
)} {recipe.steps.length > 0 && ( <>

{m.recipe.instructions}

{recipe.isBatchCook ? ( ) : (
    {recipe.steps.map((step, i) => (
  1. {i + 1}

    {step.instruction}

    {!!step.timerSeconds && (

    {step.timerSeconds >= 60 ? `${Math.floor(step.timerSeconds / 60)}m ${step.timerSeconds % 60 > 0 ? `${step.timerSeconds % 60}s` : ""}` : `${step.timerSeconds}s`}

    )}
  2. ))}
)}
)} {recipe.isBatchCook && recipe.batchDishes.length > 0 && ( <> ({ ...d, cookedAt: dishCookedAtMap.get(d.id) ?? null }))} /> )} {recipe.photos.length > 1 && ( <>

Photos

{recipe.photos.map((photo, i) => (
{`${recipe.title}
))}
)} {recipe.visibility !== "private" && ( <>
{isOwner ? ( ) : ( )}
)}
); }