84d6cfeb07
List, detail, new, edit pages. Server-side pagination, dietary tags, difficulty. Photo upload to S3-compatible storage. Version history. Multi-select grid with bulk delete/visibility. Print view. Delete confirmation dialog.
389 lines
13 KiB
TypeScript
389 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Plus, Trash2, GripVertical } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { useTranslations } from "next-intl";
|
|
import { Button } from "@/components/ui/button";
|
|
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 { DietaryTagPicker } from "./dietary-tag-picker";
|
|
import { PhotoUploader, type PhotoEntry } from "./photo-uploader";
|
|
|
|
type DietaryTags = {
|
|
vegan?: boolean;
|
|
vegetarian?: boolean;
|
|
glutenFree?: boolean;
|
|
dairyFree?: boolean;
|
|
nutFree?: boolean;
|
|
halal?: boolean;
|
|
kosher?: boolean;
|
|
};
|
|
|
|
type IngredientRow = {
|
|
id: string;
|
|
rawName: string;
|
|
quantity: string;
|
|
unit: string;
|
|
note: string;
|
|
};
|
|
|
|
type StepRow = {
|
|
id: string;
|
|
instruction: string;
|
|
timerSeconds: string;
|
|
};
|
|
|
|
type RecipeFormProps = {
|
|
recipeId?: string;
|
|
defaultValues?: {
|
|
title?: string;
|
|
description?: string;
|
|
baseServings?: number;
|
|
visibility?: "private" | "unlisted" | "public";
|
|
difficulty?: "easy" | "medium" | "hard" | null;
|
|
prepMins?: number | null;
|
|
cookMins?: number | null;
|
|
dietaryTags?: DietaryTags;
|
|
ingredients?: IngredientRow[];
|
|
steps?: StepRow[];
|
|
photos?: PhotoEntry[];
|
|
};
|
|
};
|
|
|
|
function newIngredient(): IngredientRow {
|
|
return { id: crypto.randomUUID(), rawName: "", quantity: "", unit: "", note: "" };
|
|
}
|
|
|
|
function newStep(): StepRow {
|
|
return { id: crypto.randomUUID(), instruction: "", timerSeconds: "" };
|
|
}
|
|
|
|
export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
|
const router = useRouter();
|
|
const t = useTranslations("recipeForm");
|
|
const t_recipe = useTranslations("recipe");
|
|
const t_common = useTranslations("common");
|
|
const isEdit = !!recipeId;
|
|
const id = recipeId ?? crypto.randomUUID();
|
|
|
|
const [title, setTitle] = useState(defaultValues?.title ?? "");
|
|
const [description, setDescription] = useState(defaultValues?.description ?? "");
|
|
const [baseServings, setBaseServings] = useState(String(defaultValues?.baseServings ?? 4));
|
|
const [visibility, setVisibility] = useState<"private" | "unlisted" | "public">(defaultValues?.visibility ?? "private");
|
|
const [difficulty, setDifficulty] = useState<"easy" | "medium" | "hard" | "">(defaultValues?.difficulty ?? "");
|
|
const [prepMins, setPrepMins] = useState(String(defaultValues?.prepMins ?? ""));
|
|
const [cookMins, setCookMins] = useState(String(defaultValues?.cookMins ?? ""));
|
|
const [dietaryTags, setDietaryTags] = useState<DietaryTags>(defaultValues?.dietaryTags ?? {});
|
|
const [ingredients, setIngredients] = useState<IngredientRow[]>(
|
|
defaultValues?.ingredients?.length ? defaultValues.ingredients : [newIngredient()]
|
|
);
|
|
const [steps, setSteps] = useState<StepRow[]>(
|
|
defaultValues?.steps?.length ? defaultValues.steps : [newStep()]
|
|
);
|
|
const [photos, setPhotos] = useState<PhotoEntry[]>(defaultValues?.photos ?? []);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
function updateIngredient(i: number, patch: Partial<IngredientRow>) {
|
|
setIngredients((prev) => prev.map((row, idx) => idx === i ? { ...row, ...patch } : row));
|
|
}
|
|
|
|
function removeIngredient(i: number) {
|
|
setIngredients((prev) => prev.filter((_, idx) => idx !== i));
|
|
}
|
|
|
|
function updateStep(i: number, patch: Partial<StepRow>) {
|
|
setSteps((prev) => prev.map((row, idx) => idx === i ? { ...row, ...patch } : row));
|
|
}
|
|
|
|
function removeStep(i: number) {
|
|
setSteps((prev) => prev.filter((_, idx) => idx !== i));
|
|
}
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!title.trim()) {
|
|
toast.error(t("titleRequired"));
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
try {
|
|
const payload = {
|
|
title: title.trim(),
|
|
description: description.trim() || undefined,
|
|
baseServings: parseInt(baseServings) || 4,
|
|
visibility,
|
|
difficulty: difficulty || undefined,
|
|
prepMins: prepMins ? parseInt(prepMins) : undefined,
|
|
cookMins: cookMins ? parseInt(cookMins) : undefined,
|
|
dietaryTags,
|
|
ingredients: ingredients
|
|
.filter((ing) => ing.rawName.trim())
|
|
.map((ing, i) => ({
|
|
rawName: ing.rawName.trim(),
|
|
quantity: ing.quantity.trim() || undefined,
|
|
unit: ing.unit.trim() || undefined,
|
|
note: ing.note.trim() || undefined,
|
|
order: i,
|
|
})),
|
|
steps: steps
|
|
.filter((s) => s.instruction.trim())
|
|
.map((s, i) => ({
|
|
instruction: s.instruction.trim(),
|
|
timerSeconds: s.timerSeconds ? parseInt(s.timerSeconds) : undefined,
|
|
order: i,
|
|
})),
|
|
};
|
|
|
|
const url = isEdit ? `/api/v1/recipes/${id}` : "/api/v1/recipes";
|
|
const method = isEdit ? "PUT" : "POST";
|
|
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json() as { error?: string };
|
|
toast.error(err.error ?? t("saveError"));
|
|
return;
|
|
}
|
|
|
|
const saved = await res.json() as { id: string };
|
|
toast.success(isEdit ? t("updateSuccess") : t("createSuccess"));
|
|
router.push(`/recipes/${saved.id}`);
|
|
router.refresh();
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-8 max-w-3xl">
|
|
{/* Basic info */}
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="title">{t("titleLabel")}</Label>
|
|
<Input
|
|
id="title"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder={t("titlePlaceholder")}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description">{t("descriptionLabel")}</Label>
|
|
<Textarea
|
|
id="description"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder={t("descriptionPlaceholder")}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="servings">{t("servings")}</Label>
|
|
<Input
|
|
id="servings"
|
|
type="number"
|
|
min={1}
|
|
max={100}
|
|
value={baseServings}
|
|
onChange={(e) => setBaseServings(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="prep">{t("prepMins")}</Label>
|
|
<Input
|
|
id="prep"
|
|
type="number"
|
|
min={0}
|
|
value={prepMins}
|
|
onChange={(e) => setPrepMins(e.target.value)}
|
|
placeholder="0"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="cook">{t("cookMins")}</Label>
|
|
<Input
|
|
id="cook"
|
|
type="number"
|
|
min={0}
|
|
value={cookMins}
|
|
onChange={(e) => setCookMins(e.target.value)}
|
|
placeholder="0"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="difficulty">{t("difficulty")}</Label>
|
|
<select
|
|
id="difficulty"
|
|
value={difficulty}
|
|
onChange={(e) => setDifficulty(e.target.value as "easy" | "medium" | "hard" | "")}
|
|
className="flex h-8 w-full rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
>
|
|
<option value="">—</option>
|
|
<option value="easy">{t_recipe("difficulty.easy")}</option>
|
|
<option value="medium">{t_recipe("difficulty.medium")}</option>
|
|
<option value="hard">{t_recipe("difficulty.hard")}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="visibility">{t("visibility")}</Label>
|
|
<select
|
|
id="visibility"
|
|
value={visibility}
|
|
onChange={(e) => setVisibility(e.target.value as "private" | "unlisted" | "public")}
|
|
className="flex h-8 w-full max-w-[200px] rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
>
|
|
<option value="private">{t_recipe("visibility.private")}</option>
|
|
<option value="unlisted">{t_recipe("visibility.unlisted")}</option>
|
|
<option value="public">{t_recipe("visibility.public")}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Dietary tags */}
|
|
<div className="space-y-2">
|
|
<Label>{t("dietaryTags")}</Label>
|
|
<DietaryTagPicker value={dietaryTags} onChange={setDietaryTags} />
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Photos */}
|
|
<div className="space-y-2">
|
|
<Label>{t("photos")}</Label>
|
|
<PhotoUploader recipeId={id} photos={photos} onChange={setPhotos} />
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Ingredients */}
|
|
<div className="space-y-3">
|
|
<Label>{t("ingredients")}</Label>
|
|
<div className="space-y-2">
|
|
{ingredients.map((ing, i) => (
|
|
<div key={ing.id} className="flex gap-2 items-start">
|
|
<GripVertical className="h-4 w-4 text-muted-foreground mt-2 cursor-grab shrink-0" />
|
|
<Input
|
|
value={ing.quantity}
|
|
onChange={(e) => updateIngredient(i, { quantity: e.target.value })}
|
|
placeholder={t("amount")}
|
|
className="w-16 shrink-0"
|
|
/>
|
|
<Input
|
|
value={ing.unit}
|
|
onChange={(e) => updateIngredient(i, { unit: e.target.value })}
|
|
placeholder={t("unit")}
|
|
className="w-24 shrink-0"
|
|
/>
|
|
<Input
|
|
value={ing.rawName}
|
|
onChange={(e) => updateIngredient(i, { rawName: e.target.value })}
|
|
placeholder={t("ingredient")}
|
|
className="flex-1 min-w-0"
|
|
/>
|
|
<Input
|
|
value={ing.note}
|
|
onChange={(e) => updateIngredient(i, { note: e.target.value })}
|
|
placeholder={t("note")}
|
|
className="w-48 shrink-0"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeIngredient(i)}
|
|
disabled={ingredients.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>
|
|
))}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setIngredients((p) => [...p, newIngredient()])}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
{t("addIngredient")}
|
|
</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>
|
|
<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>
|
|
))}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setSteps((p) => [...p, newStep()])}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
{t("addStep")}
|
|
</Button>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div className="flex gap-3">
|
|
<Button type="submit" disabled={saving}>
|
|
{saving ? t("saving") : isEdit ? t("saveChanges") : t("createRecipe")}
|
|
</Button>
|
|
<Button type="button" variant="outline" onClick={() => router.back()}>
|
|
{t_common("cancel")}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|