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>
This commit is contained in:
@@ -1,12 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check, Package, Loader2 } from "lucide-react";
|
||||
import { Check, Package, Loader2, GripVertical, MoreVertical, Trash2, Search, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { hasQuantity } from "@/lib/fractions";
|
||||
import { guessAisle, GROCERY_CATEGORIES } from "@/lib/grocery-categories";
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
type Item = {
|
||||
id: string;
|
||||
@@ -16,8 +60,11 @@ type Item = {
|
||||
aisle: string | null;
|
||||
checked: boolean;
|
||||
inPantry?: boolean;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
type SortMode = "category" | "alpha" | "unchecked";
|
||||
|
||||
export function ShoppingListView({
|
||||
listId,
|
||||
initialItems,
|
||||
@@ -32,8 +79,19 @@ export function ShoppingListView({
|
||||
const tCommon = useTranslations("common");
|
||||
const [items, setItems] = useState<Item[]>(initialItems);
|
||||
const [movingToPantry, setMovingToPantry] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [sortMode, setSortMode] = useState<SortMode>("category");
|
||||
const [deleteTarget, setDeleteTarget] = useState<Item | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [autoCategorizing, setAutoCategorizing] = useState(false);
|
||||
|
||||
const categoryOptions = useMemo(() => [...GROCERY_CATEGORIES, t("aisleOther")], [t]);
|
||||
|
||||
const checkedItems = items.filter((i) => i.checked);
|
||||
const isSearching = query.trim().length > 0;
|
||||
// Any item generated without an aisle (e.g. from an older list, before auto-categorization
|
||||
// existed, or added manually without one) — offering a one-click fix for those.
|
||||
const uncategorizedCount = items.filter((i) => !i.aisle).length;
|
||||
|
||||
async function moveToPantry() {
|
||||
if (checkedItems.length === 0) return;
|
||||
@@ -74,11 +132,114 @@ export function ShoppingListView({
|
||||
}
|
||||
}
|
||||
|
||||
const grouped = items.reduce<Record<string, Item[]>>((acc, item) => {
|
||||
const key = item.aisle ?? t("aisleOther");
|
||||
(acc[key] ??= []).push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
async function changeCategory(item: Item, category: string) {
|
||||
if (readOnly) return;
|
||||
const nextAisle = category === t("aisleOther") ? null : category;
|
||||
const prevAisle = item.aisle;
|
||||
setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, aisle: nextAisle } : i));
|
||||
try {
|
||||
const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ aisle: nextAisle }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
} catch {
|
||||
setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, aisle: prevAisle } : i));
|
||||
toast.error(tCommon("updateFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteItem(item: Item) {
|
||||
if (readOnly) return;
|
||||
setDeleting(true);
|
||||
const prevItems = items;
|
||||
setItems((prev) => prev.filter((i) => i.id !== item.id));
|
||||
try {
|
||||
const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
setItems(prevItems);
|
||||
toast.error(t("removeFailed"));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// One-click fix for lists whose items predate auto-categorization (or were added
|
||||
// manually without a category) — guesses an aisle for every uncategorized item and
|
||||
// persists all of them in a single batched request rather than one PUT per item.
|
||||
async function autoCategorize() {
|
||||
const targets = items.filter((i) => !i.aisle);
|
||||
if (targets.length === 0) return;
|
||||
setAutoCategorizing(true);
|
||||
const updates = targets
|
||||
.map((i) => ({ id: i.id, aisle: guessAisle(i.rawName) }))
|
||||
.filter((u) => u.aisle !== null) as { id: string; aisle: string }[];
|
||||
|
||||
if (updates.length === 0) {
|
||||
setAutoCategorizing(false);
|
||||
toast.error(t("autoCategorizeNoneFound"));
|
||||
return;
|
||||
}
|
||||
|
||||
const byId = new Map(updates.map((u) => [u.id, u.aisle]));
|
||||
setItems((prev) => prev.map((i) => byId.has(i.id) ? { ...i, aisle: byId.get(i.id)! } : i));
|
||||
try {
|
||||
const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: updates }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
toast.success(t("autoCategorizeSuccess", { count: updates.length }));
|
||||
} catch {
|
||||
toast.error(tCommon("updateFailed"));
|
||||
} finally {
|
||||
setAutoCategorizing(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Persists a new within-group order for a set of reordered items in a single batched
|
||||
// request (rather than firing one PUT per dragged item).
|
||||
async function persistOrder(reordered: Item[]) {
|
||||
const updates = reordered.map((item, index) => ({ id: item.id, sortOrder: index }));
|
||||
try {
|
||||
const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: updates }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
} catch {
|
||||
toast.error(tCommon("updateFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function handleGroupDragEnd(groupItems: Item[], event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = groupItems.findIndex((i) => i.id === active.id);
|
||||
const newIndex = groupItems.findIndex((i) => i.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
const reordered = arrayMove(groupItems, oldIndex, newIndex);
|
||||
const reorderedIds = new Set(reordered.map((i) => i.id));
|
||||
|
||||
setItems((prev) => {
|
||||
// Splice the reordered group items back into their original positions within
|
||||
// the full list, leaving every other item untouched.
|
||||
let cursor = 0;
|
||||
return prev.map((i) => (reorderedIds.has(i.id) ? reordered[cursor++]! : i));
|
||||
});
|
||||
void persistOrder(reordered);
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter((i) => i.rawName.toLowerCase().includes(q));
|
||||
}, [items, query]);
|
||||
|
||||
const checkedCount = items.filter((i) => i.checked).length;
|
||||
|
||||
@@ -86,55 +247,309 @@ export function ShoppingListView({
|
||||
return <p className="text-muted-foreground text-sm">{t("listEmptyState")}</p>;
|
||||
}
|
||||
|
||||
// Search flattens category grouping — when hunting for a specific item you want to
|
||||
// find it regardless of which aisle it landed in, and headers-with-one-match reads
|
||||
// worse than a simple flat result list. Outside of search, "category" sort mode keeps
|
||||
// the grouped view (and is the only mode where drag-to-reorder applies, since
|
||||
// "within category order" is the only ordering concept reordering makes sense for).
|
||||
const showGroups = !isSearching && sortMode === "category";
|
||||
|
||||
let flatItems: Item[] = [];
|
||||
if (!showGroups) {
|
||||
flatItems = [...filtered];
|
||||
if (sortMode === "alpha" || isSearching) {
|
||||
flatItems.sort((a, b) => a.rawName.localeCompare(b.rawName));
|
||||
} else if (sortMode === "unchecked") {
|
||||
flatItems.sort((a, b) => {
|
||||
if (a.checked !== b.checked) return a.checked ? 1 : -1;
|
||||
return (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || a.rawName.localeCompare(b.rawName);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const grouped = showGroups
|
||||
? filtered.reduce<Record<string, Item[]>>((acc, item) => {
|
||||
const key = item.aisle ?? t("aisleOther");
|
||||
(acc[key] ??= []).push(item);
|
||||
return acc;
|
||||
}, {})
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{tShopping("checkedCount", { checked: checkedCount, total: items.length })}</p>
|
||||
{checkedCount > 0 && (
|
||||
<Button size="sm" variant="outline" onClick={moveToPantry} disabled={movingToPantry}>
|
||||
{movingToPantry ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Package className="h-3.5 w-3.5" />}
|
||||
{t("moveToPantry", { count: checkedCount })}
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">{t("checkedCount", { checked: checkedCount, total: items.length })}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{!readOnly && uncategorizedCount > 0 && (
|
||||
<Button size="sm" variant="ghost" onClick={autoCategorize} disabled={autoCategorizing}>
|
||||
{autoCategorizing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||||
{t("autoCategorize")}
|
||||
</Button>
|
||||
)}
|
||||
{checkedCount > 0 && (
|
||||
<Button size="sm" variant="outline" onClick={moveToPantry} disabled={movingToPantry}>
|
||||
{movingToPantry ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Package className="h-3.5 w-3.5" />}
|
||||
{t("moveToPantry", { count: checkedCount })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([aisle, aisleItems]) => (
|
||||
<div key={aisle} className="space-y-2">
|
||||
{Object.keys(grouped).length > 1 && (
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{aisle}</h2>
|
||||
)}
|
||||
<div className="rounded-xl border divide-y">
|
||||
{aisleItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => toggleItem(item)}
|
||||
disabled={readOnly}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/30 text-left transition-colors disabled:cursor-default disabled:hover:bg-transparent"
|
||||
>
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors",
|
||||
item.checked ? "bg-primary border-primary" : "border-input"
|
||||
)}>
|
||||
{item.checked && <Check className="h-3 w-3 text-primary-foreground" />}
|
||||
</div>
|
||||
<span className={cn("flex-1 text-sm", item.checked && "line-through text-muted-foreground")}>
|
||||
{item.rawName}
|
||||
</span>
|
||||
{item.inPantry && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 rounded px-1.5 py-0.5 shrink-0">
|
||||
{tShopping("alreadyInPantry")}
|
||||
</span>
|
||||
)}
|
||||
{(hasQuantity(item.quantity) || item.unit) && (
|
||||
<span className={cn("text-xs text-muted-foreground tabular-nums shrink-0", item.checked && "opacity-50")}>
|
||||
{hasQuantity(item.quantity) ? item.quantity : ""}{item.unit ? ` ${item.unit}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<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={t("searchItemsPlaceholder")}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select value={sortMode} onValueChange={(v) => setSortMode(v as SortMode)}>
|
||||
<SelectTrigger className="sm:w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="category">{t("sortByCategory")}</SelectItem>
|
||||
<SelectItem value="alpha">{t("sortByAlpha")}</SelectItem>
|
||||
<SelectItem value="unchecked">{t("sortByUnchecked")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">{t("noSearchResults")}</p>
|
||||
) : showGroups ? (
|
||||
Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([aisle, aisleItems]) => (
|
||||
<ItemGroup
|
||||
key={aisle}
|
||||
aisle={aisle}
|
||||
items={aisleItems}
|
||||
showHeader={Object.keys(grouped).length > 1}
|
||||
readOnly={readOnly}
|
||||
categoryOptions={categoryOptions}
|
||||
tShopping={tShopping}
|
||||
onToggle={toggleItem}
|
||||
onChangeCategory={changeCategory}
|
||||
onDeleteRequest={setDeleteTarget}
|
||||
onDragEnd={(event) => handleGroupDragEnd(aisleItems, event)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<FlatItemList
|
||||
items={flatItems}
|
||||
readOnly={readOnly}
|
||||
categoryOptions={categoryOptions}
|
||||
tShopping={tShopping}
|
||||
onToggle={toggleItem}
|
||||
onChangeCategory={changeCategory}
|
||||
onDeleteRequest={setDeleteTarget}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("removeItemConfirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget ? t("removeItemConfirmDescription", { name: deleteTarget.rawName }) : ""}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleting}
|
||||
onClick={() => deleteTarget && deleteItem(deleteTarget)}
|
||||
>
|
||||
{deleting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : tCommon("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemGroup({
|
||||
aisle,
|
||||
items,
|
||||
showHeader,
|
||||
readOnly,
|
||||
categoryOptions,
|
||||
tShopping,
|
||||
onToggle,
|
||||
onChangeCategory,
|
||||
onDeleteRequest,
|
||||
onDragEnd,
|
||||
}: {
|
||||
aisle: string;
|
||||
items: Item[];
|
||||
showHeader: boolean;
|
||||
readOnly: boolean;
|
||||
categoryOptions: string[];
|
||||
tShopping: ReturnType<typeof useTranslations>;
|
||||
onToggle: (item: Item) => void;
|
||||
onChangeCategory: (item: Item, category: string) => void;
|
||||
onDeleteRequest: (item: Item) => void;
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
}) {
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{showHeader && (
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{aisle}</h2>
|
||||
)}
|
||||
<div className="rounded-xl border divide-y">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
|
||||
{items.map((item) => (
|
||||
<SortableItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
readOnly={readOnly}
|
||||
draggable={!readOnly}
|
||||
categoryOptions={categoryOptions}
|
||||
tShopping={tShopping}
|
||||
onToggle={onToggle}
|
||||
onChangeCategory={onChangeCategory}
|
||||
onDeleteRequest={onDeleteRequest}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlatItemList({
|
||||
items,
|
||||
readOnly,
|
||||
categoryOptions,
|
||||
tShopping,
|
||||
onToggle,
|
||||
onChangeCategory,
|
||||
onDeleteRequest,
|
||||
}: {
|
||||
items: Item[];
|
||||
readOnly: boolean;
|
||||
categoryOptions: string[];
|
||||
tShopping: ReturnType<typeof useTranslations>;
|
||||
onToggle: (item: Item) => void;
|
||||
onChangeCategory: (item: Item, category: string) => void;
|
||||
onDeleteRequest: (item: Item) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border divide-y">
|
||||
{items.map((item) => (
|
||||
<ItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
readOnly={readOnly}
|
||||
categoryOptions={categoryOptions}
|
||||
tShopping={tShopping}
|
||||
onToggle={onToggle}
|
||||
onChangeCategory={onChangeCategory}
|
||||
onDeleteRequest={onDeleteRequest}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableItemRow(props: ItemRowProps & { draggable: boolean }) {
|
||||
const { item, draggable } = props;
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id, disabled: !draggable });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className={cn(isDragging && "z-10 relative bg-background")}>
|
||||
<ItemRow
|
||||
{...props}
|
||||
dragHandle={draggable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 cursor-grab touch-none text-muted-foreground hover:text-foreground active:cursor-grabbing"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ItemRowProps = {
|
||||
item: Item;
|
||||
readOnly: boolean;
|
||||
categoryOptions: string[];
|
||||
tShopping: ReturnType<typeof useTranslations>;
|
||||
onToggle: (item: Item) => void;
|
||||
onChangeCategory: (item: Item, category: string) => void;
|
||||
onDeleteRequest: (item: Item) => void;
|
||||
dragHandle?: React.ReactNode;
|
||||
};
|
||||
|
||||
function ItemRow({ item, readOnly, categoryOptions, tShopping, onToggle, onChangeCategory, onDeleteRequest, dragHandle }: ItemRowProps) {
|
||||
return (
|
||||
<div className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||
{dragHandle}
|
||||
<button
|
||||
onClick={() => onToggle(item)}
|
||||
disabled={readOnly}
|
||||
className="flex flex-1 items-center gap-3 text-left disabled:cursor-default"
|
||||
>
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors",
|
||||
item.checked ? "bg-primary border-primary" : "border-input"
|
||||
)}>
|
||||
{item.checked && <Check className="h-3 w-3 text-primary-foreground" />}
|
||||
</div>
|
||||
<span className={cn("flex-1 text-sm", item.checked && "line-through text-muted-foreground")}>
|
||||
{item.rawName}
|
||||
</span>
|
||||
{item.inPantry && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 rounded px-1.5 py-0.5 shrink-0">
|
||||
{tShopping("alreadyInPantry")}
|
||||
</span>
|
||||
)}
|
||||
{(hasQuantity(item.quantity) || item.unit) && (
|
||||
<span className={cn("text-xs text-muted-foreground tabular-nums shrink-0", item.checked && "opacity-50")}>
|
||||
{hasQuantity(item.quantity) ? item.quantity : ""}{item.unit ? ` ${item.unit}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>{tShopping("changeCategory")}</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{categoryOptions.map((category) => (
|
||||
<DropdownMenuItem key={category} onClick={() => onChangeCategory(item, category)}>
|
||||
{category}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={() => onDeleteRequest(item)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{tShopping("deleteItem")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { ShoppingCart } from "lucide-react";
|
||||
import { NewShoppingListButton } from "@/components/meal-plan/new-shopping-list-button";
|
||||
import { ShoppingListActionsMenu } from "@/components/shopping-lists/shopping-list-actions-menu";
|
||||
|
||||
type ShoppingListItem = {
|
||||
id: string;
|
||||
@@ -24,9 +26,10 @@ type Props = {
|
||||
sharedLists?: SharedListItem[];
|
||||
};
|
||||
|
||||
export function ShoppingListsPageContent({ lists, sharedLists = [] }: Props) {
|
||||
export function ShoppingListsPageContent({ lists: initialLists, sharedLists = [] }: Props) {
|
||||
const t = useTranslations("shoppingLists");
|
||||
const ts = useTranslations("shareDialog");
|
||||
const [lists, setLists] = useState(initialLists);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -49,10 +52,10 @@ export function ShoppingListsPageContent({ lists, sharedLists = [] }: Props) {
|
||||
<Link
|
||||
key={list.id}
|
||||
href={`/shopping-lists/${list.id}`}
|
||||
className="flex items-center justify-between rounded-xl border p-4 hover:shadow-sm transition-shadow"
|
||||
className="flex items-center justify-between gap-2 rounded-xl border p-4 hover:shadow-sm transition-shadow"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2 className="font-semibold">{list.name}</h2>
|
||||
<div className="space-y-1 min-w-0">
|
||||
<h2 className="font-semibold truncate">{list.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{list.totalItems === 0
|
||||
? t("listEmpty")
|
||||
@@ -60,8 +63,18 @@ export function ShoppingListsPageContent({ lists, sharedLists = [] }: Props) {
|
||||
{list.generatedAt && ` · ${t("generated")}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center text-xs font-bold">
|
||||
{list.totalItems === 0 ? "—" : `${Math.round((list.checkedItems / list.totalItems) * 100)}%`}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center text-xs font-bold">
|
||||
{list.totalItems === 0 ? "—" : `${Math.round((list.checkedItems / list.totalItems) * 100)}%`}
|
||||
</div>
|
||||
<ShoppingListActionsMenu
|
||||
listId={list.id}
|
||||
name={list.name}
|
||||
onRenamed={(name) =>
|
||||
setLists((prev) => prev.map((l) => (l.id === list.id ? { ...l, name } : l)))
|
||||
}
|
||||
onDeleted={() => setLists((prev) => prev.filter((l) => l.id !== list.id))}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user