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:
Arnaud
2026-07-01 08:10:11 +02:00
parent fa2d797918
commit 84d6cfeb07
45 changed files with 5300 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
import { Nav } from "@/components/layout/nav";
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<>
<Nav />
<main className="flex-1 container mx-auto px-4 py-8">{children}</main>
</>
);
}
@@ -0,0 +1,46 @@
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, recipes, eq, and } from "@epicure/db";
import { CookingMode } from "@/components/cooking-mode/cooking-mode";
type Params = { params: Promise<{ id: string }> };
export async function generateMetadata({ params }: Params) {
const { id } = await params;
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
return { title: recipe ? `Cooking: ${recipe.title}` : "Cooking Mode" };
}
export default async function CookPage({ params }: Params) {
const { id } = await params;
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const recipe = await db.query.recipes.findFirst({
where: and(eq(recipes.id, id), eq(recipes.authorId, session.user.id)),
with: {
steps: { orderBy: (t, { asc }) => asc(t.order) },
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
},
});
if (!recipe || recipe.steps.length === 0) notFound();
return (
<CookingMode
recipeId={id}
recipeTitle={recipe.title}
steps={recipe.steps.map((s) => ({
id: s.id,
instruction: s.instruction,
timerSeconds: s.timerSeconds,
}))}
ingredients={recipe.ingredients.map((i) => ({
rawName: i.rawName,
quantity: i.quantity,
unit: i.unit,
}))}
/>
);
}
@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, recipes } from "@epicure/db";
import { and, eq } from "@epicure/db";
import { RecipeForm } from "@/components/recipe/recipe-form";
import { getPublicUrl } from "@/lib/storage";
type Params = { params: Promise<{ id: string }> };
export const metadata: Metadata = { title: "Edit Recipe" };
export default async function EditRecipePage({ params }: Params) {
const { id } = await params;
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const recipe = await 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) },
},
});
if (!recipe) notFound();
const defaultValues = {
title: recipe.title,
description: recipe.description ?? undefined,
baseServings: recipe.baseServings,
visibility: recipe.visibility,
difficulty: recipe.difficulty,
prepMins: recipe.prepMins,
cookMins: recipe.cookMins,
dietaryTags: (recipe.dietaryTags as Record<string, boolean | undefined>) ?? {},
ingredients: recipe.ingredients.map((ing) => ({
id: ing.id,
rawName: ing.rawName,
quantity: ing.quantity ?? "",
unit: ing.unit ?? "",
note: ing.note ?? "",
})),
steps: recipe.steps.map((step) => ({
id: step.id,
instruction: step.instruction,
timerSeconds: step.timerSeconds ? String(step.timerSeconds) : "",
})),
photos: recipe.photos.map((photo) => ({
key: photo.storageKey,
isCover: photo.isCover,
preview: getPublicUrl(photo.storageKey),
})),
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Edit recipe</h1>
<p className="text-muted-foreground mt-1">{recipe.title}</p>
</div>
<RecipeForm recipeId={id} defaultValues={defaultValues} />
</div>
);
}
+290
View File
@@ -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>
);
}
@@ -0,0 +1,142 @@
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, recipes, eq, and } from "@epicure/db";
import { PrintTrigger } from "@/components/recipe/print-trigger";
type Params = { params: Promise<{ id: string }> };
export default async function RecipePrintPage({ params }: Params) {
const { id } = await params;
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const recipe = await 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) },
},
});
if (!recipe) notFound();
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
const DIETARY_LABELS: Record<string, string> = {
vegan: "Vegan",
vegetarian: "Vegetarian",
glutenFree: "Gluten-free",
dairyFree: "Dairy-free",
nutFree: "Nut-free",
halal: "Halal",
kosher: "Kosher",
};
const activeTags = Object.entries(recipe.dietaryTags ?? {})
.filter(([, v]) => v)
.map(([k]) => DIETARY_LABELS[k])
.filter(Boolean);
return (
<>
<style>{`
@media print {
body { margin: 0; }
.no-print { display: none !important; }
}
body {
font-family: Georgia, 'Times New Roman', serif;
color: #1a1a1a;
max-width: 680px;
margin: 40px auto;
padding: 0 24px;
font-size: 14px;
line-height: 1.6;
}
h1 { font-size: 2em; margin: 0 0 8px; line-height: 1.2; }
h2 { font-size: 1.1em; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 1px solid #ccc; padding-bottom: 4px; margin: 24px 0 12px; }
.meta { display: flex; gap: 24px; font-size: 0.85em; color: #555; margin: 12px 0 16px; flex-wrap: wrap; }
.meta span { display: flex; align-items: center; gap: 4px; }
.tags { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
.tag { border: 1px solid #ccc; border-radius: 4px; padding: 1px 8px; font-size: 0.8em; font-family: system-ui, sans-serif; }
.description { color: #444; font-style: italic; margin-bottom: 16px; }
ul.ingredients { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 4px 24px; }
ul.ingredients li { padding: 4px 0; border-bottom: 1px dotted #e0e0e0; font-family: system-ui, sans-serif; font-size: 0.9em; }
ul.ingredients li .qty { color: #666; min-width: 60px; display: inline-block; }
ol.steps { padding-left: 20px; margin: 0; }
ol.steps li { padding: 6px 0 6px 4px; border-bottom: 1px dotted #e0e0e0; font-size: 0.95em; }
ol.steps li:last-child { border-bottom: none; }
.timer { font-size: 0.85em; color: #666; font-family: system-ui, sans-serif; margin-left: 8px; }
footer { margin-top: 40px; font-size: 0.75em; color: #aaa; text-align: center; font-family: system-ui, sans-serif; }
.print-btn {
position: fixed; top: 16px; right: 16px; padding: 8px 16px;
background: #18181b; color: white; border: none; border-radius: 6px;
cursor: pointer; font-size: 13px; font-family: system-ui, sans-serif;
}
.print-btn:hover { background: #3f3f46; }
`}</style>
<PrintTrigger />
<article>
<h1>{recipe.title}</h1>
{recipe.description && (
<p className="description">{recipe.description}</p>
)}
<div className="meta">
{recipe.baseServings && <span>Serves {recipe.baseServings}</span>}
{recipe.prepMins && <span>Prep {recipe.prepMins} min</span>}
{recipe.cookMins && <span>Cook {recipe.cookMins} min</span>}
{totalMins > 0 && <span>Total {totalMins} min</span>}
{recipe.difficulty && <span>{recipe.difficulty.charAt(0).toUpperCase() + recipe.difficulty.slice(1)}</span>}
</div>
{activeTags.length > 0 && (
<div className="tags">
{activeTags.map((tag) => (
<span key={tag} className="tag">{tag}</span>
))}
</div>
)}
{recipe.ingredients.length > 0 && (
<>
<h2>Ingredients</h2>
<ul className="ingredients">
{recipe.ingredients.map((ing) => (
<li key={ing.id}>
<span className="qty">
{[ing.quantity, ing.unit].filter(Boolean).join(" ")}
</span>
{ing.rawName}
{ing.note && <span style={{ color: "#888" }}> ({ing.note})</span>}
</li>
))}
</ul>
</>
)}
{recipe.steps.length > 0 && (
<>
<h2>Instructions</h2>
<ol className="steps">
{recipe.steps.map((step) => (
<li key={step.id}>
{step.instruction}
{step.timerSeconds && (
<span className="timer"> {Math.floor(step.timerSeconds / 60)} min</span>
)}
</li>
))}
</ol>
</>
)}
</article>
<footer>Printed from Epicure</footer>
</>
);
}
@@ -0,0 +1,58 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, recipes, pantryItems } from "@epicure/db";
import { eq } from "@epicure/db";
import { getPublicUrl } from "@/lib/storage";
import { CanCookContent } from "@/components/recipe/can-cook-content";
export const metadata: Metadata = { title: "What can I cook?" };
export default async function CanCookPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const [userRecipes, pantry] = await Promise.all([
db.query.recipes.findMany({
where: eq(recipes.authorId, session.user.id),
with: {
ingredients: true,
photos: true,
},
}),
db.query.pantryItems.findMany({
where: eq(pantryItems.userId, session.user.id),
}),
]);
const pantryKeys = new Set(pantry.map((p) => p.rawName.toLowerCase()));
const scored = userRecipes
.filter((r) => r.ingredients.length > 0)
.map((recipe) => {
const matched = recipe.ingredients.filter((ing) =>
pantryKeys.has(ing.rawName.toLowerCase())
).length;
const missing = recipe.ingredients
.filter((ing) => !pantryKeys.has(ing.rawName.toLowerCase()))
.map((ing) => ing.rawName)
.slice(0, 5);
const total = recipe.ingredients.length;
const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
return {
recipe: {
id: recipe.id,
title: recipe.title,
description: recipe.description,
photos: cover ? [{ url: getPublicUrl(cover.storageKey) }] : [],
},
matched,
total,
pct: Math.round((matched / total) * 100),
missing,
};
})
.sort((a, b) => b.pct - a.pct);
return <CanCookContent pantryCount={pantry.length} scored={scored} />;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { RecipeForm } from "@/components/recipe/recipe-form";
import { NewRecipeHeader } from "@/components/recipe/new-recipe-header";
import { PhotoImportButton } from "@/components/recipe/photo-import-button";
export const metadata: Metadata = { title: "New Recipe" };
export default function NewRecipePage() {
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<NewRecipeHeader />
<PhotoImportButton />
</div>
<RecipeForm />
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, recipes, recipeIngredients } from "@epicure/db";
import { eq, desc, and, ilike, or, sql } from "@epicure/db";
import { RecipesHeader } from "@/components/recipe/recipes-header";
import { RecipesEmptyState } from "@/components/recipe/recipes-empty-state";
import { RecipesGrid } from "@/components/recipe/recipes-grid";
export const metadata: Metadata = { title: "Recipes" };
type SearchParams = Promise<{ q?: string }>;
export default async function RecipesPage({ searchParams }: { searchParams: SearchParams }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const { q } = await searchParams;
const query = q?.trim() ?? "";
const whereClause = query
? and(
eq(recipes.authorId, session.user.id),
or(
ilike(recipes.title, `%${query}%`),
ilike(recipes.description, `%${query}%`)
)
)
: eq(recipes.authorId, session.user.id);
const userRecipes = await db.query.recipes.findMany({
where: whereClause,
orderBy: desc(recipes.updatedAt),
with: { photos: true },
});
const totalCount = query ? undefined : userRecipes.length;
return (
<div className="space-y-6">
<RecipesHeader count={totalCount ?? userRecipes.length} initialQuery={query} />
<RecipesEmptyState query={query} count={userRecipes.length} />
<RecipesGrid recipes={userRecipes} />
</div>
);
}
@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, comments, commentReactions, eq, and, count } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
const ReactionSchema = z.object({
type: z.enum(["like", "love", "laugh", "wow", "sad", "fire"]),
});
type Params = { params: Promise<{ id: string; commentId: string }> };
export async function GET(req: NextRequest, { params }: Params) {
const { commentId } = await params;
// Verify comment exists
const comment = await db.query.comments.findFirst({ where: eq(comments.id, commentId) });
if (!comment) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Get reaction counts grouped by type
const rows = await db
.select({ type: commentReactions.type, cnt: count() })
.from(commentReactions)
.where(eq(commentReactions.commentId, commentId))
.groupBy(commentReactions.type);
const counts: Record<string, number> = {};
for (const row of rows) {
counts[row.type] = row.cnt;
}
// Check for session to return user's reactions
const { session } = await requireSession();
let userReactions: string[] = [];
if (session) {
const userRows = await db
.select({ type: commentReactions.type })
.from(commentReactions)
.where(and(eq(commentReactions.commentId, commentId), eq(commentReactions.userId, session.user.id)));
userReactions = userRows.map((r) => r.type);
}
return NextResponse.json({ counts, userReactions });
}
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { commentId } = await params;
// Verify comment exists
const comment = await db.query.comments.findFirst({ where: eq(comments.id, commentId) });
if (!comment) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await req.json() as unknown;
const parsed = ReactionSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const { type } = parsed.data;
const userId = session!.user.id;
// Toggle: check if reaction exists
const existing = await db.query.commentReactions.findFirst({
where: and(
eq(commentReactions.commentId, commentId),
eq(commentReactions.userId, userId),
eq(commentReactions.type, type),
),
});
let added: boolean;
if (existing) {
await db.delete(commentReactions).where(eq(commentReactions.id, existing.id));
added = false;
} else {
await db.insert(commentReactions).values({
id: crypto.randomUUID(),
commentId,
userId,
type,
});
added = true;
}
// Return updated counts
const rows = await db
.select({ type: commentReactions.type, cnt: count() })
.from(commentReactions)
.where(eq(commentReactions.commentId, commentId))
.groupBy(commentReactions.type);
const counts: Record<string, number> = {};
for (const row of rows) {
counts[row.type] = row.cnt;
}
return NextResponse.json({ counts, added });
}
@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, recipes, comments, users, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { dispatchWebhook } from "@/lib/webhooks";
import { sendPushNotification } from "@/lib/push";
const Schema = z.object({
content: z.string().min(1).max(5000),
parentId: z.string().optional(),
});
type Params = { params: Promise<{ id: string }> };
export async function GET(_req: NextRequest, { params }: Params) {
const { id } = await params;
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
if (!recipe || recipe.visibility === "private") {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const rows = await db
.select({
id: comments.id,
content: comments.content,
parentId: comments.parentId,
createdAt: comments.createdAt,
updatedAt: comments.updatedAt,
userId: comments.userId,
userName: users.name,
userUsername: users.username,
userAvatarUrl: users.avatarUrl,
})
.from(comments)
.innerJoin(users, eq(comments.userId, users.id))
.where(eq(comments.recipeId, id))
.orderBy(comments.createdAt);
return NextResponse.json(rows);
}
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
if (!recipe || (recipe.visibility === "private" && recipe.authorId !== session!.user.id)) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
if (parsed.data.parentId) {
const parent = await db.query.comments.findFirst({
where: and(eq(comments.id, parsed.data.parentId), eq(comments.recipeId, id)),
});
if (!parent) return NextResponse.json({ error: "Parent comment not found" }, { status: 404 });
}
const commentId = crypto.randomUUID();
await db.insert(comments).values({
id: commentId,
recipeId: id,
userId: session!.user.id,
parentId: parsed.data.parentId,
content: parsed.data.content,
});
// Dispatch to recipe author's webhooks (not to the commenter themselves)
if (recipe.authorId !== session!.user.id) {
void dispatchWebhook(recipe.authorId, "comment.added", { commentId, recipeId: id, recipeTitle: recipe.title });
void sendPushNotification(recipe.authorId, {
title: "New comment on your recipe",
body: `${session!.user.name} commented on "${recipe.title}"`,
url: `/recipes/${id}`,
});
}
return NextResponse.json({ id: commentId }, { status: 201 });
}
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, recipes, cookingHistory, pantryItems, recipeIngredients, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
const Schema = z.object({
servings: z.number().int().min(1).optional(),
notes: z.string().max(2000).optional(),
deductFromPantry: z.boolean().default(true),
});
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const userId = session!.user.id;
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
if (!recipe || (recipe.visibility === "private" && recipe.authorId !== userId)) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await req.json().catch(() => ({})) as unknown;
const parsed = Schema.safeParse(body);
const data = parsed.success ? parsed.data : { deductFromPantry: true };
await db.insert(cookingHistory).values({
id: crypto.randomUUID(),
userId,
recipeId: id,
servings: data.servings,
notes: data.notes,
cookedAt: new Date(),
});
if (data.deductFromPantry) {
const ings = await db.query.recipeIngredients.findMany({
where: eq(recipeIngredients.recipeId, id),
});
const scale = data.servings ? data.servings / recipe.baseServings : 1;
const userPantry = await db.query.pantryItems.findMany({
where: eq(pantryItems.userId, userId),
});
for (const ing of ings) {
const key = ing.rawName.toLowerCase();
const pantryItem = userPantry.find(
(p) => p.rawName.toLowerCase() === key && (p.unit ?? "") === (ing.unit ?? "")
);
if (!pantryItem) continue;
const pantryQty = pantryItem.quantity ? parseFloat(pantryItem.quantity) : null;
const ingQty = ing.quantity ? parseFloat(ing.quantity) * scale : null;
if (pantryQty !== null && ingQty !== null && !isNaN(pantryQty) && !isNaN(ingQty)) {
const remaining = pantryQty - ingQty;
if (remaining <= 0) {
await db.delete(pantryItems).where(eq(pantryItems.id, pantryItem.id));
} else {
await db.update(pantryItems)
.set({ quantity: String(Math.round(remaining * 10000) / 10000) })
.where(eq(pantryItems.id, pantryItem.id));
}
} else {
// no numeric quantity to deduct — remove the item entirely
await db.delete(pantryItems).where(eq(pantryItems.id, pantryItem.id));
}
}
}
return NextResponse.json({ logged: true }, { status: 201 });
}
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, favorites, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
export async function POST(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
if (!recipe || recipe.visibility === "private" && recipe.authorId !== session!.user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
await db.insert(favorites).values({ userId: session!.user.id, recipeId: id }).onConflictDoNothing();
return NextResponse.json({ favorited: true });
}
export async function DELETE(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
await db.delete(favorites).where(and(eq(favorites.userId, session!.user.id), eq(favorites.recipeId, id)));
return NextResponse.json({ favorited: false });
}
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeIngredients } from "@epicure/db";
import { eq, and, or, ne } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { estimateNutrition } from "@/lib/ai/features/estimate-nutrition";
type Params = { params: Promise<{ id: string }> };
export async function GET(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const recipe = await db.query.recipes.findFirst({
where: and(
eq(recipes.id, id),
or(
eq(recipes.authorId, session!.user.id),
ne(recipes.visibility, "private")
)
),
});
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json({ nutrition: recipe.nutritionData ?? null });
}
export async function POST(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const recipe = await db.query.recipes.findFirst({
where: and(
eq(recipes.id, id),
or(
eq(recipes.authorId, session!.user.id),
ne(recipes.visibility, "private")
)
),
with: {
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
},
});
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
const result = await estimateNutrition({
title: recipe.title,
baseServings: recipe.baseServings,
ingredients: recipe.ingredients.map((i) => ({
rawName: i.rawName,
quantity: i.quantity,
unit: i.unit,
})),
});
await db.update(recipes).set({ nutritionData: result }).where(eq(recipes.id, id));
return NextResponse.json({ nutrition: result });
}
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, recipes, ratings, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
const Schema = z.object({
score: z.number().int().min(1).max(5),
reviewText: z.string().max(2000).optional(),
});
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
if (!recipe || (recipe.visibility === "private" && recipe.authorId !== session!.user.id)) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (recipe.authorId === session!.user.id) {
return NextResponse.json({ error: "Cannot rate your own recipe" }, { status: 400 });
}
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const existing = await db.query.ratings.findFirst({
where: and(eq(ratings.recipeId, id), eq(ratings.userId, session!.user.id)),
});
if (existing) {
await db.update(ratings)
.set({ score: parsed.data.score, reviewText: parsed.data.reviewText, updatedAt: new Date() })
.where(eq(ratings.id, existing.id));
return NextResponse.json({ updated: true });
}
await db.insert(ratings).values({
id: crypto.randomUUID(),
recipeId: id,
userId: session!.user.id,
score: parsed.data.score,
reviewText: parsed.data.reviewText,
});
return NextResponse.json({ created: true }, { status: 201 });
}
+177
View File
@@ -0,0 +1,177 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeIngredients, recipeSteps, recipeSnapshots } from "@epicure/db";
import { eq, and, max } from "@epicure/db";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { dispatchWebhook } from "@/lib/webhooks";
const UpdateRecipeSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().optional(),
baseServings: z.number().int().min(1).max(100).optional(),
visibility: z.enum(["private", "unlisted", "public"]).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
prepMins: z.number().int().min(0).nullable().optional(),
cookMins: z.number().int().min(0).nullable().optional(),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
glutenFree: z.boolean().optional(),
dairyFree: z.boolean().optional(),
nutFree: z.boolean().optional(),
halal: z.boolean().optional(),
kosher: z.boolean().optional(),
}).optional(),
ingredients: z.array(z.object({
rawName: z.string().min(1),
quantity: z.string().optional(),
unit: z.string().optional(),
note: z.string().optional(),
order: z.number().int().default(0),
})).optional(),
steps: z.array(z.object({
instruction: z.string().min(1),
timerSeconds: z.number().int().optional(),
order: z.number().int(),
})).optional(),
});
type Params = { params: Promise<{ id: string }> };
async function getOwnedRecipe(recipeId: string, userId: string) {
return db.query.recipes.findFirst({
where: and(eq(recipes.id, recipeId), eq(recipes.authorId, userId)),
with: { ingredients: { orderBy: (t, { asc }) => asc(t.order) }, steps: { orderBy: (t, { asc }) => asc(t.order) }, photos: true },
});
}
export async function GET(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
if (response) return response;
const { id } = await params;
const recipe = await getOwnedRecipe(id, session!.user.id);
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(recipe);
}
export async function PUT(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
if (response) return response;
const { id } = await params;
const existing = await 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) } },
});
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
// Create a snapshot of the current state before updating
const [maxVersionRow] = await db
.select({ v: max(recipeSnapshots.version) })
.from(recipeSnapshots)
.where(eq(recipeSnapshots.recipeId, id));
const nextVersion = (maxVersionRow?.v ?? 0) + 1;
await db.insert(recipeSnapshots).values({
id: crypto.randomUUID(),
recipeId: id,
authorId: session!.user.id,
version: nextVersion,
title: existing.title,
snapshotData: {
title: existing.title,
description: existing.description,
baseServings: existing.baseServings,
difficulty: existing.difficulty,
prepMins: existing.prepMins,
cookMins: existing.cookMins,
dietaryTags: existing.dietaryTags ?? {},
ingredients: existing.ingredients.map((i) => ({
rawName: i.rawName,
quantity: i.quantity,
unit: i.unit,
note: i.note,
order: i.order,
})),
steps: existing.steps.map((s) => ({
instruction: s.instruction,
timerSeconds: s.timerSeconds,
order: s.order,
})),
},
});
const body = await req.json() as unknown;
const parsed = UpdateRecipeSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const data = parsed.data;
await db.transaction(async (tx) => {
const updates: Partial<typeof recipes.$inferInsert> = { updatedAt: new Date() };
if (data.title !== undefined) updates.title = data.title;
if (data.description !== undefined) updates.description = data.description;
if (data.baseServings !== undefined) updates.baseServings = data.baseServings;
if (data.visibility !== undefined) updates.visibility = data.visibility;
if (data.difficulty !== undefined) updates.difficulty = data.difficulty ?? undefined;
if (data.prepMins !== undefined) updates.prepMins = data.prepMins ?? undefined;
if (data.cookMins !== undefined) updates.cookMins = data.cookMins ?? undefined;
if (data.dietaryTags !== undefined) updates.dietaryTags = data.dietaryTags;
await tx.update(recipes).set(updates).where(eq(recipes.id, id));
if (data.ingredients !== undefined) {
await tx.delete(recipeIngredients).where(eq(recipeIngredients.recipeId, id));
if (data.ingredients.length > 0) {
await tx.insert(recipeIngredients).values(
data.ingredients.map((ing, i) => ({
id: crypto.randomUUID(),
recipeId: id,
rawName: ing.rawName,
quantity: ing.quantity,
unit: ing.unit,
note: ing.note,
order: ing.order ?? i,
}))
);
}
}
if (data.steps !== undefined) {
await tx.delete(recipeSteps).where(eq(recipeSteps.recipeId, id));
if (data.steps.length > 0) {
await tx.insert(recipeSteps).values(
data.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId: id,
instruction: step.instruction,
timerSeconds: step.timerSeconds,
order: step.order ?? i,
}))
);
}
}
});
const updated = await getOwnedRecipe(id, session!.user.id);
void dispatchWebhook(session!.user.id, "recipe.updated", { id, title: updated?.title });
return NextResponse.json(updated);
}
export async function DELETE(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
if (response) return response;
const { id } = await params;
const existing = await db.query.recipes.findFirst({
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
});
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
await db.delete(recipes).where(eq(recipes.id, id));
void dispatchWebhook(session!.user.id, "recipe.deleted", { id });
return new NextResponse(null, { status: 204 });
}
@@ -0,0 +1,148 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeIngredients, recipeSteps, recipeSnapshots } from "@epicure/db";
import { eq, and, max, desc } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string; versionId: string }> };
export async function GET(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
if (response) return response;
const { id, versionId } = await params;
// Verify ownership
const recipe = await db.query.recipes.findFirst({
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
});
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
const snapshot = await db.query.recipeSnapshots.findFirst({
where: and(
eq(recipeSnapshots.id, versionId),
eq(recipeSnapshots.recipeId, id),
eq(recipeSnapshots.authorId, session!.user.id)
),
});
if (!snapshot) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(snapshot);
}
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
if (response) return response;
const { id, versionId } = await params;
const body = await req.json() as { action?: string };
if (body.action !== "restore") {
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}
// Verify ownership and get current recipe with relations
const currentRecipe = await 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) } },
});
if (!currentRecipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
// Get the snapshot to restore
const snapshot = await db.query.recipeSnapshots.findFirst({
where: and(
eq(recipeSnapshots.id, versionId),
eq(recipeSnapshots.recipeId, id),
eq(recipeSnapshots.authorId, session!.user.id)
),
});
if (!snapshot) return NextResponse.json({ error: "Snapshot not found" }, { status: 404 });
// Create a snapshot of the current state before restoring
const [maxVersionRow] = await db
.select({ v: max(recipeSnapshots.version) })
.from(recipeSnapshots)
.where(eq(recipeSnapshots.recipeId, id));
const nextVersion = (maxVersionRow?.v ?? 0) + 1;
await db.insert(recipeSnapshots).values({
id: crypto.randomUUID(),
recipeId: id,
authorId: session!.user.id,
version: nextVersion,
title: currentRecipe.title,
snapshotData: {
title: currentRecipe.title,
description: currentRecipe.description,
baseServings: currentRecipe.baseServings,
difficulty: currentRecipe.difficulty,
prepMins: currentRecipe.prepMins,
cookMins: currentRecipe.cookMins,
dietaryTags: currentRecipe.dietaryTags ?? {},
ingredients: currentRecipe.ingredients.map((i) => ({
rawName: i.rawName,
quantity: i.quantity,
unit: i.unit,
note: i.note,
order: i.order,
})),
steps: currentRecipe.steps.map((s) => ({
instruction: s.instruction,
timerSeconds: s.timerSeconds,
order: s.order,
})),
},
});
// Restore the recipe from the snapshot
const data = snapshot.snapshotData as {
title: string;
description?: string | null;
baseServings: number;
difficulty?: string | null;
prepMins?: number | null;
cookMins?: number | null;
dietaryTags?: Record<string, boolean>;
ingredients: Array<{ rawName: string; quantity?: string | null; unit?: string | null; note?: string | null; order: number }>;
steps: Array<{ instruction: string; timerSeconds?: number | null; order: number }>;
};
await db.transaction(async (tx) => {
await tx.update(recipes).set({
title: data.title,
description: data.description ?? null,
baseServings: data.baseServings,
difficulty: (data.difficulty as "easy" | "medium" | "hard" | null) ?? null,
prepMins: data.prepMins ?? null,
cookMins: data.cookMins ?? null,
dietaryTags: data.dietaryTags ?? {},
updatedAt: new Date(),
}).where(eq(recipes.id, id));
await tx.delete(recipeIngredients).where(eq(recipeIngredients.recipeId, id));
if (data.ingredients.length > 0) {
await tx.insert(recipeIngredients).values(
data.ingredients.map((ing) => ({
id: crypto.randomUUID(),
recipeId: id,
rawName: ing.rawName,
quantity: ing.quantity ?? undefined,
unit: ing.unit ?? undefined,
note: ing.note ?? undefined,
order: ing.order,
}))
);
}
await tx.delete(recipeSteps).where(eq(recipeSteps.recipeId, id));
if (data.steps.length > 0) {
await tx.insert(recipeSteps).values(
data.steps.map((step) => ({
id: crypto.randomUUID(),
recipeId: id,
instruction: step.instruction,
timerSeconds: step.timerSeconds ?? undefined,
order: step.order,
}))
);
}
});
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeSnapshots } from "@epicure/db";
import { eq, and, desc } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
export async function GET(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
if (response) return response;
const { id } = await params;
// Verify ownership
const recipe = await db.query.recipes.findFirst({
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
});
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
const versions = await db
.select({
id: recipeSnapshots.id,
version: recipeSnapshots.version,
title: recipeSnapshots.title,
createdAt: recipeSnapshots.createdAt,
})
.from(recipeSnapshots)
.where(and(eq(recipeSnapshots.recipeId, id), eq(recipeSnapshots.authorId, session!.user.id)))
.orderBy(desc(recipeSnapshots.version))
.limit(20);
return NextResponse.json(versions);
}
+45
View File
@@ -0,0 +1,45 @@
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { db, recipes, eq, and, inArray } from "@epicure/db";
const BulkDeleteSchema = z.object({
ids: z.array(z.string()).min(1).max(100),
});
const BulkUpdateSchema = z.object({
ids: z.array(z.string()).min(1).max(100),
visibility: z.enum(["private", "unlisted", "public"]).optional(),
});
export async function DELETE(req: NextRequest) {
const session = await requireSession();
if (session instanceof NextResponse) return session;
const body = BulkDeleteSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
await db
.delete(recipes)
.where(and(inArray(recipes.id, body.data.ids), eq(recipes.authorId, session.user.id)));
return NextResponse.json({ ok: true });
}
export async function PATCH(req: NextRequest) {
const session = await requireSession();
if (session instanceof NextResponse) return session;
const body = BulkUpdateSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
const { ids, visibility } = body.data;
if (!visibility) return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
await db
.update(recipes)
.set({ visibility, updatedAt: new Date() })
.where(and(inArray(recipes.id, ids), eq(recipes.authorId, session.user.id)));
return NextResponse.json({ ok: true });
}
+167
View File
@@ -0,0 +1,167 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
import { eq, desc, and } from "@epicure/db";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { dispatchWebhook } from "@/lib/webhooks";
const UNICODE_FRACTIONS: Record<string, number> = {
"½": 0.5, "⅓": 1/3, "⅔": 2/3, "¼": 0.25, "¾": 0.75,
"⅕": 0.2, "⅖": 0.4, "⅗": 0.6, "⅘": 0.8,
"⅙": 1/6, "⅚": 5/6, "⅛": 0.125, "⅜": 0.375, "⅝": 0.625, "⅞": 0.875,
};
function parseQuantity(v: string | number | undefined): string | undefined {
if (v === undefined || v === "") return undefined;
const s = String(v).trim();
if (!s) return undefined;
if (UNICODE_FRACTIONS[s] !== undefined) return String(UNICODE_FRACTIONS[s]);
for (const [frac, val] of Object.entries(UNICODE_FRACTIONS)) {
if (s.endsWith(frac)) {
const whole = s.slice(0, -frac.length).trim();
if (!whole) return String(val);
const w = parseFloat(whole);
if (!isNaN(w)) return String(w + val);
}
}
const mixed = s.match(/^(\d+)\s+(\d+)\s*\/\s*(\d+)$/);
if (mixed) {
const den = parseInt(mixed[3]!);
if (den !== 0) return String(parseInt(mixed[1]!) + parseInt(mixed[2]!) / den);
}
const slash = s.match(/^(\d+)\s*\/\s*(\d+)$/);
if (slash) {
const den = parseInt(slash[2]!);
if (den !== 0) return String(parseInt(slash[1]!) / den);
}
const n = parseFloat(s);
return isNaN(n) ? undefined : String(n);
}
const CreateRecipeSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().optional(),
baseServings: z.number().int().min(1).max(100).default(4),
visibility: z.enum(["private", "unlisted", "public"]).default("private"),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
prepMins: z.number().int().min(0).optional(),
cookMins: z.number().int().min(0).optional(),
aiGenerated: z.boolean().optional(),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
glutenFree: z.boolean().optional(),
dairyFree: z.boolean().optional(),
nutFree: z.boolean().optional(),
halal: z.boolean().optional(),
kosher: z.boolean().optional(),
}).optional(),
ingredients: z.array(z.object({
rawName: z.string().min(1),
quantity: z.union([z.number(), z.string()]).optional().transform(parseQuantity),
unit: z.string().optional(),
note: z.string().optional(),
order: z.number().int().default(0),
})).default([]),
steps: z.array(z.object({
instruction: z.string().min(1),
timerSeconds: z.number().int().optional(),
order: z.number().int().optional(),
})).default([]),
});
export async function GET(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
if (response) return response;
const { searchParams } = new URL(req.url);
const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 100);
const offset = parseInt(searchParams.get("offset") ?? "0");
const visibility = searchParams.get("visibility") as "private" | "unlisted" | "public" | null;
const conditions = [eq(recipes.authorId, session!.user.id)];
if (visibility) conditions.push(eq(recipes.visibility, visibility));
const rows = await db
.select()
.from(recipes)
.where(and(...conditions))
.orderBy(desc(recipes.updatedAt))
.limit(limit)
.offset(offset);
return NextResponse.json({ data: rows, limit, offset });
}
export async function POST(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
if (response) return response;
const body = await req.json() as unknown;
const parsed = CreateRecipeSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "recipe");
const id = crypto.randomUUID();
const now = new Date();
const data = parsed.data;
await db.transaction(async (tx) => {
await tx.insert(recipes).values({
id,
authorId: session!.user.id,
title: data.title,
description: data.description,
baseServings: data.baseServings,
visibility: data.visibility,
difficulty: data.difficulty,
prepMins: data.prepMins,
cookMins: data.cookMins,
dietaryTags: data.dietaryTags ?? {},
aiGenerated: data.aiGenerated ?? false,
createdAt: now,
updatedAt: now,
});
if (data.ingredients.length > 0) {
await tx.insert(recipeIngredients).values(
data.ingredients.map((ing, i) => ({
id: crypto.randomUUID(),
recipeId: id,
rawName: ing.rawName,
quantity: ing.quantity,
unit: ing.unit,
note: ing.note,
order: ing.order ?? i,
}))
);
}
if (data.steps.length > 0) {
await tx.insert(recipeSteps).values(
data.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId: id,
instruction: step.instruction,
timerSeconds: step.timerSeconds,
order: step.order ?? i,
}))
);
}
});
await incrementUsage(session!.user.id, "recipe");
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
void dispatchWebhook(session!.user.id, "recipe.created", { id, title: data.title });
return NextResponse.json(recipe, { status: 201 });
}
@@ -0,0 +1,187 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Wand2, Loader2, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
type Ingredient = { rawName: string };
export function AdaptRecipeButton({
recipeId,
ingredients,
}: {
recipeId: string;
ingredients: Ingredient[];
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [excluded, setExcluded] = useState<Set<string>>(new Set());
const [extraConstraints, setExtraConstraints] = useState("");
const [adapting, setAdapting] = useState(false);
const [adaptationNotes, setAdaptationNotes] = useState("");
function toggleExclude(name: string) {
setExcluded((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}
function reset() {
setExcluded(new Set());
setExtraConstraints("");
setAdaptationNotes("");
}
async function handleAdapt() {
if (excluded.size === 0 && !extraConstraints.trim()) {
toast.error("Select at least one ingredient to exclude or add a constraint");
return;
}
setAdapting(true);
setAdaptationNotes("");
try {
const res = await fetch(`/api/v1/ai/adapt/${recipeId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
excludeIngredients: Array.from(excluded),
extraConstraints: extraConstraints.trim() || undefined,
}),
});
if (!res.ok) {
const err = await res.json() as { error?: string };
toast.error(err.error ?? "Failed to adapt recipe");
return;
}
const { id, adaptationNotes: notes } = await res.json() as { id: string; adaptationNotes: string };
setAdaptationNotes(notes);
toast.success("Adapted recipe saved as draft");
setOpen(false);
reset();
router.push(`/recipes/${id}/edit`);
} finally {
setAdapting(false);
}
}
const hasConstraints = excluded.size > 0 || extraConstraints.trim().length > 0;
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<Wand2 className="h-4 w-4" />
Adapt
</Button>
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Wand2 className="h-5 w-5 text-primary" />
Adapt this recipe
</DialogTitle>
<DialogDescription>
Tap ingredients to exclude them. AI will find the best substitutes while preserving the dish.
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
{/* Ingredient chips */}
<div className="space-y-2">
<Label>
Exclude ingredients
{excluded.size > 0 && (
<span className="ml-2 text-xs text-destructive font-normal">
{excluded.size} excluded
</span>
)}
</Label>
<div className="flex flex-wrap gap-2">
{ingredients.map((ing, idx) => {
const isExcluded = excluded.has(ing.rawName);
return (
<button
key={`${ing.rawName}-${idx}`}
type="button"
onClick={() => toggleExclude(ing.rawName)}
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm border transition-all",
isExcluded
? "bg-destructive/10 border-destructive/40 text-destructive line-through"
: "bg-muted border-transparent hover:border-border hover:bg-accent"
)}
>
{isExcluded && <X className="h-3 w-3 shrink-0" />}
{ing.rawName}
</button>
);
})}
</div>
</div>
{/* Free-text constraints */}
<div className="space-y-2">
<Label htmlFor="extra-constraints">
Additional constraints
<span className="ml-2 text-xs text-muted-foreground font-normal">optional</span>
</Label>
<Textarea
id="extra-constraints"
value={extraConstraints}
onChange={(e) => setExtraConstraints(e.target.value)}
placeholder="e.g. Make it vegan, lower the calories, use pantry staples only, gluten-free…"
rows={2}
disabled={adapting}
/>
</div>
{adapting && (
<p className="text-sm text-muted-foreground">
Adapting recipe this may take 2030 seconds
</p>
)}
<div className="flex gap-2 justify-end">
<Button
variant="outline"
onClick={() => { setOpen(false); reset(); }}
disabled={adapting}
>
Cancel
</Button>
{excluded.size > 0 && (
<Button variant="ghost" size="sm" onClick={() => setExcluded(new Set())} disabled={adapting}>
Clear
</Button>
)}
<Button onClick={handleAdapt} disabled={adapting || !hasConstraints}>
{adapting
? <><Loader2 className="h-4 w-4 animate-spin" />Adapting</>
: <><Wand2 className="h-4 w-4" />Adapt recipe</>
}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,220 @@
"use client";
import { useState, useEffect } from "react";
import { ShoppingCart, Loader2, Plus, Check } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
type Ingredient = {
rawName: string;
quantity?: string | number | null;
unit?: string | null;
};
type ShoppingList = { id: string; name: string };
function scaleQty(quantity: string | number | null | undefined, scale: number): string | undefined {
if (!quantity) return undefined;
const n = typeof quantity === "number" ? quantity : parseFloat(quantity);
if (isNaN(n)) return String(quantity);
const scaled = n * scale;
return scaled % 1 === 0 ? String(scaled) : scaled.toFixed(2).replace(/\.?0+$/, "");
}
export function AddToShoppingListButton({
recipeId,
recipeTitle,
baseServings,
ingredients,
}: {
recipeId: string;
recipeTitle: string;
baseServings: number;
ingredients: Ingredient[];
}) {
const [open, setOpen] = useState(false);
const [lists, setLists] = useState<ShoppingList[]>([]);
const [loadingLists, setLoadingLists] = useState(false);
const [mode, setMode] = useState<"existing" | "new">("existing");
const [selectedListId, setSelectedListId] = useState<string>("");
const [newListName, setNewListName] = useState(`${recipeTitle} shopping`);
const [servings, setServings] = useState(baseServings);
const [adding, setAdding] = useState(false);
useEffect(() => {
if (!open) return;
setLoadingLists(true);
fetch("/api/v1/shopping-lists")
.then((r) => r.json() as Promise<ShoppingList[]>)
.then((data) => {
setLists(data);
if (data.length > 0) {
setMode("existing");
setSelectedListId(data[0]!.id);
} else {
setMode("new");
}
})
.catch(() => setMode("new"))
.finally(() => setLoadingLists(false));
}, [open]);
const scale = servings / baseServings;
const items = ingredients.map((ing) => ({
rawName: ing.rawName,
quantity: scaleQty(ing.quantity, scale),
unit: ing.unit ?? undefined,
}));
async function handleAdd() {
setAdding(true);
try {
let listId = selectedListId;
if (mode === "new") {
const res = await fetch("/api/v1/shopping-lists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newListName.trim() || recipeTitle }),
});
if (!res.ok) { toast.error("Failed to create list"); return; }
const created = await res.json() as { id: string };
listId = created.id;
}
const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items }),
});
if (!res.ok) { toast.error("Failed to add ingredients"); return; }
toast.success(`${items.length} ingredients added to list`);
setOpen(false);
} finally {
setAdding(false);
}
}
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<ShoppingCart className="h-4 w-4" />
Add to list
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShoppingCart className="h-5 w-5 text-primary" />
Add to shopping list
</DialogTitle>
<DialogDescription>
Add the ingredients of <strong>{recipeTitle}</strong> to a shopping list.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Servings scaler */}
<div className="flex items-center gap-3">
<Label className="shrink-0">Servings</Label>
<div className="flex items-center gap-2">
<Button
type="button" variant="outline" size="icon" className="h-7 w-7"
onClick={() => setServings((s) => Math.max(1, s - 1))}
disabled={servings <= 1}
></Button>
<span className="w-8 text-center font-medium">{servings}</span>
<Button
type="button" variant="outline" size="icon" className="h-7 w-7"
onClick={() => setServings((s) => s + 1)}
>+</Button>
</div>
{scale !== 1 && (
<span className="text-xs text-muted-foreground">({scale > 1 ? "×" : "÷"}{Math.abs(scale) !== 1 ? (scale > 1 ? scale.toFixed(2).replace(/\.?0+$/, "") : (1 / scale).toFixed(2).replace(/\.?0+$/, "")) : ""} scaled)</span>
)}
</div>
<Separator />
{/* List picker */}
{loadingLists ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Loader2 className="h-4 w-4 animate-spin" /> Loading your lists
</div>
) : (
<div className="space-y-3">
{lists.length > 0 && (
<RadioGroup value={mode} onValueChange={(v: string) => setMode(v as "existing" | "new")}>
<div className="flex items-center gap-2">
<RadioGroupItem value="existing" id="mode-existing" />
<Label htmlFor="mode-existing">Add to existing list</Label>
</div>
{mode === "existing" && (
<div className="ml-6 space-y-1.5">
{lists.map((list) => (
<button
key={list.id}
onClick={() => setSelectedListId(list.id)}
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors flex items-center justify-between ${
selectedListId === list.id
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-accent"
}`}
>
{list.name}
{selectedListId === list.id && <Check className="h-3.5 w-3.5" />}
</button>
))}
</div>
)}
<div className="flex items-center gap-2">
<RadioGroupItem value="new" id="mode-new" />
<Label htmlFor="mode-new">Create new list</Label>
</div>
</RadioGroup>
)}
{(mode === "new" || lists.length === 0) && (
<div className="space-y-1.5 ml-6">
<Input
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
placeholder="List name"
/>
</div>
)}
</div>
)}
<Separator />
<p className="text-xs text-muted-foreground">{items.length} ingredient{items.length !== 1 ? "s" : ""} will be added.</p>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setOpen(false)} disabled={adding}>Cancel</Button>
<Button onClick={handleAdd} disabled={adding || loadingLists || (mode === "existing" && !selectedListId)}>
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{adding ? "Adding…" : "Add ingredients"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,287 @@
"use client";
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import { Sparkles, Loader2, Camera, Type, Upload, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
const LANGUAGES = [
{ code: "en", label: "English" },
{ code: "fr", label: "French" },
{ code: "es", label: "Spanish" },
{ code: "de", label: "German" },
{ code: "it", label: "Italian" },
{ code: "pt", label: "Portuguese" },
{ code: "ja", label: "Japanese" },
{ code: "zh", label: "Chinese" },
{ code: "ar", label: "Arabic" },
];
type Tab = "describe" | "photo";
type GeneratedRecipe = {
title: string;
description?: string;
baseServings?: number;
prepMins?: number;
cookMins?: number;
difficulty?: "easy" | "medium" | "hard";
dietaryTags?: Record<string, boolean>;
ingredients: Array<{ rawName: string; quantity?: string; unit?: string; note?: string }>;
steps: Array<{ instruction: string; timerSeconds?: number }>;
};
export function AiGenerateDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const router = useRouter();
const [tab, setTab] = useState<Tab>("describe");
// describe tab
const [prompt, setPrompt] = useState("");
const [language, setLanguage] = useState("en");
// photo tab
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
const [photoBase64, setPhotoBase64] = useState<string | null>(null);
const [photoMime, setPhotoMime] = useState<string>("image/jpeg");
const fileRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
function handleClose() {
if (busy) return;
onOpenChange(false);
setTimeout(() => {
setPrompt("");
setPhotoPreview(null);
setPhotoBase64(null);
setTab("describe");
}, 200);
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setPhotoMime(file.type);
const reader = new FileReader();
reader.onload = (ev) => {
const dataUrl = ev.target?.result as string;
setPhotoPreview(dataUrl);
// strip the data:mime;base64, prefix
setPhotoBase64(dataUrl.split(",")[1] ?? null);
};
reader.readAsDataURL(file);
e.target.value = "";
}
async function handleDescribeGenerate() {
if (!prompt.trim()) return;
setBusy(true);
try {
const res = await fetch("/api/v1/ai/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: prompt.trim(), language }),
});
if (!res.ok) {
const err = await res.json() as { error?: string };
toast.error(err.error ?? "Failed to generate recipe");
return;
}
const generated = await res.json() as GeneratedRecipe;
const saveRes = await fetch("/api/v1/recipes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...generated, visibility: "private", aiGenerated: true }),
});
if (!saveRes.ok) { toast.error("Failed to save generated recipe"); return; }
const saved = await saveRes.json() as { id: string };
toast.success("Recipe generated! Review and edit before publishing.");
handleClose();
router.push(`/recipes/${saved.id}/edit`);
} finally {
setBusy(false);
}
}
async function handlePhotoGenerate() {
if (!photoBase64) return;
setBusy(true);
try {
const res = await fetch("/api/v1/ai/import-photo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageBase64: photoBase64, mimeType: photoMime }),
});
if (!res.ok) {
let msg = "Failed to analyze photo";
try { msg = ((await res.json()) as { error?: string }).error ?? msg; } catch { /* non-JSON body */ }
toast.error(msg);
return;
}
const { id } = await res.json() as { id: string };
toast.success("Recipe recognized! Review and edit before publishing.");
handleClose();
router.push(`/recipes/${id}/edit`);
} finally {
setBusy(false);
}
}
const canSubmit = tab === "describe" ? !!prompt.trim() : !!photoBase64;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
Generate recipe with AI
</DialogTitle>
<DialogDescription>
Describe a dish in words or snap a photo AI builds the full recipe.
</DialogDescription>
</DialogHeader>
{/* Tab switcher */}
<div className="flex gap-1 p-1 bg-muted rounded-lg">
{([
{ key: "describe", label: "Describe", icon: Type },
{ key: "photo", label: "From photo", icon: Camera },
] as { key: Tab; label: string; icon: React.ElementType }[]).map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setTab(key)}
disabled={busy}
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 px-3 rounded-md text-sm font-medium transition-all",
tab === key
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
{/* Describe tab */}
{tab === "describe" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ai-prompt">What do you want to cook?</Label>
<Textarea
id="ai-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="e.g. A creamy mushroom risotto with parmesan, serves 4, easy difficulty"
rows={4}
disabled={busy}
/>
</div>
<div className="space-y-2">
<Label>Recipe language</Label>
<Select value={language} onValueChange={(v) => v && setLanguage(v)} disabled={busy}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((l) => (
<SelectItem key={l.code} value={l.code}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{/* Photo tab */}
{tab === "photo" && (
<div className="space-y-3">
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleFileChange}
/>
{photoPreview ? (
<div className="relative rounded-xl overflow-hidden border bg-muted">
<img
src={photoPreview}
alt="Dish preview"
className="w-full max-h-64 object-cover"
/>
<button
onClick={() => { setPhotoPreview(null); setPhotoBase64(null); }}
className="absolute top-2 right-2 h-7 w-7 rounded-full bg-black/60 flex items-center justify-center text-white hover:bg-black/80 transition-colors"
disabled={busy}
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<button
onClick={() => fileRef.current?.click()}
disabled={busy}
className="w-full border-2 border-dashed rounded-xl p-10 flex flex-col items-center gap-3 text-muted-foreground hover:border-primary/50 hover:text-foreground transition-colors"
>
<Upload className="h-8 w-8" />
<div className="text-sm text-center">
<span className="font-medium text-foreground">Click to upload</span> or drag a food photo
<p className="text-xs mt-1">JPEG, PNG or WebP · AI will reverse-engineer the recipe</p>
</div>
</button>
)}
{photoPreview && (
<p className="text-xs text-muted-foreground text-center">
AI will analyze this dish and infer ingredients, quantities, and cooking steps.
</p>
)}
</div>
)}
<FakeProgressBar
active={busy}
durationMs={tab === "photo" ? 12000 : 10000}
label={busy ? (tab === "photo" ? "Analyzing dish photo…" : "Generating recipe…") : undefined}
/>
{/* Actions */}
<div className="flex gap-2 justify-end pt-1">
<Button variant="outline" onClick={handleClose} disabled={busy}>
Cancel
</Button>
<Button
onClick={tab === "describe" ? () => { void handleDescribeGenerate(); } : () => { void handlePhotoGenerate(); }}
disabled={!canSubmit || busy}
>
{busy ? (
<><Loader2 className="h-4 w-4 animate-spin" />Analyzing</>
) : (
<><Sparkles className="h-4 w-4" />{tab === "photo" ? "Recognize dish" : "Generate"}</>
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,138 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Package } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type ScoredItem = {
recipe: {
id: string;
title: string;
description: string | null;
photos: Array<{ url: string }>;
};
matched: number;
total: number;
pct: number;
missing: string[];
};
type Props = {
pantryCount: number;
scored: ScoredItem[];
};
function RecipeRow({ s }: { s: ScoredItem }) {
const t = useTranslations("canCook");
const cover = s.recipe.photos?.[0];
return (
<Link
href={`/recipes/${s.recipe.id}`}
className="flex items-center gap-4 rounded-xl border p-3 hover:bg-accent transition-colors"
>
{cover ? (
<img src={cover.url} alt="" className="h-14 w-14 rounded-lg object-cover shrink-0" />
) : (
<div className="h-14 w-14 rounded-lg bg-muted shrink-0" />
)}
<div className="flex-1 min-w-0 space-y-1">
<p className="font-medium truncate">{s.recipe.title}</p>
<div className="flex items-center gap-2">
<Progress value={s.pct} className="h-1.5 flex-1 max-w-[120px]" />
<span className="text-xs text-muted-foreground">
{t("ingredientProgress", { matched: s.matched, total: s.total })}
</span>
</div>
{s.missing.length > 0 && (
<p className="text-xs text-muted-foreground truncate">
{t("missing")}: {s.missing.join(", ")}{s.missing.length < s.total - s.matched ? "…" : ""}
</p>
)}
</div>
<Badge variant={s.pct === 100 ? "default" : "secondary"} className="shrink-0">
{s.pct}%
</Badge>
</Link>
);
}
export function CanCookContent({ pantryCount, scored }: Props) {
const t = useTranslations("canCook");
if (pantryCount === 0) {
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground mt-1">{t("subtitle")}</p>
</div>
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed rounded-xl gap-4">
<Package className="h-8 w-8 text-muted-foreground" />
<p className="text-muted-foreground text-sm">{t("emptyPantry")}</p>
<Link href="/pantry" className={cn(buttonVariants({ size: "sm" }))}>
{t("addPantryItems")}
</Link>
</div>
</div>
);
}
const canCook = scored.filter((s) => s.pct === 100);
const almostCook = scored.filter((s) => s.pct >= 70 && s.pct < 100);
const rest = scored.filter((s) => s.pct < 70 && s.pct > 0);
return (
<div className="space-y-8 max-w-2xl">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground mt-1">{t("subtitle", { count: pantryCount })}</p>
</div>
{canCook.length > 0 && (
<section className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
{t("readyToCook")}
<Badge>{canCook.length}</Badge>
</h2>
<div className="space-y-2">
{canCook.map((s) => <RecipeRow key={s.recipe.id} s={s} />)}
</div>
</section>
)}
{almostCook.length > 0 && (
<section className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
{t("almostThere")} <span className="text-sm font-normal text-muted-foreground">(7099%)</span>
<Badge variant="secondary">{almostCook.length}</Badge>
</h2>
<div className="space-y-2">
{almostCook.map((s) => <RecipeRow key={s.recipe.id} s={s} />)}
</div>
</section>
)}
{canCook.length === 0 && almostCook.length === 0 && (
<div className="flex flex-col items-center justify-center h-48 border-2 border-dashed rounded-xl gap-3">
<p className="text-muted-foreground text-sm">{t("noMatches")}</p>
<Link href="/pantry" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
{t("addPantryItems")}
</Link>
</div>
)}
{rest.length > 0 && (
<section className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground">{t("partialMatches")}</h2>
<div className="space-y-2">
{rest.slice(0, 5).map((s) => <RecipeRow key={s.recipe.id} s={s} />)}
</div>
</section>
)}
</div>
);
}
@@ -0,0 +1,67 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
export function DeleteRecipeButton({ recipeId }: { recipeId: string }) {
const [deleting, setDeleting] = useState(false);
const router = useRouter();
async function handleDelete() {
setDeleting(true);
try {
const res = await fetch(`/api/v1/recipes/${recipeId}`, { method: "DELETE" });
if (!res.ok) {
const data = await res.json() as { error?: string };
throw new Error(data.error ?? "Delete failed");
}
toast.success("Recipe deleted");
router.push("/recipes");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Delete failed");
setDeleting(false);
}
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete recipe?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. The recipe and all its data will be permanently deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => { void handleDelete(); }}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? "Deleting…" : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,51 @@
"use client";
type DietaryTags = {
vegan?: boolean;
vegetarian?: boolean;
glutenFree?: boolean;
dairyFree?: boolean;
nutFree?: boolean;
halal?: boolean;
kosher?: boolean;
};
const TAG_LABELS: Record<keyof DietaryTags, string> = {
vegan: "Vegan",
vegetarian: "Vegetarian",
glutenFree: "Gluten-free",
dairyFree: "Dairy-free",
nutFree: "Nut-free",
halal: "Halal",
kosher: "Kosher",
};
export function DietaryTagPicker({
value,
onChange,
}: {
value: DietaryTags;
onChange: (tags: DietaryTags) => void;
}) {
return (
<div className="flex flex-wrap gap-2">
{(Object.entries(TAG_LABELS) as [keyof DietaryTags, string][]).map(([key, label]) => {
const active = !!value[key];
return (
<button
key={key}
type="button"
onClick={() => onChange({ ...value, [key]: !active })}
className={`rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
active
? "border-primary bg-primary text-primary-foreground"
: "border-input bg-background hover:bg-muted"
}`}
>
{label}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,155 @@
"use client";
import { useState } from "react";
import { Wine, Sparkles, Loader2, Coffee, Beer, GlassWater, Leaf } from "lucide-react";
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
type Drink = {
name: string;
type: "wine" | "beer" | "cocktail" | "spirit" | "non-alcoholic" | "hot";
alcoholic: boolean;
description: string;
examples: string[];
whyItPairs: string;
servingTip?: string;
};
const TYPE_ICON: Record<Drink["type"], React.ElementType> = {
wine: Wine,
beer: Beer,
cocktail: Wine,
spirit: Wine,
"non-alcoholic": GlassWater,
hot: Coffee,
};
const TYPE_LABEL: Record<Drink["type"], string> = {
wine: "Wine",
beer: "Beer",
cocktail: "Cocktail",
spirit: "Spirit",
"non-alcoholic": "Sans alcool",
hot: "Chaud",
};
export function DrinkPairingButton({ recipeId }: { recipeId: string }) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [drinks, setDrinks] = useState<Drink[]>([]);
async function suggest() {
setLoading(true);
setDrinks([]);
try {
const res = await fetch(`/api/v1/ai/drinks/${recipeId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ count: 4 }),
});
if (!res.ok) {
toast.error("Failed to suggest drinks");
return;
}
const data = await res.json() as { drinks: Drink[] };
setDrinks(data.drinks);
} finally {
setLoading(false);
}
}
function handleOpen() {
setOpen(true);
if (drinks.length === 0) suggest();
}
return (
<>
<Button variant="outline" size="sm" onClick={handleOpen}>
<Wine className="h-4 w-4" />
Drinks
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Wine className="h-5 w-5 text-primary" />
Drink pairings
</DialogTitle>
<DialogDescription>
AI-suggested drinks that complement this recipe.
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="py-6 space-y-3">
<FakeProgressBar active={loading} durationMs={8000} label="Finding perfect pairings…" />
</div>
) : drinks.length === 0 ? (
<div className="flex flex-col items-center gap-4 py-8">
<Button onClick={suggest} size="lg">
<Sparkles className="h-4 w-4" />
Suggest drinks
</Button>
</div>
) : (
<div className="space-y-3">
{drinks.map((drink, i) => {
const Icon = TYPE_ICON[drink.type];
return (
<div key={i} className="rounded-lg border p-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 shrink-0 h-8 w-8 rounded-full bg-muted flex items-center justify-center">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold">{drink.name}</span>
<Badge variant="outline" className="text-xs">{TYPE_LABEL[drink.type]}</Badge>
{!drink.alcoholic && (
<Badge variant="secondary" className="text-xs flex items-center gap-1">
<Leaf className="h-2.5 w-2.5" />
Sans alcool
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{drink.description}</p>
{drink.examples.length > 0 && (
<p className="text-xs text-muted-foreground">
<span className="font-medium">e.g. </span>
{drink.examples.join(" · ")}
</p>
)}
<p className="text-xs text-muted-foreground italic">&ldquo;{drink.whyItPairs}&rdquo;</p>
{drink.servingTip && (
<p className="text-xs text-muted-foreground border-l-2 border-muted pl-2">{drink.servingTip}</p>
)}
</div>
</div>
{i < drinks.length - 1 && <Separator className="mt-3" />}
</div>
);
})}
<Button variant="outline" className="w-full" onClick={suggest} disabled={loading}>
<Sparkles className="h-4 w-4" />
Regenerate
</Button>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,249 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
import { UtensilsCrossed, Sparkles, Loader2, ChefHat, Salad, Wine, Cake, Sandwich, Soup, Check } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
type Pairing = {
name: string;
role: "starter" | "side" | "salad" | "bread" | "drink" | "dessert" | "sauce";
description: string;
whyItPairs: string;
difficulty: "easy" | "medium" | "hard";
prepTimeMins?: number;
};
const ROLE_ICON: Record<Pairing["role"], React.ElementType> = {
starter: Soup,
side: ChefHat,
salad: Salad,
bread: Sandwich,
drink: Wine,
dessert: Cake,
sauce: ChefHat,
};
const ROLE_LABEL: Record<Pairing["role"], string> = {
starter: "Starter",
side: "Side",
salad: "Salad",
bread: "Bread",
drink: "Drink",
dessert: "Dessert",
sauce: "Sauce",
};
const DIFFICULTY_VARIANT = {
easy: "default",
medium: "secondary",
hard: "destructive",
} as const;
export function MealPairingButton({ recipeId }: { recipeId: string }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [generatingProgress, setGeneratingProgress] = useState<{ current: number; total: number } | null>(null);
const [pairings, setPairings] = useState<Pairing[]>([]);
async function suggest() {
setLoading(true);
setPairings([]);
setSelected(new Set());
try {
const res = await fetch(`/api/v1/ai/pairings/${recipeId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ count: 4 }),
});
if (!res.ok) { toast.error("Failed to suggest pairings"); return; }
const data = await res.json() as { pairings: Pairing[] };
setPairings(data.pairings);
} finally {
setLoading(false);
}
}
function toggleSelect(i: number) {
setSelected((prev) => {
const next = new Set(prev);
next.has(i) ? next.delete(i) : next.add(i);
return next;
});
}
async function generateSelected() {
const indices = Array.from(selected).sort();
setGeneratingProgress({ current: 0, total: indices.length });
const savedIds: string[] = [];
for (let n = 0; n < indices.length; n++) {
const pairing = pairings[indices[n]!]!;
setGeneratingProgress({ current: n + 1, total: indices.length });
try {
const genRes = await fetch("/api/v1/ai/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: pairing.name }),
});
if (!genRes.ok) { toast.error(`Failed to generate "${pairing.name}"`); continue; }
const generated = await genRes.json() as {
title: string; description?: string; baseServings?: number; prepMins?: number;
cookMins?: number; difficulty?: string; dietaryTags?: Record<string, boolean>;
ingredients: Array<{ rawName: string; quantity?: string; unit?: string; note?: string }>;
steps: Array<{ instruction: string; timerSeconds?: number }>;
};
const saveRes = await fetch("/api/v1/recipes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...generated, visibility: "private", aiGenerated: true }),
});
if (!saveRes.ok) { toast.error(`Failed to save "${pairing.name}"`); continue; }
const saved = await saveRes.json() as { id: string };
savedIds.push(saved.id);
} catch {
toast.error(`Error generating "${pairing.name}"`);
}
}
setGeneratingProgress(null);
setSelected(new Set());
setOpen(false);
if (savedIds.length === 1) {
toast.success("Recipe generated — review before publishing");
router.push(`/recipes/${savedIds[0]}/edit`);
} else if (savedIds.length > 1) {
toast.success(`${savedIds.length} recipes generated — find them in your library`);
router.push("/recipes");
}
}
return (
<>
<Button variant="outline" size="sm" onClick={() => { setOpen(true); if (pairings.length === 0) suggest(); }}>
<UtensilsCrossed className="h-4 w-4" />
Pair meal
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-5xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UtensilsCrossed className="h-5 w-5 text-primary" />
Complete the meal
</DialogTitle>
<DialogDescription>
AI-suggested dishes that pair well with this recipe. Generate any of them as a new recipe.
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="py-6 space-y-3">
<FakeProgressBar active={loading} durationMs={8000} label="Finding perfect pairings…" />
</div>
) : pairings.length === 0 ? (
<div className="flex flex-col items-center gap-4 py-8">
<Button onClick={suggest} size="lg">
<Sparkles className="h-4 w-4" />
Suggest pairings
</Button>
</div>
) : (
<div className="space-y-3">
{pairings.map((pairing, i) => {
const Icon = ROLE_ICON[pairing.role];
const isSelected = selected.has(i);
return (
<div
key={i}
onClick={() => !generatingProgress && toggleSelect(i)}
className={cn(
"rounded-lg border p-4 cursor-pointer transition-colors",
isSelected ? "border-primary bg-primary/5" : "hover:border-muted-foreground/40"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"mt-0.5 shrink-0 h-5 w-5 rounded border-2 flex items-center justify-center transition-colors",
isSelected ? "border-primary bg-primary" : "border-muted-foreground/40"
)}>
{isSelected && <Check className="h-3 w-3 text-primary-foreground" strokeWidth={3} />}
</div>
<div className="mt-0.5 shrink-0 h-8 w-8 rounded-full bg-muted flex items-center justify-center">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold">{pairing.name}</span>
<Badge variant="outline" className="text-xs">{ROLE_LABEL[pairing.role]}</Badge>
<Badge variant={DIFFICULTY_VARIANT[pairing.difficulty]} className="text-xs">{pairing.difficulty}</Badge>
{pairing.prepTimeMins && (
<span className="text-xs text-muted-foreground">{pairing.prepTimeMins}m</span>
)}
</div>
<p className="text-sm text-muted-foreground">{pairing.description}</p>
<p className="text-xs text-muted-foreground italic">&ldquo;{pairing.whyItPairs}&rdquo;</p>
</div>
</div>
{i < pairings.length - 1 && <Separator className="mt-3 -mx-4 w-[calc(100%+2rem)]" />}
</div>
);
})}
{generatingProgress && (
<FakeProgressBar
active={!!generatingProgress}
durationMs={generatingProgress.total * 10000}
label={`Generating recipe ${generatingProgress.current} of ${generatingProgress.total}`}
/>
)}
<div className="flex gap-2 pt-1">
<Button
variant="outline"
className="flex-1"
onClick={(e) => { e.stopPropagation(); suggest(); }}
disabled={!!generatingProgress}
>
<Sparkles className="h-4 w-4" />
Regenerate
</Button>
<Button
className="flex-1"
onClick={(e) => { e.stopPropagation(); generateSelected(); }}
disabled={selected.size === 0 || !!generatingProgress}
>
{generatingProgress ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Generating {generatingProgress.current}/{generatingProgress.total}
</>
) : (
<>
<Sparkles className="h-4 w-4" />
Generate{selected.size > 0 ? ` (${selected.size})` : ""}
</>
)}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,11 @@
"use client";
import { useTranslations } from "next-intl";
export function NewRecipeHeader() {
const t = useTranslations("recipes");
return (
<div>
<h1 className="text-3xl font-bold tracking-tight">{t("newTitle")}</h1>
<p className="text-muted-foreground mt-1">{t("newSubtitle")}</p>
</div>
);
}
@@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
type NutritionData = {
perServing: {
calories: number;
proteinG: number;
carbsG: number;
fatG: number;
fiberG: number;
sodiumMg: number;
};
};
interface NutritionPanelProps {
recipeId: string;
initialData?: NutritionData | null;
}
export function NutritionPanel({ recipeId, initialData }: NutritionPanelProps) {
const [nutrition, setNutrition] = useState<NutritionData | null>(initialData ?? null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleEstimate() {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/v1/recipes/${recipeId}/nutrition`, {
method: "POST",
});
if (!res.ok) {
const body = await res.json() as { error?: string };
throw new Error(body.error ?? "Failed to estimate nutrition");
}
const data = await res.json() as { nutrition: NutritionData };
setNutrition(data.nutrition);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setLoading(false);
}
}
if (!nutrition && !loading) {
return (
<div className="flex flex-col gap-2">
{error && <p className="text-sm text-destructive">{error}</p>}
<Button variant="outline" onClick={handleEstimate} disabled={loading}>
Estimate nutrition
</Button>
</div>
);
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Nutrition per serving</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={handleEstimate}
disabled={loading}
className="text-xs text-muted-foreground"
>
{loading ? "Estimating…" : "Re-estimate"}
</Button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</CardHeader>
{nutrition && (
<CardContent>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
<div className="col-span-3 sm:col-span-6 flex items-baseline gap-1">
<span className="text-4xl font-bold tabular-nums">
{Math.round(nutrition.perServing.calories)}
</span>
<span className="text-sm text-muted-foreground">kcal</span>
</div>
<MacroCell
label="Protein"
value={nutrition.perServing.proteinG}
unit="g"
/>
<MacroCell
label="Carbs"
value={nutrition.perServing.carbsG}
unit="g"
/>
<MacroCell
label="Fat"
value={nutrition.perServing.fatG}
unit="g"
/>
<MacroCell
label="Fiber"
value={nutrition.perServing.fiberG}
unit="g"
/>
<MacroCell
label="Sodium"
value={nutrition.perServing.sodiumMg}
unit="mg"
/>
</div>
</CardContent>
)}
{loading && !nutrition && (
<CardContent>
<p className="text-sm text-muted-foreground animate-pulse">
Estimating nutrition
</p>
</CardContent>
)}
</Card>
);
}
function MacroCell({
label,
value,
unit,
}: {
label: string;
value: number;
unit: string;
}) {
return (
<div className="flex flex-col gap-0.5 rounded-md bg-muted p-2">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="font-semibold tabular-nums">
{Math.round(value)}
<span className="text-xs font-normal text-muted-foreground ml-0.5">
{unit}
</span>
</span>
</div>
);
}
@@ -0,0 +1,83 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Camera, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
export function PhotoImportButton() {
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
const [loading, setLoading] = useState(false);
function handleClick() {
fileRef.current?.click();
}
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setLoading(true);
try {
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// Strip the data:mime/type;base64, prefix
const comma = result.indexOf(",");
resolve(comma !== -1 ? result.slice(comma + 1) : result);
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
const res = await fetch("/api/v1/ai/import-photo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
imageBase64: base64,
mimeType: file.type,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({})) as { error?: string };
throw new Error(data.error ?? `Request failed: ${res.status}`);
}
const { id } = await res.json() as { id: string };
router.push(`/recipes/${id}/edit`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to import recipe from photo.");
} finally {
setLoading(false);
// Reset input so the same file can be re-selected
if (fileRef.current) fileRef.current.value = "";
}
}
return (
<div className="space-y-2">
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleFile}
/>
<Button onClick={handleClick} disabled={loading} variant="outline">
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Camera className="mr-2 h-4 w-4" />
)}
Import from Photo
</Button>
<FakeProgressBar active={loading} durationMs={12000} label={loading ? "Analyzing photo…" : undefined} />
</div>
);
}
@@ -0,0 +1,117 @@
"use client";
import { useState, useRef } from "react";
import { Upload, X, Star } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getPublicUrl } from "@/lib/storage";
export type PhotoEntry = {
key: string;
isCover: boolean;
preview: string;
};
export function PhotoUploader({
recipeId,
photos,
onChange,
}: {
recipeId: string;
photos: PhotoEntry[];
onChange: (photos: PhotoEntry[]) => void;
}) {
const [uploading, setUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
async function handleFiles(files: FileList) {
setUploading(true);
try {
for (const file of Array.from(files)) {
const res = await fetch("/api/v1/upload/presign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ recipeId, contentType: file.type }),
});
if (!res.ok) continue;
const { url, key } = await res.json() as { url: string; key: string };
await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
const isFirst = photos.length === 0;
onChange([...photos, { key, isCover: isFirst, preview: URL.createObjectURL(file) }]);
}
} finally {
setUploading(false);
}
}
function setCover(key: string) {
onChange(photos.map((p) => ({ ...p, isCover: p.key === key })));
}
function remove(key: string) {
const filtered = photos.filter((p) => p.key !== key);
if (filtered.length > 0 && !filtered.some((p) => p.isCover)) {
filtered[0]!.isCover = true;
}
onChange(filtered);
}
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-3">
{photos.map((photo) => (
<div key={photo.key} className="relative group">
<img
src={photo.preview || getPublicUrl(photo.key)}
alt=""
className={`h-24 w-24 rounded-lg object-cover border-2 ${
photo.isCover ? "border-primary" : "border-transparent"
}`}
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 rounded-lg transition-opacity flex items-center justify-center gap-1">
<button
type="button"
onClick={() => setCover(photo.key)}
className="rounded-full bg-white/20 p-1 hover:bg-white/40 transition-colors"
title="Set as cover"
>
<Star className={`h-3 w-3 ${photo.isCover ? "text-yellow-400" : "text-white"}`} />
</button>
<button
type="button"
onClick={() => remove(photo.key)}
className="rounded-full bg-white/20 p-1 hover:bg-red-500/80 transition-colors"
title="Remove"
>
<X className="h-3 w-3 text-white" />
</button>
</div>
{photo.isCover && (
<span className="absolute bottom-1 left-1 text-[10px] bg-primary text-primary-foreground rounded px-1">
cover
</span>
)}
</div>
))}
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="h-24 w-24 rounded-lg border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/50 flex flex-col items-center justify-center gap-1 text-muted-foreground transition-colors disabled:opacity-50"
>
<Upload className="h-4 w-4" />
<span className="text-xs">{uploading ? "Uploading…" : "Add photo"}</span>
</button>
</div>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/avif"
multiple
className="hidden"
onChange={(e) => e.target.files && handleFiles(e.target.files)}
/>
</div>
);
}
@@ -0,0 +1,18 @@
"use client";
import { Printer } from "lucide-react";
import { Button } from "@/components/ui/button";
export function PrintButton({ recipeId }: { recipeId: string }) {
function handlePrint() {
const win = window.open(`/recipes/${recipeId}/print`, "_blank", "width=800,height=900");
win?.focus();
}
return (
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4" />
Print
</Button>
);
}
@@ -0,0 +1,12 @@
"use client";
export function PrintTrigger() {
return (
<button
className="print-btn no-print"
onClick={() => window.print()}
>
Print / Save PDF
</button>
);
}
@@ -0,0 +1,85 @@
import Link from "next/link";
import { Clock, Users, Lock, Globe, Link2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { getPublicUrl } from "@/lib/storage";
type Recipe = {
id: string;
title: string;
description: string | null;
baseServings: number;
prepMins: number | null;
cookMins: number | null;
difficulty: "easy" | "medium" | "hard" | null;
visibility: "private" | "unlisted" | "public";
updatedAt: Date;
photos?: Array<{ storageKey: string; isCover: boolean }>;
};
const VISIBILITY_ICON = {
private: Lock,
unlisted: Link2,
public: Globe,
};
const DIFFICULTY_COLOR = {
easy: "default",
medium: "secondary",
hard: "destructive",
} as const;
export function RecipeCard({ recipe }: { recipe: Recipe }) {
const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
return (
<Link href={`/recipes/${recipe.id}`}>
<Card className="group overflow-hidden hover:shadow-md transition-shadow h-full flex flex-col">
{cover ? (
<div className="aspect-video overflow-hidden bg-muted">
<img
src={getPublicUrl(cover.storageKey)}
alt={recipe.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
) : (
<div className="aspect-video bg-gradient-to-br from-muted to-muted/50 flex items-center justify-center text-4xl">
🍽
</div>
)}
<CardHeader className="pb-2">
<h3 className="font-semibold leading-tight line-clamp-2 group-hover:text-primary transition-colors">
{recipe.title}
</h3>
{recipe.description && (
<p className="text-sm text-muted-foreground line-clamp-2">{recipe.description}</p>
)}
</CardHeader>
<CardContent className="pb-2 flex-1" />
<CardFooter className="pt-4 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{recipe.baseServings}
</span>
{totalMins > 0 && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{totalMins}m
</span>
)}
{recipe.difficulty && (
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4">
{recipe.difficulty}
</Badge>
)}
</div>
<VisibilityIcon className="h-3 w-3" />
</CardFooter>
</Card>
</Link>
);
}
+388
View File
@@ -0,0 +1,388 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, Trash2, GripVertical } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { DietaryTagPicker } from "./dietary-tag-picker";
import { PhotoUploader, type PhotoEntry } from "./photo-uploader";
type DietaryTags = {
vegan?: boolean;
vegetarian?: boolean;
glutenFree?: boolean;
dairyFree?: boolean;
nutFree?: boolean;
halal?: boolean;
kosher?: boolean;
};
type IngredientRow = {
id: string;
rawName: string;
quantity: string;
unit: string;
note: string;
};
type StepRow = {
id: string;
instruction: string;
timerSeconds: string;
};
type RecipeFormProps = {
recipeId?: string;
defaultValues?: {
title?: string;
description?: string;
baseServings?: number;
visibility?: "private" | "unlisted" | "public";
difficulty?: "easy" | "medium" | "hard" | null;
prepMins?: number | null;
cookMins?: number | null;
dietaryTags?: DietaryTags;
ingredients?: IngredientRow[];
steps?: StepRow[];
photos?: PhotoEntry[];
};
};
function newIngredient(): IngredientRow {
return { id: crypto.randomUUID(), rawName: "", quantity: "", unit: "", note: "" };
}
function newStep(): StepRow {
return { id: crypto.randomUUID(), instruction: "", timerSeconds: "" };
}
export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
const router = useRouter();
const t = useTranslations("recipeForm");
const t_recipe = useTranslations("recipe");
const t_common = useTranslations("common");
const isEdit = !!recipeId;
const id = recipeId ?? crypto.randomUUID();
const [title, setTitle] = useState(defaultValues?.title ?? "");
const [description, setDescription] = useState(defaultValues?.description ?? "");
const [baseServings, setBaseServings] = useState(String(defaultValues?.baseServings ?? 4));
const [visibility, setVisibility] = useState<"private" | "unlisted" | "public">(defaultValues?.visibility ?? "private");
const [difficulty, setDifficulty] = useState<"easy" | "medium" | "hard" | "">(defaultValues?.difficulty ?? "");
const [prepMins, setPrepMins] = useState(String(defaultValues?.prepMins ?? ""));
const [cookMins, setCookMins] = useState(String(defaultValues?.cookMins ?? ""));
const [dietaryTags, setDietaryTags] = useState<DietaryTags>(defaultValues?.dietaryTags ?? {});
const [ingredients, setIngredients] = useState<IngredientRow[]>(
defaultValues?.ingredients?.length ? defaultValues.ingredients : [newIngredient()]
);
const [steps, setSteps] = useState<StepRow[]>(
defaultValues?.steps?.length ? defaultValues.steps : [newStep()]
);
const [photos, setPhotos] = useState<PhotoEntry[]>(defaultValues?.photos ?? []);
const [saving, setSaving] = useState(false);
function updateIngredient(i: number, patch: Partial<IngredientRow>) {
setIngredients((prev) => prev.map((row, idx) => idx === i ? { ...row, ...patch } : row));
}
function removeIngredient(i: number) {
setIngredients((prev) => prev.filter((_, idx) => idx !== i));
}
function updateStep(i: number, patch: Partial<StepRow>) {
setSteps((prev) => prev.map((row, idx) => idx === i ? { ...row, ...patch } : row));
}
function removeStep(i: number) {
setSteps((prev) => prev.filter((_, idx) => idx !== i));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) {
toast.error(t("titleRequired"));
return;
}
setSaving(true);
try {
const payload = {
title: title.trim(),
description: description.trim() || undefined,
baseServings: parseInt(baseServings) || 4,
visibility,
difficulty: difficulty || undefined,
prepMins: prepMins ? parseInt(prepMins) : undefined,
cookMins: cookMins ? parseInt(cookMins) : undefined,
dietaryTags,
ingredients: ingredients
.filter((ing) => ing.rawName.trim())
.map((ing, i) => ({
rawName: ing.rawName.trim(),
quantity: ing.quantity.trim() || undefined,
unit: ing.unit.trim() || undefined,
note: ing.note.trim() || undefined,
order: i,
})),
steps: steps
.filter((s) => s.instruction.trim())
.map((s, i) => ({
instruction: s.instruction.trim(),
timerSeconds: s.timerSeconds ? parseInt(s.timerSeconds) : undefined,
order: i,
})),
};
const url = isEdit ? `/api/v1/recipes/${id}` : "/api/v1/recipes";
const method = isEdit ? "PUT" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json() as { error?: string };
toast.error(err.error ?? t("saveError"));
return;
}
const saved = await res.json() as { id: string };
toast.success(isEdit ? t("updateSuccess") : t("createSuccess"));
router.push(`/recipes/${saved.id}`);
router.refresh();
} finally {
setSaving(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-8 max-w-3xl">
{/* Basic info */}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">{t("titleLabel")}</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("titlePlaceholder")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">{t("descriptionLabel")}</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("descriptionPlaceholder")}
rows={3}
/>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label htmlFor="servings">{t("servings")}</Label>
<Input
id="servings"
type="number"
min={1}
max={100}
value={baseServings}
onChange={(e) => setBaseServings(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="prep">{t("prepMins")}</Label>
<Input
id="prep"
type="number"
min={0}
value={prepMins}
onChange={(e) => setPrepMins(e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cook">{t("cookMins")}</Label>
<Input
id="cook"
type="number"
min={0}
value={cookMins}
onChange={(e) => setCookMins(e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="difficulty">{t("difficulty")}</Label>
<select
id="difficulty"
value={difficulty}
onChange={(e) => setDifficulty(e.target.value as "easy" | "medium" | "hard" | "")}
className="flex h-8 w-full rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value=""></option>
<option value="easy">{t_recipe("difficulty.easy")}</option>
<option value="medium">{t_recipe("difficulty.medium")}</option>
<option value="hard">{t_recipe("difficulty.hard")}</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="visibility">{t("visibility")}</Label>
<select
id="visibility"
value={visibility}
onChange={(e) => setVisibility(e.target.value as "private" | "unlisted" | "public")}
className="flex h-8 w-full max-w-[200px] rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value="private">{t_recipe("visibility.private")}</option>
<option value="unlisted">{t_recipe("visibility.unlisted")}</option>
<option value="public">{t_recipe("visibility.public")}</option>
</select>
</div>
</div>
<Separator />
{/* Dietary tags */}
<div className="space-y-2">
<Label>{t("dietaryTags")}</Label>
<DietaryTagPicker value={dietaryTags} onChange={setDietaryTags} />
</div>
<Separator />
{/* Photos */}
<div className="space-y-2">
<Label>{t("photos")}</Label>
<PhotoUploader recipeId={id} photos={photos} onChange={setPhotos} />
</div>
<Separator />
{/* Ingredients */}
<div className="space-y-3">
<Label>{t("ingredients")}</Label>
<div className="space-y-2">
{ingredients.map((ing, i) => (
<div key={ing.id} className="flex gap-2 items-start">
<GripVertical className="h-4 w-4 text-muted-foreground mt-2 cursor-grab shrink-0" />
<Input
value={ing.quantity}
onChange={(e) => updateIngredient(i, { quantity: e.target.value })}
placeholder={t("amount")}
className="w-16 shrink-0"
/>
<Input
value={ing.unit}
onChange={(e) => updateIngredient(i, { unit: e.target.value })}
placeholder={t("unit")}
className="w-24 shrink-0"
/>
<Input
value={ing.rawName}
onChange={(e) => updateIngredient(i, { rawName: e.target.value })}
placeholder={t("ingredient")}
className="flex-1 min-w-0"
/>
<Input
value={ing.note}
onChange={(e) => updateIngredient(i, { note: e.target.value })}
placeholder={t("note")}
className="w-48 shrink-0"
/>
<button
type="button"
onClick={() => removeIngredient(i)}
disabled={ingredients.length === 1}
className="mt-1.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-30"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIngredients((p) => [...p, newIngredient()])}
>
<Plus className="h-4 w-4" />
{t("addIngredient")}
</Button>
</div>
<Separator />
{/* Steps */}
<div className="space-y-3">
<Label>{t("steps")}</Label>
<div className="space-y-3">
{steps.map((step, i) => (
<div key={step.id} className="flex gap-3 items-start">
<div className="mt-2.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-bold text-muted-foreground">
{i + 1}
</div>
<Textarea
value={step.instruction}
onChange={(e) => updateStep(i, { instruction: e.target.value })}
placeholder={t("stepPlaceholder", { n: i + 1 })}
rows={2}
className="flex-1 min-w-0"
/>
<Input
value={step.timerSeconds}
onChange={(e) => updateStep(i, { timerSeconds: e.target.value })}
placeholder={t("timerSeconds")}
type="number"
min={0}
className="w-28 shrink-0"
/>
<button
type="button"
onClick={() => removeStep(i)}
disabled={steps.length === 1}
className="mt-2.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-30"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setSteps((p) => [...p, newStep()])}
>
<Plus className="h-4 w-4" />
{t("addStep")}
</Button>
</div>
<Separator />
<div className="flex gap-3">
<Button type="submit" disabled={saving}>
{saving ? t("saving") : isEdit ? t("saveChanges") : t("createRecipe")}
</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
{t_common("cancel")}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,37 @@
"use client";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { PlusCircle } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function RecipesEmptyState({ query, count }: { query: string; count: number }) {
const t = useTranslations("recipes");
if (count > 0) {
return query ? (
<p className="text-sm text-muted-foreground">
{count} {count !== 1 ? t("resultPlural") : t("resultSingular")} {t("for")} &ldquo;{query}&rdquo;
</p>
) : null;
}
return (
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed rounded-xl gap-4">
{query ? (
<>
<p className="text-muted-foreground text-sm">{t("noMatch")} &ldquo;{query}&rdquo;</p>
<Link href="/recipes" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
{t("clearSearch")}
</Link>
</>
) : (
<>
<p className="text-muted-foreground text-sm">{t("noRecipes")}</p>
<Link href="/recipes/new" className={cn(buttonVariants({ size: "sm" }))}>
<PlusCircle className="h-4 w-4" />
{t("createFirst")}
</Link>
</>
)}
</div>
);
}
+321
View File
@@ -0,0 +1,321 @@
"use client";
import { useState, useCallback } from "react";
import { Trash2, Globe, Lock, Link2, X, Check, ListChecks } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
import { getPublicUrl } from "@/lib/storage";
import { Badge } from "@/components/ui/badge";
import { Clock, Users } from "lucide-react";
import Link from "next/link";
import { cn } from "@/lib/utils";
type Recipe = {
id: string;
title: string;
description: string | null;
baseServings: number;
prepMins: number | null;
cookMins: number | null;
difficulty: "easy" | "medium" | "hard" | null;
visibility: "private" | "unlisted" | "public";
updatedAt: Date;
photos?: Array<{ storageKey: string; isCover: boolean }>;
};
const DIFFICULTY_COLOR = { easy: "default", medium: "secondary", hard: "destructive" } as const;
const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe };
function SelectableRecipeCard({
recipe,
selected,
selectMode,
onToggle,
}: {
recipe: Recipe;
selected: boolean;
selectMode: boolean;
onToggle: (id: string) => void;
}) {
const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
const inner = (
<div
className={cn(
"group relative overflow-hidden rounded-xl border bg-card flex flex-col h-full transition-all duration-200",
selectMode && "cursor-pointer select-none",
selected
? "ring-2 ring-primary border-primary shadow-lg shadow-primary/10"
: selectMode
? "hover:border-muted-foreground/40"
: "hover:shadow-md hover:border-muted-foreground/30"
)}
>
{/* Cover image */}
<div className="relative aspect-video overflow-hidden bg-muted shrink-0">
{cover ? (
<img
src={getPublicUrl(cover.storageKey)}
alt={recipe.title}
className={cn(
"w-full h-full object-cover transition-transform duration-300",
!selectMode && "group-hover:scale-105",
selectMode && selected && "brightness-75"
)}
/>
) : (
<div className={cn(
"w-full h-full flex items-center justify-center text-4xl transition-opacity",
selectMode && !selected && "opacity-60"
)}>
🍽
</div>
)}
{/* Selection overlay tint */}
{selectMode && selected && (
<div className="absolute inset-0 bg-primary/20" />
)}
{/* Checkbox */}
{selectMode ? (
<div className={cn(
"absolute top-2.5 left-2.5 h-6 w-6 rounded-full border-2 flex items-center justify-center transition-all duration-150 shadow-sm",
selected
? "bg-primary border-primary"
: "bg-black/30 border-white/70 group-hover:border-white"
)}>
{selected && <Check className="h-3.5 w-3.5 text-primary-foreground stroke-[3]" />}
</div>
) : null}
</div>
{/* Body */}
<div className="flex flex-col flex-1 p-3 gap-1">
<h3 className={cn(
"font-semibold leading-tight line-clamp-2 text-sm transition-colors",
!selectMode && "group-hover:text-primary"
)}>
{recipe.title}
</h3>
{recipe.description && (
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
{recipe.description}
</p>
)}
</div>
{/* Footer */}
<div className="px-3 pb-3 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2.5">
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{recipe.baseServings}
</span>
{totalMins > 0 && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{totalMins}m
</span>
)}
{recipe.difficulty && (
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4 px-1.5">
{recipe.difficulty}
</Badge>
)}
</div>
<VisibilityIcon className="h-3 w-3 shrink-0" />
</div>
</div>
);
if (selectMode) {
return (
<div
onClick={() => onToggle(recipe.id)}
role="checkbox"
aria-checked={selected}
className="h-full"
>
{inner}
</div>
);
}
return (
<Link href={`/recipes/${recipe.id}`} className="h-full block">
{inner}
</Link>
);
}
export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] }) {
const [recipes, setRecipes] = useState(initialRecipes);
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const toggleSelect = useCallback((id: string) => {
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}, []);
const toggleAll = () => {
setSelected((prev) =>
prev.size === recipes.length ? new Set() : new Set(recipes.map((r) => r.id))
);
};
const exitSelect = () => {
setSelectMode(false);
setSelected(new Set());
};
async function bulkDelete() {
if (!confirm(`Delete ${selected.size} recipe${selected.size !== 1 ? "s" : ""}? This cannot be undone.`)) return;
setBusy(true);
try {
const res = await fetch("/api/v1/recipes/bulk", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: [...selected] }),
});
if (!res.ok) throw new Error("Delete failed");
setRecipes((prev) => prev.filter((r) => !selected.has(r.id)));
toast.success(`${selected.size} recipe${selected.size !== 1 ? "s" : ""} deleted`);
exitSelect();
} catch {
toast.error("Delete failed");
} finally {
setBusy(false);
}
}
async function bulkSetVisibility(visibility: "private" | "unlisted" | "public") {
setBusy(true);
try {
const res = await fetch("/api/v1/recipes/bulk", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: [...selected], visibility }),
});
if (!res.ok) throw new Error("Update failed");
setRecipes((prev) =>
prev.map((r) => selected.has(r.id) ? { ...r, visibility } : r)
);
toast.success(`${selected.size} recipe${selected.size !== 1 ? "s" : ""} set to ${visibility}`);
exitSelect();
} catch {
toast.error("Update failed");
} finally {
setBusy(false);
}
}
if (recipes.length === 0) return null;
const allSelected = selected.size === recipes.length;
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between min-h-[36px]">
{selectMode ? (
<div className="flex items-center gap-3">
<button
onClick={toggleAll}
className="text-sm font-medium text-primary hover:underline"
>
{allSelected ? "Deselect all" : "Select all"}
</button>
<span className="text-sm text-muted-foreground">
{selected.size > 0
? `${selected.size} selected`
: "Click cards to select"}
</span>
</div>
) : (
<div />
)}
<Button
variant={selectMode ? "ghost" : "ghost"}
size="sm"
onClick={selectMode ? exitSelect : () => setSelectMode(true)}
className={cn("gap-1.5", selectMode && "text-muted-foreground")}
>
{selectMode ? (
<><X className="h-4 w-4" />Cancel</>
) : (
<><ListChecks className="h-4 w-4" />Select</>
)}
</Button>
</div>
{/* Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 items-start">
{recipes.map((recipe) => (
<SelectableRecipeCard
key={recipe.id}
recipe={recipe}
selected={selected.has(recipe.id)}
selectMode={selectMode}
onToggle={toggleSelect}
/>
))}
</div>
{/* Floating bulk action bar */}
{selectMode && selected.size > 0 && (
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 animate-in slide-in-from-bottom-4 duration-200">
<div className="flex items-center gap-2 bg-popover border shadow-2xl rounded-2xl px-5 py-3">
<span className="text-sm font-semibold tabular-nums mr-1">
{selected.size} selected
</span>
<div className="w-px h-5 bg-border mx-1" />
<DropdownMenu>
<DropdownMenuTrigger
disabled={busy}
className={buttonVariants({ variant: "ghost", size: "sm" }) + " gap-1.5"}
>
<Globe className="h-4 w-4" />
Visibility
</DropdownMenuTrigger>
<DropdownMenuContent align="center" side="top">
<DropdownMenuItem onClick={() => { void bulkSetVisibility("public"); }}>
<Globe className="h-4 w-4 mr-2 text-green-500" /> Make public
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { void bulkSetVisibility("unlisted"); }}>
<Link2 className="h-4 w-4 mr-2 text-yellow-500" /> Make unlisted
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { void bulkSetVisibility("private"); }}>
<Lock className="h-4 w-4 mr-2 text-muted-foreground" /> Make private
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="destructive"
size="sm"
onClick={() => { void bulkDelete(); }}
disabled={busy}
className="gap-1.5"
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,84 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
import { PlusCircle, Sparkles, Link2, Search, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { AiGenerateDialog } from "./ai-generate-dialog";
import { UrlImportDialog } from "./url-import-dialog";
import { cn } from "@/lib/utils";
export function RecipesHeader({ count, initialQuery = "" }: { count: number; initialQuery?: string }) {
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("recipes");
const [aiOpen, setAiOpen] = useState(false);
const [urlOpen, setUrlOpen] = useState(false);
const [query, setQuery] = useState(initialQuery);
const [, startTransition] = useTransition();
function handleSearch(value: string) {
setQuery(value);
startTransition(() => {
const params = new URLSearchParams();
if (value.trim()) params.set("q", value.trim());
router.push(`${pathname}?${params.toString()}`);
});
}
return (
<>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground mt-1">
{count === 0
? t("subtitle")
: t(count !== 1 ? "resultPlural" : "resultSingular", { count })}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setUrlOpen(true)}>
<Link2 className="h-4 w-4" />
{t("importUrl")}
</Button>
<Button variant="outline" size="sm" onClick={() => setAiOpen(true)}>
<Sparkles className="h-4 w-4" />
{t("generate")}
</Button>
<Link href="/recipes/new" className={cn(buttonVariants({ size: "sm" }))}>
<PlusCircle className="h-4 w-4" />
{t("new")}
</Link>
</div>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder={t("search")}
className="pl-9 pr-9"
/>
{query && (
<button
onClick={() => handleSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
<AiGenerateDialog open={aiOpen} onOpenChange={setAiOpen} />
<UrlImportDialog open={urlOpen} onOpenChange={setUrlOpen} />
</>
);
}
@@ -0,0 +1,156 @@
"use client";
import { useState } from "react";
import { Minus, Plus, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { scaleQuantity } from "@/lib/fractions";
import { SubstituteIngredientPopover } from "@/components/recipe/substitute-ingredient-popover";
type Ingredient = {
id: string;
rawName: string;
quantity: string | null;
unit: string | null;
note: string | null;
order: number;
};
export type ScaledIngredient = {
rawName: string;
quantity: string;
unit: string | null;
note?: string;
};
export function ServingScaler({
baseServings,
ingredients,
recipeTitle,
recipeId,
onAiScale,
}: {
baseServings: number;
ingredients: Ingredient[];
recipeTitle?: string;
recipeId?: string;
onAiScale?: (ingredients: ScaledIngredient[] | null) => void;
}) {
const [servings, setServings] = useState(baseServings);
const [aiScaledIngredients, setAiScaledIngredients] = useState<ScaledIngredient[] | null>(null);
const [aiScaling, setAiScaling] = useState(false);
const min = 1;
const max = 100;
async function handleAiScale() {
if (!recipeId) return;
setAiScaling(true);
try {
const res = await fetch("/api/v1/ai/scale", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ recipeId, targetServings: servings }),
});
if (!res.ok) return;
const data = await res.json() as { ingredients: ScaledIngredient[] };
setAiScaledIngredients(data.ingredients);
onAiScale?.(data.ingredients);
} finally {
setAiScaling(false);
}
}
function dismissAiScale() {
setAiScaledIngredients(null);
onAiScale?.(null);
}
return (
<div className="space-y-4">
<div className="flex items-center gap-3 flex-wrap">
<span className="text-sm font-medium text-muted-foreground">Servings</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0"
onClick={() => setServings((s) => Math.max(min, s - 1))}
disabled={servings <= min}
>
<Minus className="h-3 w-3" />
</Button>
<span className="w-8 text-center font-semibold tabular-nums">{servings}</span>
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0"
onClick={() => setServings((s) => Math.min(max, s + 1))}
disabled={servings >= max}
>
<Plus className="h-3 w-3" />
</Button>
</div>
{servings !== baseServings && (
<button
className="text-xs text-muted-foreground hover:text-foreground underline"
onClick={() => setServings(baseServings)}
>
reset
</button>
)}
{recipeId && servings !== baseServings && (
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={handleAiScale}
disabled={aiScaling}
>
<Sparkles className="h-3 w-3" />
{aiScaling ? "Scaling…" : "AI Scale"}
</Button>
)}
</div>
{aiScaledIngredients && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Sparkles className="h-3 w-3 shrink-0" />
<span>AI-scaled quantities shown below</span>
<button
className="ml-auto hover:text-foreground"
onClick={dismissAiScale}
aria-label="Dismiss AI scaling"
>
<X className="h-3 w-3" />
</button>
</div>
)}
<ul className="space-y-2">
{ingredients
.sort((a, b) => a.order - b.order)
.map((ing) => {
const aiIng = aiScaledIngredients?.find((s) => s.rawName === ing.rawName);
const scaled = scaleQuantity(ing.quantity, baseServings, servings);
return (
<li key={ing.id} className="flex gap-2 text-sm group">
<span className="font-medium tabular-nums min-w-[3rem] text-right">
{aiIng
? `${aiIng.quantity}${aiIng.unit ? ` ${aiIng.unit}` : ""}`
: scaled
? `${scaled}${ing.unit ? ` ${ing.unit}` : ""}`
: ing.unit ?? ""}
</span>
<span className="flex items-center gap-1">
{ing.rawName}
{(aiIng?.note ?? ing.note) && (
<span className="text-muted-foreground">, {aiIng?.note ?? ing.note}</span>
)}
{recipeTitle && <SubstituteIngredientPopover ingredient={ing.rawName} recipeTitle={recipeTitle} />}
</span>
</li>
);
})}
</ul>
</div>
);
}
@@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import { Shuffle, Loader2 } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
type Substitution = {
name: string;
ratio: string;
note: string;
flavorImpact: "minimal" | "moderate" | "significant";
};
const IMPACT_VARIANT = {
minimal: "default",
moderate: "secondary",
significant: "destructive",
} as const;
export function SubstituteIngredientPopover({
ingredient,
recipeTitle,
}: {
ingredient: string;
recipeTitle: string;
}) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [substitutions, setSubstitutions] = useState<Substitution[]>([]);
const [fetched, setFetched] = useState(false);
async function fetchSubstitutions() {
if (fetched) return;
setLoading(true);
try {
const res = await fetch("/api/v1/ai/substitute", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ingredient, recipeTitle }),
});
if (!res.ok) return;
const data = await res.json() as { substitutions: Substitution[] };
setSubstitutions(data.substitutions);
setFetched(true);
} finally {
setLoading(false);
}
}
function handleOpen(v: boolean) {
setOpen(v);
if (v && !fetched) fetchSubstitutions();
}
return (
<Popover open={open} onOpenChange={handleOpen}>
<PopoverTrigger
className="h-5 w-5 opacity-0 group-hover:opacity-60 hover:opacity-100 transition-opacity shrink-0 inline-flex items-center justify-center rounded hover:bg-accent"
title={`Substitute for ${ingredient}`}
>
<Shuffle className="h-3 w-3" />
</PopoverTrigger>
<PopoverContent className="w-80 p-3 space-y-3" align="start">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Substitutes for <span className="text-foreground normal-case">{ingredient}</span>
</p>
{loading && (
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Finding substitutes
</div>
)}
{!loading && substitutions.length > 0 && (
<div className="space-y-2.5">
{substitutions.map((sub, i) => (
<div key={i} className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{sub.name}</span>
<span className="text-xs text-muted-foreground font-mono">{sub.ratio}</span>
<Badge variant={IMPACT_VARIANT[sub.flavorImpact]} className="text-[10px] px-1.5 py-0 ml-auto">
{sub.flavorImpact}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{sub.note}</p>
</div>
))}
</div>
)}
{!loading && fetched && substitutions.length === 0 && (
<p className="text-sm text-muted-foreground py-1">No substitutions found.</p>
)}
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Languages, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const LANGUAGES = [
{ code: "French", label: "French" },
{ code: "Spanish", label: "Spanish" },
{ code: "German", label: "German" },
{ code: "Italian", label: "Italian" },
{ code: "Portuguese", label: "Portuguese" },
{ code: "English", label: "English" },
{ code: "Japanese", label: "Japanese" },
{ code: "Chinese", label: "Chinese" },
{ code: "Arabic", label: "Arabic" },
{ code: "Dutch", label: "Dutch" },
{ code: "Polish", label: "Polish" },
{ code: "Russian", label: "Russian" },
];
export function TranslateButton({ recipeId }: { recipeId: string }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [targetLanguage, setTargetLanguage] = useState("French");
const [translating, setTranslating] = useState(false);
async function handleTranslate() {
setTranslating(true);
try {
const res = await fetch(`/api/v1/ai/translate/${recipeId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetLanguage }),
});
if (!res.ok) {
const err = await res.json() as { error?: string };
toast.error(err.error ?? "Failed to translate recipe");
return;
}
const { id } = await res.json() as { id: string };
toast.success("Translation saved as new draft recipe");
setOpen(false);
router.push(`/recipes/${id}/edit`);
} finally {
setTranslating(false);
}
}
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<Languages className="h-4 w-4" />
Translate
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Languages className="h-5 w-5 text-primary" />
Translate Recipe
</DialogTitle>
<DialogDescription>
AI will translate the title, description, ingredients, and steps. Quantities and units are preserved.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Target language</Label>
<Select value={targetLanguage} onValueChange={(v) => v && setTargetLanguage(v)} disabled={translating}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((l) => (
<SelectItem key={l.code} value={l.code}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{translating && (
<p className="text-sm text-muted-foreground">This may take 2030 seconds</p>
)}
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setOpen(false)} disabled={translating}>
Cancel
</Button>
<Button onClick={handleTranslate} disabled={translating}>
{translating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Translating
</>
) : (
<>
<Languages className="h-4 w-4" />
Translate
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Link2, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type ImportedRecipe = {
title: string;
ingredients: Array<{ rawName: string; quantity?: string; unit?: string }>;
steps: Array<{ instruction: string }>;
};
export function UrlImportDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const router = useRouter();
const [url, setUrl] = useState("");
const [importing, setImporting] = useState(false);
async function handleImport() {
if (!url.trim()) return;
setImporting(true);
try {
const res = await fetch("/api/v1/ai/import-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: url.trim() }),
});
if (!res.ok) {
const err = await res.json() as { error?: string };
toast.error(err.error ?? "Failed to import recipe");
return;
}
const imported = await res.json() as ImportedRecipe;
const saveRes = await fetch("/api/v1/recipes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...imported,
visibility: "private",
sourceUrl: url.trim(),
}),
});
if (!saveRes.ok) {
toast.error("Failed to save imported recipe");
return;
}
const saved = await saveRes.json() as { id: string };
toast.success("Recipe imported! Review before publishing.");
onOpenChange(false);
router.push(`/recipes/${saved.id}/edit`);
} finally {
setImporting(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Link2 className="h-5 w-5 text-primary" />
Import recipe from URL
</DialogTitle>
<DialogDescription>
Paste a recipe URL and AI will extract the ingredients and instructions.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="import-url">Recipe URL</Label>
<Input
id="import-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://..."
disabled={importing}
onKeyDown={(e) => e.key === "Enter" && handleImport()}
/>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={importing}>
Cancel
</Button>
<Button onClick={handleImport} disabled={!url.trim() || importing}>
{importing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Importing
</>
) : (
<>
<Link2 className="h-4 w-4" />
Import
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,46 @@
"use client";
import { useState } from "react";
import { GitBranch } from "lucide-react";
import { Button } from "@/components/ui/button";
import { VariationsDialog } from "./variations-dialog";
export function VariationsButton({
recipeId,
baseServings,
difficulty,
prepMins,
cookMins,
ingredients,
steps,
}: {
recipeId: string;
baseServings: number;
difficulty?: string | null;
prepMins?: number | null;
cookMins?: number | null;
ingredients: Array<{ rawName: string; quantity?: string | number | null; unit?: string | null; note?: string | null; order: number }>;
steps: Array<{ instruction: string; timerSeconds?: number | null; order: number }>;
}) {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<GitBranch className="h-4 w-4" />
Variations
</Button>
<VariationsDialog
recipeId={recipeId}
baseServings={baseServings}
difficulty={difficulty}
prepMins={prepMins}
cookMins={cookMins}
ingredients={ingredients}
steps={steps}
open={open}
onOpenChange={setOpen}
/>
</>
);
}
@@ -0,0 +1,273 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
import { Sparkles, Loader2, ChevronRight, Replace } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
type Variation = {
title: string;
description: string;
changedIngredients: Array<{ original: string; replacement: string; note?: string }>;
changedSteps?: Array<{ stepNumber: number; change: string }>;
dietaryTags?: Record<string, boolean>;
};
type Ingredient = {
rawName: string;
quantity?: string | number | null;
unit?: string | null;
note?: string | null;
order: number;
};
type Step = {
instruction: string;
timerSeconds?: number | null;
order: number;
};
export function VariationsDialog({
recipeId,
baseServings,
difficulty,
prepMins,
cookMins,
ingredients,
steps,
open,
onOpenChange,
}: {
recipeId: string;
baseServings: number;
difficulty?: string | null;
prepMins?: number | null;
cookMins?: number | null;
ingredients: Ingredient[];
steps: Step[];
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const router = useRouter();
const [generating, setGenerating] = useState(false);
const [applying, setApplying] = useState<number | null>(null);
const [variations, setVariations] = useState<Variation[]>([]);
const [directions, setDirections] = useState("");
async function generate() {
setGenerating(true);
setVariations([]);
try {
const res = await fetch(`/api/v1/ai/variations/${recipeId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ count: 3, directions: directions.trim() || undefined }),
});
if (!res.ok) {
const err = await res.json() as { error?: string };
toast.error(err.error ?? "Failed to generate variations");
return;
}
const data = await res.json() as { variations: Variation[] };
setVariations(data.variations);
} finally {
setGenerating(false);
}
}
async function apply(variation: Variation, index: number) {
setApplying(index);
try {
const updatedIngredients = ingredients.map((ing) => {
const change = variation.changedIngredients.find(
(c) => ing.rawName.toLowerCase().includes(c.original.toLowerCase())
);
if (change) {
return { ...ing, rawName: change.replacement, note: change.note ?? ing.note };
}
return ing;
});
const updatedSteps = steps.map((step, i) => {
const change = variation.changedSteps?.find((c) => c.stepNumber === i + 1);
if (change) {
return { ...step, instruction: `${step.instruction} [Variation: ${change.change}]` };
}
return step;
});
const saveRes = await fetch("/api/v1/recipes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: variation.title,
description: variation.description,
baseServings,
difficulty,
prepMins,
cookMins,
dietaryTags: variation.dietaryTags ?? {},
visibility: "private",
aiGenerated: true,
ingredients: updatedIngredients.map((ing, i) => ({
rawName: ing.rawName,
quantity: ing.quantity ? String(ing.quantity) : undefined,
unit: ing.unit ?? undefined,
note: ing.note ?? undefined,
order: i,
})),
steps: updatedSteps.map((step, i) => ({
instruction: step.instruction,
timerSeconds: step.timerSeconds ?? undefined,
order: i,
})),
}),
});
if (!saveRes.ok) {
toast.error("Failed to save variation");
return;
}
const saved = await saveRes.json() as { id: string };
toast.success("Variation created — review and edit before publishing");
onOpenChange(false);
router.push(`/recipes/${saved.id}/edit`);
} finally {
setApplying(null);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
AI Recipe Variations
</DialogTitle>
<DialogDescription>
Generate creative variations of this recipe dietary adaptations, flavor twists, technique changes.
</DialogDescription>
</DialogHeader>
{variations.length === 0 ? (
<div className="flex flex-col gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="directions">Directions <span className="text-muted-foreground font-normal">(optional)</span></Label>
<Textarea
id="directions"
placeholder="e.g. make it vegan, use only pantry staples, reduce sugar…"
value={directions}
onChange={(e) => setDirections(e.target.value)}
disabled={generating}
rows={3}
maxLength={500}
/>
</div>
<Button onClick={generate} disabled={generating} size="lg" className="self-center">
{generating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Generating variations
</>
) : (
<>
<Sparkles className="h-4 w-4" />
Generate 3 variations
</>
)}
</Button>
<FakeProgressBar active={generating} durationMs={22000} label={generating ? "Generating 3 creative variations…" : undefined} />
</div>
) : (
<div className="space-y-4">
{variations.map((v, i) => (
<div key={i} className="rounded-lg border p-4 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1 flex-1">
<h3 className="font-semibold">{v.title}</h3>
<p className="text-sm text-muted-foreground">{v.description}</p>
</div>
<Button
size="sm"
onClick={() => apply(v, i)}
disabled={applying !== null}
>
{applying === i ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
Apply
</Button>
</div>
{v.changedIngredients.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ingredient changes</p>
<div className="space-y-1">
{v.changedIngredients.map((c, j) => (
<div key={j} className="flex items-center gap-2 text-sm">
<Replace className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-muted-foreground line-through">{c.original}</span>
<span></span>
<span>{c.replacement}</span>
{c.note && <span className="text-muted-foreground text-xs">({c.note})</span>}
</div>
))}
</div>
</div>
)}
{v.changedSteps && v.changedSteps.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Step changes</p>
<div className="space-y-1">
{v.changedSteps.map((c, j) => (
<div key={j} className="flex gap-2 text-sm">
<Badge variant="outline" className="text-xs shrink-0">Step {c.stepNumber}</Badge>
<span className="text-muted-foreground">{c.change}</span>
</div>
))}
</div>
</div>
)}
{v.dietaryTags && Object.entries(v.dietaryTags).some(([, val]) => val) && (
<div className="flex flex-wrap gap-1">
{Object.entries(v.dietaryTags)
.filter(([, val]) => val)
.map(([tag]) => (
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
))}
</div>
)}
{i < variations.length - 1 && <Separator />}
</div>
))}
<Button variant="outline" className="w-full" onClick={generate} disabled={generating}>
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
Regenerate
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,177 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { History, RotateCcw, ChevronDown } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
type VersionSummary = {
id: string;
version: number;
title: string;
createdAt: string;
};
type SnapshotData = {
ingredients: unknown[];
steps: unknown[];
};
type VersionDetail = {
id: string;
version: number;
title: string;
createdAt: string;
snapshotData: SnapshotData;
};
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
export function VersionHistoryButton({ recipeId }: { recipeId: string }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [versions, setVersions] = useState<VersionSummary[]>([]);
const [loading, setLoading] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [expandedData, setExpandedData] = useState<Record<string, VersionDetail>>({});
const [restoringId, setRestoringId] = useState<string | null>(null);
async function handleOpen(isOpen: boolean) {
setOpen(isOpen);
if (isOpen) {
setLoading(true);
try {
const res = await fetch(`/api/v1/recipes/${recipeId}/versions`);
if (res.ok) {
const data = await res.json() as VersionSummary[];
setVersions(data);
}
} finally {
setLoading(false);
}
}
}
async function handleExpand(version: VersionSummary) {
if (expandedId === version.id) {
setExpandedId(null);
return;
}
setExpandedId(version.id);
if (!expandedData[version.id]) {
const res = await fetch(`/api/v1/recipes/${recipeId}/versions/${version.id}`);
if (res.ok) {
const data = await res.json() as VersionDetail;
setExpandedData((prev) => ({ ...prev, [version.id]: data }));
}
}
}
async function handleRestore(version: VersionSummary) {
setRestoringId(version.id);
try {
const res = await fetch(`/api/v1/recipes/${recipeId}/versions/${version.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "restore" }),
});
if (res.ok) {
toast.success(`Recipe restored to version ${version.version}`);
setOpen(false);
router.refresh();
} else {
toast.error("Failed to restore version");
}
} finally {
setRestoringId(null);
}
}
return (
<Sheet open={open} onOpenChange={handleOpen}>
<SheetTrigger render={
<Button variant="ghost" size="sm">
<History className="h-4 w-4" />
History
</Button>
} />
<SheetContent>
<SheetHeader>
<SheetTitle>Version History</SheetTitle>
</SheetHeader>
<div className="mt-6 space-y-2">
{loading && (
<p className="text-sm text-muted-foreground">Loading versions...</p>
)}
{!loading && versions.length === 0 && (
<p className="text-sm text-muted-foreground">No versions yet. Versions are saved automatically when you edit this recipe.</p>
)}
{versions.map((v) => {
const isExpanded = expandedId === v.id;
const detail = expandedData[v.id];
return (
<div key={v.id} className="border rounded-lg overflow-hidden">
<div className="flex items-center gap-2 p-3">
<button
className="flex-1 text-left text-sm"
onClick={() => void handleExpand(v)}
>
<span className="font-medium">v{v.version}</span>
<span className="text-muted-foreground"> {v.title} {formatDate(v.createdAt)}</span>
</button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
title="Expand"
onClick={() => void handleExpand(v)}
>
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} />
</Button>
<Button
variant="outline"
size="sm"
className="shrink-0"
disabled={restoringId === v.id}
onClick={() => void handleRestore(v)}
>
<RotateCcw className="h-3 w-3" />
Restore
</Button>
</div>
{isExpanded && (
<div className="px-3 pb-3 text-sm text-muted-foreground border-t pt-2">
{!detail ? (
<p>Loading...</p>
) : (
<p>
{(detail.snapshotData.ingredients as unknown[]).length} ingredient{(detail.snapshotData.ingredients as unknown[]).length !== 1 ? "s" : ""},{" "}
{(detail.snapshotData.steps as unknown[]).length} step{(detail.snapshotData.steps as unknown[]).length !== 1 ? "s" : ""}
</p>
)}
</div>
)}
</div>
);
})}
</div>
</SheetContent>
</Sheet>
);
}