feat(recipes): full recipe CRUD with photos, print, version history
List, detail, new, edit pages. Server-side pagination, dietary tags, difficulty. Photo upload to S3-compatible storage. Version history. Multi-select grid with bulk delete/visibility. Print view. Delete confirmation dialog.
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
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 { 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 { 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";
|
||||
|
||||
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_LABEL = { private: "Private", unlisted: "Unlisted", public: "Public" };
|
||||
const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe };
|
||||
const DIFFICULTY_COLOR = { easy: "default", medium: "secondary", hard: "destructive" } as const;
|
||||
|
||||
const DIETARY_LABELS: Record<string, string> = {
|
||||
vegan: "Vegan",
|
||||
vegetarian: "Vegetarian",
|
||||
glutenFree: "Gluten-free",
|
||||
dairyFree: "Dairy-free",
|
||||
nutFree: "Nut-free",
|
||||
halal: "Halal",
|
||||
kosher: "Kosher",
|
||||
};
|
||||
|
||||
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 [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])
|
||||
.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>
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
||||
<FavoriteButton recipeId={id} initialFavorited={isFavorited} />
|
||||
<MealPairingButton recipeId={id} />
|
||||
<DrinkPairingButton recipeId={id} />
|
||||
{recipe.visibility === "public" && (
|
||||
<Link href={`/r/${id}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
{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,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
<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,
|
||||
}))}
|
||||
/>
|
||||
{recipe.steps.length > 0 && (
|
||||
<Link href={`/recipes/${id}/cook`} className={cn(buttonVariants({ variant: "default", size: "sm" }))}>
|
||||
<Play className="h-4 w-4" />
|
||||
Cook
|
||||
</Link>
|
||||
)}
|
||||
<PrintButton recipeId={id} />
|
||||
<VersionHistoryButton recipeId={id} />
|
||||
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
<DeleteRecipeButton recipeId={id} />
|
||||
</div>
|
||||
|
||||
{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]}>{recipe.difficulty}</Badge>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<Users className="h-4 w-4" />
|
||||
{recipe.baseServings} servings
|
||||
</span>
|
||||
{recipe.prepMins && (
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
{recipe.prepMins}m prep
|
||||
</span>
|
||||
)}
|
||||
{recipe.cookMins && (
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<ChefHat className="h-4 w-4" />
|
||||
{recipe.cookMins}m cook
|
||||
</span>
|
||||
)}
|
||||
{totalMins > 0 && recipe.prepMins && recipe.cookMins && (
|
||||
<span className="text-muted-foreground">({totalMins}m total)</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 />
|
||||
|
||||
{/* Serving scaler + ingredients */}
|
||||
{recipe.ingredients.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">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">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} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user