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:
@@ -22,6 +22,7 @@ export default async function EditRecipePage({ params }: Params) {
|
||||
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
steps: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
photos: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
batchDishes: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,12 +49,23 @@ export default async function EditRecipePage({ params }: Params) {
|
||||
id: step.id,
|
||||
instruction: step.instruction,
|
||||
timerSeconds: step.timerSeconds ? String(step.timerSeconds) : "",
|
||||
appliesTo: step.appliesTo ?? [],
|
||||
})),
|
||||
photos: recipe.photos.map((photo) => ({
|
||||
key: photo.storageKey,
|
||||
isCover: photo.isCover,
|
||||
preview: getPublicUrl(photo.storageKey),
|
||||
})),
|
||||
isBatchCook: recipe.isBatchCook,
|
||||
dishes: recipe.batchDishes.map((dish) => ({
|
||||
id: dish.id,
|
||||
name: dish.name,
|
||||
description: dish.description ?? "",
|
||||
fridgeDays: String(dish.fridgeDays),
|
||||
freezerFriendly: dish.freezerFriendly,
|
||||
freezerNote: dish.freezerNote ?? "",
|
||||
dayOfInstructions: dish.dayOfInstructions,
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, recipes, recipeIngredients, recipeSteps, recipePhotos, recipeSnapshots, ratings } from "@epicure/db";
|
||||
import { db, recipes, recipeIngredients, recipeSteps, recipePhotos, recipeBatchDishes, recipeSnapshots, ratings } from "@epicure/db";
|
||||
import { eq, and, max, isNotNull } from "@epicure/db";
|
||||
import { z } from "zod";
|
||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
@@ -40,11 +40,21 @@ const UpdateRecipeSchema = z.object({
|
||||
instruction: z.string().min(1).max(2000),
|
||||
timerSeconds: z.number().int().min(0).max(86400).optional(),
|
||||
order: z.number().int(),
|
||||
appliesTo: z.array(z.string().min(1).max(100)).max(20).default([]),
|
||||
})).max(100).optional(),
|
||||
photos: z.array(z.object({
|
||||
key: z.string().min(1).max(500),
|
||||
isCover: z.boolean().default(false),
|
||||
})).max(20).optional(),
|
||||
isBatchCook: z.boolean().optional(),
|
||||
dishes: z.array(z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
fridgeDays: z.number().int().min(1).max(14),
|
||||
freezerFriendly: z.boolean().default(false),
|
||||
freezerNote: z.string().max(300).optional(),
|
||||
dayOfInstructions: z.string().min(1).max(1000),
|
||||
})).max(10).optional(),
|
||||
});
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
@@ -52,7 +62,12 @@ type Params = { params: Promise<{ id: string }> };
|
||||
async function getOwnedRecipe(recipeId: string, userId: string) {
|
||||
return db.query.recipes.findFirst({
|
||||
where: and(eq(recipes.id, recipeId), eq(recipes.authorId, userId)),
|
||||
with: { ingredients: { orderBy: (t, { asc }) => asc(t.order) }, steps: { orderBy: (t, { asc }) => asc(t.order) }, photos: true },
|
||||
with: {
|
||||
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
steps: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
photos: true,
|
||||
batchDishes: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,6 +93,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
||||
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
steps: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
photos: true,
|
||||
batchDishes: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
},
|
||||
});
|
||||
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
@@ -136,6 +152,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
||||
if (data.cookMins !== undefined) updates.cookMins = data.cookMins ?? undefined;
|
||||
if (data.tags !== undefined) updates.tags = data.tags;
|
||||
if (data.dietaryTags !== undefined) updates.dietaryTags = data.dietaryTags;
|
||||
if (data.isBatchCook !== undefined) updates.isBatchCook = data.isBatchCook;
|
||||
|
||||
await tx.update(recipes).set(updates).where(eq(recipes.id, id));
|
||||
|
||||
@@ -166,6 +183,26 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
||||
instruction: step.instruction,
|
||||
timerSeconds: step.timerSeconds,
|
||||
order: step.order ?? i,
|
||||
appliesTo: step.appliesTo,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.dishes !== undefined) {
|
||||
await tx.delete(recipeBatchDishes).where(eq(recipeBatchDishes.recipeId, id));
|
||||
if (data.dishes.length > 0) {
|
||||
await tx.insert(recipeBatchDishes).values(
|
||||
data.dishes.map((dish, i) => ({
|
||||
id: crypto.randomUUID(),
|
||||
recipeId: id,
|
||||
name: dish.name,
|
||||
description: dish.description,
|
||||
order: i,
|
||||
fridgeDays: dish.fridgeDays,
|
||||
freezerFriendly: dish.freezerFriendly,
|
||||
freezerNote: dish.freezerNote,
|
||||
dayOfInstructions: dish.dayOfInstructions,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, recipes, recipeIngredients, recipeSteps, recipePhotos } from "@epicure/db";
|
||||
import { db, recipes, recipeIngredients, recipeSteps, recipePhotos, recipeBatchDishes } from "@epicure/db";
|
||||
import { eq, desc, and } from "@epicure/db";
|
||||
import { z } from "zod";
|
||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
@@ -42,11 +42,21 @@ const CreateRecipeSchema = z.object({
|
||||
instruction: z.string().min(1).max(2000),
|
||||
timerSeconds: z.number().int().min(0).max(86400).optional(),
|
||||
order: z.number().int().optional(),
|
||||
appliesTo: z.array(z.string().min(1).max(100)).max(20).default([]),
|
||||
})).max(100).default([]),
|
||||
photos: z.array(z.object({
|
||||
key: z.string().min(1).max(500),
|
||||
isCover: z.boolean().default(false),
|
||||
})).max(20).default([]),
|
||||
isBatchCook: z.boolean().default(false),
|
||||
dishes: z.array(z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
fridgeDays: z.number().int().min(1).max(14),
|
||||
freezerFriendly: z.boolean().default(false),
|
||||
freezerNote: z.string().max(300).optional(),
|
||||
dayOfInstructions: z.string().min(1).max(1000),
|
||||
})).max(10).default([]),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
@@ -110,6 +120,7 @@ export async function POST(req: NextRequest) {
|
||||
dietaryTags: data.dietaryTags ?? {},
|
||||
aiGenerated: data.aiGenerated ?? false,
|
||||
language: data.language,
|
||||
isBatchCook: data.isBatchCook,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
@@ -136,6 +147,7 @@ export async function POST(req: NextRequest) {
|
||||
instruction: step.instruction,
|
||||
timerSeconds: step.timerSeconds,
|
||||
order: step.order ?? i,
|
||||
appliesTo: step.appliesTo,
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -151,6 +163,22 @@ export async function POST(req: NextRequest) {
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (data.dishes.length > 0) {
|
||||
await tx.insert(recipeBatchDishes).values(
|
||||
data.dishes.map((dish, i) => ({
|
||||
id: crypto.randomUUID(),
|
||||
recipeId: id,
|
||||
name: dish.name,
|
||||
description: dish.description,
|
||||
order: i,
|
||||
fridgeDays: dish.fridgeDays,
|
||||
freezerFriendly: dish.freezerFriendly,
|
||||
freezerNote: dish.freezerNote,
|
||||
dayOfInstructions: dish.dayOfInstructions,
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -660,7 +660,21 @@
|
||||
"discardChangesTitle": "Discard changes?",
|
||||
"discardChangesDescription": "You have unsaved changes. If you leave now, they will be lost.",
|
||||
"keepEditing": "Keep editing",
|
||||
"discardChanges": "Discard changes"
|
||||
"discardChanges": "Discard changes",
|
||||
"batchCookToggle": "This is a batch-cook recipe",
|
||||
"batchCookToggleHelp": "Split this recipe into several dishes cooked together, each with its own storage and reheat guidance.",
|
||||
"dishes": "Dishes",
|
||||
"addDish": "Add dish",
|
||||
"dishName": "Dish name",
|
||||
"dishDescription": "Description",
|
||||
"fridgeDays": "Fridge (days)",
|
||||
"freezerFriendly": "Freezer-friendly",
|
||||
"freezerNote": "Freezer note",
|
||||
"dayOfInstructions": "Day-of instructions",
|
||||
"dishesRequired": "Add at least one dish",
|
||||
"dayOfInstructionsRequired": "Every dish needs day-of instructions",
|
||||
"appliesTo": "Applies to",
|
||||
"sharedPrep": "Shared prep"
|
||||
},
|
||||
"canCook": {
|
||||
"title": "What can I cook?",
|
||||
|
||||
@@ -648,7 +648,21 @@
|
||||
"discardChangesTitle": "Abandonner les modifications ?",
|
||||
"discardChangesDescription": "Vous avez des modifications non enregistrées. Si vous quittez maintenant, elles seront perdues.",
|
||||
"keepEditing": "Continuer l'édition",
|
||||
"discardChanges": "Abandonner"
|
||||
"discardChanges": "Abandonner",
|
||||
"batchCookToggle": "Ceci est une recette de batch cooking",
|
||||
"batchCookToggleHelp": "Divisez cette recette en plusieurs plats cuisinés ensemble, chacun avec ses propres consignes de conservation et de réchauffage.",
|
||||
"dishes": "Plats",
|
||||
"addDish": "Ajouter un plat",
|
||||
"dishName": "Nom du plat",
|
||||
"dishDescription": "Description",
|
||||
"fridgeDays": "Frigo (jours)",
|
||||
"freezerFriendly": "Se congèle bien",
|
||||
"freezerNote": "Note de congélation",
|
||||
"dayOfInstructions": "Consignes le jour J",
|
||||
"dishesRequired": "Ajoutez au moins un plat",
|
||||
"dayOfInstructionsRequired": "Chaque plat nécessite des consignes pour le jour J",
|
||||
"appliesTo": "Concerne",
|
||||
"sharedPrep": "Préparation commune"
|
||||
},
|
||||
"canCook": {
|
||||
"title": "Que puis-je cuisiner ?",
|
||||
|
||||
Reference in New Issue
Block a user