refactor(catalog): unify commands and embed guarded catalog workspace
CI / check (push) Successful in 1m16s
CI / deploy (push) Successful in 28s

This commit is contained in:
Simo committed 2026-09-06 19:40:52 +02:00
1 parent 16f35568f5
commit 193b6686a9
57 files changed
+3268 -728

No files matched your search

@@ -4,7 +4,11 @@ import { ChevronRight, Home } from "lucide-react";
import { Fragment, useEffect } from "react";
import { useCatalogManager } from "./use-catalog-store";
export function BreadcrumbBar() {
export function BreadcrumbBar({
onSelectPage,
}: {
onSelectPage: (id: number) => void;
}) {
const { state, dispatch, catalogType } = useCatalogManager();
const { selectedPageId, ancestors } = state;
const catQs = catalogType === "bc" ? "&catalog=bc" : "";
@@ -40,7 +44,7 @@ export function BreadcrumbBar() {
<ChevronRight className="h-3 w-3 shrink-0" />
<button
type="button"
onClick={() => dispatch({ type: "SELECT_PAGE", id: a.id })}
onClick={() => onSelectPage(a.id)}
className="shrink-0 hover:text-foreground transition-colors truncate max-w-40"
>
{a.caption}
@@ -1,6 +1,7 @@
"use client";
import { LayoutGrid, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
@@ -8,6 +9,9 @@ import { createCatalogPage } from "@/actions/catalog";
import { createBcPage } from "@/actions/catalog-bc";
import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { CatalogSelection } from "@/features/catalog/client/catalog-selection-url";
import { useCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
import type { RootTab } from "@/types/catalog";
import { BreadcrumbBar } from "./breadcrumb-bar";
import { InlineEditor, type PageUpdateData } from "./inline-editor";
@@ -21,12 +25,49 @@ import {
useCatalogManager,
} from "./use-catalog-store";
const EMPTY_ROOTS: RootTab[] = [];
interface CatalogManagerProps {
initialRoots?: RootTab[];
catalogType?: CatalogManagerMode;
canEdit?: boolean;
totals?: CatalogStatsTotals | null;
}
export function CatalogManagerWorkspace({
initialRoots = EMPTY_ROOTS,
catalogType = "normal",
canEdit = false,
totals = null,
}: CatalogManagerProps) {
const t = useTranslations("pages.admin.catalog.workspace");
return (
<section
aria-label={t("manager")}
className="flex h-[calc(100dvh-12rem)] min-h-[32rem] min-w-0 flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]"
>
<CatalogManagerProvider
key={catalogType}
catalogType={catalogType}
canEdit={canEdit}
>
<CatalogManagerInner
open
embedded
initialRoots={initialRoots}
totals={totals}
/>
</CatalogManagerProvider>
</section>
);
}
/**
* Visual Catalog Manager — matches habbo-next UX (full-viewport editor).
* Portal overlay (not Base UI Dialog) so the tree + editor mount reliably.
*/
export function CatalogManagerDialog({
initialRoots = [],
initialRoots = EMPTY_ROOTS,
catalogType = "normal",
canEdit = false,
totals = null,
@@ -44,17 +85,19 @@ export function CatalogManagerDialog({
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
const previousFocus = document.activeElement as HTMLElement | null;
const focusFrame = requestAnimationFrame(() =>
document
.querySelector<HTMLElement>(
'[aria-labelledby="visual-catalog-manager-title"] button[aria-label="Close"]',
)
?.focus(),
);
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => {
cancelAnimationFrame(focusFrame);
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
previousFocus?.focus();
};
}, [open]);
@@ -103,24 +146,81 @@ export function CatalogManagerDialog({
}
function CatalogManagerInner({
embedded = false,
open,
onOpenChange,
initialRoots,
totals,
}: {
embedded?: boolean;
open: boolean;
onOpenChange: (v: boolean) => void;
onOpenChange?: (v: boolean) => void;
initialRoots: RootTab[];
totals?: CatalogStatsTotals | null;
}) {
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
const catQs = catalogTreeQs(catalogType);
const rootRequests = useLatestRequest();
const tActions = useTranslations("pages.admin.actions");
const t = useTranslations("pages.admin.catalog.workspace");
const mayLeave = useCallback(
() => !state.editorDirty || window.confirm(tActions("leaveUnsaved")),
[state.editorDirty, tActions],
);
const restoreSelection = useCallback(
(selection: CatalogSelection) => {
dispatch({ type: "RESTORE_SELECTION", ...selection });
},
[dispatch],
);
const saveSelectionUrl = useCatalogNavigation({
enabled: embedded,
restore: restoreSelection,
mayLeave,
});
const close = useCallback(() => {
if (mayLeave()) onOpenChange?.(false);
}, [mayLeave, onOpenChange]);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (embedded || event.key !== "Escape" || event.defaultPrevented) return;
const target = event.target as HTMLElement;
const focusedDialog = target.closest?.("[role=dialog]");
if (
focusedDialog &&
focusedDialog.getAttribute("aria-labelledby") !==
"visual-catalog-manager-title"
)
return;
if (target.closest?.("[role=listbox], [role=menu], [role=combobox]"))
return;
event.preventDefault();
close();
};
const onBeforeUnload = (event: BeforeUnloadEvent) => {
if (state.editorDirty) {
event.preventDefault();
event.returnValue = "";
}
};
window.addEventListener("keydown", onKey);
window.addEventListener("beforeunload", onBeforeUnload);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("beforeunload", onBeforeUnload);
};
}, [close, state.editorDirty, embedded]);
const loadRootPages = useCallback(async () => {
const request = rootRequests.start();
dispatch({ type: "SET_LOADING_ROOT", loading: true });
try {
const res = await fetch(`/api/admin/catalog/tree?parentId=-1${catQs}`);
const res = await fetch(`/api/admin/catalog/tree?parentId=-1${catQs}`, {
signal: request.signal,
});
const data = await res.json().catch(() => ({}));
if (!request.isCurrent()) return;
if (!res.ok) throw new Error(data.error || "Failed to load roots");
const pages: RootTab[] = (data.pages || []).map(
(p: Record<string, unknown>) => ({
@@ -132,51 +232,32 @@ function CatalogManagerInner({
}),
);
dispatch({ type: "SET_ROOT_PAGES", pages });
const activeStillValid =
state.activeTabId !== null &&
pages.some((p) => p.id === state.activeTabId);
if (pages.length > 0 && !activeStillValid) {
dispatch({ type: "SET_ACTIVE_TAB", id: pages[0].id });
}
} catch {
if (!request.isCurrent()) return;
toast.error("Failed to load catalog root pages");
dispatch({ type: "SET_LOADING_ROOT", loading: false });
}
}, [dispatch, state.activeTabId, catQs]);
}, [dispatch, catQs, rootRequests]);
// Seed from SSR immediately, then refresh from API.
// Seed once on opening, then refresh independently of category selection.
useEffect(() => {
if (!open) return;
if (initialRoots.length > 0 && state.rootPages.length === 0) {
const pages = initialRoots.map((p) => ({
...p,
id: Number(p.id),
enabled: String(p.enabled),
}));
dispatch({ type: "SET_ROOT_PAGES", pages });
if (state.activeTabId === null && pages[0]) {
dispatch({ type: "SET_ACTIVE_TAB", id: pages[0].id });
}
}
if (initialRoots.length)
dispatch({ type: "SET_ROOT_PAGES", pages: initialRoots });
void loadRootPages();
}, [
open,
initialRoots,
state.rootPages.length,
state.activeTabId,
dispatch,
loadRootPages,
]);
return () => rootRequests.cancel();
}, [open, initialRoots, dispatch, loadRootPages, rootRequests]);
useEffect(() => {
if (!open) dispatch({ type: "ON_DIALOG_CLOSE" });
}, [open, dispatch]);
const handleTabClick = (id: number) => {
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
if (id === state.activeTabId) return;
if (!mayLeave()) {
return;
}
saveSelectionUrl({ rootId: id, pageId: null });
dispatch({ type: "SET_ACTIVE_TAB", id });
};
@@ -204,25 +285,24 @@ function CatalogManagerInner({
};
const handleSelectPage = (id: number) => {
if (id === state.selectedPageId) return;
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
return;
}
saveSelectionUrl({ rootId: state.activeTabId, pageId: id > 0 ? id : null });
dispatch({ type: "SELECT_PAGE", id: id > 0 ? id : null });
};
const handleEditTab = (id: number) => {
if (state.activeTabId !== id) {
dispatch({ type: "SET_ACTIVE_TAB", id });
}
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
return;
}
if (id === state.selectedPageId && id === state.activeTabId) return;
if (!mayLeave()) return;
saveSelectionUrl({ rootId: id, pageId: id });
if (state.activeTabId !== id) dispatch({ type: "SET_ACTIVE_TAB", id });
dispatch({ type: "SELECT_PAGE", id });
};
const handleEditorSaved = (updated: PageUpdateData) => {
dispatch({ type: "UPDATE_NODE", update: { ...updated } });
dispatch({ type: "SET_DIRTY", dirty: false });
const rootIdx = state.rootPages.findIndex((p) => p.id === updated.id);
if (rootIdx !== -1) {
@@ -254,19 +334,21 @@ function CatalogManagerInner({
<LayoutGrid className="h-4 w-4" />
{heading}
</h2>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => onOpenChange(false)}
aria-label="Close"
>
<X className="h-4 w-4" />
</Button>
{!embedded && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={close}
aria-label="Close"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<BreadcrumbBar />
<BreadcrumbBar onSelectPage={handleSelectPage} />
<RootTabs
tabs={state.rootPages}
@@ -274,6 +356,7 @@ function CatalogManagerInner({
onTabClick={handleTabClick}
onEditTab={handleEditTab}
onCreateRoot={handleCreateRoot}
onMutation={loadRootPages}
loading={state.loadingRoot}
/>
@@ -281,17 +364,28 @@ function CatalogManagerInner({
className="flex min-h-0 flex-1 overflow-hidden"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<SortableTree
activeTabId={state.activeTabId}
selectedPageId={state.selectedPageId}
onSelectPage={handleSelectPage}
onTreeMutation={loadRootPages}
nodeUpdate={null}
/>
<div
className="flex min-h-0 min-w-0 flex-1 flex-col"
className={`${state.selectedPageId ? "hidden md:flex" : "flex"} min-h-0 min-w-0 flex-1 md:flex-none [&>div]:w-full md:[&>div]:w-80`}
>
<SortableTree
activeTabId={state.activeTabId}
selectedPageId={state.selectedPageId}
onSelectPage={handleSelectPage}
onTreeMutation={loadRootPages}
nodeUpdate={null}
/>
</div>
<div
className={`${state.selectedPageId ? "flex" : "hidden md:flex"} min-h-0 min-w-0 flex-1 flex-col`}
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<Button
variant="ghost"
className="shrink-0 self-start md:hidden"
onClick={() => handleSelectPage(0)}
>
{t("backToCategories")}
</Button>
<InlineEditor
pageId={state.selectedPageId}
onSaved={handleEditorSaved}
@@ -11,6 +11,7 @@ import {
Save,
Settings,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { updateCatalogPage } from "@/actions/catalog";
@@ -41,6 +42,7 @@ import {
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
import {
CATALOG_LAYOUTS,
type CatalogLayout,
@@ -106,7 +108,21 @@ interface InlineEditorProps {
type FormState = Record<string, string | number | null>;
export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
export function InlineEditor(props: InlineEditorProps) {
const { catalogType } = useCatalogManager();
return (
<InlineEditorSession key={`${catalogType}:${props.pageId}`} {...props} />
);
}
function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
const pageRequests = useLatestRequest();
const itemRequests = useLatestRequest();
const saveRequests = useLatestRequest();
const tActions = useTranslations("pages.admin.actions");
const tNavigation = useTranslations("pages.admin.navigation");
const t = useTranslations("pages.admin.catalog.workspace");
const [offersDirty, setOffersDirty] = useState(false);
const { dispatch, catalogType, canEdit } = useCatalogManager();
const catQs = catalogTreeQs(catalogType);
const pageHref =
@@ -126,6 +142,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
const [itemsData, setItemsData] = useState<ItemsData | null>(null);
const [itemsLoading, setItemsLoading] = useState(false);
const [itemsLoaded, setItemsLoaded] = useState(false);
const [itemsError, setItemsError] = useState(false);
// ── Dirty tracking ──────────────────────────────────────────
const isDirty = useMemo(() => {
@@ -133,8 +150,8 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
}, [form, original]);
useEffect(() => {
dispatch({ type: "SET_DIRTY", dirty: isDirty });
}, [isDirty, dispatch]);
dispatch({ type: "SET_DIRTY", dirty: isDirty || offersDirty });
}, [isDirty, offersDirty, dispatch]);
// ── Ctrl+S handler ──────────────────────────────────────────
useEffect(() => {
@@ -151,11 +168,16 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
const loadPage = useCallback(
async (id: number) => {
const request = pageRequests.start();
setLoading(true);
try {
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}${catQs}`);
const res = await fetch(
`/api/admin/catalog/tree?pageId=${id}${catQs}`,
{ signal: request.signal },
);
if (!res.ok) throw new Error();
const data = await res.json();
if (!request.isCurrent()) return;
const p = data.page as PageDetail;
setPage(p);
const formData: FormState = {
@@ -163,8 +185,9 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
pageLayout: p.pageLayout,
enabled: String(p.enabled),
visible: String(p.visible),
minRank: p.minRank ?? 0,
clubOnly: String(p.clubOnly ?? "0"),
...(catalogType === "normal"
? { minRank: p.minRank ?? 0, clubOnly: String(p.clubOnly ?? "0") }
: {}),
orderNum: p.orderNum,
iconImage: p.iconImage,
iconColor: p.iconColor,
@@ -179,29 +202,41 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
setForm(formData);
setOriginal(formData);
} catch {
if (!request.isCurrent()) return;
toast.error("Failed to load page");
setPage(null);
} finally {
setLoading(false);
if (request.isCurrent()) setLoading(false);
}
},
[catQs],
[catQs, catalogType, pageRequests],
);
const loadItemsData = useCallback(async (id: number) => {
setItemsLoading(true);
try {
const res = await fetch(`/api/admin/catalog/items?pageId=${id}`);
if (!res.ok) throw new Error();
const data = await res.json();
setItemsData(data as ItemsData);
setItemsLoaded(true);
} catch {
toast.error("Failed to load items");
} finally {
setItemsLoading(false);
}
}, []);
const loadItemsData = useCallback(
async (id: number) => {
const request = itemRequests.start();
setItemsError(false);
setItemsLoading(true);
try {
const res = await fetch(`/api/admin/catalog/items?pageId=${id}`, {
signal: request.signal,
});
if (!res.ok) throw new Error();
const data = await res.json();
if (!request.isCurrent()) return;
setItemsData(data as ItemsData);
setItemsLoaded(true);
} catch {
if (!request.isCurrent()) return;
setItemsLoaded(true);
setItemsError(true);
toast.error("Failed to load items");
} finally {
if (request.isCurrent()) setItemsLoading(false);
}
},
[itemRequests],
);
const reloadItems = useCallback(() => {
if (pageId && pageId > 0) loadItemsData(pageId);
@@ -250,15 +285,21 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
}
async function handleSave() {
if (!page || !canEdit) return;
if (!page || !canEdit || saving) return;
const request = saveRequests.start();
setSaving(true);
try {
const result =
catalogType === "bc"
? await updateBcPage({ id: page.id, ...form })
: await updateCatalogPage({ id: page.id, ...form });
? await updateBcPage({ id: page.id, ...form, expected: original })
: await updateCatalogPage({
id: page.id,
...form,
expected: original,
});
if (!request.isCurrent()) return;
if (!result.ok) throw new Error(result.error || "Failed to save");
toast.success("Page updated & catalog synced");
toast.success(tActions("saved"));
setOriginal({ ...form });
setPage((prev) =>
prev
@@ -281,9 +322,10 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
iconImage: (form.iconImage as number) ?? page.iconImage,
});
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save");
if (request.isCurrent())
toast.error(e instanceof Error ? e.message : "Failed to save");
} finally {
setSaving(false);
if (request.isCurrent()) setSaving(false);
}
}
@@ -303,7 +345,14 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
);
}
if (!page) return null;
if (!page)
return (
<div className="flex flex-1 items-center justify-center">
<Button variant="outline" onClick={() => loadPage(pageId)}>
{t("retryPage")}
</Button>
</div>
);
const layoutColor =
LAYOUT_COLORS[String(form.pageLayout)] || "bg-muted text-muted-foreground";
@@ -386,7 +435,12 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
onValueChange={(tab) => {
if (tab === activeTab) return;
if (offersDirty && !window.confirm(tActions("leaveUnsaved"))) return;
setOffersDirty(false);
setActiveTab(tab);
}}
className="flex-1 flex flex-col min-h-0"
>
<TabsList className="mx-4 mt-2 shrink-0">
@@ -490,17 +544,19 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
className="h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Min Rank</Label>
<Input
type="number"
value={form.minRank as number}
onChange={(e) =>
update("minRank", parseInt(e.target.value, 10) || 1)
}
className="h-8 text-sm"
/>
</div>
{catalogType === "normal" && (
<div className="space-y-1.5">
<Label className="text-xs">Min Rank</Label>
<Input
type="number"
value={form.minRank as number}
onChange={(e) =>
update("minRank", parseInt(e.target.value, 10) || 1)
}
className="h-8 text-sm"
/>
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs">Icon</Label>
<IconPicker
@@ -526,13 +582,15 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
checked={form.visible === "1"}
onChange={(v) => update("visible", v ? "1" : "0")}
/>
<ToggleRow
id="ie-club"
label="Club Only"
description="Requires HC subscription"
checked={form.clubOnly === "1"}
onChange={(v) => update("clubOnly", v ? "1" : "0")}
/>
{catalogType === "normal" && (
<ToggleRow
id="ie-club"
label="Club Only"
description="Requires HC subscription"
checked={form.clubOnly === "1"}
onChange={(v) => update("clubOnly", v ? "1" : "0")}
/>
)}
</div>
{/* Headline image picker */}
@@ -641,7 +699,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save & Sync
{tNavigation("save")}
</Button>
</div>
</div>
@@ -662,7 +720,13 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
</Button>
</Link>
</div>
) : itemsLoading ? (
) : itemsError && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Button variant="outline" onClick={reloadItems}>
{t("retryItems")}
</Button>
</div>
) : itemsLoading && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
@@ -704,6 +768,8 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
{/* Items table */}
<CatalogItemsTable
onDirtyChange={setOffersDirty}
onRefresh={reloadItems}
items={itemsData.items}
pageId={page.id}
pageLayout={page.pageLayout}
@@ -725,7 +791,13 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
Translate tools are available for the shop catalog only.
</div>
) : itemsLoading ? (
) : itemsError && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Button variant="outline" onClick={reloadItems}>
{t("retryItems")}
</Button>
</div>
) : itemsLoading && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
@@ -22,12 +22,12 @@ import { useCallback, useState } from "react";
import { toast } from "sonner";
import {
deleteTreePage,
reorderTreePage,
reorderCatalogPages,
toggleCatalogPage,
} from "@/actions/catalog";
import {
deleteBcTreePage,
reorderBcTreePage,
reorderBcCatalogPages,
toggleBcPage,
} from "@/actions/catalog-bc";
import { CatalogIcon } from "@/components/admin/catalog-tree";
@@ -40,6 +40,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { cn } from "@/lib/utils";
import { useCatalogManager } from "./use-catalog-store";
@@ -54,6 +55,7 @@ interface RootTabsProps {
onEditTab: (id: number) => void | Promise<void>;
onCreateRoot: () => void;
loading?: boolean;
onMutation?: () => void;
}
export function RootTabs({
@@ -63,6 +65,7 @@ export function RootTabs({
onEditTab,
onCreateRoot,
loading,
onMutation,
}: RootTabsProps) {
const { catalogType, canEdit } = useCatalogManager();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
@@ -85,26 +88,26 @@ export function RootTabs({
const newIndex = tabs.findIndex((t) => t.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
if (!canEdit) return;
if (!canEdit || !requestCatalogNavigation()) return;
const newOrder = arrayMove(tabs, oldIndex, newIndex);
// Batch reorder
try {
const reorder =
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
const results = await Promise.allSettled(
newOrder.map((tab, idx) =>
reorder({ pageId: tab.id, newOrderNum: idx + 1 }),
),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed) toast.error(`${failed} reorder(s) failed`);
else toast.success("Tabs reordered");
catalogType === "bc" ? reorderBcCatalogPages : reorderCatalogPages;
const result = await reorder({
parentId: -1,
ids: newOrder.map((tab) => tab.id),
expectedIds: tabs.map((tab) => tab.id),
});
if (!result.ok) throw Error("Failed to reorder tabs");
toast.success("Tabs reordered");
onMutation?.();
} catch {
toast.error("Failed to reorder tabs");
}
},
[tabs, canEdit, catalogType],
[tabs, canEdit, catalogType, onMutation],
);
const handleToggle = async (tab: RootTab) => {
@@ -118,6 +121,7 @@ export function RootTabs({
toast.success(
`${tab.caption} ${tab.enabled === "1" ? "disabled" : "enabled"}`,
);
onMutation?.();
} catch {
toast.error("Failed to toggle");
}
@@ -138,6 +142,7 @@ export function RootTabs({
await deleteTreePage({ pageId: tab.id, mode: "reparent" });
}
toast.success(`"${tab.caption}" deleted`);
onMutation?.();
} catch {
toast.error("Failed to delete");
}
@@ -36,6 +36,7 @@ import { toast } from "sonner";
import {
createCatalogPage,
deleteTreePage,
reorderCatalogPages,
reorderTreePage,
toggleCatalogPage,
updateCatalogPage,
@@ -43,6 +44,7 @@ import {
import {
createBcPage,
deleteBcTreePage,
reorderBcCatalogPages,
reorderBcTreePage,
toggleBcPage,
updateBcPage,
@@ -113,7 +115,6 @@ export function SortableTree({
const [subpageName, setSubpageName] = useState("");
const [creatingSubpage, setCreatingSubpage] = useState(false);
const [searching, setSearching] = useState(false);
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const [activeId, setActiveId] = useState<number | null>(null);
const [treeLoading, setTreeLoading] = useState(false);
const [filter, setFilter] = useState<FilterMode>("all");
@@ -189,29 +190,32 @@ export function SortableTree({
void loadTabTree();
}, [activeTabId, loadTabTree, dispatch]);
// Debounced search
// Ignore stale search completions when the query or catalog changes.
useEffect(() => {
if (!searchQuery.trim()) {
setSearchResults(null);
return;
}
if (searchTimeout.current) clearTimeout(searchTimeout.current);
searchTimeout.current = setTimeout(async () => {
const controller = new AbortController();
setSearchResults(null);
setSearching(false);
if (!searchQuery.trim()) return;
const timeout = setTimeout(async () => {
setSearching(true);
try {
const res = await fetch(
`/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}${catQs}`,
{ signal: controller.signal },
);
if (res.ok) {
const data = await res.json();
setSearchResults(data.pages || []);
}
if (!res.ok) throw new Error("Search failed");
const data = await res.json();
if (!controller.signal.aborted) setSearchResults(data.pages || []);
} catch {
if (!controller.signal.aborted)
toast.error("Failed to search catalog pages");
} finally {
setSearching(false);
if (!controller.signal.aborted) setSearching(false);
}
}, 300);
return () => {
if (searchTimeout.current) clearTimeout(searchTimeout.current);
controller.abort();
clearTimeout(timeout);
};
}, [searchQuery, catQs]);
@@ -423,7 +427,8 @@ export function SortableTree({
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over || active.id === over.id || activeTabId === null) return;
if (!canEdit || !over || active.id === over.id || activeTabId === null)
return;
const activeNode = nodes.get(active.id as number);
const overNode = nodes.get(over.id as number);
@@ -446,16 +451,15 @@ export function SortableTree({
if (!canEdit) return;
const reorder =
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
catalogType === "bc" ? reorderBcCatalogPages : reorderCatalogPages;
try {
const results = await Promise.allSettled(
newOrder.map((id, idx) =>
reorder({ pageId: id, newOrderNum: idx + 1 }),
),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) toast.error(`${failed} reorder(s) failed`);
else toast.success("Reordered");
const result = await reorder({
parentId,
ids: newOrder,
expectedIds: siblings,
});
if (!result.ok) throw Error("Failed to reorder");
toast.success("Reordered");
onTreeMutation();
} catch {
toast.error("Failed to reorder");
@@ -317,6 +317,7 @@ export const TreeItemContent = React.memo(
},
(prev, next) => {
return (
prev.onSelect === next.onSelect &&
prev.node.id === next.node.id &&
prev.node.caption === next.node.caption &&
prev.node.enabled === next.node.enabled &&
@@ -59,6 +59,7 @@ const initialState: CatalogManagerState = {
// ── Actions ───────────────────────────────────────────────────
export type CatalogAction =
| { type: "RESTORE_SELECTION"; rootId: number | null; pageId: number | null }
| { type: "SET_ROOT_PAGES"; pages: RootTab[] }
| { type: "SET_LOADING_ROOT"; loading: boolean }
| { type: "SET_ACTIVE_TAB"; id: number | null }
@@ -93,14 +94,33 @@ function reducer(
action: CatalogAction,
): CatalogManagerState {
switch (action.type) {
case "RESTORE_SELECTION":
return {
...state,
activeTabId:
action.rootId ?? state.rootPages[0]?.id ?? state.activeTabId,
selectedPageId: action.pageId,
focusedId: action.pageId,
editorDirty: false,
multiSelectedIds: new Set(),
ancestors: [],
};
case "SET_ROOT_PAGES":
return { ...state, rootPages: action.pages, loadingRoot: false };
return {
...state,
rootPages: action.pages,
loadingRoot: false,
activeTabId: action.pages.some((page) => page.id === state.activeTabId)
? state.activeTabId
: (action.pages[0]?.id ?? null),
};
case "SET_LOADING_ROOT":
return { ...state, loadingRoot: action.loading };
case "SET_ACTIVE_TAB":
return {
...state,
activeTabId: action.id,
editorDirty: false,
selectedPageId: null,
multiSelectedIds: new Set(),
focusedId: null,
@@ -110,6 +130,7 @@ function reducer(
return {
...state,
selectedPageId: action.id,
editorDirty: false,
multiSelectedIds: new Set(),
focusedId: action.id,
};
@@ -1,55 +0,0 @@
"use client";
import { AlertTriangle } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { fixEverythingAction } from "@/actions/furni-maintenance";
import { Button } from "@/components/ui/button";
/**
* One-click "fix everything" for the furniture tables. Runs sprite fix, catalog
* offer fix, FurnitureData reconciliation, dedup and ID alignment in order, all
* inside a single DB transaction on the server. Heavy — guarded by a confirm.
*/
export function CatalogFixAllButton() {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function run() {
if (
!window.confirm(
"Alle meubel-tabellen repareren (sprite-id, offer_id, dubbelen, IDs naar FurnitureData)?\nDit is een zware, niet-omkeerbare actie. Zorg dat er een backup is.",
)
) {
return;
}
setBusy(true);
try {
const res = await fixEverythingAction();
if (res.ok) {
toast.success("Alle reparaties uitgevoerd");
router.refresh();
} else {
toast.error(res.error ?? "Mislukt");
}
} catch (e) {
toast.error(String(e));
} finally {
setBusy(false);
}
}
return (
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={() => void run()}
title="Alle meubel-tabellen repareren"
>
<AlertTriangle className="mr-2 h-4 w-4" />
{busy ? "Bezig…" : "Fix alles"}
</Button>
);
}