Files
Epicure/apps/web/components/shopping-lists/shopping-list-actions-menu.tsx
T
Arnaud 5afc7cd182 feat: shopping list rename/delete, item reorder+categories+search, ingredient-quantity parsing fix
- Fixed a real i18n bug: the checked-count line called the wrong
  translation namespace and rendered the literal key on screen
- Shopping lists can now be renamed and deleted from both the list index
  and detail pages (API already supported delete; rename was net new)
- Root-caused "long list UI is off": meal-plan-generated lists never set
  an aisle, so every item fell into one undifferentiated "Other" bucket
  despite the grouping UI existing. Added a keyword-based aisle guesser
  wired into list generation (fallback only, never overrides an explicit
  aisle) plus a one-click "auto-categorize" for existing lists
- Items can now be reordered by drag-and-drop within a category (dnd-kit),
  recategorized via a dropdown, deleted, searched, and sorted (category /
  alphabetical / unchecked-first); searching flattens the grouped view
- Fixed a separate bug: AI-generated ingredients sometimes embedded the
  quantity/unit in the name itself (e.g. "2 cups flour" as one string).
  Added extractIngredientQuantity() as a Zod transform at both recipe
  create/update routes (the choke point every creation path funnels
  through) to split it back out, plus schema descriptions on the AI
  ingredient schemas as a prevention layer

New migration 0028 (shopping_list_items.sort_order), left unapplied like
the others. Verified with typecheck, lint, and a clean --no-cache docker
build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 10:29:28 +02:00

175 lines
6.0 KiB
TypeScript

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button, buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
type Props = {
listId: string;
name: string;
/** Called after a successful rename, so the caller can update its own state. If omitted, falls back to router.refresh(). */
onRenamed?: (name: string) => void;
/** Called after a successful delete, so the caller can update its own state (e.g. remove the row). */
onDeleted?: () => void;
/** Path to navigate to after deleting (e.g. back to the list index from the detail page). */
redirectAfterDeleteTo?: string;
className?: string;
};
/** Owner-only rename/delete menu for a shopping list. Used on both the list index page and the list detail page. */
export function ShoppingListActionsMenu({ listId, name, onRenamed, onDeleted, redirectAfterDeleteTo, className }: Props) {
const t = useTranslations("shoppingLists");
const tCommon = useTranslations("common");
const router = useRouter();
const [renameOpen, setRenameOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [newName, setNewName] = useState(name);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
function openRename() {
setNewName(name);
setRenameOpen(true);
}
async function handleRename() {
const trimmed = newName.trim();
if (!trimmed || trimmed === name) {
setRenameOpen(false);
return;
}
setSaving(true);
try {
const res = await fetch(`/api/v1/shopping-lists/${listId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: trimmed }),
});
if (!res.ok) throw new Error("failed");
toast.success(t("listRenamed"));
setRenameOpen(false);
if (onRenamed) onRenamed(trimmed);
else router.refresh();
} catch {
toast.error(t("listRenameFailed"));
} finally {
setSaving(false);
}
}
async function handleDelete() {
setDeleting(true);
try {
const res = await fetch(`/api/v1/shopping-lists/${listId}`, { method: "DELETE" });
if (!res.ok) throw new Error("failed");
toast.success(t("listDeleted"));
setDeleteOpen(false);
onDeleted?.();
if (redirectAfterDeleteTo) router.push(redirectAfterDeleteTo);
else router.refresh();
} catch {
toast.error(t("listDeleteFailed"));
setDeleting(false);
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
aria-label={t("actionsMenuLabel")}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), className)}
onClick={(e: React.MouseEvent) => {
// Rows on the index page are wrapped in a Link — stop the click
// from bubbling to it and prevent the anchor's default navigation.
e.preventDefault();
e.stopPropagation();
}}
>
<MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={openRename}>
<Pencil className="h-4 w-4 mr-2" /> {t("rename")}
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={() => setDeleteOpen(true)}>
<Trash2 className="h-4 w-4 mr-2" /> {tCommon("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("renameListTitle")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("renameListLabel")}</Label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleRename();
}}
/>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setRenameOpen(false)}>
{tCommon("cancel")}
</Button>
<Button onClick={() => void handleRename()} disabled={!newName.trim() || saving}>
{saving ? t("renaming") : tCommon("save")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteListConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("deleteListConfirmDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
void handleDelete();
}}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}