9ba2996022
Adds a "Save offline" button on the recipe page that force-refetches the current page so the service worker's cache picks up a fresh copy right now, plus a small IndexedDB-backed list (lib/offline-db.ts) of what's been saved. The /offline fallback page now reads that list and renders it instead of being a dead end with just a "go back" link. Also fixes the service worker's network-first fetch handler, which never wrote successful responses into its cache -- meaning the existing offline page's claim that "recently visited recipes are available" was never actually true. It populates the cache on every successful GET now. Background sync: marking a batch-cook dish as cooked while offline (the only existing mark-cooked call site in the app) now queues the request in IndexedDB instead of just failing, and registers a Background Sync (public/sw.js's "sync" listener replays the queue) for Chromium; lib/offline-queue.ts's online-event fallback covers Safari/Firefox, which never fire that event at all. Both replay paths read/write the same IndexedDB store so either one drains it. Also removes apps/web/public/manifest.json, a stale static manifest that layout.tsx used to link to before the previous commit pointed it at the real generated route (app/manifest.ts) -- it had gone unnoticed and unused since. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
518 lines
22 KiB
TypeScript
518 lines
22 KiB
TypeScript
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 { SaveOfflineButton } from "@/components/recipe/save-offline-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 { getFeaturePrefs } from "@/lib/feature-prefs";
|
|
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<Metadata> {
|
|
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, featurePrefs] = 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(),
|
|
getFeaturePrefs(session.user.id),
|
|
]);
|
|
|
|
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<string, string>();
|
|
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 (
|
|
<div className="max-w-4xl mx-auto space-y-8 pb-20">
|
|
{/* 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. */}
|
|
<KeepScreenAwake />
|
|
{/* Header */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-3xl font-bold tracking-tight">{recipe.title}</h1>
|
|
{recipe.isBatchCook && (
|
|
<Badge variant="secondary" className="shrink-0">{m.recipe.batchCookBadge}</Badge>
|
|
)}
|
|
</div>
|
|
{!isOwner && recipe.author.username && (
|
|
<Link
|
|
href={`/u/${recipe.author.username}`}
|
|
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
|
|
>
|
|
<Avatar className="h-6 w-6">
|
|
{recipe.author.avatarUrl && <AvatarImage src={recipe.author.avatarUrl} alt={recipe.author.name} />}
|
|
<AvatarFallback className="text-[10px]">{recipe.author.name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
</Avatar>
|
|
{formatMessage(m.recipe.byAuthor, { name: recipe.author.name })}
|
|
</Link>
|
|
)}
|
|
{forkedFrom && (
|
|
<Link
|
|
href={`/recipes/${forkedFrom.parent.id}`}
|
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
|
>
|
|
{formatMessage(m.recipe.forkedFrom, { title: forkedFrom.parent.title })}
|
|
</Link>
|
|
)}
|
|
<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} />
|
|
{!recipe.isBatchCook && recipe.recipeType !== "drink" && (
|
|
<>
|
|
<MealPairingButton recipeId={id} locked={locked.mealPairing} />
|
|
<DrinkPairingButton recipeId={id} locked={locked.drinkPairing} />
|
|
</>
|
|
)}
|
|
{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,
|
|
}))}
|
|
locked={locked.variations}
|
|
/>
|
|
<ForkRecipeButton recipeId={id} variant={isOwner ? "duplicate" : "fork"} />
|
|
<ShareRecipeButton recipeId={id} visibility={recipe.visibility} />
|
|
<SaveOfflineButton recipeId={id} recipeTitle={recipe.title} />
|
|
<PrintButton recipeId={id} />
|
|
<ExportMarkdownButton
|
|
markdown={recipeToMarkdown({
|
|
title: recipe.title,
|
|
description: recipe.description,
|
|
baseServings: recipe.baseServings,
|
|
prepMins: recipe.prepMins,
|
|
cookMins: recipe.cookMins,
|
|
difficulty: recipe.difficulty,
|
|
sourceUrl: recipe.sourceUrl,
|
|
ingredients: recipe.ingredients,
|
|
steps: recipe.steps,
|
|
isBatchCook: recipe.isBatchCook,
|
|
batchDishes: recipe.batchDishes,
|
|
})}
|
|
filename={recipe.title}
|
|
/>
|
|
{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.isBatchCook ? stripMarkdown(recipe.description) : 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>
|
|
)}
|
|
|
|
{recipe.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{recipe.tags.map((tag) => (
|
|
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Cover photo */}
|
|
{cover && (
|
|
<div className="relative aspect-video overflow-hidden rounded-xl bg-muted">
|
|
<Image
|
|
src={getPublicUrl(cover.storageKey)}
|
|
unoptimized
|
|
alt={recipe.title}
|
|
fill
|
|
className="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}
|
|
unitPref={unitPref}
|
|
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} initialManual={recipe.nutritionManual} />
|
|
</div>
|
|
)}
|
|
|
|
{recipe.steps.length > 0 && (
|
|
<>
|
|
<Separator />
|
|
<div className="space-y-6">
|
|
<h2 className="text-xl font-semibold">{m.recipe.instructions}</h2>
|
|
{recipe.isBatchCook ? (
|
|
<BatchCookSteps steps={recipe.steps} />
|
|
) : (
|
|
<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.isBatchCook && recipe.batchDishes.length > 0 && (
|
|
<>
|
|
<Separator />
|
|
<BatchCookDishes
|
|
recipeId={id}
|
|
dishes={recipe.batchDishes.map((d) => ({ ...d, cookedAt: dishCookedAtMap.get(d.id) ?? null }))}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{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, i) => (
|
|
<div key={photo.id} className="relative aspect-square rounded-lg overflow-hidden bg-muted">
|
|
<Image
|
|
src={getPublicUrl(photo.storageKey)}
|
|
unoptimized
|
|
alt={`${recipe.title} photo ${i + 1}`}
|
|
fill
|
|
className="object-cover"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{recipe.visibility !== "private" && (
|
|
<>
|
|
<Separator />
|
|
<div className="space-y-4">
|
|
{isOwner ? (
|
|
<RatingStars recipeId={id} initialScore={myScore} readonly />
|
|
) : (
|
|
<CookedItReview
|
|
recipeId={id}
|
|
initialScore={myScore}
|
|
initialText={myRating?.reviewText ?? ""}
|
|
initialPhotoKey={myRating?.photoKey ?? null}
|
|
/>
|
|
)}
|
|
</div>
|
|
<Separator />
|
|
<CommentsSection recipeId={id} currentUserId={session.user.id} />
|
|
</>
|
|
)}
|
|
|
|
<Separator />
|
|
<RecipeNotes recipeId={id} initialContent={myNote?.content ?? ""} />
|
|
|
|
{featurePrefs.chatbots && <RecipeChatPanel recipeId={id} recipeTitle={recipe.title} />}
|
|
</div>
|
|
);
|
|
}
|