2154512e54
Recipe cards, the recipe detail page, and meal planner still rendered raw
"{n} servings"/"{n}m cook"/"{n} srv" text and untranslated difficulty labels
outside the earlier i18n pass. Also wires the user's locale into AI features
that previously always answered in English regardless of app language:
recipe chat, ingredient substitution, recipe ideas, and generate-from-idea.
The recipe-language picker in the AI generate dialog now defaults to the
user's locale instead of always defaulting to English.
325 lines
13 KiB
TypeScript
325 lines
13 KiB
TypeScript
import type { Metadata } from "next";
|
|
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 } 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 { VersionHistoryButton } from "@/components/recipe/version-history-button";
|
|
import { DeleteRecipeButton } from "@/components/recipe/delete-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, avg } from "@epicure/db";
|
|
import { and, eq, count } from "@epicure/db";
|
|
import { Badge } from "@/components/ui/badge";
|
|
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 { CommentsSection } from "@/components/social/comments-section";
|
|
import { getPublicUrl } from "@/lib/storage";
|
|
import { cn } from "@/lib/utils";
|
|
import { RecipeChatPanel } from "@/components/recipe/recipe-chat-panel";
|
|
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
|
|
|
type Params = { params: Promise<{ id: string }> };
|
|
|
|
export async function generateMetadata({ params }: Params): Promise<Metadata> {
|
|
const { id } = await params;
|
|
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
|
|
return { title: recipe?.title ?? "Recipe" };
|
|
}
|
|
|
|
const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe };
|
|
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 [recipe, ratingData, favoriteData] = await Promise.all([
|
|
db.query.recipes.findFirst({
|
|
where: and(eq(recipes.id, id), eq(recipes.authorId, 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) },
|
|
},
|
|
}),
|
|
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)) }),
|
|
]);
|
|
|
|
if (!recipe) notFound();
|
|
|
|
const avgScore = ratingData[0]?.avgScore ? parseFloat(ratingData[0].avgScore) : null;
|
|
const ratingCount = ratingData[0]?.total ?? 0;
|
|
const isFavorited = !!favoriteData;
|
|
|
|
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 (
|
|
<div className="max-w-4xl mx-auto space-y-8">
|
|
{/* Header */}
|
|
<div className="space-y-4">
|
|
<h1 className="text-3xl font-bold tracking-tight">{recipe.title}</h1>
|
|
<TooltipProvider>
|
|
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
|
{recipe.steps.length > 0 && (
|
|
<Tooltip>
|
|
<TooltipTrigger render={
|
|
<Link href={`/recipes/${id}/cook`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
|
<Play className="h-4 w-4" />
|
|
</Link>
|
|
} />
|
|
<TooltipContent>{m.recipe.cookAction}</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
<FavoriteButton recipeId={id} initialFavorited={isFavorited} />
|
|
<MealPairingButton recipeId={id} />
|
|
<DrinkPairingButton recipeId={id} />
|
|
{recipe.visibility === "public" && (
|
|
<Tooltip>
|
|
<TooltipTrigger render={
|
|
<Link href={`/r/${id}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
|
<ExternalLink className="h-4 w-4" />
|
|
</Link>
|
|
} />
|
|
<TooltipContent>View publicly</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
{recipe.ingredients.length > 0 && (
|
|
<AddToShoppingListButton
|
|
recipeId={id}
|
|
recipeTitle={recipe.title}
|
|
baseServings={recipe.baseServings}
|
|
ingredients={recipe.ingredients.map((ing) => ({
|
|
rawName: ing.rawName,
|
|
quantity: ing.quantity,
|
|
unit: ing.unit,
|
|
}))}
|
|
/>
|
|
)}
|
|
{(!recipe.language || recipe.language !== (session.user as { locale?: string }).locale) && (
|
|
<TranslateButton recipeId={id} />
|
|
)}
|
|
{recipe.ingredients.length > 0 && (
|
|
<AdaptRecipeButton
|
|
recipeId={id}
|
|
ingredients={recipe.ingredients.map((ing) => ({ rawName: ing.rawName }))}
|
|
/>
|
|
)}
|
|
<VariationsButton
|
|
recipeId={id}
|
|
baseServings={recipe.baseServings}
|
|
difficulty={recipe.difficulty}
|
|
prepMins={recipe.prepMins}
|
|
cookMins={recipe.cookMins}
|
|
ingredients={recipe.ingredients.map((ing) => ({
|
|
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,
|
|
}))}
|
|
/>
|
|
<PrintButton recipeId={id} />
|
|
<VersionHistoryButton recipeId={id} />
|
|
<Tooltip>
|
|
<TooltipTrigger render={
|
|
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
|
<Pencil className="h-4 w-4" />
|
|
</Link>
|
|
} />
|
|
<TooltipContent>Edit</TooltipContent>
|
|
</Tooltip>
|
|
<DeleteRecipeButton recipeId={id} />
|
|
</div>
|
|
</TooltipProvider>
|
|
|
|
{avgScore !== null && (
|
|
<div className="flex items-center gap-2">
|
|
<RatingStars recipeId={id} initialScore={Math.round(avgScore)} readonly size="sm" />
|
|
<span className="text-sm text-muted-foreground">{avgScore.toFixed(1)} ({ratingCount})</span>
|
|
</div>
|
|
)}
|
|
|
|
{recipe.description && (
|
|
<p className="text-muted-foreground leading-relaxed">{recipe.description}</p>
|
|
)}
|
|
|
|
<div className="flex flex-wrap items-center gap-3 text-sm">
|
|
{recipe.difficulty && (
|
|
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]}>{m.recipe.difficulty[recipe.difficulty]}</Badge>
|
|
)}
|
|
<span className="flex items-center gap-1 text-muted-foreground">
|
|
<Users className="h-4 w-4" />
|
|
{formatMessage(m.recipe.servings, { count: recipe.baseServings })}
|
|
</span>
|
|
{recipe.prepMins && (
|
|
<span className="flex items-center gap-1 text-muted-foreground">
|
|
<Clock className="h-4 w-4" />
|
|
{formatMessage(m.recipe.prep, { mins: recipe.prepMins })}
|
|
</span>
|
|
)}
|
|
{recipe.cookMins && (
|
|
<span className="flex items-center gap-1 text-muted-foreground">
|
|
<ChefHat className="h-4 w-4" />
|
|
{formatMessage(m.recipe.cook, { mins: recipe.cookMins })}
|
|
</span>
|
|
)}
|
|
{totalMins > 0 && recipe.prepMins && recipe.cookMins && (
|
|
<span className="text-muted-foreground">({formatMessage(m.recipe.total, { mins: totalMins })})</span>
|
|
)}
|
|
<span className="flex items-center gap-1 text-muted-foreground ml-auto">
|
|
<VisibilityIcon className="h-4 w-4" />
|
|
{VISIBILITY_LABEL[recipe.visibility]}
|
|
</span>
|
|
</div>
|
|
|
|
{activeDietaryTags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{activeDietaryTags.map((tag) => (
|
|
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Cover photo */}
|
|
{cover && (
|
|
<div className="aspect-video overflow-hidden rounded-xl bg-muted">
|
|
<img
|
|
src={getPublicUrl(cover.storageKey)}
|
|
alt={recipe.title}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
{/* Empty state */}
|
|
{recipe.ingredients.length === 0 && recipe.steps.length === 0 && (
|
|
<div className="flex flex-col items-center gap-4 py-12 text-center">
|
|
<p className="text-muted-foreground">No ingredients or steps yet.</p>
|
|
<div className="flex items-center gap-2">
|
|
<GenerateContentButton
|
|
recipeId={id}
|
|
title={recipe.title}
|
|
description={recipe.description}
|
|
/>
|
|
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
|
<Pencil className="h-4 w-4" />
|
|
Edit manually
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Serving scaler + ingredients */}
|
|
{recipe.ingredients.length > 0 && (
|
|
<div className="space-y-4">
|
|
<h2 className="text-xl font-semibold">{m.recipe.ingredients}</h2>
|
|
<ServingScaler
|
|
baseServings={recipe.baseServings}
|
|
recipeTitle={recipe.title}
|
|
ingredients={recipe.ingredients.map((ing) => ({
|
|
id: ing.id,
|
|
rawName: ing.rawName,
|
|
quantity: ing.quantity,
|
|
unit: ing.unit,
|
|
note: ing.note,
|
|
order: ing.order,
|
|
}))}
|
|
/>
|
|
<NutritionPanel recipeId={id} initialData={recipe.nutritionData} />
|
|
</div>
|
|
)}
|
|
|
|
{recipe.steps.length > 0 && (
|
|
<>
|
|
<Separator />
|
|
<div className="space-y-6">
|
|
<h2 className="text-xl font-semibold">{m.recipe.instructions}</h2>
|
|
<ol className="space-y-6">
|
|
{recipe.steps.map((step, i) => (
|
|
<li key={step.id} className="flex gap-4">
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-bold">
|
|
{i + 1}
|
|
</div>
|
|
<div className="flex-1 space-y-1 pt-1">
|
|
<p className="leading-relaxed">{step.instruction}</p>
|
|
{step.timerSeconds && (
|
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
|
<Clock className="h-3 w-3" />
|
|
{step.timerSeconds >= 60
|
|
? `${Math.floor(step.timerSeconds / 60)}m ${step.timerSeconds % 60 > 0 ? `${step.timerSeconds % 60}s` : ""}`
|
|
: `${step.timerSeconds}s`}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{recipe.photos.length > 1 && (
|
|
<>
|
|
<Separator />
|
|
<div className="space-y-3">
|
|
<h2 className="text-xl font-semibold">Photos</h2>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{recipe.photos.map((photo) => (
|
|
<div key={photo.id} className="aspect-square rounded-lg overflow-hidden bg-muted">
|
|
<img src={getPublicUrl(photo.storageKey)} alt="" className="w-full h-full object-cover" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{recipe.visibility !== "private" && (
|
|
<>
|
|
<Separator />
|
|
<div className="space-y-4">
|
|
<RatingStars recipeId={id} initialScore={0} />
|
|
</div>
|
|
<Separator />
|
|
<CommentsSection recipeId={id} currentUserId={session.user.id} />
|
|
</>
|
|
)}
|
|
|
|
<RecipeChatPanel recipeId={id} recipeTitle={recipe.title} />
|
|
</div>
|
|
);
|
|
}
|