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