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:
@@ -8,6 +8,7 @@ import { db, shoppingLists, eq } from "@epicure/db";
|
||||
import { ShoppingListView } from "@/components/meal-plan/shopping-list-view";
|
||||
import { ShareShoppingListButton } from "@/components/shopping-lists/share-shopping-list-button";
|
||||
import { GroceryExportButton } from "@/components/shopping-lists/grocery-export-button";
|
||||
import { ShoppingListActionsMenu } from "@/components/shopping-lists/shopping-list-actions-menu";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
|
||||
@@ -30,7 +31,7 @@ export default async function ShoppingListPage({ params }: Params) {
|
||||
|
||||
const list = await db.query.shoppingLists.findFirst({
|
||||
where: eq(shoppingLists.id, id),
|
||||
with: { items: { orderBy: (t, { asc }) => [asc(t.aisle), asc(t.rawName)] } },
|
||||
with: { items: { orderBy: (t, { asc }) => [asc(t.aisle), asc(t.sortOrder), asc(t.rawName)] } },
|
||||
});
|
||||
if (!list) notFound();
|
||||
|
||||
@@ -58,6 +59,9 @@ export default async function ShoppingListPage({ params }: Params) {
|
||||
markdown={shoppingListToMarkdown({ name: list.name, items: list.items })}
|
||||
filename={list.name}
|
||||
/>
|
||||
{access.role === "owner" && (
|
||||
<ShoppingListActionsMenu listId={id} name={list.name} redirectAfterDeleteTo="/shopping-lists" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ShoppingListView
|
||||
@@ -70,6 +74,7 @@ export default async function ShoppingListPage({ params }: Params) {
|
||||
unit: i.unit,
|
||||
aisle: i.aisle,
|
||||
checked: i.checked,
|
||||
sortOrder: i.sortOrder,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
import { deleteObject } from "@/lib/storage";
|
||||
import { dispatchWebhook } from "@/lib/webhooks";
|
||||
import { parseQuantity } from "@/lib/parse-quantity";
|
||||
import { extractIngredientQuantity } from "@/lib/extract-ingredient-quantity";
|
||||
|
||||
const UpdateRecipeSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
@@ -31,6 +32,9 @@ const UpdateRecipeSchema = z.object({
|
||||
unit: z.string().max(50).optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
order: z.number().int().default(0),
|
||||
}).transform((ing) => {
|
||||
const { rawName, quantity, unit } = extractIngredientQuantity(ing.rawName, ing.quantity, ing.unit);
|
||||
return { ...ing, rawName, quantity, unit };
|
||||
})).max(100).optional(),
|
||||
steps: z.array(z.object({
|
||||
instruction: z.string().min(1).max(2000),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
|
||||
import { dispatchWebhook } from "@/lib/webhooks";
|
||||
import { parseQuantity } from "@/lib/parse-quantity";
|
||||
import { extractIngredientQuantity } from "@/lib/extract-ingredient-quantity";
|
||||
|
||||
const CreateRecipeSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
@@ -33,6 +34,9 @@ const CreateRecipeSchema = z.object({
|
||||
unit: z.string().max(50).optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
order: z.number().int().default(0),
|
||||
}).transform((ing) => {
|
||||
const { rawName, quantity, unit } = extractIngredientQuantity(ing.rawName, ing.quantity, ing.unit);
|
||||
return { ...ing, rawName, quantity, unit };
|
||||
})).max(100).default([]),
|
||||
steps: z.array(z.object({
|
||||
instruction: z.string().min(1).max(2000),
|
||||
|
||||
@@ -8,6 +8,11 @@ type Params = { params: Promise<{ id: string; itemId: string }> };
|
||||
|
||||
const UpdateItemSchema = z.object({
|
||||
checked: z.boolean().optional(),
|
||||
rawName: z.string().min(1).optional(),
|
||||
quantity: z.string().nullable().optional(),
|
||||
unit: z.string().nullable().optional(),
|
||||
aisle: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: Params) {
|
||||
@@ -24,9 +29,35 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
||||
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.update(shoppingListItems)
|
||||
.set({ checked: parsed.data.checked ?? false })
|
||||
.where(and(eq(shoppingListItems.id, itemId), eq(shoppingListItems.listId, id)));
|
||||
const data = parsed.data;
|
||||
const patch: Partial<typeof shoppingListItems.$inferInsert> = {};
|
||||
if (data.checked !== undefined) patch.checked = data.checked;
|
||||
if (data.rawName !== undefined) patch.rawName = data.rawName;
|
||||
if (data.quantity !== undefined) patch.quantity = data.quantity;
|
||||
if (data.unit !== undefined) patch.unit = data.unit;
|
||||
if (data.aisle !== undefined) patch.aisle = data.aisle;
|
||||
if (data.sortOrder !== undefined) patch.sortOrder = data.sortOrder;
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await db.update(shoppingListItems)
|
||||
.set(patch)
|
||||
.where(and(eq(shoppingListItems.id, itemId), eq(shoppingListItems.listId, id)));
|
||||
}
|
||||
|
||||
return NextResponse.json({ updated: true });
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
const { id, itemId } = await params;
|
||||
|
||||
const access = await getShoppingListAccess(id, session!.user.id);
|
||||
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
await db.delete(shoppingListItems)
|
||||
.where(and(eq(shoppingListItems.id, itemId), eq(shoppingListItems.listId, id)));
|
||||
|
||||
return NextResponse.json({ deleted: true });
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, shoppingListItems } from "@epicure/db";
|
||||
import { db, shoppingListItems, eq, and } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
|
||||
import { guessAisle } from "@/lib/grocery-categories";
|
||||
|
||||
const AddItemsSchema = z.object({
|
||||
items: z.array(z.object({
|
||||
@@ -13,6 +14,17 @@ const AddItemsSchema = z.object({
|
||||
})).min(1),
|
||||
});
|
||||
|
||||
// Bulk-update a set of existing items (sortOrder and/or aisle) in one request —
|
||||
// used for drag-and-drop reordering (avoids one PUT per item on every drag) and
|
||||
// for the "auto-categorize" action on lists whose items predate aisle-guessing.
|
||||
const BulkUpdateSchema = z.object({
|
||||
items: z.array(z.object({
|
||||
id: z.string().min(1),
|
||||
sortOrder: z.number().int().optional(),
|
||||
aisle: z.string().nullable().optional(),
|
||||
})).min(1),
|
||||
});
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function POST(req: NextRequest, { params }: Params) {
|
||||
@@ -35,10 +47,39 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
rawName: item.rawName,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
aisle: item.aisle,
|
||||
// Only guess when the caller didn't provide an explicit aisle — never override.
|
||||
aisle: item.aisle ?? guessAisle(item.rawName) ?? undefined,
|
||||
checked: false,
|
||||
}))
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true }, { status: 201 });
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
const { id } = await params;
|
||||
|
||||
const access = await getShoppingListAccess(id, session!.user.id);
|
||||
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const body = await req.json() as unknown;
|
||||
const parsed = BulkUpdateSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||
|
||||
await Promise.all(
|
||||
parsed.data.items.map((item) => {
|
||||
const patch: Partial<typeof shoppingListItems.$inferInsert> = {};
|
||||
if (item.sortOrder !== undefined) patch.sortOrder = item.sortOrder;
|
||||
if (item.aisle !== undefined) patch.aisle = item.aisle;
|
||||
if (Object.keys(patch).length === 0) return Promise.resolve();
|
||||
return db.update(shoppingListItems)
|
||||
.set(patch)
|
||||
.where(and(eq(shoppingListItems.id, item.id), eq(shoppingListItems.listId, id)));
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ export async function GET(_req: NextRequest, { params }: Params) {
|
||||
return NextResponse.json(list);
|
||||
}
|
||||
|
||||
const PatchSchema = z.object({ completed: z.boolean() });
|
||||
const PatchSchema = z.object({
|
||||
completed: z.boolean().optional(),
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
@@ -32,12 +35,18 @@ export async function PATCH(req: NextRequest, { params }: Params) {
|
||||
|
||||
const access = await getShoppingListAccess(id, session!.user.id);
|
||||
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const body = PatchSchema.safeParse(await req.json());
|
||||
if (!body.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||
|
||||
if (body.data.name !== undefined) {
|
||||
// Renaming is owner-only, same as delete
|
||||
if (access.role !== "owner") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
await db.update(shoppingLists).set({ name: body.data.name }).where(eq(shoppingLists.id, id));
|
||||
}
|
||||
|
||||
if (body.data.completed) {
|
||||
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
// Mark all items as checked
|
||||
await db.update(shoppingListItems).set({ checked: true }).where(eq(shoppingListItems.listId, id));
|
||||
void dispatchWebhook(session!.user.id, "shopping_list.completed", { id, name: access.list.name });
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { db, shoppingLists, shoppingListItems, mealPlans, mealPlanEntries, recipeIngredients, pantryItems, eq, and, desc, inArray } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { applyPantryToItems, mergeIngredients } from "@/lib/pantry-shopping-match";
|
||||
import { guessAisle } from "@/lib/grocery-categories";
|
||||
|
||||
const CreateSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
@@ -77,15 +78,18 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
if (items.length > 0) {
|
||||
await db.insert(shoppingListItems).values(
|
||||
items.map((item) => ({
|
||||
items.map((item, index) => ({
|
||||
id: crypto.randomUUID(),
|
||||
listId,
|
||||
rawName: item.rawName,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
aisle: item.aisle,
|
||||
// Never override an explicit aisle — only guess one when the item doesn't have one
|
||||
// (which today is every item coming from the meal-plan generation path).
|
||||
aisle: item.aisle ?? guessAisle(item.rawName) ?? undefined,
|
||||
checked: item.inPantry === true && !item.quantity,
|
||||
inPantry: item.inPantry ?? false,
|
||||
sortOrder: index,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -10,9 +10,9 @@ const MealPlanSchema = z.object({
|
||||
title: z.string().max(150),
|
||||
description: z.string().max(300),
|
||||
ingredients: z.array(z.object({
|
||||
rawName: z.string(),
|
||||
quantity: z.number().optional(),
|
||||
unit: z.string().optional(),
|
||||
rawName: z.string().describe("Ingredient name only, e.g. 'flour' — never include the quantity or unit here."),
|
||||
quantity: z.number().optional().describe("A number only — never combined with the unit."),
|
||||
unit: z.string().optional().describe("The unit only, e.g. 'cup', 'g' — never combined with the quantity or name."),
|
||||
})).max(20),
|
||||
steps: z.array(z.object({
|
||||
instruction: z.string(),
|
||||
|
||||
@@ -17,9 +17,13 @@ export const stepSchema = z.object({
|
||||
|
||||
export function ingredientSchema<Q extends z.ZodTypeAny>(quantity: Q) {
|
||||
return z.object({
|
||||
rawName: z.string(),
|
||||
quantity: quantity.optional(),
|
||||
unit: z.string().optional(),
|
||||
rawName: z.string().describe(
|
||||
"The ingredient name ONLY — e.g. 'flour', 'egg', 'olive oil'. Never include the " +
|
||||
"quantity or unit here (not '2 cups flour', not '3 eggs'); those go in the separate " +
|
||||
"quantity and unit fields."
|
||||
),
|
||||
quantity: quantity.optional().describe("A number only, e.g. 0.25, 1.5, 2 — never combined with the unit."),
|
||||
unit: z.string().optional().describe("The unit only, e.g. 'cup', 'tbsp', 'g', 'ml' — never combined with the quantity or name."),
|
||||
note: z.string().optional(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { resolveModel, type AiConfig } from "../factory";
|
||||
const ScaledIngredientsSchema = z.object({
|
||||
ingredients: z.array(
|
||||
z.object({
|
||||
rawName: z.string(),
|
||||
quantity: z.string(),
|
||||
rawName: z.string().describe("Ingredient name only, e.g. 'flour' — never include the scaled quantity or unit here."),
|
||||
quantity: z.string().describe("The scaled quantity as a number only, e.g. '3' or '1.5'."),
|
||||
unit: z.string().nullable(),
|
||||
note: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ const TranslationOutputSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
ingredients: z.array(z.object({
|
||||
rawName: z.string(),
|
||||
rawName: z.string().describe("Translated ingredient name only — never add the quantity or unit, those are handled separately and unaffected by translation."),
|
||||
note: z.string().optional(),
|
||||
})),
|
||||
steps: z.array(z.object({
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { parseQuantity } from "./parse-quantity";
|
||||
|
||||
const KNOWN_UNITS = new Set([
|
||||
"cup", "cups", "c",
|
||||
"tablespoon", "tablespoons", "tbsp", "tbsps", "tbs",
|
||||
"teaspoon", "teaspoons", "tsp", "tsps",
|
||||
"gram", "grams", "g",
|
||||
"kilogram", "kilograms", "kg",
|
||||
"ounce", "ounces", "oz",
|
||||
"pound", "pounds", "lb", "lbs",
|
||||
"milliliter", "milliliters", "millilitre", "millilitres", "ml",
|
||||
"liter", "liters", "litre", "litres", "l",
|
||||
"pinch", "pinches", "dash", "dashes",
|
||||
"clove", "cloves", "slice", "slices", "piece", "pieces",
|
||||
"can", "cans", "jar", "jars", "package", "packages", "pkg",
|
||||
"bunch", "bunches", "sprig", "sprigs", "stick", "sticks",
|
||||
"quart", "quarts", "qt", "pint", "pints", "pt",
|
||||
"fl", // matches the first token of "fl oz" — handled below
|
||||
]);
|
||||
|
||||
// Leading numeric token: plain int/decimal, unicode fraction, mixed number ("1 1/2"), or
|
||||
// simple fraction ("1/2") — mirrors what parseQuantity already knows how to parse.
|
||||
const LEADING_NUMBER =
|
||||
/^\s*(\d+\s+\d+\s*\/\s*\d+|\d+\s*\/\s*\d+|\d+(?:\.\d+)?|[¼½¾⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞])\s*/;
|
||||
|
||||
/**
|
||||
* Some AI-generated (or pasted/imported) ingredients arrive with the quantity baked into
|
||||
* the name itself (e.g. rawName: "2 cups flour", quantity/unit left empty) instead of the
|
||||
* expected separate fields. Detects a leading quantity (+ optional recognized unit) in
|
||||
* rawName and pulls it out, so the ingredient name shown to the user is just "flour", not
|
||||
* "2 cups flour". Never runs when an explicit quantity was already provided — an entry
|
||||
* with real quantity/unit fields is trusted as-is, this is only a fallback for the case
|
||||
* where the model (or a copy-pasted ingredient line) merged everything into the name.
|
||||
*/
|
||||
export function extractIngredientQuantity(
|
||||
rawName: string,
|
||||
quantity: string | undefined,
|
||||
unit: string | undefined
|
||||
): { rawName: string; quantity: string | undefined; unit: string | undefined } {
|
||||
if (quantity !== undefined && quantity !== "") return { rawName, quantity, unit };
|
||||
|
||||
const match = rawName.match(LEADING_NUMBER);
|
||||
if (!match) return { rawName, quantity, unit };
|
||||
|
||||
const numberToken = match[1]!.trim();
|
||||
const parsedQuantity = parseQuantity(numberToken);
|
||||
if (parsedQuantity === undefined) return { rawName, quantity, unit };
|
||||
|
||||
let rest = rawName.slice(match[0].length).trim();
|
||||
if (!rest) return { rawName, quantity, unit }; // nothing left — not actually a name+quantity string
|
||||
|
||||
let extractedUnit = unit;
|
||||
const unitMatch = rest.match(/^([a-zA-Z.]+)\s+(.*)$/);
|
||||
if (unitMatch && !extractedUnit) {
|
||||
const candidate = unitMatch[1]!.replace(/\.$/, "").toLowerCase();
|
||||
if (candidate === "fl" && unitMatch[2]!.toLowerCase().startsWith("oz")) {
|
||||
extractedUnit = "fl oz";
|
||||
rest = unitMatch[2]!.replace(/^oz\.?\s*/i, "").trim();
|
||||
} else if (KNOWN_UNITS.has(candidate)) {
|
||||
extractedUnit = candidate;
|
||||
rest = unitMatch[2]!.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (!rest) return { rawName, quantity, unit }; // stripping the unit ate the whole string — bail out
|
||||
|
||||
return { rawName: rest, quantity: parsedQuantity, unit: extractedUnit };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Lightweight keyword-based aisle guesser for shopping list items.
|
||||
*
|
||||
* This is intentionally NOT an exhaustive ingredient database — just a few dozen
|
||||
* common keyword -> category mappings covering typical recipe ingredients, so
|
||||
* items generated from a meal plan (which never have an explicit `aisle` set
|
||||
* today) land in a reasonable category instead of an undifferentiated "Other"
|
||||
* bucket. When nothing matches, returns `null` and the caller falls back to
|
||||
* "Other" as before.
|
||||
*
|
||||
* Only ever used as a FALLBACK when an item doesn't already have an explicit
|
||||
* `aisle` — never overrides a user- or API-provided value.
|
||||
*/
|
||||
|
||||
export const GROCERY_CATEGORIES = [
|
||||
"Produce",
|
||||
"Dairy & Eggs",
|
||||
"Meat & Seafood",
|
||||
"Bakery",
|
||||
"Frozen",
|
||||
"Pantry",
|
||||
"Spices & Condiments",
|
||||
"Beverages",
|
||||
] as const;
|
||||
|
||||
export type GroceryCategory = (typeof GROCERY_CATEGORIES)[number];
|
||||
|
||||
// Ordered map of category -> keywords. Checked in order, first match wins, so
|
||||
// more specific keywords should generally come before more generic ones.
|
||||
const CATEGORY_KEYWORDS: [GroceryCategory, string[]][] = [
|
||||
["Produce", [
|
||||
"lettuce", "spinach", "kale", "arugula", "cabbage", "carrot", "celery", "onion",
|
||||
"garlic", "shallot", "scallion", "leek", "potato", "sweet potato", "tomato",
|
||||
"cucumber", "zucchini", "squash", "pepper", "chili", "chile", "broccoli",
|
||||
"cauliflower", "mushroom", "avocado", "lemon", "lime", "orange", "apple",
|
||||
"banana", "berry", "berries", "grape", "melon", "peach", "pear", "plum",
|
||||
"mango", "pineapple", "cilantro", "parsley", "basil", "mint", "dill",
|
||||
"thyme", "rosemary", "ginger", "corn", "peas", "beans", "asparagus",
|
||||
"radish", "beet", "fennel", "herb", "greens",
|
||||
]],
|
||||
["Dairy & Eggs", [
|
||||
"milk", "cream", "yogurt", "yoghurt", "butter", "cheese", "egg", "eggs",
|
||||
"sour cream", "cottage cheese", "mascarpone", "ricotta", "buttermilk",
|
||||
"half and half", "creme fraiche",
|
||||
]],
|
||||
["Meat & Seafood", [
|
||||
"chicken", "beef", "pork", "lamb", "turkey", "bacon", "sausage", "ham",
|
||||
"steak", "ground beef", "mince", "salmon", "tuna", "shrimp", "prawn",
|
||||
"cod", "tilapia", "fish", "crab", "lobster", "scallop", "mussel", "clam",
|
||||
"chorizo", "prosciutto", "duck",
|
||||
]],
|
||||
["Bakery", [
|
||||
"bread", "baguette", "roll", "bun", "bagel", "tortilla", "pita", "naan",
|
||||
"croissant", "muffin", "brioche", "loaf",
|
||||
]],
|
||||
["Frozen", [
|
||||
"frozen", "ice cream", "popsicle", "frozen peas", "frozen berries",
|
||||
]],
|
||||
["Beverages", [
|
||||
"juice", "soda", "water", "coffee", "tea", "wine", "beer", "sparkling",
|
||||
"kombucha", "cider",
|
||||
]],
|
||||
["Spices & Condiments", [
|
||||
"salt", "pepper flakes", "cumin", "paprika", "cinnamon", "nutmeg",
|
||||
"oregano", "turmeric", "cayenne", "curry powder", "chili powder", "spice",
|
||||
"vanilla", "ketchup", "mustard", "mayo", "mayonnaise", "soy sauce",
|
||||
"hot sauce", "vinegar", "olive oil", "vegetable oil", "sesame oil",
|
||||
"honey", "maple syrup", "jam", "sauce", "dressing", "salsa",
|
||||
]],
|
||||
["Pantry", [
|
||||
"flour", "sugar", "rice", "pasta", "noodle", "spaghetti", "quinoa",
|
||||
"oats", "oatmeal", "cereal", "beans", "lentil", "chickpea", "canned",
|
||||
"stock", "broth", "bouillon", "yeast", "baking powder", "baking soda",
|
||||
"cornstarch", "breadcrumb", "nut", "almond", "walnut", "peanut", "cashew",
|
||||
"chocolate", "cocoa", "coconut milk", "tomato paste", "tomato sauce",
|
||||
"crushed tomato",
|
||||
]],
|
||||
];
|
||||
|
||||
/**
|
||||
* Guesses a grocery aisle/category from a raw ingredient name via simple
|
||||
* keyword matching. Returns `null` when nothing matches (caller should fall
|
||||
* back to "Other").
|
||||
*/
|
||||
export function guessAisle(rawName: string): GroceryCategory | null {
|
||||
const name = rawName.toLowerCase().trim();
|
||||
if (!name) return null;
|
||||
|
||||
for (const [category, keywords] of CATEGORY_KEYWORDS) {
|
||||
for (const keyword of keywords) {
|
||||
if (name.includes(keyword)) return category;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -654,6 +654,16 @@
|
||||
"aisleOther": "Other",
|
||||
"listEmptyState": "This list is empty.",
|
||||
"checkedCount": "{checked}/{total} checked",
|
||||
"searchItemsPlaceholder": "Search items…",
|
||||
"sortByCategory": "By category",
|
||||
"sortByAlpha": "Alphabetical",
|
||||
"sortByUnchecked": "Unchecked first",
|
||||
"noSearchResults": "No items match your search.",
|
||||
"autoCategorize": "Auto-categorize",
|
||||
"autoCategorizeSuccess": "{count, plural, one {1 item categorized} other {{count} items categorized}}",
|
||||
"autoCategorizeNoneFound": "Couldn't guess a category for the remaining items",
|
||||
"removeItemConfirmTitle": "Remove item?",
|
||||
"removeItemConfirmDescription": "Remove \"{name}\" from this list?",
|
||||
"moveToPantry": "Move {count} to pantry",
|
||||
"listCreated": "List created",
|
||||
"days": {
|
||||
@@ -803,7 +813,20 @@
|
||||
"ingredientsWillBeAdded": "{count, plural, one {1 ingredient will be added.} other {{count} ingredients will be added.}}",
|
||||
"adding": "Adding…",
|
||||
"addIngredients": "Add ingredients",
|
||||
"addedCount": "{count} ingredients added to list"
|
||||
"addedCount": "{count} ingredients added to list",
|
||||
"actionsMenuLabel": "List actions",
|
||||
"rename": "Rename",
|
||||
"renameListTitle": "Rename list",
|
||||
"renameListLabel": "List name",
|
||||
"renaming": "Saving…",
|
||||
"listRenamed": "List renamed",
|
||||
"listRenameFailed": "Failed to rename list",
|
||||
"deleteListConfirmTitle": "Delete list?",
|
||||
"deleteListConfirmDescription": "This action cannot be undone. The list and all its items will be permanently deleted.",
|
||||
"listDeleted": "List deleted",
|
||||
"listDeleteFailed": "Failed to delete list",
|
||||
"changeCategory": "Change category",
|
||||
"deleteItem": "Delete item"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Collections",
|
||||
|
||||
@@ -642,6 +642,16 @@
|
||||
"aisleOther": "Autre",
|
||||
"listEmptyState": "Cette liste est vide.",
|
||||
"checkedCount": "{checked}/{total} cochés",
|
||||
"searchItemsPlaceholder": "Rechercher des articles…",
|
||||
"sortByCategory": "Par catégorie",
|
||||
"sortByAlpha": "Alphabétique",
|
||||
"sortByUnchecked": "Non cochés d'abord",
|
||||
"noSearchResults": "Aucun article ne correspond à votre recherche.",
|
||||
"autoCategorize": "Catégoriser automatiquement",
|
||||
"autoCategorizeSuccess": "{count, plural, one {1 article catégorisé} other {{count} articles catégorisés}}",
|
||||
"autoCategorizeNoneFound": "Impossible de deviner une catégorie pour les articles restants",
|
||||
"removeItemConfirmTitle": "Supprimer l'article ?",
|
||||
"removeItemConfirmDescription": "Supprimer « {name} » de cette liste ?",
|
||||
"moveToPantry": "Déplacer {count} vers le garde-manger",
|
||||
"listCreated": "Liste créée",
|
||||
"days": {
|
||||
@@ -791,7 +801,20 @@
|
||||
"ingredientsWillBeAdded": "{count, plural, one {1 ingrédient sera ajouté.} other {{count} ingrédients seront ajoutés.}}",
|
||||
"adding": "Ajout…",
|
||||
"addIngredients": "Ajouter les ingrédients",
|
||||
"addedCount": "{count} ingrédients ajoutés à la liste"
|
||||
"addedCount": "{count} ingrédients ajoutés à la liste",
|
||||
"actionsMenuLabel": "Actions de la liste",
|
||||
"rename": "Renommer",
|
||||
"renameListTitle": "Renommer la liste",
|
||||
"renameListLabel": "Nom de la liste",
|
||||
"renaming": "Enregistrement…",
|
||||
"listRenamed": "Liste renommée",
|
||||
"listRenameFailed": "Échec du renommage de la liste",
|
||||
"deleteListConfirmTitle": "Supprimer la liste ?",
|
||||
"deleteListConfirmDescription": "Cette action est irréversible. La liste et tous ses articles seront définitivement supprimés.",
|
||||
"listDeleted": "Liste supprimée",
|
||||
"listDeleteFailed": "Échec de la suppression de la liste",
|
||||
"changeCategory": "Changer de catégorie",
|
||||
"deleteItem": "Supprimer l'article"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Collections",
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
"@aws-sdk/client-s3": "^3.1075.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1075.0",
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@epicure/db": "workspace:*",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@zxing/browser": "^0.2.1",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "shopping_list_items" ADD COLUMN "sort_order" integer DEFAULT 0 NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -197,6 +197,13 @@
|
||||
"when": 1783667700330,
|
||||
"tag": "0027_special_siren",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "7",
|
||||
"when": 1783671061049,
|
||||
"tag": "0028_smiling_ezekiel_stane",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -73,6 +73,7 @@ export const shoppingListItems = pgTable("shopping_list_items", {
|
||||
aisle: text("aisle"),
|
||||
checked: boolean("checked").notNull().default(false),
|
||||
inPantry: boolean("in_pantry").notNull().default(false),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
});
|
||||
|
||||
export const shoppingListMembers = pgTable("shopping_list_members", {
|
||||
|
||||
Generated
+56
@@ -32,6 +32,15 @@ importers:
|
||||
'@base-ui/react':
|
||||
specifier: ^1.6.0
|
||||
version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@dnd-kit/sortable':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.2.4)
|
||||
'@epicure/db':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/db
|
||||
@@ -629,6 +638,28 @@ packages:
|
||||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/core@6.3.1':
|
||||
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/sortable@10.0.0':
|
||||
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||
peerDependencies:
|
||||
'@dnd-kit/core': ^6.3.0
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/utilities@3.2.2':
|
||||
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dotenvx/dotenvx@1.75.1':
|
||||
resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==}
|
||||
hasBin: true
|
||||
@@ -6010,6 +6041,31 @@ snapshots:
|
||||
|
||||
'@csstools/css-tokenizer@4.0.0': {}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@dnd-kit/accessibility': 3.1.1(react@19.2.4)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
|
||||
react: 19.2.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/utilities@3.2.2(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dotenvx/dotenvx@1.75.1':
|
||||
dependencies:
|
||||
'@dotenvx/primitives': 0.8.0
|
||||
|
||||
Reference in New Issue
Block a user