Polish catalog studio: undo delete, save status pill, faster totals
This commit is contained in:
1 parent
6e0ffff94b
commit
67430e7e9d
5 files changed
+187
-9
No files matched your search
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user