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 { asc, count, eq, or } from "drizzle-orm";
|
||||||
import { Crown, Store, Wrench } from "lucide-react";
|
import { Crown, Store, Wrench } from "lucide-react";
|
||||||
|
import { unstable_cache } from "next/cache";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
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[]> {
|
async function loadRootPages(catalogType: "normal" | "bc"): Promise<RootTab[]> {
|
||||||
const pagesTable = catalogType === "bc" ? CatalogPagesBc : CatalogPages;
|
const pagesTable = catalogType === "bc" ? CatalogPagesBc : CatalogPages;
|
||||||
const rootRaw = await db
|
const rootRaw = await db
|
||||||
@@ -78,7 +85,7 @@ export default async function CatalogPagesPage({
|
|||||||
const catalogType = sp.catalog === "bc" ? "bc" : "normal";
|
const catalogType = sp.catalog === "bc" ? "bc" : "normal";
|
||||||
|
|
||||||
const [totals, rootPages] = await Promise.all([
|
const [totals, rootPages] = await Promise.all([
|
||||||
loadTotals(catalogType),
|
getCachedTotals(catalogType),
|
||||||
loadRootPages(catalogType),
|
loadRootPages(catalogType),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Languages,
|
Languages,
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
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 { toast } from "sonner";
|
||||||
import { updateCatalogPage } from "@/actions/catalog";
|
import { updateCatalogPage } from "@/actions/catalog";
|
||||||
import { updateBcPage } from "@/actions/catalog-bc";
|
import { updateBcPage } from "@/actions/catalog-bc";
|
||||||
@@ -135,6 +136,15 @@ function InlineEditorSession({
|
|||||||
const [page, setPage] = useState<PageDetail | null>(null);
|
const [page, setPage] = useState<PageDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = 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 [form, setForm] = useState<FormState>({});
|
||||||
const [original, setOriginal] = useState<FormState>({});
|
const [original, setOriginal] = useState<FormState>({});
|
||||||
const [showTexts, setShowTexts] = useState(false);
|
const [showTexts, setShowTexts] = useState(false);
|
||||||
@@ -308,6 +318,9 @@ function InlineEditorSession({
|
|||||||
if (!request.isCurrent()) return;
|
if (!request.isCurrent()) return;
|
||||||
if (!result.ok) throw new Error(result.error || "Failed to save");
|
if (!result.ok) throw new Error(result.error || "Failed to save");
|
||||||
toast.success(tActions("saved"));
|
toast.success(tActions("saved"));
|
||||||
|
setJustSaved(true);
|
||||||
|
if (savedTimer.current) clearTimeout(savedTimer.current);
|
||||||
|
savedTimer.current = setTimeout(() => setJustSaved(false), 2500);
|
||||||
setOriginal({ ...form });
|
setOriginal({ ...form });
|
||||||
setPage((prev) =>
|
setPage((prev) =>
|
||||||
prev
|
prev
|
||||||
@@ -387,14 +400,32 @@ function InlineEditorSession({
|
|||||||
>
|
>
|
||||||
{form.pageLayout as string}
|
{form.pageLayout as string}
|
||||||
</Badge>
|
</Badge>
|
||||||
{(isDirty || offersDirty) && (
|
{saving ? (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
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" />
|
<Loader2 className="h-2.5 w-2.5 mr-0.5 animate-spin" />
|
||||||
{t("unsaved")}
|
{tActions("saving")}
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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 { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
|
||||||
import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
|
import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
restoreDeletedPage,
|
||||||
|
showUndoableDeleteToast,
|
||||||
|
} from "./undo-page-delete";
|
||||||
import { useCatalogManager } from "./use-catalog-store";
|
import { useCatalogManager } from "./use-catalog-store";
|
||||||
|
|
||||||
export type { RootTab } from "@/types/catalog";
|
export type { RootTab } from "@/types/catalog";
|
||||||
@@ -171,6 +175,13 @@ export function RootTabs({
|
|||||||
|
|
||||||
const handleDelete = async (tab: RootTab) => {
|
const handleDelete = async (tab: RootTab) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
|
const snapshot = {
|
||||||
|
caption: tab.caption,
|
||||||
|
parentId: -1,
|
||||||
|
iconImage: tab.iconImage,
|
||||||
|
enabled: tab.enabled,
|
||||||
|
orderNum: tab.orderNum,
|
||||||
|
};
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: "Delete root page",
|
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.`,
|
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 {
|
} else {
|
||||||
await deleteTreePage({ pageId: tab.id, mode: "reparent" });
|
await deleteTreePage({ pageId: tab.id, mode: "reparent" });
|
||||||
}
|
}
|
||||||
toast.success(`"${tab.caption}" deleted`);
|
|
||||||
onMutation?.();
|
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 {
|
} catch {
|
||||||
toast.error("Failed to delete");
|
toast.error("Failed to delete");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useVirtualizer } from "@tanstack/react-virtual";
|
|||||||
import {
|
import {
|
||||||
ChevronsDownUp,
|
ChevronsDownUp,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
|
FolderOpen,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Loader2,
|
Loader2,
|
||||||
MousePointer,
|
MousePointer,
|
||||||
@@ -67,6 +68,10 @@ import {
|
|||||||
TreeItemContent,
|
TreeItemContent,
|
||||||
type TreeItemProps,
|
type TreeItemProps,
|
||||||
} from "./tree-item";
|
} from "./tree-item";
|
||||||
|
import {
|
||||||
|
restoreDeletedPage,
|
||||||
|
showUndoableDeleteToast,
|
||||||
|
} from "./undo-page-delete";
|
||||||
import { catalogTreeQs, useCatalogManager } from "./use-catalog-store";
|
import { catalogTreeQs, useCatalogManager } from "./use-catalog-store";
|
||||||
|
|
||||||
/* ─── Types ──────────────────────────────────────────────── */
|
/* ─── Types ──────────────────────────────────────────────── */
|
||||||
@@ -662,6 +667,18 @@ export function SortableTree({
|
|||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
const node = nodes.get(id);
|
const node = nodes.get(id);
|
||||||
const parentId = node?.parentId ?? -1;
|
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 =
|
const childInfo =
|
||||||
node && node.childCount > 0
|
node && node.childCount > 0
|
||||||
? ` Its ${node.childCount} child page(s) will be moved to the parent.`
|
? ` 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.
|
// Children were reparented up — refresh only the affected parent.
|
||||||
await refreshParent(parentId);
|
await refreshParent(parentId);
|
||||||
onTreeMutation();
|
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 {
|
} catch {
|
||||||
toast.error("Failed to delete page");
|
toast.error("Failed to delete page");
|
||||||
}
|
}
|
||||||
@@ -1053,8 +1086,21 @@ export function SortableTree({
|
|||||||
{/* Tree body (virtualized) */}
|
{/* Tree body (virtualized) */}
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||||
{treeLoading ? (
|
{treeLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="space-y-1 p-2" aria-hidden="true">
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
{[...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>
|
</div>
|
||||||
) : searchResults !== null ? (
|
) : searchResults !== null ? (
|
||||||
<div className="p-1">
|
<div className="p-1">
|
||||||
@@ -1068,6 +1114,17 @@ export function SortableTree({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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 ? (
|
) : sortMode ? (
|
||||||
/* Sort mode: dnd-kit enabled, but only for visible virtual items */
|
/* Sort mode: dnd-kit enabled, but only for visible virtual items */
|
||||||
<DndContext
|
<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