feat: admin Ingredients CRUD UI; fix "page not found" after deleting shopping list/recipe/collection (v0.85.0)
Admin > Ingredients lets admins add/edit/delete canonical ingredients and their aliases (e.g. "sel"/"sel fin"/"table salt") without touching code — previously only settable by hand in packages/db/src/seed.ts. Deleting just unlinks referencing pantry items/recipe ingredients (onDelete: set null), nothing else changes. Fixed: deleting a shopping list, recipe, or collection from its own detail page used router.push to navigate away, leaving the deleted resource's URL in browser history — one back-button press landed on a real "Page not found" screen. All three now use router.replace so the dead URL never sits in history. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||||
|
|
||||||
|
## 0.85.0 — 2026-07-24 21:00
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Admin → Ingredients: manage canonical ingredients and their name variants (e.g. "sel"/"sel fin"/"table salt") without a code change — previously only editable by hand in the seed file.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Deleting a shopping list, recipe, or collection from its own page could leave you one back-button press away from a "Page not found" screen — the deleted URL stayed in browser history. Now replaces the history entry instead of pushing a new one.
|
||||||
|
|
||||||
## 0.84.0 — 2026-07-24 20:00
|
## 0.84.0 — 2026-07-24 20:00
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+3
-1
@@ -61,6 +61,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
|
|||||||
| Weekly nutrition rollup | Exists | | `apps/web/app/api/v1/meal-plans/[weekStart]/nutrition` |
|
| Weekly nutrition rollup | Exists | | `apps/web/app/api/v1/meal-plans/[weekStart]/nutrition` |
|
||||||
| Meal plan print + Markdown export | Exists | Undocumented until this pass despite section 1 mentioning print/markdown generally | `/print/meal-plan`, `apps/web/lib/markdown/meal-plan.ts` |
|
| Meal plan print + Markdown export | Exists | Undocumented until this pass despite section 1 mentioning print/markdown generally | `/print/meal-plan`, `apps/web/lib/markdown/meal-plan.ts` |
|
||||||
| Multiple shopping lists | Exists | | `apps/web/app/api/v1/shopping-lists/**` |
|
| Multiple shopping lists | Exists | | `apps/web/app/api/v1/shopping-lists/**` |
|
||||||
|
| "Page not found" after deleting (closed 2026-07-24) | Fixed | Deleting a shopping list, recipe, or collection from its own detail page navigated away with `router.push`, leaving the just-deleted URL in browser history — hitting the back button landed straight on a real 404 (`(app)/not-found.tsx`, literally "Page not found"). Switched all three to `router.replace` so the dead URL is never in history to go back to. | `apps/web/components/shopping-lists/shopping-list-actions-menu.tsx`, `apps/web/components/recipe/delete-recipe-button.tsx`, `apps/web/components/collections/delete-collection-dialog.tsx` |
|
||||||
| Generate list from meal-plan week | Exists | Merges duplicate ingredients, flags items already covered by pantry (never silently drops) | `apps/web/lib/pantry-shopping-match.ts` |
|
| Generate list from meal-plan week | Exists | Merges duplicate ingredients, flags items already covered by pantry (never silently drops) | `apps/web/lib/pantry-shopping-match.ts` |
|
||||||
| Add to list from a single recipe | Exists | Separate path from meal-plan-based generation; also reachable via the cooking-chat AI tool ("add to shopping list") | `apps/web/components/recipe/add-to-shopping-list-button.tsx`, `apps/web/lib/ai/tools/add-to-shopping-list-tool.ts` |
|
| Add to list from a single recipe | Exists | Separate path from meal-plan-based generation; also reachable via the cooking-chat AI tool ("add to shopping list") | `apps/web/components/recipe/add-to-shopping-list-button.tsx`, `apps/web/lib/ai/tools/add-to-shopping-list-tool.ts` |
|
||||||
| Shared/collaborative lists | Exists | Member roles **and** an anonymous public link that can grant full anonymous edit rights (Google-Docs-style), not just read — `isPublic` + `publicEditable`; disabling `isPublic` force-revokes `publicEditable`. Also has its own print view with a QR code, and a public page distinct from the meal-plan one. | `apps/web/lib/shopping-list-access.ts`, `apps/web/app/s/[id]/page.tsx`, `/print/shopping-list/[id]` |
|
| Shared/collaborative lists | Exists | Member roles **and** an anonymous public link that can grant full anonymous edit rights (Google-Docs-style), not just read — `isPublic` + `publicEditable`; disabling `isPublic` force-revokes `publicEditable`. Also has its own print view with a QR code, and a public page distinct from the meal-plan one. | `apps/web/lib/shopping-list-access.ts`, `apps/web/app/s/[id]/page.tsx`, `/print/shopping-list/[id]` |
|
||||||
@@ -70,7 +71,8 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
|
|||||||
| Grocery delivery integration | **Partial / stub** | Generic export payload works (integrator-shaped — no visible in-app UI consumer besides the Instacart adapter); Instacart adapter is an explicit stub (requires a partnership agreement Epicure doesn't have — returns 501 if unconfigured, throws if "configured") | `apps/web/lib/grocery-providers/instacart.ts` |
|
| Grocery delivery integration | **Partial / stub** | Generic export payload works (integrator-shaped — no visible in-app UI consumer besides the Instacart adapter); Instacart adapter is an explicit stub (requires a partnership agreement Epicure doesn't have — returns 501 if unconfigured, throws if "configured") | `apps/web/lib/grocery-providers/instacart.ts` |
|
||||||
| Other delivery/price integrations (DoorDash, Kroger, Walmart, live pricing) | **Missing** | Confirmed absent by repo-wide search | — |
|
| Other delivery/price integrations (DoorDash, Kroger, Walmart, live pricing) | **Missing** | Confirmed absent by repo-wide search | — |
|
||||||
| Pantry manual CRUD | Exists (extended 2026-07-24, twice) | Full edit dialog added (previously add+delete only, despite the API already supporting `PUT`) — name/quantity/unit/expiry plus **notes** (free text) and **category** (same `GROCERY_CATEGORIES` slugs shopping lists use; edit dialog's category dropdown initially displayed the raw slug/`__other__` instead of the translated label — fixed by passing a value→label render function to `SelectValue`, same pattern already used in `shopping-list-view.tsx`'s sort dropdown). List always renders all 9 category sections (8 `GROCERY_CATEGORIES` + Other) as collapsible groups, even empty ones — not just categories that currently have items — and items can be dragged between sections (dnd-kit `useDraggable`/`useDroppable`, cross-category drop only; no within-category manual reorder since pantry items have no `sortOrder` column to persist one). An "Auto-categorize" action (same `guessAisle` heuristic as the shopping list) fills in categories for uncategorized items in one click. Includes a bulk case-insensitive name+unit merge endpoint (used by the scan-confirm flow). | `apps/web/app/api/v1/pantry/**`, `apps/web/components/meal-plan/pantry-manager.tsx`, `apps/web/components/pantry/pantry-item-dialog.tsx` |
|
| Pantry manual CRUD | Exists (extended 2026-07-24, twice) | Full edit dialog added (previously add+delete only, despite the API already supporting `PUT`) — name/quantity/unit/expiry plus **notes** (free text) and **category** (same `GROCERY_CATEGORIES` slugs shopping lists use; edit dialog's category dropdown initially displayed the raw slug/`__other__` instead of the translated label — fixed by passing a value→label render function to `SelectValue`, same pattern already used in `shopping-list-view.tsx`'s sort dropdown). List always renders all 9 category sections (8 `GROCERY_CATEGORIES` + Other) as collapsible groups, even empty ones — not just categories that currently have items — and items can be dragged between sections (dnd-kit `useDraggable`/`useDroppable`, cross-category drop only; no within-category manual reorder since pantry items have no `sortOrder` column to persist one). An "Auto-categorize" action (same `guessAisle` heuristic as the shopping list) fills in categories for uncategorized items in one click. Includes a bulk case-insensitive name+unit merge endpoint (used by the scan-confirm flow). | `apps/web/app/api/v1/pantry/**`, `apps/web/components/meal-plan/pantry-manager.tsx`, `apps/web/components/pantry/pantry-item-dialog.tsx` |
|
||||||
| Ingredient alias matching (new 2026-07-24) | Exists | The `ingredients` table (canonical name + `aliases[]`) existed but was never populated or queried anywhere. Seeded with ~10 bilingual EN/FR staples (salt, sugar, pepper, flour, butter, milk, egg, onion, garlic, olive oil — `packages/db/src/seed.ts`) and wired into every place that compares ingredient names by raw text: pantry add/edit (sets `ingredientId` when a name/alias matches), the can-cook / "use it up soon" scorer, auto-deduct-on-cook, and shopping-list pantry-awareness on generation. Resolution happens at compare-time from free text (`resolveIngredientKey`), not from a stored FK on both sides — recipe ingredients still don't carry `ingredientId`. **No UI to add/edit aliases** — the seed list in `packages/db/src/seed.ts` is the only place today; growing it means editing that file and re-running `pnpm db:seed` (idempotent, `onConflictDoNothing` on name). | `apps/web/lib/ingredient-match.ts`, `packages/db/src/seed.ts` |
|
| Ingredient alias matching (new 2026-07-24) | Exists | The `ingredients` table (canonical name + `aliases[]`) existed but was never populated or queried anywhere. Seeded with ~10 bilingual EN/FR staples (salt, sugar, pepper, flour, butter, milk, egg, onion, garlic, olive oil — `packages/db/src/seed.ts`) and wired into every place that compares ingredient names by raw text: pantry add/edit (sets `ingredientId` when a name/alias matches), the can-cook / "use it up soon" scorer, auto-deduct-on-cook, and shopping-list pantry-awareness on generation. Resolution happens at compare-time from free text (`resolveIngredientKey`), not from a stored FK on both sides — recipe ingredients still don't carry `ingredientId`. | `apps/web/lib/ingredient-match.ts`, `packages/db/src/seed.ts` |
|
||||||
|
| Admin: Ingredients CRUD (new 2026-07-24) | Exists | Admin-only page to manage canonical ingredients + aliases without a code change/redeploy — previously the seed file was the only place to add them. Create/edit/delete; name is unique (409 on collision); deleting an ingredient just unlinks any pantry item/recipe ingredient that pointed at it (`ingredientId` is `onDelete: "set null"` on both), nothing else is deleted. | `apps/web/app/admin/ingredients/page.tsx`, `apps/web/app/api/v1/admin/ingredients/**`, `apps/web/components/admin/ingredients-manager.tsx` |
|
||||||
| Merge duplicate pantry items (new 2026-07-24, relaxed same day) | Exists | One-shot cleanup for pre-existing pantry rows that are the same ingredient under different names (added before alias matching existed) — groups by resolved ingredient key **alone** (unit is deliberately not part of the grouping key, so two rows merge even with mismatched, missing, or differently-unit'd quantities); sums quantities only when every row in a group has a parseable quantity **and** the same unit, otherwise keeps the first known (quantity, unit) pair rather than guessing. Concatenates notes, keeps the soonest expiry. Manual trigger (a button in the pantry toolbar), not automatic — manual single-item add still always inserts a new row rather than silently merging, since two batches of the same ingredient can have different expiry dates worth tracking separately. | `apps/web/app/api/v1/pantry/merge-duplicates/route.ts` |
|
| Merge duplicate pantry items (new 2026-07-24, relaxed same day) | Exists | One-shot cleanup for pre-existing pantry rows that are the same ingredient under different names (added before alias matching existed) — groups by resolved ingredient key **alone** (unit is deliberately not part of the grouping key, so two rows merge even with mismatched, missing, or differently-unit'd quantities); sums quantities only when every row in a group has a parseable quantity **and** the same unit, otherwise keeps the first known (quantity, unit) pair rather than guessing. Concatenates notes, keeps the soonest expiry. Manual trigger (a button in the pantry toolbar), not automatic — manual single-item add still always inserts a new row rather than silently merging, since two batches of the same ingredient can have different expiry dates worth tracking separately. | `apps/web/app/api/v1/pantry/merge-duplicates/route.ts` |
|
||||||
| Auto-deduct pantry on cook | Exists (closed 2026-07-24) | Both UI callers that previously hardcoded `deductFromPantry: false` (`batch-cook-dishes.tsx`, `meal-planner.tsx`) now pass `true`. The new general "Mark cooked" feature (see below) additionally exposes it as a per-cook checkbox, default checked, rather than a silent always-on. Matching now goes through the ingredient-alias resolver, not a raw name string compare. | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts`, `apps/web/components/recipe/batch-cook-dishes.tsx`, `apps/web/components/meal-plan/meal-planner.tsx` |
|
| Auto-deduct pantry on cook | Exists (closed 2026-07-24) | Both UI callers that previously hardcoded `deductFromPantry: false` (`batch-cook-dishes.tsx`, `meal-planner.tsx`) now pass `true`. The new general "Mark cooked" feature (see below) additionally exposes it as a per-cook checkbox, default checked, rather than a silent always-on. Matching now goes through the ingredient-alias resolver, not a raw name string compare. | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts`, `apps/web/components/recipe/batch-cook-dishes.tsx`, `apps/web/components/meal-plan/meal-planner.tsx` |
|
||||||
| Cook log edit/delete (new 2026-07-24) | Exists | Plain (non-batch) cook-log entries can now be listed, edited (date/servings/notes), and removed — previously log-once, no way to fix a mistake or remove a duplicate entry. The recipe page's "Cooked N times" text is a hover tooltip (up to 8 dates, "+N more" beyond) that also opens a full manage sheet on click. Editing/deleting never touches pantry quantities — a deduction from when the entry was created isn't reversed or reapplied. | `apps/web/app/api/v1/recipes/[id]/cooked/[logId]/route.ts`, `apps/web/components/recipe/{mark-cooked-section,edit-cook-log-dialog}.tsx` |
|
| Cook log edit/delete (new 2026-07-24) | Exists | Plain (non-batch) cook-log entries can now be listed, edited (date/servings/notes), and removed — previously log-once, no way to fix a mistake or remove a duplicate entry. The recipe page's "Cooked N times" text is a hover tooltip (up to 8 dates, "+N more" beyond) that also opens a full manage sheet on click. Editing/deleting never touches pantry quantities — a deduction from when the entry was created isn't reversed or reapplied. | `apps/web/app/api/v1/recipes/[id]/cooked/[logId]/route.ts`, `apps/web/components/recipe/{mark-cooked-section,edit-cook-log-dialog}.tsx` |
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { db, ingredients, asc } from "@epicure/db";
|
||||||
|
import { requireFullAdminPage } from "@/lib/require-admin-page";
|
||||||
|
import { IngredientsManager } from "@/components/admin/ingredients-manager";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {};
|
||||||
|
|
||||||
|
export default async function AdminIngredientsPage() {
|
||||||
|
await requireFullAdminPage();
|
||||||
|
|
||||||
|
const rows = await db.select().from(ingredients).orderBy(asc(ingredients.name));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Ingredients</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mt-1">
|
||||||
|
Canonical ingredients and their name variants (e.g. "sel", "sel fin", "table salt") — lets pantry items and recipe ingredients written differently still be recognized as the same thing. Used by pantry add/edit, can-cook scoring, auto-deduct on cook, and shopping-list pantry-awareness.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<IngredientsManager
|
||||||
|
initialIngredients={rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
aliases: r.aliases,
|
||||||
|
category: r.category,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge, Mail, Flag, History, LifeBuoy, TrendingUp, Webhook, CreditCard } from "lucide-react";
|
import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge, Mail, Flag, History, LifeBuoy, TrendingUp, Webhook, CreditCard, Tag } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { getStaffRole } from "@/lib/require-admin-page";
|
import { getStaffRole } from "@/lib/require-admin-page";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
@@ -16,6 +16,7 @@ const adminNav = [
|
|||||||
{ href: "/admin/reports", label: "Reports", icon: Flag, adminOnly: false },
|
{ href: "/admin/reports", label: "Reports", icon: Flag, adminOnly: false },
|
||||||
{ href: "/admin/support", label: "Support", icon: LifeBuoy, adminOnly: true },
|
{ href: "/admin/support", label: "Support", icon: LifeBuoy, adminOnly: true },
|
||||||
{ href: "/admin/tiers", label: "Tier Limits", icon: Gauge, adminOnly: true },
|
{ href: "/admin/tiers", label: "Tier Limits", icon: Gauge, adminOnly: true },
|
||||||
|
{ href: "/admin/ingredients", label: "Ingredients", icon: Tag, adminOnly: true },
|
||||||
{ href: "/admin/billing", label: "Billing", icon: CreditCard, adminOnly: true },
|
{ href: "/admin/billing", label: "Billing", icon: CreditCard, adminOnly: true },
|
||||||
{ href: "/admin/webhooks", label: "Webhooks", icon: Webhook, adminOnly: true },
|
{ href: "/admin/webhooks", label: "Webhooks", icon: Webhook, adminOnly: true },
|
||||||
{ href: "/admin/audit-logs", label: "Audit Logs", icon: ClipboardList, adminOnly: true },
|
{ href: "/admin/audit-logs", label: "Audit Logs", icon: ClipboardList, adminOnly: true },
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, ingredients, eq } from "@epicure/db";
|
||||||
|
import { requireAdmin } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
const Schema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(100).optional(),
|
||||||
|
aliases: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
|
||||||
|
category: z.string().max(50).nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest, { params }: Params) {
|
||||||
|
const { response } = await requireAdmin();
|
||||||
|
if (response) return response;
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const parsed = Schema.safeParse(await req.json().catch(() => null));
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||||
|
|
||||||
|
const data = parsed.data;
|
||||||
|
try {
|
||||||
|
await db.update(ingredients).set({
|
||||||
|
...(data.name && { name: data.name }),
|
||||||
|
...(data.aliases && { aliases: data.aliases }),
|
||||||
|
...(data.category !== undefined && { category: data.category ?? undefined }),
|
||||||
|
}).where(eq(ingredients.id, id));
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "An ingredient with this name already exists" }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ updated: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlinks (doesn't cascade-delete) any pantry item / recipe ingredient that
|
||||||
|
// pointed at this canonical entry — both onDelete: "set null".
|
||||||
|
export async function DELETE(req: NextRequest, { params }: Params) {
|
||||||
|
const { response } = await requireAdmin();
|
||||||
|
if (response) return response;
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
await db.delete(ingredients).where(eq(ingredients.id, id));
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, ingredients, asc } from "@epicure/db";
|
||||||
|
import { requireAdmin } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
const Schema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(100),
|
||||||
|
aliases: z.array(z.string().trim().min(1).max(100)).max(50).default([]),
|
||||||
|
category: z.string().max(50).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { response } = await requireAdmin();
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const rows = await db.select().from(ingredients).orderBy(asc(ingredients.name));
|
||||||
|
return NextResponse.json({ data: rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { response } = await requireAdmin();
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const parsed = Schema.safeParse(await req.json().catch(() => null));
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
try {
|
||||||
|
await db.insert(ingredients).values({
|
||||||
|
id,
|
||||||
|
name: parsed.data.name,
|
||||||
|
aliases: parsed.data.aliases,
|
||||||
|
category: parsed.data.category,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "An ingredient with this name already exists" }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ id }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,7 +32,9 @@ export function DeleteCollectionDialog({ collectionId }: { collectionId: string
|
|||||||
const res = await fetch(`/api/v1/collections/${collectionId}?deleteRecipes=${deleteRecipes}`, { method: "DELETE" });
|
const res = await fetch(`/api/v1/collections/${collectionId}?deleteRecipes=${deleteRecipes}`, { method: "DELETE" });
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
toast.success(t("deleteSuccess"));
|
toast.success(t("deleteSuccess"));
|
||||||
router.push("/collections");
|
// replace, not push — the deleted collection's URL should never sit
|
||||||
|
// in history, or the browser back button lands straight on a 404.
|
||||||
|
router.replace("/collections");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("deleteFailed"));
|
toast.error(t("deleteFailed"));
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ export function DeleteRecipeButton({ recipeId }: { recipeId: string }) {
|
|||||||
throw new Error(data.error ?? "Delete failed");
|
throw new Error(data.error ?? "Delete failed");
|
||||||
}
|
}
|
||||||
toast.success(t("deleted"));
|
toast.success(t("deleted"));
|
||||||
router.push("/recipes");
|
// replace, not push — the deleted recipe's URL should never sit in
|
||||||
|
// history, or the browser back button lands straight on a 404.
|
||||||
|
router.replace("/recipes");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : t("deleteFailed"));
|
toast.error(err instanceof Error ? err.message : t("deleteFailed"));
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ export function ShoppingListActionsMenu({ listId, name, onRenamed, onDeleted, re
|
|||||||
toast.success(t("listDeleted"));
|
toast.success(t("listDeleted"));
|
||||||
setDeleteOpen(false);
|
setDeleteOpen(false);
|
||||||
onDeleted?.();
|
onDeleted?.();
|
||||||
if (redirectAfterDeleteTo) router.push(redirectAfterDeleteTo);
|
// replace, not push — the deleted list's URL should never sit in
|
||||||
|
// history, or the browser back button lands straight on a 404.
|
||||||
|
if (redirectAfterDeleteTo) router.replace(redirectAfterDeleteTo);
|
||||||
else router.refresh();
|
else router.refresh();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("listDeleteFailed"));
|
toast.error(t("listDeleteFailed"));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||||
export const APP_VERSION = "0.84.0";
|
export const APP_VERSION = "0.85.0";
|
||||||
|
|
||||||
export type ChangelogEntry = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,16 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.85.0",
|
||||||
|
date: "2026-07-24 21:00",
|
||||||
|
added: [
|
||||||
|
"Admin > Ingredients: manage canonical ingredients and their name variants (e.g. \"sel\"/\"sel fin\"/\"table salt\") without a code change — previously only editable by hand in the seed file.",
|
||||||
|
],
|
||||||
|
fixed: [
|
||||||
|
"Deleting a shopping list, recipe, or collection from its own page could leave you one back-button press away from a \"Page not found\" screen — the deleted URL stayed in browser history. Now replaces the history entry instead of pushing a new one.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.84.0",
|
version: "0.84.0",
|
||||||
date: "2026-07-24 20:00",
|
date: "2026-07-24 20:00",
|
||||||
|
|||||||
@@ -975,6 +975,16 @@ export function generateOpenApiSpec(): object {
|
|||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const IngredientRef = registry.register("Ingredient", z.object({
|
||||||
|
id: z.string(), name: z.string(), aliases: z.array(z.string()), category: z.string().nullable(),
|
||||||
|
}));
|
||||||
|
const UpsertIngredientRef = registry.register("UpsertIngredient", z.object({
|
||||||
|
name: z.string().min(1).max(100), aliases: z.array(z.string().min(1).max(100)).max(50).default([]), category: z.string().max(50).optional(),
|
||||||
|
}));
|
||||||
|
registry.registerPath({ method: "get", path: "/api/v1/admin/ingredients", summary: "List canonical ingredients", description: "Admin only. Used for name/alias matching (\"sel\"/\"sel fin\"/\"table salt\" recognized as the same ingredient) across pantry, can-cook scoring, auto-deduct, and shopping-list generation.", security: adminSecurity, responses: { 200: { description: "Ingredients", content: { "application/json": { schema: z.object({ data: z.array(IngredientRef) }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "post", path: "/api/v1/admin/ingredients", summary: "Create a canonical ingredient", description: "Admin only. name must be unique.", security: adminSecurity, request: { body: { content: { "application/json": { schema: UpsertIngredientRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Name already exists", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "put", path: "/api/v1/admin/ingredients/{id}", summary: "Update a canonical ingredient", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: UpsertIngredientRef.partial() } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Name already exists", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "delete", path: "/api/v1/admin/ingredients/{id}", summary: "Delete a canonical ingredient", description: "Admin only. Unlinks (doesn't cascade-delete) any pantry item or recipe ingredient that referenced it.", security: adminSecurity, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
registry.registerPath({ method: "get", path: "/api/v1/admin/feature-flags", summary: "Get the full feature x tier toggle matrix", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Matrix", content: { "application/json": { schema: FeatureFlagMatrixRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "get", path: "/api/v1/admin/feature-flags", summary: "Get the full feature x tier toggle matrix", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Matrix", content: { "application/json": { schema: FeatureFlagMatrixRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
registry.registerPath({ method: "patch", path: "/api/v1/admin/feature-flags", summary: "Enable or disable a feature for a tier", description: "Admin only. Disabling a feature doesn't hide its button client-side — the corresponding AI route (variations/drinks/pairings) returns 403 with code FEATURE_DISABLED for users on that tier, and the UI shows an upgrade prompt instead.", security: adminSecurity, request: { body: { content: { "application/json": { schema: UpdateFeatureFlagRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "patch", path: "/api/v1/admin/feature-flags", summary: "Enable or disable a feature for a tier", description: "Admin only. Disabling a feature doesn't hide its button client-side — the corresponding AI route (variations/drinks/pairings) returns 403 with code FEATURE_DISABLED for users on that tier, and the UI shows an upgrade prompt instead.", security: adminSecurity, request: { body: { content: { "application/json": { schema: UpdateFeatureFlagRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.84.0",
|
"version": "0.85.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.84.0",
|
"version": "0.85.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user