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>
This commit is contained in:
Arnaud
2026-07-24 23:03:49 +02:00
parent b243cc3b8b
commit 75fd0d09eb
6 changed files with 183 additions and 33 deletions
+167 -29
View File
@@ -1,11 +1,19 @@
"use client";
import { useState } from "react";
import { useMemo, useRef, useState, type KeyboardEvent } from "react";
import { toast } from "sonner";
import { Plus, Pencil, Trash2, Tag } from "lucide-react";
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,
@@ -24,6 +32,20 @@ import {
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;
@@ -32,8 +54,66 @@ type Ingredient = {
category: string | null;
};
function parseAliases(input: string): string[] {
return [...new Set(input.split(",").map((a) => a.trim()).filter(Boolean))];
// 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({
@@ -48,15 +128,14 @@ function IngredientDialog({
onSaved: (ingredient: Ingredient) => void;
}) {
const [name, setName] = useState(ingredient?.name ?? "");
const [aliasesText, setAliasesText] = useState(ingredient?.aliases.join(", ") ?? "");
const [category, setCategory] = useState(ingredient?.category ?? "");
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;
const aliases = parseAliases(aliasesText);
setSaving(true);
try {
const res = await fetch(
@@ -64,7 +143,7 @@ function IngredientDialog({
{
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: trimmedName, aliases, category: category.trim() || null }),
body: JSON.stringify({ name: trimmedName, aliases, category: category === UNCATEGORIZED ? null : category }),
}
);
if (!res.ok) {
@@ -74,7 +153,7 @@ function IngredientDialog({
}
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 });
onSaved({ id, name: trimmedName, aliases, category: category === UNCATEGORIZED ? null : category });
onOpenChange(false);
} finally {
setSaving(false);
@@ -93,20 +172,25 @@ function IngredientDialog({
<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"
/>
<Label>Aliases</Label>
<AliasTagInput aliases={aliases} onChange={setAliases} />
<p className="text-xs text-muted-foreground">
Matched case-insensitively against pantry item and recipe ingredient names.
Matched case- and accent-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" />
<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>
@@ -124,6 +208,8 @@ function IngredientDialog({
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);
@@ -148,33 +234,85 @@ export function IngredientsManager({ initialIngredients }: { initialIngredients:
}
}
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 justify-end">
<Button size="sm" onClick={openCreate}>
<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">
{ingredients.map((ingredient) => (
{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">
<div className="flex items-center gap-2 flex-wrap">
<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>
)}
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">{categoryLabel(ingredient.category)}</Badge>
</div>
{ingredient.aliases.length > 0 && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{ingredient.aliases.join(", ")}</p>
<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">
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.86.0";
export const APP_VERSION = "0.87.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.87.0",
date: "2026-07-24 21:50",
added: [
"Admin > Ingredients: search box (name + alias), category filter with live counts, aliases entered as removable tag chips, and category picked from a dropdown instead of free text — needed once the list grew past a handful of entries.",
],
},
{
version: "0.86.0",
date: "2026-07-24 21:30",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.86.0",
"version": "0.87.0",
"private": true,
"scripts": {
"dev": "next dev",