feat: manually create and edit batch-cook recipes
recipe-form.tsx previously had no batch-cook awareness at all — editing an AI-generated batch-cook recipe and saving silently corrupted it (steps lost their per-dish `appliesTo` tags, recipeBatchDishes rows went stale/orphaned, since the create/update API schemas and edit-page query never touched them). Adds a "batch-cook recipe" toggle to the form, an editable dish list (name, description, fridge days, freezer-friendly/note, day-of instructions), and per-step dish tagging (click a dish chip to mark which dish(es) that step advances; empty = shared prep). Renaming a dish propagates to any step still tagged with the old name instead of orphaning it. Wired through Create/Update API schemas and the edit page's query/payload. Verified locally: created a 2-dish batch recipe from scratch through the real form, confirmed it renders identically to an AI-generated one (grouped steps, dishes & storage cards), edited it, renamed a dish, reloaded, and confirmed the step tag followed the rename rather than orphaning.
This commit is contained in:
@@ -10,6 +10,8 @@ import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -45,6 +47,17 @@ type StepRow = {
|
||||
id: string;
|
||||
instruction: string;
|
||||
timerSeconds: string;
|
||||
appliesTo: string[];
|
||||
};
|
||||
|
||||
type DishRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
fridgeDays: string;
|
||||
freezerFriendly: boolean;
|
||||
freezerNote: string;
|
||||
dayOfInstructions: string;
|
||||
};
|
||||
|
||||
type RecipeFormProps = {
|
||||
@@ -62,6 +75,8 @@ type RecipeFormProps = {
|
||||
ingredients?: IngredientRow[];
|
||||
steps?: StepRow[];
|
||||
photos?: PhotoEntry[];
|
||||
isBatchCook?: boolean;
|
||||
dishes?: DishRow[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -70,7 +85,11 @@ function newIngredient(): IngredientRow {
|
||||
}
|
||||
|
||||
function newStep(): StepRow {
|
||||
return { id: crypto.randomUUID(), instruction: "", timerSeconds: "" };
|
||||
return { id: crypto.randomUUID(), instruction: "", timerSeconds: "", appliesTo: [] };
|
||||
}
|
||||
|
||||
function newDish(): DishRow {
|
||||
return { id: crypto.randomUUID(), name: "", description: "", fridgeDays: "3", freezerFriendly: false, freezerNote: "", dayOfInstructions: "" };
|
||||
}
|
||||
|
||||
export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
||||
@@ -99,6 +118,10 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
||||
defaultValues?.steps?.length ? defaultValues.steps : [newStep()]
|
||||
);
|
||||
const [photos, setPhotos] = useState<PhotoEntry[]>(defaultValues?.photos ?? []);
|
||||
const [isBatchCook, setIsBatchCook] = useState(defaultValues?.isBatchCook ?? false);
|
||||
const [dishes, setDishes] = useState<DishRow[]>(
|
||||
defaultValues?.dishes?.length ? defaultValues.dishes : [newDish()]
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
|
||||
@@ -125,6 +148,8 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
||||
ingredients,
|
||||
steps,
|
||||
photos,
|
||||
isBatchCook,
|
||||
dishes,
|
||||
]);
|
||||
|
||||
// Browser-level guard: warn on tab close / reload / external navigation while dirty.
|
||||
@@ -182,6 +207,35 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
||||
setSteps((prev) => prev.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
function toggleStepDish(i: number, dishName: string) {
|
||||
setSteps((prev) => prev.map((row, idx) => {
|
||||
if (idx !== i) return row;
|
||||
const has = row.appliesTo.includes(dishName);
|
||||
return { ...row, appliesTo: has ? row.appliesTo.filter((n) => n !== dishName) : [...row.appliesTo, dishName] };
|
||||
}));
|
||||
}
|
||||
|
||||
function updateDish(i: number, patch: Partial<DishRow>) {
|
||||
const oldName = dishes[i]?.name;
|
||||
setDishes((prev) => prev.map((row, idx) => idx === i ? { ...row, ...patch } : row));
|
||||
// appliesTo tracks dishes by name (matches the DB's join-by-string-array
|
||||
// design), so a rename must be propagated or steps silently detach.
|
||||
if (patch.name !== undefined && oldName && patch.name !== oldName) {
|
||||
setSteps((prev) => prev.map((row) => ({
|
||||
...row,
|
||||
appliesTo: row.appliesTo.map((n) => n === oldName ? patch.name! : n),
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
function removeDish(i: number) {
|
||||
const removedName = dishes[i]?.name;
|
||||
setDishes((prev) => prev.filter((_, idx) => idx !== i));
|
||||
if (removedName) {
|
||||
setSteps((prev) => prev.map((row) => ({ ...row, appliesTo: row.appliesTo.filter((n) => n !== removedName) })));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) {
|
||||
@@ -204,12 +258,36 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredDishes = isBatchCook
|
||||
? dishes
|
||||
.filter((d) => d.name.trim())
|
||||
.map((d) => ({
|
||||
name: d.name.trim(),
|
||||
description: d.description.trim() || undefined,
|
||||
fridgeDays: parseInt(d.fridgeDays) || 3,
|
||||
freezerFriendly: d.freezerFriendly,
|
||||
freezerNote: d.freezerNote.trim() || undefined,
|
||||
dayOfInstructions: d.dayOfInstructions.trim(),
|
||||
}))
|
||||
: [];
|
||||
|
||||
if (isBatchCook && filteredDishes.length === 0) {
|
||||
toast.error(t("dishesRequired"));
|
||||
return;
|
||||
}
|
||||
if (isBatchCook && filteredDishes.some((d) => !d.dayOfInstructions)) {
|
||||
toast.error(t("dayOfInstructionsRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
const dishNames = new Set(filteredDishes.map((d) => d.name));
|
||||
const filteredSteps = steps
|
||||
.filter((s) => s.instruction.trim())
|
||||
.map((s, i) => ({
|
||||
instruction: s.instruction.trim(),
|
||||
timerSeconds: s.timerSeconds ? parseInt(s.timerSeconds) : undefined,
|
||||
order: i,
|
||||
appliesTo: isBatchCook ? s.appliesTo.filter((n) => dishNames.has(n)) : [],
|
||||
}));
|
||||
|
||||
if (filteredSteps.length === 0) {
|
||||
@@ -232,6 +310,8 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
||||
ingredients: filteredIngredients,
|
||||
steps: filteredSteps,
|
||||
photos: photos.map((p) => ({ key: p.key, isCover: p.isCover })),
|
||||
isBatchCook,
|
||||
dishes: filteredDishes,
|
||||
};
|
||||
|
||||
const url = isEdit ? `/api/v1/recipes/${id}` : "/api/v1/recipes";
|
||||
@@ -399,6 +479,17 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Batch cooking */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="batch-cook" checked={isBatchCook} onCheckedChange={setIsBatchCook} />
|
||||
<Label htmlFor="batch-cook" className="cursor-pointer">{t("batchCookToggle")}</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("batchCookToggleHelp")}</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Photos */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("photos")}</Label>
|
||||
@@ -462,38 +553,151 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Batch-cook dishes */}
|
||||
{isBatchCook && (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<Label>{t("dishes")}</Label>
|
||||
<div className="space-y-4">
|
||||
{dishes.map((dish, i) => (
|
||||
<div key={dish.id} className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex gap-2 items-start">
|
||||
<Input
|
||||
value={dish.name}
|
||||
onChange={(e) => updateDish(i, { name: e.target.value })}
|
||||
placeholder={t("dishName")}
|
||||
className="flex-1 min-w-0 font-medium"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeDish(i)}
|
||||
disabled={dishes.length === 1}
|
||||
className="mt-1.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-30"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={dish.description}
|
||||
onChange={(e) => updateDish(i, { description: e.target.value })}
|
||||
placeholder={t("dishDescription")}
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 items-end">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`fridge-${dish.id}`} className="text-xs text-muted-foreground">{t("fridgeDays")}</Label>
|
||||
<Input
|
||||
id={`fridge-${dish.id}`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={14}
|
||||
value={dish.fridgeDays}
|
||||
onChange={(e) => updateDish(i, { fridgeDays: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-1.5">
|
||||
<Switch
|
||||
id={`freezer-${dish.id}`}
|
||||
checked={dish.freezerFriendly}
|
||||
onCheckedChange={(v) => updateDish(i, { freezerFriendly: v })}
|
||||
/>
|
||||
<Label htmlFor={`freezer-${dish.id}`} className="cursor-pointer text-xs">{t("freezerFriendly")}</Label>
|
||||
</div>
|
||||
{dish.freezerFriendly && (
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label htmlFor={`freezer-note-${dish.id}`} className="text-xs text-muted-foreground">{t("freezerNote")}</Label>
|
||||
<Input
|
||||
id={`freezer-note-${dish.id}`}
|
||||
value={dish.freezerNote}
|
||||
onChange={(e) => updateDish(i, { freezerNote: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`dayof-${dish.id}`} className="text-xs text-muted-foreground">{t("dayOfInstructions")}</Label>
|
||||
<Textarea
|
||||
id={`dayof-${dish.id}`}
|
||||
value={dish.dayOfInstructions}
|
||||
onChange={(e) => updateDish(i, { dayOfInstructions: e.target.value })}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDishes((p) => [...p, newDish()])}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("addDish")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Steps */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("steps")}</Label>
|
||||
<div className="space-y-3">
|
||||
{steps.map((step, i) => (
|
||||
<div key={step.id} className="flex gap-3 items-start">
|
||||
<div className="mt-2.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-bold text-muted-foreground">
|
||||
{i + 1}
|
||||
<div key={step.id} className="space-y-1.5">
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="mt-2.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-bold text-muted-foreground">
|
||||
{i + 1}
|
||||
</div>
|
||||
<Textarea
|
||||
value={step.instruction}
|
||||
onChange={(e) => updateStep(i, { instruction: e.target.value })}
|
||||
placeholder={t("stepPlaceholder", { n: i + 1 })}
|
||||
rows={2}
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<Input
|
||||
value={step.timerSeconds}
|
||||
onChange={(e) => updateStep(i, { timerSeconds: e.target.value })}
|
||||
placeholder={t("timerSeconds")}
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-28 shrink-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStep(i)}
|
||||
disabled={steps.length === 1}
|
||||
className="mt-2.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-30"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={step.instruction}
|
||||
onChange={(e) => updateStep(i, { instruction: e.target.value })}
|
||||
placeholder={t("stepPlaceholder", { n: i + 1 })}
|
||||
rows={2}
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<Input
|
||||
value={step.timerSeconds}
|
||||
onChange={(e) => updateStep(i, { timerSeconds: e.target.value })}
|
||||
placeholder={t("timerSeconds")}
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-28 shrink-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStep(i)}
|
||||
disabled={steps.length === 1}
|
||||
className="mt-2.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-30"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
{isBatchCook && dishes.some((d) => d.name.trim()) && (
|
||||
<div className="flex flex-wrap gap-1.5 pl-9">
|
||||
<span className="text-xs text-muted-foreground self-center">{t("appliesTo")}:</span>
|
||||
{dishes.filter((d) => d.name.trim()).map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
onClick={() => toggleStepDish(i, d.name.trim())}
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-xs border transition-colors",
|
||||
step.appliesTo.includes(d.name.trim())
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-transparent text-muted-foreground border-input hover:border-muted-foreground/50"
|
||||
)}
|
||||
>
|
||||
{d.name.trim()}
|
||||
</button>
|
||||
))}
|
||||
{step.appliesTo.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground italic self-center">{t("sharedPrep")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user