70ad1bec9c
Cross-category drops worked (previous fix) but had no live preview — the item stayed rendered in its original group the whole time you were dragging over a different one, since dnd-kit only computes a transform for items actually present in a group's array. Added the standard multi-container dnd-kit pattern: onDragOver moves the item into the target group locally as soon as you drag over it (pure preview, nothing persisted yet); onDragEnd finalizes the exact position and only fires a network request for whatever actually changed — order within the final group, and the aisle itself only if it differs from what it was when the drag started. Also handles dropping outside any group: the local preview move is reverted instead of silently leaving an unpersisted, visually-wrong position until refresh. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
708 lines
26 KiB
TypeScript
708 lines
26 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useRef, useState } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { cn } from "@/lib/utils";
|
|
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,
|
|
DragOverEvent,
|
|
DragStartEvent,
|
|
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;
|
|
rawName: string;
|
|
quantity: string | null;
|
|
unit: string | null;
|
|
aisle: string | null;
|
|
checked: boolean;
|
|
inPantry?: boolean;
|
|
sortOrder?: number;
|
|
};
|
|
|
|
type SortMode = "category" | "alpha" | "unchecked";
|
|
|
|
// Sentinel group key for items with no aisle (the "Other" bucket) — using a stable,
|
|
// locale-independent key (rather than the translated "Other" text) as the group
|
|
// identifier avoids that identifier changing across locales and lets it double as a
|
|
// dnd-kit droppable/group key.
|
|
const OTHER_KEY = "__other__";
|
|
|
|
export function ShoppingListView({
|
|
listId,
|
|
initialItems,
|
|
readOnly = false,
|
|
}: {
|
|
listId: string;
|
|
initialItems: Item[];
|
|
readOnly?: boolean;
|
|
}) {
|
|
const t = useTranslations("mealPlan");
|
|
const tShopping = useTranslations("shoppingLists");
|
|
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 sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
|
// Aisle the dragged item had when the drag started — captured so onDragEnd can tell
|
|
// whether the item actually changed category (and needs a persisted aisle update) as
|
|
// opposed to just being reordered within its original group.
|
|
const dragStartAisleRef = useRef<string | null>(null);
|
|
const [deleteTarget, setDeleteTarget] = useState<Item | null>(null);
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [autoCategorizing, setAutoCategorizing] = useState(false);
|
|
|
|
// Predefined categories, plus any custom category already in use on this list
|
|
// (so a custom category someone created stays selectable for other items too),
|
|
// plus "Other" (represented as `null` under the hood) always last.
|
|
const customCategoriesInUse = useMemo(
|
|
() => Array.from(new Set(items.map((i) => i.aisle).filter((a): a is string => !!a && !(GROCERY_CATEGORIES as readonly string[]).includes(a)))).sort(),
|
|
[items]
|
|
);
|
|
const categoryOptions = useMemo(
|
|
() => [...GROCERY_CATEGORIES, ...customCategoriesInUse],
|
|
[customCategoriesInUse]
|
|
);
|
|
|
|
function categoryLabel(category: string): string {
|
|
return (GROCERY_CATEGORIES as readonly string[]).includes(category)
|
|
? tShopping(`categories.${category}`)
|
|
: category;
|
|
}
|
|
|
|
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;
|
|
setMovingToPantry(true);
|
|
try {
|
|
const res = await fetch("/api/v1/pantry/bulk", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
items: checkedItems.map((i) => ({
|
|
rawName: i.rawName,
|
|
quantity: i.quantity ?? undefined,
|
|
unit: i.unit ?? undefined,
|
|
})),
|
|
}),
|
|
});
|
|
if (!res.ok) { toast.error(t("moveToPantryFailed")); return; }
|
|
toast.success(t("addedToPantry", { count: checkedItems.length }));
|
|
} finally {
|
|
setMovingToPantry(false);
|
|
}
|
|
}
|
|
|
|
async function toggleItem(item: Item) {
|
|
if (readOnly) return;
|
|
const next = !item.checked;
|
|
setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, checked: next } : i));
|
|
try {
|
|
const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ checked: next }),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
} catch {
|
|
setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, checked: !next } : i));
|
|
toast.error(tCommon("updateFailed"));
|
|
}
|
|
}
|
|
|
|
async function changeCategory(item: Item, nextAisle: string | null) {
|
|
if (readOnly) return;
|
|
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 groupKeyOf(item: Item): string {
|
|
return item.aisle ?? OTHER_KEY;
|
|
}
|
|
|
|
// Persists just the aisle for one item, without touching local state — used at drag
|
|
// end once the item has already visually settled into its destination group via
|
|
// onDragOver. Reverts on failure (only the field, not its position — a rare-case
|
|
// rollback, not worth re-deriving the exact prior array position for).
|
|
async function persistAisleChange(itemId: string, nextAisle: string | null, prevAisle: string | null) {
|
|
try {
|
|
const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${itemId}`, {
|
|
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 === itemId ? { ...i, aisle: prevAisle } : i));
|
|
toast.error(tCommon("updateFailed"));
|
|
}
|
|
}
|
|
|
|
function handleDragStart(event: DragStartEvent) {
|
|
const activeItem = items.find((i) => i.id === event.active.id);
|
|
dragStartAisleRef.current = activeItem?.aisle ?? null;
|
|
}
|
|
|
|
// Live cross-category preview: without this, dragging over a different group's rows
|
|
// does nothing visually until drop, since dnd-kit only computes a transform for items
|
|
// that are actually present in the target group's array. Moving the item into the
|
|
// destination group's position in `items` as soon as it's dragged over makes it
|
|
// render there immediately, like any other multi-container dnd-kit board. Purely
|
|
// local — nothing is persisted until drag end.
|
|
function handleDragOver(event: DragOverEvent) {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
|
|
const activeItem = items.find((i) => i.id === active.id);
|
|
const overItem = items.find((i) => i.id === over.id);
|
|
if (!activeItem || !overItem) return;
|
|
if (groupKeyOf(activeItem) === groupKeyOf(overItem)) return;
|
|
|
|
setItems((prev) => {
|
|
const withoutActive = prev.filter((i) => i.id !== activeItem.id);
|
|
const overIndex = withoutActive.findIndex((i) => i.id === overItem.id);
|
|
if (overIndex === -1) return prev;
|
|
const next = [...withoutActive];
|
|
next.splice(overIndex, 0, { ...activeItem, aisle: overItem.aisle });
|
|
return next;
|
|
});
|
|
}
|
|
|
|
// Single handler for the whole list (not per-group) — a per-group DndContext can
|
|
// only ever resolve drops within its own group, which is exactly why cross-category
|
|
// dragging didn't work before. By the time this fires, onDragOver has already moved
|
|
// the item into its destination group locally, so this just finalizes the exact
|
|
// position within that group and persists whatever actually changed: the new order,
|
|
// and — only if the category actually changed since drag start — the new aisle.
|
|
function handleDragEnd(event: DragEndEvent) {
|
|
const { active, over } = event;
|
|
const startAisle = dragStartAisleRef.current;
|
|
dragStartAisleRef.current = null;
|
|
|
|
if (!over) {
|
|
// Dropped outside any droppable — onDragOver may have already moved the item
|
|
// into a different group locally (pure preview, never persisted); put it back
|
|
// rather than leaving a moved-but-unsaved item that only "resets" on refresh.
|
|
setItems((prev) => prev.map((i) => i.id === active.id ? { ...i, aisle: startAisle } : i));
|
|
return;
|
|
}
|
|
|
|
const activeItem = items.find((i) => i.id === active.id);
|
|
if (!activeItem) return;
|
|
|
|
const destKey = groupKeyOf(activeItem);
|
|
const groupItems = items.filter((i) => groupKeyOf(i) === destKey);
|
|
const oldIndex = groupItems.findIndex((i) => i.id === active.id);
|
|
const newIndex = active.id === over.id
|
|
? oldIndex
|
|
: groupItems.findIndex((i) => i.id === over.id);
|
|
|
|
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
|
const reordered = arrayMove(groupItems, oldIndex, newIndex);
|
|
const reorderedIds = new Set(reordered.map((i) => i.id));
|
|
setItems((prev) => {
|
|
let cursor = 0;
|
|
return prev.map((i) => (reorderedIds.has(i.id) ? reordered[cursor++]! : i));
|
|
});
|
|
void persistOrder(reordered);
|
|
}
|
|
|
|
if (startAisle !== activeItem.aisle) {
|
|
void persistAisleChange(activeItem.id, activeItem.aisle, startAisle);
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
if (items.length === 0) {
|
|
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 = groupKeyOf(item);
|
|
(acc[key] ??= []).push(item);
|
|
return acc;
|
|
}, {})
|
|
: {};
|
|
|
|
function groupLabel(key: string): string {
|
|
return key === OTHER_KEY ? tShopping("aisleOther") : categoryLabel(key);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<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>
|
|
|
|
<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>
|
|
{(v: SortMode) => ({ category: t("sortByCategory"), alpha: t("sortByAlpha"), unchecked: t("sortByUnchecked") })[v]}
|
|
</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 ? (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={handleDragStart}
|
|
onDragOver={handleDragOver}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<div className="space-y-6">
|
|
{Object.entries(grouped).sort(([a], [b]) => groupLabel(a).localeCompare(groupLabel(b))).map(([key, groupItems]) => (
|
|
<ItemGroup
|
|
key={key}
|
|
aisleLabel={groupLabel(key)}
|
|
items={groupItems}
|
|
showHeader={Object.keys(grouped).length > 1}
|
|
readOnly={readOnly}
|
|
categoryOptions={categoryOptions}
|
|
categoryLabel={categoryLabel}
|
|
tShopping={tShopping}
|
|
onToggle={toggleItem}
|
|
onChangeCategory={changeCategory}
|
|
onDeleteRequest={setDeleteTarget}
|
|
/>
|
|
))}
|
|
</div>
|
|
</DndContext>
|
|
) : (
|
|
<FlatItemList
|
|
items={flatItems}
|
|
readOnly={readOnly}
|
|
categoryOptions={categoryOptions}
|
|
categoryLabel={categoryLabel}
|
|
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({
|
|
aisleLabel,
|
|
items,
|
|
showHeader,
|
|
readOnly,
|
|
categoryOptions,
|
|
categoryLabel,
|
|
tShopping,
|
|
onToggle,
|
|
onChangeCategory,
|
|
onDeleteRequest,
|
|
}: {
|
|
aisleLabel: string;
|
|
items: Item[];
|
|
showHeader: boolean;
|
|
readOnly: boolean;
|
|
categoryOptions: string[];
|
|
categoryLabel: (category: string) => string;
|
|
tShopping: ReturnType<typeof useTranslations>;
|
|
onToggle: (item: Item) => void;
|
|
onChangeCategory: (item: Item, category: string | null) => void;
|
|
onDeleteRequest: (item: Item) => void;
|
|
}) {
|
|
return (
|
|
<div className="space-y-2">
|
|
{showHeader && (
|
|
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{aisleLabel}</h2>
|
|
)}
|
|
<div className="rounded-xl border divide-y">
|
|
<SortableContext items={items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
|
|
{items.map((item) => (
|
|
<SortableItemRow
|
|
key={item.id}
|
|
item={item}
|
|
readOnly={readOnly}
|
|
draggable={!readOnly}
|
|
categoryOptions={categoryOptions}
|
|
categoryLabel={categoryLabel}
|
|
tShopping={tShopping}
|
|
onToggle={onToggle}
|
|
onChangeCategory={onChangeCategory}
|
|
onDeleteRequest={onDeleteRequest}
|
|
/>
|
|
))}
|
|
</SortableContext>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FlatItemList({
|
|
items,
|
|
readOnly,
|
|
categoryOptions,
|
|
categoryLabel,
|
|
tShopping,
|
|
onToggle,
|
|
onChangeCategory,
|
|
onDeleteRequest,
|
|
}: {
|
|
items: Item[];
|
|
readOnly: boolean;
|
|
categoryOptions: string[];
|
|
categoryLabel: (category: string) => string;
|
|
tShopping: ReturnType<typeof useTranslations>;
|
|
onToggle: (item: Item) => void;
|
|
onChangeCategory: (item: Item, category: string | null) => 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}
|
|
categoryLabel={categoryLabel}
|
|
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[];
|
|
categoryLabel: (category: string) => string;
|
|
tShopping: ReturnType<typeof useTranslations>;
|
|
onToggle: (item: Item) => void;
|
|
onChangeCategory: (item: Item, category: string | null) => void;
|
|
onDeleteRequest: (item: Item) => void;
|
|
dragHandle?: React.ReactNode;
|
|
};
|
|
|
|
function ItemRow({ item, readOnly, categoryOptions, categoryLabel, tShopping, onToggle, onChangeCategory, onDeleteRequest, dragHandle }: ItemRowProps) {
|
|
const [newCategory, setNewCategory] = useState("");
|
|
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 className="w-56">
|
|
{categoryOptions.map((category) => (
|
|
<DropdownMenuItem key={category} onClick={() => onChangeCategory(item, category)}>
|
|
{categoryLabel(category)}
|
|
</DropdownMenuItem>
|
|
))}
|
|
<DropdownMenuItem onClick={() => onChangeCategory(item, null)}>
|
|
{tShopping("aisleOther")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<div className="flex items-center gap-1 p-1.5" onClick={(e) => e.stopPropagation()}>
|
|
<Input
|
|
value={newCategory}
|
|
onChange={(e) => setNewCategory(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && newCategory.trim()) {
|
|
onChangeCategory(item, newCategory.trim());
|
|
setNewCategory("");
|
|
}
|
|
}}
|
|
placeholder={tShopping("newCategoryPlaceholder")}
|
|
className="h-7 text-xs"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-7 shrink-0 px-2 text-xs"
|
|
disabled={!newCategory.trim()}
|
|
onClick={() => { onChangeCategory(item, newCategory.trim()); setNewCategory(""); }}
|
|
>
|
|
{tShopping("addCategory")}
|
|
</Button>
|
|
</div>
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem variant="destructive" onClick={() => onDeleteRequest(item)}>
|
|
<Trash2 className="h-4 w-4" />
|
|
{tShopping("deleteItem")}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|