75fd0d09eb
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>
367 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|