|
|
|
@@ -0,0 +1,228 @@
|
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState } from "react";
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
import { Plus, Pencil, Trash2, Tag } from "lucide-react";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
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";
|
|
|
|
|
|
|
|
|
|
type Ingredient = {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
aliases: string[];
|
|
|
|
|
category: string | null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function parseAliases(input: string): string[] {
|
|
|
|
|
return [...new Set(input.split(",").map((a) => a.trim()).filter(Boolean))];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 [aliasesText, setAliasesText] = useState(ingredient?.aliases.join(", ") ?? "");
|
|
|
|
|
const [category, setCategory] = useState(ingredient?.category ?? "");
|
|
|
|
|
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(
|
|
|
|
|
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.trim() || null }),
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
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.trim() || null });
|
|
|
|
|
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 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"
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
Matched case-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" />
|
|
|
|
|
</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 [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 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}>
|
|
|
|
|
<Plus className="h-4 w-4" /> New ingredient
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{ingredients.length === 0 ? (
|
|
|
|
|
<EmptyState icon={Tag} title="No ingredients yet" description="Add a canonical ingredient and its common name variants." compact />
|
|
|
|
|
) : (
|
|
|
|
|
<div className="rounded-xl border divide-y">
|
|
|
|
|
{ingredients.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">
|
|
|
|
|
<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>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{ingredient.aliases.length > 0 && (
|
|
|
|
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">{ingredient.aliases.join(", ")}</p>
|
|
|
|
|
)}
|
|
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|