feat: admin Ingredients CRUD UI; fix "page not found" after deleting shopping list/recipe/collection (v0.85.0)

Admin > Ingredients lets admins add/edit/delete canonical ingredients and their aliases (e.g. "sel"/"sel fin"/"table salt") without touching code — previously only settable by hand in packages/db/src/seed.ts. Deleting just unlinks referencing pantry items/recipe ingredients (onDelete: set null), nothing else changes.

Fixed: deleting a shopping list, recipe, or collection from its own detail page used router.push to navigate away, leaving the deleted resource's URL in browser history — one back-button press landed on a real "Page not found" screen. All three now use router.replace so the dead URL never sits in history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-24 18:42:42 +02:00
parent ebe3216c04
commit 4ae0bd580e
14 changed files with 389 additions and 8 deletions
@@ -0,0 +1,228 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Plus, Pencil, Trash2, Tag } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { EmptyState } from "@/components/shared/empty-state";
type Ingredient = {
id: string;
name: string;
aliases: string[];
category: string | null;
};
function parseAliases(input: string): string[] {
return [...new Set(input.split(",").map((a) => a.trim()).filter(Boolean))];
}
function IngredientDialog({
ingredient,
open,
onOpenChange,
onSaved,
}: {
ingredient: Ingredient | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved: (ingredient: Ingredient) => void;
}) {
const [name, setName] = useState(ingredient?.name ?? "");
const [aliasesText, setAliasesText] = useState(ingredient?.aliases.join(", ") ?? "");
const [category, setCategory] = useState(ingredient?.category ?? "");
const [saving, setSaving] = useState(false);
const isEdit = !!ingredient;
async function handleSave() {
const trimmedName = name.trim();
if (!trimmedName) return;
const aliases = parseAliases(aliasesText);
setSaving(true);
try {
const res = await fetch(
isEdit ? `/api/v1/admin/ingredients/${ingredient.id}` : "/api/v1/admin/ingredients",
{
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: trimmedName, aliases, category: category.trim() || null }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => null) as { error?: string } | null;
toast.error(data?.error ?? "Failed to save");
return;
}
toast.success(isEdit ? "Ingredient updated" : "Ingredient created");
const id = isEdit ? ingredient.id : ((await res.json()) as { id: string }).id;
onSaved({ id, name: trimmedName, aliases, category: category.trim() || null });
onOpenChange(false);
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit ingredient" : "New ingredient"}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="ingredient-name">Canonical name</Label>
<Input id="ingredient-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. salt" />
</div>
<div className="space-y-1.5">
<Label htmlFor="ingredient-aliases">Aliases (comma-separated)</Label>
<Input
id="ingredient-aliases"
value={aliasesText}
onChange={(e) => setAliasesText(e.target.value)}
placeholder="e.g. sel, sel fin, sel de table, table salt"
/>
<p className="text-xs text-muted-foreground">
Matched case-insensitively against pantry item and recipe ingredient names.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="ingredient-category">Category (optional)</Label>
<Input id="ingredient-category" value={category} onChange={(e) => setCategory(e.target.value)} placeholder="e.g. spicesCondiments" />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button type="button" onClick={() => { void handleSave(); }} disabled={saving || !name.trim()}>
{saving ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function IngredientsManager({ initialIngredients }: { initialIngredients: Ingredient[] }) {
const [ingredients, setIngredients] = useState<Ingredient[]>(initialIngredients);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingIngredient, setEditingIngredient] = useState<Ingredient | null>(null);
const [confirmId, setConfirmId] = useState<string | null>(null);
function openCreate() {
setEditingIngredient(null);
setDialogOpen(true);
}
function openEdit(ingredient: Ingredient) {
setEditingIngredient(ingredient);
setDialogOpen(true);
}
async function handleDelete(id: string) {
const res = await fetch(`/api/v1/admin/ingredients/${id}`, { method: "DELETE" });
if (res.ok) {
setIngredients((prev) => prev.filter((i) => i.id !== id));
toast.success("Ingredient deleted");
} else {
toast.error("Failed to delete");
}
}
const pendingDelete = ingredients.find((i) => i.id === confirmId) ?? null;
return (
<div className="space-y-4 max-w-3xl">
<div className="flex justify-end">
<Button size="sm" onClick={openCreate}>
<Plus className="h-4 w-4" /> New ingredient
</Button>
</div>
{ingredients.length === 0 ? (
<EmptyState icon={Tag} title="No ingredients yet" description="Add a canonical ingredient and its common name variants." compact />
) : (
<div className="rounded-xl border divide-y">
{ingredients.map((ingredient) => (
<div key={ingredient.id} className="flex items-center gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{ingredient.name}</span>
{ingredient.category && (
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground bg-muted rounded px-1.5 py-0.5">
{ingredient.category}
</span>
)}
</div>
{ingredient.aliases.length > 0 && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{ingredient.aliases.join(", ")}</p>
)}
</div>
<button onClick={() => openEdit(ingredient)} aria-label="Edit" className="text-muted-foreground hover:text-foreground transition-colors shrink-0">
<Pencil className="h-4 w-4" />
</button>
<button onClick={() => setConfirmId(ingredient.id)} aria-label="Delete" className="text-muted-foreground hover:text-destructive transition-colors shrink-0">
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
<IngredientDialog
ingredient={editingIngredient}
open={dialogOpen}
onOpenChange={setDialogOpen}
onSaved={(saved) => {
setIngredients((prev) => {
const exists = prev.some((i) => i.id === saved.id);
const next = exists ? prev.map((i) => (i.id === saved.id ? saved : i)) : [...prev, saved];
return next.sort((a, b) => a.name.localeCompare(b.name));
});
}}
/>
<AlertDialog open={confirmId !== null} onOpenChange={(open) => !open && setConfirmId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this ingredient?</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete ? `"${pendingDelete.name}" and its aliases will no longer be recognized as a match. Pantry items and recipe ingredients already linked to it just lose that link — nothing is deleted.` : ""}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (confirmId) void handleDelete(confirmId);
setConfirmId(null);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -32,7 +32,9 @@ export function DeleteCollectionDialog({ collectionId }: { collectionId: string
const res = await fetch(`/api/v1/collections/${collectionId}?deleteRecipes=${deleteRecipes}`, { method: "DELETE" });
if (!res.ok) throw new Error();
toast.success(t("deleteSuccess"));
router.push("/collections");
// replace, not push — the deleted collection's URL should never sit
// in history, or the browser back button lands straight on a 404.
router.replace("/collections");
router.refresh();
} catch {
toast.error(t("deleteFailed"));
@@ -35,7 +35,9 @@ export function DeleteRecipeButton({ recipeId }: { recipeId: string }) {
throw new Error(data.error ?? "Delete failed");
}
toast.success(t("deleted"));
router.push("/recipes");
// replace, not push — the deleted recipe's URL should never sit in
// history, or the browser back button lands straight on a 404.
router.replace("/recipes");
} catch (err) {
toast.error(err instanceof Error ? err.message : t("deleteFailed"));
setDeleting(false);
@@ -83,7 +83,9 @@ export function ShoppingListActionsMenu({ listId, name, onRenamed, onDeleted, re
toast.success(t("listDeleted"));
setDeleteOpen(false);
onDeleted?.();
if (redirectAfterDeleteTo) router.push(redirectAfterDeleteTo);
// replace, not push — the deleted list's URL should never sit in
// history, or the browser back button lands straight on a 404.
if (redirectAfterDeleteTo) router.replace(redirectAfterDeleteTo);
else router.refresh();
} catch {
toast.error(t("listDeleteFailed"));