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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(" "),
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user