"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 = { 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(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) { 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 (
inputRef.current?.focus()} > {aliases.map((alias) => ( {alias} ))} 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" />
); } 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(ingredient?.aliases ?? []); const [category, setCategory] = useState(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 ( {isEdit ? "Edit ingredient" : "New ingredient"}
setName(e.target.value)} placeholder="e.g. salt" />

Matched case- and accent-insensitively against pantry item and recipe ingredient names.

); } export function IngredientsManager({ initialIngredients }: { initialIngredients: Ingredient[] }) { const [ingredients, setIngredients] = useState(initialIngredients); const [query, setQuery] = useState(""); const [categoryFilter, setCategoryFilter] = useState(ALL_CATEGORIES); const [dialogOpen, setDialogOpen] = useState(false); const [editingIngredient, setEditingIngredient] = useState(null); const [confirmId, setConfirmId] = useState(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(); 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 (
setQuery(e.target.value)} placeholder="Search name or alias…" className="pl-8" />

{filtered.length === ingredients.length ? `${ingredients.length} ingredient${ingredients.length === 1 ? "" : "s"}` : `${filtered.length} of ${ingredients.length} ingredients`}

{ingredients.length === 0 ? ( ) : filtered.length === 0 ? (

No ingredients match your search.

) : (
{filtered.map((ingredient) => (
{ingredient.name} {categoryLabel(ingredient.category)}
{ingredient.aliases.length > 0 && (
{ingredient.aliases.map((alias) => ( {alias} ))}
)}
))}
)} { 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)); }); }} /> !open && setConfirmId(null)}> Delete this ingredient? {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.` : ""} Cancel { if (confirmId) void handleDelete(confirmId); setConfirmId(null); }} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > Delete
); }