Polish catalog studio: undo delete, save status pill, faster totals

This commit is contained in:
openhands committed 2026-09-14 18:24:47 +02:00
1 parent 6e0ffff94b
commit 67430e7e9d
5 files changed
+187 -9

No files matched your search

+8 -1
View File
@@ -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<Totals> {
};
}
const getCachedTotals = unstable_cache(
async (catalogType: "normal" | "bc") => loadTotals(catalogType),
["admin-catalog-totals"],
{ revalidate: 30 },
);
async function loadRootPages(catalogType: "normal" | "bc"): Promise<RootTab[]> {
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),
]);
@@ -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<PageDetail | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const savedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (savedTimer.current) clearTimeout(savedTimer.current);
},
[],
);
const [form, setForm] = useState<FormState>({});
const [original, setOriginal] = useState<FormState>({});
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}
</Badge>
{(isDirty || offersDirty) && (
{saving ? (
<Badge
variant="outline"
className="h-4 px-1.5 text-[9px] text-[var(--admin-warning)] border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
className="h-4 px-1.5 text-[9px] text-[var(--admin-info)] border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)]"
>
<AlertCircle className="h-2.5 w-2.5 mr-0.5" />
{t("unsaved")}
<Loader2 className="h-2.5 w-2.5 mr-0.5 animate-spin" />
{tActions("saving")}
</Badge>
) : justSaved && !isDirty && !offersDirty ? (
<Badge
variant="outline"
className="h-4 px-1.5 text-[9px] text-[var(--admin-success)] border-[var(--admin-success-border)] bg-[var(--admin-success-subtle)]"
>
<Check className="h-2.5 w-2.5 mr-0.5" />
{tActions("saved")}
</Badge>
) : (
(isDirty || offersDirty) && (
<Badge
variant="outline"
className="h-4 px-1.5 text-[9px] text-[var(--admin-warning)] border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
>
<AlertCircle className="h-2.5 w-2.5 mr-0.5" />
{t("unsaved")}
</Badge>
)
)}
</div>
<p className="text-xs text-muted-foreground">
@@ -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");
}
@@ -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) */}
<div ref={scrollRef} className="flex-1 overflow-y-auto">
{treeLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<div className="space-y-1 p-2" aria-hidden="true">
{[...Array(8)].map((_, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder rows
key={i}
className="flex items-center gap-2 rounded-md px-2 py-1.5"
style={{ marginLeft: `${(i % 3) * 14}px` }}
>
<div className="h-4 w-4 shrink-0 animate-pulse rounded bg-muted" />
<div
className="h-3 animate-pulse rounded bg-muted"
style={{ width: `${55 + ((i * 13) % 35)}%` }}
/>
</div>
))}
</div>
) : searchResults !== null ? (
<div className="p-1">
@@ -1068,6 +1114,17 @@ export function SortableTree({
</div>
))}
</div>
) : flatItems.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center">
<FolderOpen className="h-8 w-8 text-muted-foreground/40" />
<p className="text-xs font-medium text-muted-foreground">
No pages in this category
</p>
<p className="max-w-[200px] text-[10px] text-muted-foreground/70">
Create a page with the + button, or switch to sort mode to reorder
existing ones.
</p>
</div>
) : sortMode ? (
/* Sort mode: dnd-kit enabled, but only for visible virtual items */
<DndContext
@@ -0,0 +1,64 @@
import { toast } from "sonner";
import { createCatalogPage } from "@/actions/catalog";
import { createBcPage } from "@/actions/catalog-bc";
import type { CatalogManagerMode } from "./use-catalog-store";
export interface DeletedPageSnapshot {
caption: string;
parentId: number;
pageLayout?: string;
iconImage?: number;
iconColor?: number;
enabled?: string;
visible?: string;
minRank?: number;
orderNum?: number;
}
/**
* Re-creates a page that was just deleted, preserving its placement and
* presentation fields. Item rows and text content cannot be restored (they
* were already removed server-side), but editing intent survives.
*/
export async function restoreDeletedPage(
catalogType: CatalogManagerMode,
snapshot: DeletedPageSnapshot,
): Promise<number | null> {
try {
const input = {
parentId: snapshot.parentId ?? -1,
caption: snapshot.caption,
pageLayout: snapshot.pageLayout ?? "default_3x3",
iconImage: snapshot.iconImage ?? 0,
iconColor: snapshot.iconColor ?? 0,
enabled: (snapshot.enabled ?? "1") as "0" | "1",
visible: (snapshot.visible ?? "1") as "0" | "1",
orderNum: snapshot.orderNum ?? 0,
};
const result =
catalogType === "bc"
? await createBcPage(input)
: await createCatalogPage(
catalogType === "normal"
? { ...input, minRank: snapshot.minRank ?? 1 }
: input,
);
if (!result.ok) throw new Error(result.error);
return Number(result.data?.id) || null;
} catch {
return null;
}
}
/** Success toast that doubles as the undo trigger for a page deletion. */
export function showUndoableDeleteToast(
message: string,
onUndo: () => void | Promise<void>,
) {
toast.success(message, {
action: {
label: "Undo",
onClick: () => void onUndo(),
},
});
}