Files
Epicure/apps/web/app/(app)/recipes/[id]/page.tsx
T
Arnaud ee7946316c feat: show recipe source link, keep screen awake while viewing a recipe
- sourceUrl was already saved on URL import but never displayed —
  render it as a link (hostname only) under the description.
- Extract cook-mode's wake lock into a reusable useWakeLock hook, add
  visibility-change re-acquisition (a wake lock releases when the tab
  goes background and won't come back on its own), and use it on the
  regular recipe view too, not just cook mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 10:32:23 +02:00

368 lines
15 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 { ShareRecipeButton } from "@/components/recipe/share-recipe-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, or, count, inArray } 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 { KeepScreenAwake } from "@/components/recipe/keep-screen-awake";
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),
or(eq(recipes.authorId, session.user.id), inArray(recipes.visibility, ["public", "unlisted"]))
),
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 isOwner = recipe.authorId === session.user.id;
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">
<KeepScreenAwake />
{/* 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>{m.recipe.viewPublicly}</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,
}))}
/>
)}
{isOwner && (!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,
}))}
/>
<ShareRecipeButton recipeId={id} visibility={recipe.visibility} />
<PrintButton recipeId={id} />
{isOwner && (
<>
<VersionHistoryButton
recipeId={id}
currentSnapshot={{
title: recipe.title,
description: recipe.description,
ingredients: recipe.ingredients.map((ing) => ({
rawName: ing.rawName,
quantity: ing.quantity,
unit: ing.unit,
note: ing.note,
})),
steps: recipe.steps.map((s) => ({
instruction: s.instruction,
timerSeconds: s.timerSeconds,
})),
}}
/>
<Tooltip>
<TooltipTrigger render={
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
<Pencil className="h-4 w-4" />
</Link>
} />
<TooltipContent>{m.recipe.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>
)}
{recipe.sourceUrl && (
<a
href={recipe.sourceUrl}
target="_blank"
rel="noopener noreferrer nofollow"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground underline underline-offset-4"
>
<ExternalLink className="h-3 w-3" />
{m.recipe.source}: {new URL(recipe.sourceUrl).hostname.replace(/^www\./, "")}
</a>
)}
<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>
{isOwner && (
<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>
);
}