Files
Epicure/apps/web/components/admin/ingredients-manager.tsx
T
Arnaud 75fd0d09eb feat: search, category filter, and tag-input UX for admin Ingredients page (v0.87.0)
With ~137 seeded entries the flat unfiltered list stopped being usable. Added a search box (name + alias, case/accent-insensitive), a category filter dropdown with live per-category counts, aliases entered as removable tag chips instead of one comma-separated field, and the category field is now a GROCERY_CATEGORIES dropdown instead of free text (was a typo away from creating an unmatched-anywhere category).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 23:03:49 +02:00

367 lines
14 KiB
TypeScript

"use client";
import { useMemo, useRef, useState, type KeyboardEvent } from "react";
import { toast } from "sonner";
import { Plus, Pencil, Trash2, Tag, X, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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";
import { GROCERY_CATEGORIES, type GroceryCategory } from "@/lib/grocery-categories";
const CATEGORY_LABELS: Record<GroceryCategory, string> = {
produce: "Produce",
dairyEggs: "Dairy & Eggs",
meatSeafood: "Meat & Seafood",
bakery: "Bakery",
frozen: "Frozen",
pantry: "Pantry",
spicesCondiments: "Spices & Condiments",
beverages: "Beverages",
};
const UNCATEGORIZED = "__uncategorized__";
const ALL_CATEGORIES = "__all__";
type Ingredient = {
id: string;
name: string;
aliases: string[];
category: string | null;
};
// Same case/accent-insensitive intent as lib/ingredient-match.ts's
// normalize() — duplicated rather than imported, since that module pulls
// in the DB client (server-only) and can't be bundled for the browser.
function normalize(s: string): string {
return s.trim().toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
}
function categoryLabel(category: string | null): string {
if (!category) return "Uncategorized";
return CATEGORY_LABELS[category as GroceryCategory] ?? category;
}
function AliasTagInput({ aliases, onChange }: { aliases: string[]; onChange: (aliases: string[]) => void }) {
const [input, setInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
function addAlias(raw: string) {
const alias = raw.trim();
if (!alias || aliases.some((a) => normalize(a) === normalize(alias)) || aliases.length >= 50) return;
onChange([...aliases, alias]);
setInput("");
}
function removeAlias(alias: string) {
onChange(aliases.filter((a) => a !== alias));
}
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addAlias(input);
} else if (e.key === "Backspace" && !input && aliases.length > 0) {
onChange(aliases.slice(0, -1));
}
}
return (
<div
className="flex flex-wrap gap-1.5 min-h-9 rounded-lg border border-input bg-transparent px-2.5 py-1.5 cursor-text focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50"
onClick={() => inputRef.current?.focus()}
>
{aliases.map((alias) => (
<span key={alias} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs bg-muted text-muted-foreground">
{alias}
<button type="button" onClick={(e) => { e.stopPropagation(); removeAlias(alias); }} className="hover:text-foreground transition-colors" aria-label={`Remove ${alias}`}>
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
<input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => { if (input.trim()) addAlias(input); }}
placeholder={aliases.length === 0 ? "Type an alias, press Enter…" : ""}
className="flex-1 min-w-[120px] bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
);
}
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 [aliases, setAliases] = useState<string[]>(ingredient?.aliases ?? []);
const [category, setCategory] = useState<string>(ingredient?.category ?? UNCATEGORIZED);
const [saving, setSaving] = useState(false);
const isEdit = !!ingredient;
async function handleSave() {
const trimmedName = name.trim();
if (!trimmedName) return;
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 === UNCATEGORIZED ? null : category }),
}
);
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 === UNCATEGORIZED ? null : category });
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>Aliases</Label>
<AliasTagInput aliases={aliases} onChange={setAliases} />
<p className="text-xs text-muted-foreground">
Matched case- and accent-insensitively against pantry item and recipe ingredient names.
</p>
</div>
<div className="space-y-1.5">
<Label>Category</Label>
<Select value={category} onValueChange={(v) => setCategory(v ?? UNCATEGORIZED)}>
<SelectTrigger className="w-full">
<SelectValue>{(v: string) => categoryLabel(v === UNCATEGORIZED ? null : v)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={UNCATEGORIZED}>Uncategorized</SelectItem>
{GROCERY_CATEGORIES.map((c) => (
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>
))}
</SelectContent>
</Select>
</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 [query, setQuery] = useState("");
const [categoryFilter, setCategoryFilter] = useState<string>(ALL_CATEGORIES);
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 categoryCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const i of ingredients) {
const key = i.category ?? UNCATEGORIZED;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return counts;
}, [ingredients]);
const filtered = useMemo(() => {
const q = normalize(query);
return ingredients
.filter((i) => categoryFilter === ALL_CATEGORIES || (i.category ?? UNCATEGORIZED) === categoryFilter)
.filter((i) => {
if (!q) return true;
if (normalize(i.name).includes(q)) return true;
return i.aliases.some((a) => normalize(a).includes(q));
})
.sort((a, b) => a.name.localeCompare(b.name));
}, [ingredients, query, categoryFilter]);
const pendingDelete = ingredients.find((i) => i.id === confirmId) ?? null;
return (
<div className="space-y-4 max-w-3xl">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 gap-2">
<div className="relative flex-1 max-w-sm">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search name or alias…"
className="pl-8"
/>
</div>
<Select value={categoryFilter} onValueChange={(v) => setCategoryFilter(v ?? ALL_CATEGORIES)}>
<SelectTrigger className="w-44 shrink-0">
<SelectValue>{(v: string) => (v === ALL_CATEGORIES ? "All categories" : categoryLabel(v === UNCATEGORIZED ? null : v))}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_CATEGORIES}>All categories ({ingredients.length})</SelectItem>
<SelectItem value={UNCATEGORIZED}>Uncategorized ({categoryCounts.get(UNCATEGORIZED) ?? 0})</SelectItem>
{GROCERY_CATEGORIES.map((c) => (
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]} ({categoryCounts.get(c) ?? 0})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button size="sm" onClick={openCreate} className="shrink-0">
<Plus className="h-4 w-4" /> New ingredient
</Button>
</div>
<p className="text-xs text-muted-foreground">
{filtered.length === ingredients.length
? `${ingredients.length} ingredient${ingredients.length === 1 ? "" : "s"}`
: `${filtered.length} of ${ingredients.length} ingredients`}
</p>
{ingredients.length === 0 ? (
<EmptyState icon={Tag} title="No ingredients yet" description="Add a canonical ingredient and its common name variants." compact />
) : filtered.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">No ingredients match your search.</p>
) : (
<div className="rounded-xl border divide-y">
{filtered.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 flex-wrap">
<span className="font-medium text-sm">{ingredient.name}</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">{categoryLabel(ingredient.category)}</Badge>
</div>
{ingredient.aliases.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{ingredient.aliases.map((alias) => (
<span key={alias} className="text-[11px] text-muted-foreground bg-muted rounded px-1.5 py-0.5">{alias}</span>
))}
</div>
)}
</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>
);
}