Files
Epicure/apps/web/components/shopping-lists/shopping-list-actions-menu.tsx
Arnaud 521ecce68f refactor: shopping list rename/delete — direct buttons instead of "..." menu
This was the actual menu being reported (a prior fix mistakenly targeted
the per-item category dropdown instead, reverted). Replaced the single
MoreVertical dropdown trigger (rename + delete hidden behind it) with two
directly visible icon buttons + tooltips, matching the recipe detail
page's icon-row convention. Used on both the shopping-lists index rows
and the list detail page header, unchanged at both call sites since only
the component's internals changed.

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

185 lines
6.3 KiB
TypeScript

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
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);
}
}
// Rows on the index page are wrapped in a Link — stop the click from bubbling to it
// and prevent the anchor's default navigation.
function stopRowNavigation(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
}
return (
<>
<TooltipProvider>
<div className={cn("flex items-center gap-1", className)}>
<Tooltip>
<TooltipTrigger render={
<Button
variant="ghost"
size="icon"
aria-label={t("rename")}
onClick={(e: React.MouseEvent) => { stopRowNavigation(e); openRename(); }}
>
<Pencil className="h-4 w-4" />
</Button>
} />
<TooltipContent>{t("rename")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger render={
<Button
variant="ghost"
size="icon"
aria-label={tCommon("delete")}
onClick={(e: React.MouseEvent) => { stopRowNavigation(e); setDeleteOpen(true); }}
>
<Trash2 className="h-4 w-4" />
</Button>
} />
<TooltipContent>{tCommon("delete")}</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<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>
</>
);
}