feat(recipes): full recipe CRUD with photos, print, version history
List, detail, new, edit pages. Server-side pagination, dietary tags, difficulty. Photo upload to S3-compatible storage. Version history. Multi-select grid with bulk delete/visibility. Print view. Delete confirmation dialog.
This commit is contained in:
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 20–30 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">(70–99%)</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">“{drink.whyItPairs}”</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">“{pairing.whyItPairs}”</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>
|
||||
);
|
||||
}
|
||||
@@ -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")} “{query}”
|
||||
</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")} “{query}”</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>
|
||||
);
|
||||
}
|
||||
@@ -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 20–30 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user