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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user