From 67430e7e9d4492a81206f36f5955fdbfc4a7baaf Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 14 Sep 2026 18:24:47 +0200 Subject: [PATCH] Polish catalog studio: undo delete, save status pill, faster totals --- src/app/admin/catalog/page.tsx | 9 ++- .../admin/catalog-manager/inline-editor.tsx | 41 ++++++++++-- .../admin/catalog-manager/root-tabs.tsx | 21 +++++- .../admin/catalog-manager/sortable-tree.tsx | 61 +++++++++++++++++- .../admin/catalog-manager/undo-page-delete.ts | 64 +++++++++++++++++++ 5 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 src/components/admin/catalog-manager/undo-page-delete.ts diff --git a/src/app/admin/catalog/page.tsx b/src/app/admin/catalog/page.tsx index c9fa8722..443f54a9 100644 --- a/src/app/admin/catalog/page.tsx +++ b/src/app/admin/catalog/page.tsx @@ -1,5 +1,6 @@ import { asc, count, eq, or } from "drizzle-orm"; import { Crown, Store, Wrench } from "lucide-react"; +import { unstable_cache } from "next/cache"; import Link from "next/link"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; @@ -42,6 +43,12 @@ async function loadTotals(catalogType: "normal" | "bc"): Promise { }; } +const getCachedTotals = unstable_cache( + async (catalogType: "normal" | "bc") => loadTotals(catalogType), + ["admin-catalog-totals"], + { revalidate: 30 }, +); + async function loadRootPages(catalogType: "normal" | "bc"): Promise { const pagesTable = catalogType === "bc" ? CatalogPagesBc : CatalogPages; const rootRaw = await db @@ -78,7 +85,7 @@ export default async function CatalogPagesPage({ const catalogType = sp.catalog === "bc" ? "bc" : "normal"; const [totals, rootPages] = await Promise.all([ - loadTotals(catalogType), + getCachedTotals(catalogType), loadRootPages(catalogType), ]); diff --git a/src/components/admin/catalog-manager/inline-editor.tsx b/src/components/admin/catalog-manager/inline-editor.tsx index 69136b4d..339f9a91 100644 --- a/src/components/admin/catalog-manager/inline-editor.tsx +++ b/src/components/admin/catalog-manager/inline-editor.tsx @@ -2,6 +2,7 @@ import { AlertCircle, + Check, ChevronDown, ChevronRight, Languages, @@ -12,7 +13,7 @@ import { Settings, } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { updateCatalogPage } from "@/actions/catalog"; import { updateBcPage } from "@/actions/catalog-bc"; @@ -135,6 +136,15 @@ function InlineEditorSession({ const [page, setPage] = useState(null); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); + const [justSaved, setJustSaved] = useState(false); + const savedTimer = useRef | null>(null); + + useEffect( + () => () => { + if (savedTimer.current) clearTimeout(savedTimer.current); + }, + [], + ); const [form, setForm] = useState({}); const [original, setOriginal] = useState({}); const [showTexts, setShowTexts] = useState(false); @@ -308,6 +318,9 @@ function InlineEditorSession({ if (!request.isCurrent()) return; if (!result.ok) throw new Error(result.error || "Failed to save"); toast.success(tActions("saved")); + setJustSaved(true); + if (savedTimer.current) clearTimeout(savedTimer.current); + savedTimer.current = setTimeout(() => setJustSaved(false), 2500); setOriginal({ ...form }); setPage((prev) => prev @@ -387,14 +400,32 @@ function InlineEditorSession({ > {form.pageLayout as string} - {(isDirty || offersDirty) && ( + {saving ? ( - - {t("unsaved")} + + {tActions("saving")} + ) : justSaved && !isDirty && !offersDirty ? ( + + + {tActions("saved")} + + ) : ( + (isDirty || offersDirty) && ( + + + {t("unsaved")} + + ) )}

diff --git a/src/components/admin/catalog-manager/root-tabs.tsx b/src/components/admin/catalog-manager/root-tabs.tsx index ade14412..fad84d29 100644 --- a/src/components/admin/catalog-manager/root-tabs.tsx +++ b/src/components/admin/catalog-manager/root-tabs.tsx @@ -55,6 +55,10 @@ import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog- import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog"; import type { DuplicateInput } from "@/features/catalog/domain/duplicate"; import { cn } from "@/lib/utils"; +import { + restoreDeletedPage, + showUndoableDeleteToast, +} from "./undo-page-delete"; import { useCatalogManager } from "./use-catalog-store"; export type { RootTab } from "@/types/catalog"; @@ -171,6 +175,13 @@ export function RootTabs({ const handleDelete = async (tab: RootTab) => { if (!canEdit) return; + const snapshot = { + caption: tab.caption, + parentId: -1, + iconImage: tab.iconImage, + enabled: tab.enabled, + orderNum: tab.orderNum, + }; const ok = await confirm({ title: "Delete root page", description: `Delete root page "${tab.caption}"? Child pages (if any) will be moved to root; items on this page will be deleted.`, @@ -183,8 +194,16 @@ export function RootTabs({ } else { await deleteTreePage({ pageId: tab.id, mode: "reparent" }); } - toast.success(`"${tab.caption}" deleted`); onMutation?.(); + showUndoableDeleteToast(`Page "${tab.caption}" deleted`, async () => { + const newId = await restoreDeletedPage(catalogType, snapshot); + if (!newId) { + toast.error("Could not restore page"); + return; + } + onMutation?.(); + void onTabClick(newId); + }); } catch { toast.error("Failed to delete"); } diff --git a/src/components/admin/catalog-manager/sortable-tree.tsx b/src/components/admin/catalog-manager/sortable-tree.tsx index 1676c004..09adfd4b 100644 --- a/src/components/admin/catalog-manager/sortable-tree.tsx +++ b/src/components/admin/catalog-manager/sortable-tree.tsx @@ -19,6 +19,7 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { ChevronsDownUp, ChevronsUpDown, + FolderOpen, GripVertical, Loader2, MousePointer, @@ -67,6 +68,10 @@ import { TreeItemContent, type TreeItemProps, } from "./tree-item"; +import { + restoreDeletedPage, + showUndoableDeleteToast, +} from "./undo-page-delete"; import { catalogTreeQs, useCatalogManager } from "./use-catalog-store"; /* ─── Types ──────────────────────────────────────────────── */ @@ -662,6 +667,18 @@ export function SortableTree({ if (!canEdit) return; const node = nodes.get(id); const parentId = node?.parentId ?? -1; + const snapshot = node + ? { + caption: node.caption, + parentId: node.parentId, + pageLayout: node.pageLayout, + iconImage: node.iconImage, + iconColor: node.iconColor, + enabled: node.enabled, + visible: node.visible, + orderNum: node.orderNum, + } + : null; const childInfo = node && node.childCount > 0 ? ` Its ${node.childCount} child page(s) will be moved to the parent.` @@ -687,6 +704,22 @@ export function SortableTree({ // Children were reparented up — refresh only the affected parent. await refreshParent(parentId); onTreeMutation(); + if (snapshot) { + showUndoableDeleteToast( + `Page "${node?.caption ?? ""}" deleted`, + async () => { + const newId = await restoreDeletedPage(catalogType, snapshot); + if (!newId) { + toast.error("Could not restore page"); + return; + } + dispatch({ type: "EXPAND_PATH", ids: [snapshot.parentId] }); + await refreshParent(snapshot.parentId); + onTreeMutation(); + onSelectPage(newId); + }, + ); + } } catch { toast.error("Failed to delete page"); } @@ -1053,8 +1086,21 @@ export function SortableTree({ {/* Tree body (virtualized) */}

{treeLoading ? ( -
- +