refactor(catalog): unify commands and embed guarded catalog workspace
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user