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),
|
servings: z.number().int().min(1).max(12).default(4),
|
||||||
dietaryPrefs: z.string().max(200).optional(),
|
dietaryPrefs: z.string().max(200).optional(),
|
||||||
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -50,6 +51,7 @@ export async function POST(req: NextRequest) {
|
|||||||
servings: parsed.data.servings,
|
servings: parsed.data.servings,
|
||||||
dietaryPrefs: parsed.data.dietaryPrefs,
|
dietaryPrefs: parsed.data.dietaryPrefs,
|
||||||
difficulty: parsed.data.difficulty,
|
difficulty: parsed.data.difficulty,
|
||||||
|
description: parsed.data.description,
|
||||||
},
|
},
|
||||||
{ ...config, userContext: privateBio ?? undefined },
|
{ ...config, userContext: privateBio ?? undefined },
|
||||||
locale
|
locale
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const CreateRecipeSchema = z.object({
|
|||||||
tags: z.array(z.string().min(1).max(50)).max(20).default([]),
|
tags: z.array(z.string().min(1).max(50)).max(20).default([]),
|
||||||
aiGenerated: z.boolean().optional(),
|
aiGenerated: z.boolean().optional(),
|
||||||
language: z.string().max(10).optional(),
|
language: z.string().max(10).optional(),
|
||||||
|
sourceUrl: z.string().url().max(2000).optional(),
|
||||||
dietaryTags: z.object({
|
dietaryTags: z.object({
|
||||||
vegan: z.boolean().optional(),
|
vegan: z.boolean().optional(),
|
||||||
vegetarian: z.boolean().optional(),
|
vegetarian: z.boolean().optional(),
|
||||||
@@ -120,6 +121,7 @@ export async function POST(req: NextRequest) {
|
|||||||
dietaryTags: data.dietaryTags ?? {},
|
dietaryTags: data.dietaryTags ?? {},
|
||||||
aiGenerated: data.aiGenerated ?? false,
|
aiGenerated: data.aiGenerated ?? false,
|
||||||
language: data.language,
|
language: data.language,
|
||||||
|
sourceUrl: data.sourceUrl,
|
||||||
isBatchCook: data.isBatchCook,
|
isBatchCook: data.isBatchCook,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
@@ -22,18 +22,6 @@ import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
|
|||||||
import { useLocale } from "@/lib/i18n/provider";
|
import { useLocale } from "@/lib/i18n/provider";
|
||||||
import { BatchCookFields, type BatchCookFieldsState } from "./batch-cook-fields";
|
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 = [
|
const SURPRISE_PROMPTS = [
|
||||||
"A cozy one-pot dish with whatever is in my pantry on a rainy evening",
|
"A cozy one-pot dish with whatever is in my pantry on a rainy evening",
|
||||||
"A vibrant street food recipe from Southeast Asia",
|
"A vibrant street food recipe from Southeast Asia",
|
||||||
@@ -83,7 +71,6 @@ export function AiGenerateDialog({
|
|||||||
|
|
||||||
// describe tab
|
// describe tab
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [language, setLanguage] = useState(locale);
|
|
||||||
const [difficulty, setDifficulty] = useState<"" | "easy" | "medium" | "hard">("");
|
const [difficulty, setDifficulty] = useState<"" | "easy" | "medium" | "hard">("");
|
||||||
|
|
||||||
// photo tab
|
// photo tab
|
||||||
@@ -99,6 +86,7 @@ export function AiGenerateDialog({
|
|||||||
servings: 4,
|
servings: 4,
|
||||||
difficulty: "",
|
difficulty: "",
|
||||||
dietaryPrefs: "",
|
dietaryPrefs: "",
|
||||||
|
description: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -110,7 +98,7 @@ export function AiGenerateDialog({
|
|||||||
setPrompt("");
|
setPrompt("");
|
||||||
setPhotoPreview(null);
|
setPhotoPreview(null);
|
||||||
setPhotoBase64(null);
|
setPhotoBase64(null);
|
||||||
setBatchFields({ dinners: 4, lunches: 0, servings: 4, difficulty: "", dietaryPrefs: "" });
|
setBatchFields({ dinners: 4, lunches: 0, servings: 4, difficulty: "", dietaryPrefs: "", description: "" });
|
||||||
setTab("describe");
|
setTab("describe");
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
@@ -137,7 +125,7 @@ export function AiGenerateDialog({
|
|||||||
const res = await fetch("/api/v1/ai/generate", {
|
const res = await fetch("/api/v1/ai/generate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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) {
|
if (!res.ok) {
|
||||||
const err = await res.json() as { error?: string };
|
const err = await res.json() as { error?: string };
|
||||||
@@ -148,7 +136,7 @@ export function AiGenerateDialog({
|
|||||||
const saveRes = await fetch("/api/v1/recipes", {
|
const saveRes = await fetch("/api/v1/recipes", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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; }
|
if (!saveRes.ok) { toast.error(t("saveError")); return; }
|
||||||
const saved = await saveRes.json() as { id: string };
|
const saved = await saveRes.json() as { id: string };
|
||||||
@@ -196,6 +184,7 @@ export function AiGenerateDialog({
|
|||||||
servings: batchFields.servings,
|
servings: batchFields.servings,
|
||||||
dietaryPrefs: batchFields.dietaryPrefs.trim() || undefined,
|
dietaryPrefs: batchFields.dietaryPrefs.trim() || undefined,
|
||||||
difficulty: batchFields.difficulty || undefined,
|
difficulty: batchFields.difficulty || undefined,
|
||||||
|
description: batchFields.description.trim() || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -280,34 +269,19 @@ export function AiGenerateDialog({
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<Label>{t("difficulty")}</Label>
|
||||||
<Label>{t("language")}</Label>
|
<Select value={difficulty} onValueChange={(v) => setDifficulty(v as typeof difficulty)} disabled={busy}>
|
||||||
<Select value={language} onValueChange={(v) => v && setLanguage(v)} disabled={busy}>
|
<SelectTrigger>
|
||||||
<SelectTrigger>
|
<SelectValue placeholder={t("anyDifficulty")} />
|
||||||
<SelectValue />
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectContent>
|
||||||
<SelectContent>
|
<SelectItem value="">{t("anyDifficulty")}</SelectItem>
|
||||||
{LANGUAGES.map((l) => (
|
<SelectItem value="easy">{tRecipe("difficulty.easy")}</SelectItem>
|
||||||
<SelectItem key={l.code} value={l.code}>{l.label}</SelectItem>
|
<SelectItem value="medium">{tRecipe("difficulty.medium")}</SelectItem>
|
||||||
))}
|
<SelectItem value="hard">{tRecipe("difficulty.hard")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Minus, Plus } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
function Counter({
|
function Counter({
|
||||||
@@ -37,6 +38,7 @@ export type BatchCookFieldsState = {
|
|||||||
servings: number;
|
servings: number;
|
||||||
difficulty: "" | "easy" | "medium" | "hard";
|
difficulty: "" | "easy" | "medium" | "hard";
|
||||||
dietaryPrefs: string;
|
dietaryPrefs: string;
|
||||||
|
description: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BatchCookFields({
|
export function BatchCookFields({
|
||||||
@@ -54,6 +56,17 @@ export function BatchCookFields({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
<Label>{t("dinners")}</Label>
|
<Label>{t("dinners")}</Label>
|
||||||
<Counter value={state.dinners} onChange={(v) => onChange({ ...state, dinners: v })} disabled={disabled} />
|
<Counter value={state.dinners} onChange={(v) => onChange({ ...state, dinners: v })} disabled={disabled} />
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function BatchCookGenerateDialog({
|
|||||||
servings: 4,
|
servings: 4,
|
||||||
difficulty: "",
|
difficulty: "",
|
||||||
dietaryPrefs: "",
|
dietaryPrefs: "",
|
||||||
|
description: "",
|
||||||
});
|
});
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ export function BatchCookGenerateDialog({
|
|||||||
servings: fields.servings,
|
servings: fields.servings,
|
||||||
dietaryPrefs: fields.dietaryPrefs.trim() || undefined,
|
dietaryPrefs: fields.dietaryPrefs.trim() || undefined,
|
||||||
difficulty: fields.difficulty || undefined,
|
difficulty: fields.difficulty || undefined,
|
||||||
|
description: fields.description.trim() || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -497,8 +497,8 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
|
|||||||
|
|
||||||
{/* Floating bulk action bar */}
|
{/* Floating bulk action bar */}
|
||||||
{selectMode && selected.size > 0 && (
|
{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="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 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
<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>
|
<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" />
|
<div className="w-px h-5 bg-border mx-1 shrink-0" />
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export async function generateBatchCook(
|
|||||||
servings?: number;
|
servings?: number;
|
||||||
dietaryPrefs?: string;
|
dietaryPrefs?: string;
|
||||||
difficulty?: "easy" | "medium" | "hard";
|
difficulty?: "easy" | "medium" | "hard";
|
||||||
|
description?: string;
|
||||||
},
|
},
|
||||||
config?: AiConfig & { userContext?: string },
|
config?: AiConfig & { userContext?: string },
|
||||||
locale?: string
|
locale?: string
|
||||||
@@ -59,6 +60,9 @@ export async function generateBatchCook(
|
|||||||
const difficultyClause = difficulty
|
const difficultyClause = difficulty
|
||||||
? `Keep techniques at a ${difficulty} skill level.`
|
? `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.
|
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.`,
|
`Design exactly ${totalDishes} distinct dishes, each for ${servings} servings.`,
|
||||||
dietaryClause,
|
dietaryClause,
|
||||||
difficultyClause,
|
difficultyClause,
|
||||||
|
descriptionClause,
|
||||||
].filter(Boolean).join(" "),
|
].filter(Boolean).join(" "),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -555,6 +555,8 @@
|
|||||||
"dishCount": "{count, plural, one {# dish} other {# dishes}}",
|
"dishCount": "{count, plural, one {# dish} other {# dishes}}",
|
||||||
"wizardTitle": "Plan a batch-cooking session",
|
"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.",
|
"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",
|
"dinners": "Dinners",
|
||||||
"lunches": "Lunches",
|
"lunches": "Lunches",
|
||||||
"servingsPerMeal": "Servings per meal",
|
"servingsPerMeal": "Servings per meal",
|
||||||
|
|||||||
@@ -546,6 +546,8 @@
|
|||||||
"dishCount": "{count, plural, one {# plat} other {# plats}}",
|
"dishCount": "{count, plural, one {# plat} other {# plats}}",
|
||||||
"wizardTitle": "Planifier une session de batch cooking",
|
"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.",
|
"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",
|
"dinners": "Dîners",
|
||||||
"lunches": "Déjeuners",
|
"lunches": "Déjeuners",
|
||||||
"servingsPerMeal": "Portions par repas",
|
"servingsPerMeal": "Portions par repas",
|
||||||
|
|||||||
Reference in New Issue
Block a user