From 13128df19fb8f1a782b02f4cb53738169875402f Mon Sep 17 00:00:00 2001 From: Arnaud Date: Sun, 12 Jul 2026 15:06:48 +0200 Subject: [PATCH] feat: locale-based generation, describe field for batch cooking; fix bulk-bar width and sourceUrl not saving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI recipe generation always used the app's own locale rather than a separate language picker — removed the picker, generation and the saved recipe's language now both follow useLocale() directly. - Bulk-select action bar had an ungated max-w-md that capped it at 448px even past the sm: breakpoint where sm:w-auto was supposed to let it grow to content — added sm:max-w-none so desktop never scrolls. - Batch-cooking generation had no free-text "describe" field like the standard recipe generator does — added one (BatchCookFields, both the standalone dialog and the AI dialog's batch tab), threaded through the API route and generateBatchCook's prompt as additional guidance. - Recipes imported from a URL never actually got their sourceUrl persisted — CreateRecipeSchema didn't declare the field, so Zod silently stripped it before it ever reached the insert. The detail page's "Source: " link already existed but was dead code for every real import until now. Verified all 4 live: generate dialog has no language selector, batch tab has a working describe textarea, bulk-select bar renders at full content width with zero horizontal overflow on desktop, and a recipe created with sourceUrl now round-trips and renders its source link. --- .../api/v1/ai/batch-cook/generate/route.ts | 2 + apps/web/app/api/v1/recipes/route.ts | 2 + .../components/recipe/ai-generate-dialog.tsx | 62 ++++++------------- .../components/recipe/batch-cook-fields.tsx | 13 ++++ .../recipe/batch-cook-generate-dialog.tsx | 2 + apps/web/components/recipe/recipes-grid.tsx | 4 +- .../lib/ai/features/generate-batch-cook.ts | 5 ++ apps/web/messages/en.json | 2 + apps/web/messages/fr.json | 2 + 9 files changed, 48 insertions(+), 46 deletions(-) diff --git a/apps/web/app/api/v1/ai/batch-cook/generate/route.ts b/apps/web/app/api/v1/ai/batch-cook/generate/route.ts index 078af46..8794e2d 100644 --- a/apps/web/app/api/v1/ai/batch-cook/generate/route.ts +++ b/apps/web/app/api/v1/ai/batch-cook/generate/route.ts @@ -15,6 +15,7 @@ const Schema = z.object({ 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(), }); export async function POST(req: NextRequest) { @@ -50,6 +51,7 @@ export async function POST(req: NextRequest) { servings: parsed.data.servings, dietaryPrefs: parsed.data.dietaryPrefs, difficulty: parsed.data.difficulty, + description: parsed.data.description, }, { ...config, userContext: privateBio ?? undefined }, locale diff --git a/apps/web/app/api/v1/recipes/route.ts b/apps/web/app/api/v1/recipes/route.ts index a643496..c16f675 100644 --- a/apps/web/app/api/v1/recipes/route.ts +++ b/apps/web/app/api/v1/recipes/route.ts @@ -19,6 +19,7 @@ const CreateRecipeSchema = z.object({ tags: z.array(z.string().min(1).max(50)).max(20).default([]), aiGenerated: z.boolean().optional(), language: z.string().max(10).optional(), + sourceUrl: z.string().url().max(2000).optional(), dietaryTags: z.object({ vegan: z.boolean().optional(), vegetarian: z.boolean().optional(), @@ -120,6 +121,7 @@ export async function POST(req: NextRequest) { dietaryTags: data.dietaryTags ?? {}, aiGenerated: data.aiGenerated ?? false, language: data.language, + sourceUrl: data.sourceUrl, isBatchCook: data.isBatchCook, createdAt: now, updatedAt: now, diff --git a/apps/web/components/recipe/ai-generate-dialog.tsx b/apps/web/components/recipe/ai-generate-dialog.tsx index 71ac71d..267ac6e 100644 --- a/apps/web/components/recipe/ai-generate-dialog.tsx +++ b/apps/web/components/recipe/ai-generate-dialog.tsx @@ -22,18 +22,6 @@ import { FakeProgressBar } from "@/components/ui/fake-progress-bar"; import { useLocale } from "@/lib/i18n/provider"; import { BatchCookFields, type BatchCookFieldsState } from "./batch-cook-fields"; -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" }, -]; - const SURPRISE_PROMPTS = [ "A cozy one-pot dish with whatever is in my pantry on a rainy evening", "A vibrant street food recipe from Southeast Asia", @@ -83,7 +71,6 @@ export function AiGenerateDialog({ // describe tab const [prompt, setPrompt] = useState(""); - const [language, setLanguage] = useState(locale); const [difficulty, setDifficulty] = useState<"" | "easy" | "medium" | "hard">(""); // photo tab @@ -99,6 +86,7 @@ export function AiGenerateDialog({ servings: 4, difficulty: "", dietaryPrefs: "", + description: "", }); const [busy, setBusy] = useState(false); @@ -110,7 +98,7 @@ export function AiGenerateDialog({ setPrompt(""); setPhotoPreview(null); setPhotoBase64(null); - setBatchFields({ dinners: 4, lunches: 0, servings: 4, difficulty: "", dietaryPrefs: "" }); + setBatchFields({ dinners: 4, lunches: 0, servings: 4, difficulty: "", dietaryPrefs: "", description: "" }); setTab("describe"); }, 200); } @@ -137,7 +125,7 @@ export function AiGenerateDialog({ const res = await fetch("/api/v1/ai/generate", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ prompt: prompt.trim(), language, difficulty: difficulty || undefined }), + body: JSON.stringify({ prompt: prompt.trim(), language: locale, difficulty: difficulty || undefined }), }); if (!res.ok) { const err = await res.json() as { error?: string }; @@ -148,7 +136,7 @@ export function AiGenerateDialog({ const saveRes = await fetch("/api/v1/recipes", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...generated, visibility: "private", aiGenerated: true, language }), + body: JSON.stringify({ ...generated, visibility: "private", aiGenerated: true, language: locale }), }); if (!saveRes.ok) { toast.error(t("saveError")); return; } const saved = await saveRes.json() as { id: string }; @@ -196,6 +184,7 @@ export function AiGenerateDialog({ servings: batchFields.servings, dietaryPrefs: batchFields.dietaryPrefs.trim() || undefined, difficulty: batchFields.difficulty || undefined, + description: batchFields.description.trim() || undefined, }), }); if (!res.ok) { @@ -280,34 +269,19 @@ export function AiGenerateDialog({ disabled={busy} /> -
-
- - -
-
- - -
+
+ +
)} diff --git a/apps/web/components/recipe/batch-cook-fields.tsx b/apps/web/components/recipe/batch-cook-fields.tsx index bc3a59b..3b6fe9e 100644 --- a/apps/web/components/recipe/batch-cook-fields.tsx +++ b/apps/web/components/recipe/batch-cook-fields.tsx @@ -5,6 +5,7 @@ import { Minus, Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; function Counter({ @@ -37,6 +38,7 @@ export type BatchCookFieldsState = { servings: number; difficulty: "" | "easy" | "medium" | "hard"; dietaryPrefs: string; + description: string; }; export function BatchCookFields({ @@ -54,6 +56,17 @@ export function BatchCookFields({ return (
+
+ +