feat: generate a complete themed meal from a collection (v0.52.0)

New "Generate meal" button on any owned collection — pick a theme (free
text) and 2-6 courses (starter/main/side/dessert/drink), one generateObject
call produces a coherent recipe per course (shared cuisine/style, matching
flavors across courses) and all of them get saved as real recipes and
added to that collection in one action.

Follows the meal-plan generation route's established pattern: single
withAiQuota charge for the whole generation, then a pre-flight loop
charging the tier's recipe limit once per generated recipe (rolled back
on a partial breach) before the insert transaction runs. Recipes are
tagged with their course name for later filtering; visibility defaults to
private like every other AI-generated recipe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-19 12:16:28 +02:00
parent 1b72135958
commit 8f83a1eaae
11 changed files with 430 additions and 5 deletions
+5
View File
@@ -2,6 +2,11 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
## 0.52.0 — 2026-07-19 13:55
### Added
- Generate a complete meal from a collection — pick a theme and 2+ courses (starter, main, side, dessert, drink), AI generates one coherent, matching recipe per course and adds them all to that collection in one go.
## 0.51.7 — 2026-07-19 13:35 ## 0.51.7 — 2026-07-19 13:35
### Fixed ### Fixed
@@ -9,6 +9,7 @@ import { RecipeCard } from "@/components/recipe/recipe-card";
import { CollectionRecipesGrid } from "@/components/collections/collection-recipes-grid"; import { CollectionRecipesGrid } from "@/components/collections/collection-recipes-grid";
import { ForkCollectionButton } from "@/components/collections/fork-collection-button"; import { ForkCollectionButton } from "@/components/collections/fork-collection-button";
import { ShareCollectionButton } from "@/components/collections/share-collection-button"; import { ShareCollectionButton } from "@/components/collections/share-collection-button";
import { GenerateMealDialog } from "@/components/collections/generate-meal-dialog";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
@@ -65,6 +66,7 @@ export default async function CollectionPage({ params }: Params) {
/> />
</> </>
)} )}
{isOwner && <GenerateMealDialog collectionId={id} />}
{isOwner && <ShareCollectionButton collectionId={id} />} {isOwner && <ShareCollectionButton collectionId={id} />}
{!isOwner && col.isPublic && ( {!isOwner && col.isPublic && (
<ForkCollectionButton collectionId={id} /> <ForkCollectionButton collectionId={id} />
@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, recipes, recipeIngredients, recipeSteps, collections, collectionRecipes, eq, and } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { checkAndIncrementTierLimit, incrementUsage, TierLimitError } from "@/lib/tiers";
import { generateMeal, MEAL_COURSES } from "@/lib/ai/features/generate-meal";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
const Schema = z.object({
collectionId: z.string().min(1),
theme: z.string().min(1).max(200),
courses: z.array(z.enum(MEAL_COURSES)).min(2).max(6).refine((c) => new Set(c).size === c.length, "Duplicate courses"),
servings: z.number().int().min(1).max(20).default(4),
dietaryPrefs: z.string().max(200).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const userId = session!.user.id;
const tier = session!.user.tier as "free" | "pro" | "family";
const { collectionId, theme, courses, servings, dietaryPrefs, difficulty } = parsed.data;
const collection = await db.query.collections.findFirst({
where: and(eq(collections.id, collectionId), eq(collections.userId, userId)),
});
if (!collection) return NextResponse.json({ error: "Not found" }, { status: 404 });
const limited = await applyRateLimit(`rl:ai:${userId}`, 3, 60);
if (limited) return limited;
const locale = (session!.user as { locale?: string }).locale ?? "en";
const [configResult, privateBio] = await Promise.all([
resolveAiConfigOrError(() => getDefaultProviderWithKey(userId, "mealPlan")),
getUserPrivateBio(userId),
]);
if (!configResult.ok) return configResult.response;
const config = configResult.data;
const result = await withAiQuota(userId, tier, () =>
generateMeal(
{ theme, courses, servings, dietaryPrefs, difficulty },
{ ...config, userContext: privateBio ?? undefined },
locale
), { skipQuota: config.isByok }
);
if (!result.ok) return result.response;
const meal = result.data;
// Each recipe in the meal creates a real recipe row — charge the recipe
// limit for all of them before inserting anything, refunding on breach
// so a rejected meal doesn't consume quota (same pattern as meal-plan
// generation).
let chargedRecipes = 0;
try {
for (let i = 0; i < meal.recipes.length; i++) {
await checkAndIncrementTierLimit(userId, tier, "recipe");
chargedRecipes++;
}
} catch (err) {
if (err instanceof TierLimitError) {
if (chargedRecipes > 0) await incrementUsage(userId, "recipe", -chargedRecipes);
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
}
throw err;
}
const created: Array<{ id: string; title: string; course: string }> = [];
await db.transaction(async (tx) => {
for (const recipe of meal.recipes) {
const recipeId = crypto.randomUUID();
await tx.insert(recipes).values({
id: recipeId,
authorId: userId,
title: recipe.title,
description: recipe.description,
baseServings: recipe.baseServings,
recipeType: recipe.recipeType,
visibility: "private",
aiGenerated: true,
difficulty: recipe.difficulty ?? null,
prepMins: recipe.prepMins ?? null,
cookMins: recipe.cookMins ?? null,
dietaryTags: recipe.dietaryTags ?? {},
tags: [recipe.course],
});
if (recipe.ingredients.length > 0) {
await tx.insert(recipeIngredients).values(
recipe.ingredients.map((ing, i) => ({
id: crypto.randomUUID(),
recipeId,
rawName: ing.rawName,
quantity: ing.quantity != null ? String(ing.quantity) : null,
unit: ing.unit ?? null,
note: ing.note ?? null,
order: i,
}))
);
}
if (recipe.steps.length > 0) {
await tx.insert(recipeSteps).values(
recipe.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId,
instruction: step.instruction,
timerSeconds: step.timerSeconds ?? null,
order: i,
}))
);
}
await tx.insert(collectionRecipes).values({ collectionId, recipeId }).onConflictDoNothing();
created.push({ id: recipeId, title: recipe.title, course: recipe.course });
}
});
return NextResponse.json({ collectionId, recipes: created });
}
@@ -0,0 +1,155 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { ChefHat, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { MEAL_COURSES, type MealCourse } from "@/lib/ai/features/generate-meal";
type CreatedRecipe = { id: string; title: string; course: string };
export function GenerateMealDialog({ collectionId }: { collectionId: string }) {
const router = useRouter();
const t = useTranslations("collections");
const [open, setOpen] = useState(false);
const [theme, setTheme] = useState("");
const [courses, setCourses] = useState<MealCourse[]>(["main", "dessert"]);
const [servings, setServings] = useState("4");
const [dietaryPrefs, setDietaryPrefs] = useState("");
const [generating, setGenerating] = useState(false);
function toggleCourse(course: MealCourse) {
setCourses((prev) => (prev.includes(course) ? prev.filter((c) => c !== course) : [...prev, course]));
}
async function handleGenerate() {
if (!theme.trim() || courses.length < 2) return;
setGenerating(true);
try {
const res = await fetch("/api/v1/ai/generate-meal", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
collectionId,
theme: theme.trim(),
courses,
servings: parseInt(servings) || 4,
dietaryPrefs: dietaryPrefs.trim() || undefined,
}),
});
if (!res.ok) {
const data = (await res.json()) as { error?: string };
throw new Error(data.error ?? t("generateMealFailed"));
}
const data = (await res.json()) as { recipes: CreatedRecipe[] };
toast.success(t("generateMealSuccess", { count: data.recipes.length }));
setOpen(false);
setTheme("");
router.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : t("generateMealFailed"));
} finally {
setGenerating(false);
}
}
return (
<>
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => setOpen(true)}>
<ChefHat className="h-4 w-4" />
{t("generateMeal")}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t("generateMealTitle")}</DialogTitle>
<DialogDescription>{t("generateMealDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="meal-theme">{t("themeLabel")}</Label>
<Input
id="meal-theme"
value={theme}
onChange={(e) => setTheme(e.target.value)}
placeholder={t("themePlaceholder")}
maxLength={200}
/>
</div>
<div className="space-y-2">
<Label>{t("coursesLabel")}</Label>
<div className="flex flex-wrap gap-2">
{MEAL_COURSES.map((course) => (
<button
key={course}
type="button"
onClick={() => toggleCourse(course)}
aria-pressed={courses.includes(course)}
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
courses.includes(course)
? "border-foreground bg-accent"
: "border-input text-muted-foreground hover:bg-accent/50"
}`}
>
{t(`course.${course}`)}
</button>
))}
</div>
{courses.length < 2 && <p className="text-xs text-muted-foreground">{t("minCoursesHint")}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="meal-servings">{t("servingsLabel")}</Label>
<Input
id="meal-servings"
type="number"
min={1}
max={20}
value={servings}
onChange={(e) => setServings(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="meal-dietary">{t("dietaryLabel")}</Label>
<Input
id="meal-dietary"
value={dietaryPrefs}
onChange={(e) => setDietaryPrefs(e.target.value)}
placeholder={t("dietaryPlaceholder")}
maxLength={200}
/>
</div>
</div>
</div>
<DialogFooter>
<Button
onClick={() => { void handleGenerate(); }}
disabled={generating || !theme.trim() || courses.length < 2}
className="gap-1.5"
>
<Sparkles className="h-4 w-4" />
{generating ? t("generatingMeal") : t("generateMealButton")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { generateObject } from "ai";
import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory";
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
export const MEAL_COURSES = ["starter", "main", "side", "dessert", "drink"] as const;
export type MealCourse = (typeof MEAL_COURSES)[number];
const MealOutputSchema = z.object({
recipes: z.array(z.object({
course: z.enum(MEAL_COURSES),
recipeType: z.enum(["dish", "drink"]).describe("\"drink\" only for a beverage/cocktail with no cooking step."),
title: z.string().max(150),
description: z.string().max(300),
baseServings: z.number().int().min(1).max(100),
prepMins: z.number().int().min(0).max(360).optional(),
cookMins: z.number().int().min(0).max(360).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
dietaryTags: dietaryTagsSchema,
ingredients: z.array(ingredientSchema(z.number())).max(30),
steps: z.array(stepSchema).max(20),
})).min(1).max(6),
});
export type GeneratedMeal = z.infer<typeof MealOutputSchema>;
const LANG: Record<string, string> = { en: "English", fr: "French" };
/** Generates a themed, coherent set of recipes in one call — one per
* requested course — so they read as a matching meal (shared cuisine,
* complementary flavors) rather than N unrelated recipes. */
export async function generateMeal(
options: {
theme: string;
courses: MealCourse[];
servings?: number;
dietaryPrefs?: string;
difficulty?: "easy" | "medium" | "hard";
},
config?: AiConfig & { userContext?: string },
locale?: string
): Promise<GeneratedMeal> {
const model = resolveModel(config);
const lang = LANG[locale ?? "en"] ?? "English";
const servings = options.servings ?? 4;
const dietaryClause = options.dietaryPrefs?.trim() ? `Dietary requirements: ${options.dietaryPrefs.trim()}.` : "";
const difficultyClause = options.difficulty
? `All recipes must be ${options.difficulty} difficulty: ${{ easy: "simple techniques, few steps, everyday ingredients", medium: "moderate skill, standard techniques", hard: "advanced techniques, multiple components" }[options.difficulty]}.`
: "";
const systemPrompt =
`You are a professional chef planning a complete, coherent meal — not a list of unrelated recipes. ` +
`Generate exactly one recipe per requested course, all sharing a consistent cuisine/style that fits the theme, ` +
`with flavors and ingredients that complement each other across courses (e.g. a light starter before a rich main, ` +
`a dessert or drink that fits the same culinary tradition). For ingredients: quantity must be a number only ` +
`(e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml') — never combine them. ` +
`Set recipeType to "drink" only for a beverage/cocktail with no cooking step; for a drink, omit cookMins and ` +
`default baseServings to 1 unless the theme implies otherwise. Respond in ${lang}.` +
(config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : "");
const { object } = await generateObject({
model,
schema: MealOutputSchema,
system: systemPrompt,
prompt: [
`Theme: ${options.theme.trim()}.`,
`Generate these courses, in this order: ${options.courses.join(", ")}.`,
`Plan for ${servings} servings per course (except drinks, which default to 1 unless stated otherwise).`,
dietaryClause,
difficultyClause,
].filter(Boolean).join(" "),
});
// Belt-and-suspenders in case the model ignores the drink-specific
// instructions above — same rationale as generateRecipe().
return {
recipes: object.recipes.map((r) => (r.recipeType === "drink" ? { ...r, cookMins: undefined } : r)),
};
}
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together. // Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.51.7"; export const APP_VERSION = "0.52.0";
export type ChangelogEntry = { export type ChangelogEntry = {
version: string; version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.52.0",
date: "2026-07-19 13:55",
added: [
"Generate a complete meal from a collection — pick a theme and 2+ courses (starter, main, side, dessert, drink), AI generates one coherent, matching recipe per course and adds them all to that collection in one go.",
],
},
{ {
version: "0.51.7", version: "0.51.7",
date: "2026-07-19 13:35", date: "2026-07-19 13:35",
+1
View File
@@ -319,6 +319,7 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "post", path: "/api/v1/ai/variations/{id}", summary: "Suggest creative variations on a recipe (author only)", description: "Consumes AI quota.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ count: z.number().int().min(1).max(5).default(3), directions: z.string().max(500).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } } } }, responses: { 200: { description: "Variations", content: { "application/json": { schema: z.object({ variations: z.array(z.record(z.string(), z.unknown())) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/ai/variations/{id}", summary: "Suggest creative variations on a recipe (author only)", description: "Consumes AI quota.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ count: z.number().int().min(1).max(5).default(3), directions: z.string().max(500).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } } } }, responses: { 200: { description: "Variations", content: { "application/json": { schema: z.object({ variations: z.array(z.record(z.string(), z.unknown())) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/batch-cook/generate", summary: "Generate a batch-cooking plan (multiple dishes) as a new recipe", description: "Rate-limited: 3 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ dinners: z.number().int().min(0).max(6).default(4), lunches: z.number().int().min(0).max(6).default(0), servings: z.number().int().min(1).max(12).default(4), dietaryPrefs: z.string().max(200).optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional(), description: z.string().max(500).optional() }) } }, required: true } }, responses: { 200: { description: "New recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error or no dinners/lunches chosen", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/ai/batch-cook/generate", summary: "Generate a batch-cooking plan (multiple dishes) as a new recipe", description: "Rate-limited: 3 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ dinners: z.number().int().min(0).max(6).default(4), lunches: z.number().int().min(0).max(6).default(0), servings: z.number().int().min(1).max(12).default(4), dietaryPrefs: z.string().max(200).optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional(), description: z.string().max(500).optional() }) } }, required: true } }, responses: { 200: { description: "New recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error or no dinners/lunches chosen", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/meal-plan/generate", summary: "Generate a week's meal plan (creates a draft recipe per entry)", description: "Rate-limited: 3 req/min. Consumes AI quota. Charges your tier's recipe limit for each generated entry (refunded if the limit is exceeded).", security, request: { body: { content: { "application/json": { schema: z.object({ weekStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), dietaryPrefs: z.string().max(200).optional(), servings: z.number().int().min(1).max(20).default(2), days: z.array(z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])).min(1).max(7).default(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]), usePantry: z.boolean().default(false), pantryMode: z.boolean().default(false), difficulty: z.enum(["easy", "medium", "hard"]).optional(), targetNutritionGoals: z.boolean().default(false) }) } }, required: true } }, responses: { 200: { description: "Created entries", content: { "application/json": { schema: z.object({ weekStart: z.string(), entries: z.array(z.object({ id: z.string(), day: z.string(), mealType: z.string(), recipeId: z.string(), recipeTitle: z.string() })) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/ai/meal-plan/generate", summary: "Generate a week's meal plan (creates a draft recipe per entry)", description: "Rate-limited: 3 req/min. Consumes AI quota. Charges your tier's recipe limit for each generated entry (refunded if the limit is exceeded).", security, request: { body: { content: { "application/json": { schema: z.object({ weekStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), dietaryPrefs: z.string().max(200).optional(), servings: z.number().int().min(1).max(20).default(2), days: z.array(z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])).min(1).max(7).default(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]), usePantry: z.boolean().default(false), pantryMode: z.boolean().default(false), difficulty: z.enum(["easy", "medium", "hard"]).optional(), targetNutritionGoals: z.boolean().default(false) }) } }, required: true } }, responses: { 200: { description: "Created entries", content: { "application/json": { schema: z.object({ weekStart: z.string(), entries: z.array(z.object({ id: z.string(), day: z.string(), mealType: z.string(), recipeId: z.string(), recipeTitle: z.string() })) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/generate-meal", summary: "Generate a themed, coherent set of recipes (one per course) and add them to a collection", description: "Rate-limited: 3 req/min. Consumes AI quota (one call covers the whole meal). Charges your tier's recipe limit once per generated recipe (refunded if the limit is exceeded). The collection must be owned by the caller.", security, request: { body: { content: { "application/json": { schema: z.object({ collectionId: z.string(), theme: z.string().min(1).max(200), courses: z.array(z.enum(["starter", "main", "side", "dessert", "drink"])).min(2).max(6), servings: z.number().int().min(1).max(20).default(4), dietaryPrefs: z.string().max(200).optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional() }) } }, required: true } }, responses: { 200: { description: "Created recipes, already added to the collection", content: { "application/json": { schema: z.object({ collectionId: z.string(), recipes: z.array(z.object({ id: z.string(), title: z.string(), course: z.string() })) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Collection not found", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/recipe-ideas", summary: "Generate 6 diverse recipe idea cards (not saved)", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().max(300).optional() }) } } } }, responses: { 200: { description: "Six recipe ideas", content: { "application/json": { schema: z.array(z.object({ title: z.string(), description: z.string(), tags: z.array(z.string()).max(4), difficulty: z.enum(["easy", "medium", "hard"]), totalMins: z.number().int().optional() })) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/ai/recipe-ideas", summary: "Generate 6 diverse recipe idea cards (not saved)", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().max(300).optional() }) } } } }, responses: { 200: { description: "Six recipe ideas", content: { "application/json": { schema: z.array(z.object({ title: z.string(), description: z.string(), tags: z.array(z.string()).max(4), difficulty: z.enum(["easy", "medium", "hard"]), totalMins: z.number().int().optional() })) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
const ProposedRecipeRef = registry.register("ProposedRecipe", z.object({ const ProposedRecipeRef = registry.register("ProposedRecipe", z.object({
title: z.string(), description: z.string().optional(), baseServings: z.number().int(), title: z.string(), description: z.string().optional(), baseServings: z.number().int(),
+22 -1
View File
@@ -1208,7 +1208,28 @@
"removedFromCollection": "{count, plural, one {1 recipe removed from this collection} other {{count} recipes removed from this collection}}", "removedFromCollection": "{count, plural, one {1 recipe removed from this collection} other {{count} recipes removed from this collection}}",
"removeFromCollectionFailed": "Failed to remove from collection", "removeFromCollectionFailed": "Failed to remove from collection",
"removeFromCollectionConfirmTitle": "{count, plural, one {Remove 1 recipe from this collection?} other {Remove {count} recipes from this collection?}}", "removeFromCollectionConfirmTitle": "{count, plural, one {Remove 1 recipe from this collection?} other {Remove {count} recipes from this collection?}}",
"removeFromCollectionConfirmDescription": "The recipe itself won't be deleted, just removed from this collection." "removeFromCollectionConfirmDescription": "The recipe itself won't be deleted, just removed from this collection.",
"generateMeal": "Generate meal",
"generateMealTitle": "Generate a complete meal",
"generateMealDescription": "AI generates one recipe per course, all matching the same theme, and adds them to this collection.",
"themeLabel": "Theme",
"themePlaceholder": "e.g. Italian dinner for a birthday",
"coursesLabel": "Courses (pick at least 2)",
"course": {
"starter": "Starter",
"main": "Main",
"side": "Side",
"dessert": "Dessert",
"drink": "Drink"
},
"minCoursesHint": "Pick at least 2 courses.",
"servingsLabel": "Servings",
"dietaryLabel": "Dietary needs",
"dietaryPlaceholder": "e.g. vegetarian, no nuts",
"generatingMeal": "Generating…",
"generateMealButton": "Generate",
"generateMealSuccess": "{count, plural, one {1 recipe added} other {{count} recipes added}} to this collection",
"generateMealFailed": "Failed to generate meal"
}, },
"shareDialog": { "shareDialog": {
"invite": "Invite", "invite": "Invite",
+22 -1
View File
@@ -1199,7 +1199,28 @@
"removedFromCollection": "{count, plural, one {1 recette retirée de cette collection} other {{count} recettes retirées de cette collection}}", "removedFromCollection": "{count, plural, one {1 recette retirée de cette collection} other {{count} recettes retirées de cette collection}}",
"removeFromCollectionFailed": "Échec du retrait de la collection", "removeFromCollectionFailed": "Échec du retrait de la collection",
"removeFromCollectionConfirmTitle": "{count, plural, one {Retirer 1 recette de cette collection ?} other {Retirer {count} recettes de cette collection ?}}", "removeFromCollectionConfirmTitle": "{count, plural, one {Retirer 1 recette de cette collection ?} other {Retirer {count} recettes de cette collection ?}}",
"removeFromCollectionConfirmDescription": "La recette elle-même ne sera pas supprimée, seulement retirée de cette collection." "removeFromCollectionConfirmDescription": "La recette elle-même ne sera pas supprimée, seulement retirée de cette collection.",
"generateMeal": "Générer un repas",
"generateMealTitle": "Générer un repas complet",
"generateMealDescription": "L'IA génère une recette par plat, toutes selon le même thème, et les ajoute à cette collection.",
"themeLabel": "Thème",
"themePlaceholder": "ex. Dîner italien pour un anniversaire",
"coursesLabel": "Plats (choisissez-en au moins 2)",
"course": {
"starter": "Entrée",
"main": "Plat",
"side": "Accompagnement",
"dessert": "Dessert",
"drink": "Boisson"
},
"minCoursesHint": "Choisissez au moins 2 plats.",
"servingsLabel": "Portions",
"dietaryLabel": "Contraintes alimentaires",
"dietaryPlaceholder": "ex. végétarien, sans noix",
"generatingMeal": "Génération…",
"generateMealButton": "Générer",
"generateMealSuccess": "{count, plural, one {1 recette ajoutée} other {{count} recettes ajoutées}} à cette collection",
"generateMealFailed": "Échec de la génération du repas"
}, },
"shareDialog": { "shareDialog": {
"invite": "Inviter", "invite": "Inviter",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@epicure/web", "name": "@epicure/web",
"version": "0.51.7", "version": "0.52.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "epicure", "name": "epicure",
"version": "0.51.7", "version": "0.52.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm --filter web dev", "dev": "pnpm --filter web dev",