feat: locale-based generation, describe field for batch cooking; fix bulk-bar width and sourceUrl not saving

- 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: <hostname>" 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.
This commit is contained in:
Arnaud
2026-07-12 15:06:48 +02:00
parent 5b8c50dd1e
commit 13128df19f
9 changed files with 48 additions and 46 deletions
@@ -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
+2
View File
@@ -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,
@@ -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}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{t("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 className="space-y-2">
<Label>{t("difficulty")}</Label>
<Select value={difficulty} onValueChange={(v) => setDifficulty(v as typeof difficulty)} disabled={busy}>
<SelectTrigger>
<SelectValue placeholder={t("anyDifficulty")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{t("anyDifficulty")}</SelectItem>
<SelectItem value="easy">{tRecipe("difficulty.easy")}</SelectItem>
<SelectItem value="medium">{tRecipe("difficulty.medium")}</SelectItem>
<SelectItem value="hard">{tRecipe("difficulty.hard")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t("difficulty")}</Label>
<Select value={difficulty} onValueChange={(v) => setDifficulty(v as typeof difficulty)} disabled={busy}>
<SelectTrigger>
<SelectValue placeholder={t("anyDifficulty")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{t("anyDifficulty")}</SelectItem>
<SelectItem value="easy">{tRecipe("difficulty.easy")}</SelectItem>
<SelectItem value="medium">{tRecipe("difficulty.medium")}</SelectItem>
<SelectItem value="hard">{tRecipe("difficulty.hard")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
@@ -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 (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="batch-description">{t("describe")}</Label>
<Textarea
id="batch-description"
value={state.description}
onChange={(e) => onChange({ ...state, description: e.target.value })}
placeholder={t("describePlaceholder")}
rows={3}
disabled={disabled}
/>
</div>
<div className="flex items-center justify-between">
<Label>{t("dinners")}</Label>
<Counter value={state.dinners} onChange={(v) => onChange({ ...state, dinners: v })} disabled={disabled} />
@@ -33,6 +33,7 @@ export function BatchCookGenerateDialog({
servings: 4,
difficulty: "",
dietaryPrefs: "",
description: "",
});
const [busy, setBusy] = useState(false);
@@ -53,6 +54,7 @@ export function BatchCookGenerateDialog({
servings: fields.servings,
dietaryPrefs: fields.dietaryPrefs.trim() || undefined,
difficulty: fields.difficulty || undefined,
description: fields.description.trim() || undefined,
}),
});
if (!res.ok) {
+2 -2
View File
@@ -497,8 +497,8 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
{/* Floating bulk action bar */}
{selectMode && selected.size > 0 && (
<div className="fixed bottom-4 sm:bottom-8 left-1/2 -translate-x-1/2 z-50 w-[calc(100vw-2rem)] max-w-md sm:w-auto animate-in slide-in-from-bottom-4 duration-200">
<div className="flex items-center gap-1 sm:gap-2 bg-popover border shadow-2xl rounded-2xl px-2 sm:px-5 py-2 sm:py-3 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
<div className="fixed bottom-4 sm:bottom-8 left-1/2 -translate-x-1/2 z-50 w-[calc(100vw-2rem)] sm:w-auto sm:max-w-none animate-in slide-in-from-bottom-4 duration-200">
<div className="flex items-center gap-1 sm:gap-2 bg-popover border shadow-2xl rounded-2xl px-2 sm:px-5 py-2 sm:py-3 overflow-x-auto sm:overflow-visible [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
<span className="text-sm font-semibold tabular-nums mr-1 shrink-0">{selected.size}</span>
<div className="w-px h-5 bg-border mx-1 shrink-0" />
<DropdownMenu>
@@ -37,6 +37,7 @@ export async function generateBatchCook(
servings?: number;
dietaryPrefs?: string;
difficulty?: "easy" | "medium" | "hard";
description?: string;
},
config?: AiConfig & { userContext?: string },
locale?: string
@@ -59,6 +60,9 @@ export async function generateBatchCook(
const difficultyClause = difficulty
? `Keep techniques at a ${difficulty} skill level.`
: "";
const descriptionClause = options.description?.trim()
? `Additional guidance: ${options.description.trim()}.`
: "";
const systemPrompt = `You are a professional meal-prep chef writing ONE unified "batch cooking" recipe page — not several separate recipes. This mirrors real French batch-cooking guides: one shopping list, then a single prep session structured in two phases, covering multiple meals for the days ahead.
@@ -92,6 +96,7 @@ Write every description and instruction as plain prose — never use markdown fo
`Design exactly ${totalDishes} distinct dishes, each for ${servings} servings.`,
dietaryClause,
difficultyClause,
descriptionClause,
].filter(Boolean).join(" "),
});
+2
View File
@@ -555,6 +555,8 @@
"dishCount": "{count, plural, one {# dish} other {# dishes}}",
"wizardTitle": "Plan a batch-cooking session",
"wizardDescription": "Choose how many meals to cover — we'll design a shared shopping list and one prep session for all of them.",
"describe": "Describe what you're after",
"describePlaceholder": "e.g. Mediterranean flavors, use up the squash in my pantry, kid-friendly…",
"dinners": "Dinners",
"lunches": "Lunches",
"servingsPerMeal": "Servings per meal",
+2
View File
@@ -546,6 +546,8 @@
"dishCount": "{count, plural, one {# plat} other {# plats}}",
"wizardTitle": "Planifier une session de batch cooking",
"wizardDescription": "Choisissez combien de repas couvrir — on génère une liste de courses partagée et une seule session de préparation pour tout.",
"describe": "Décrivez ce que vous recherchez",
"describePlaceholder": "ex. saveurs méditerranéennes, utiliser la courge du garde-manger, adapté aux enfants…",
"dinners": "Dîners",
"lunches": "Déjeuners",
"servingsPerMeal": "Portions par repas",