feat(housekeeping): cut over administration to ase

This commit is contained in:
Simo committed 2026-08-30 20:35:22 +02:00
1 parent c9e35cf602
commit 2b8f73a91d
457 files changed
+589 -55936

No files matched your search

@@ -1,63 +0,0 @@
"use client";
import { ChevronRight, Home } from "lucide-react";
import { Fragment, useEffect } from "react";
import { useCatalogManager } from "./use-catalog-store";
export function BreadcrumbBar() {
const { state, dispatch, catalogType } = useCatalogManager();
const { selectedPageId, ancestors } = state;
const catQs = catalogType === "bc" ? "&catalog=bc" : "";
useEffect(() => {
if (!selectedPageId || selectedPageId <= 0) {
dispatch({ type: "SET_ANCESTORS", ancestors: [] });
return;
}
const ac = new AbortController();
fetch(`/api/admin/catalog/tree?ancestors=${selectedPageId}${catQs}`, {
signal: ac.signal,
})
.then((r) => r.json())
.then((d) => {
if (!ac.signal.aborted) {
dispatch({ type: "SET_ANCESTORS", ancestors: d.ancestors ?? [] });
}
})
.catch(() => {});
return () => ac.abort();
}, [selectedPageId, dispatch, catQs]);
if (!selectedPageId || ancestors.length === 0) return null;
return (
<div className="flex items-center gap-1 px-4 py-1 border-b bg-muted/20 text-xs text-muted-foreground overflow-x-auto shrink-0">
<Home className="h-3 w-3 shrink-0" />
{ancestors.map((a) => (
<Fragment key={a.id}>
<ChevronRight className="h-3 w-3 shrink-0" />
<button
type="button"
onClick={() => dispatch({ type: "SELECT_PAGE", id: a.id })}
className="shrink-0 hover:text-foreground transition-colors truncate max-w-40"
>
{a.caption}
</button>
</Fragment>
))}
{/* Current page */}
{(() => {
const node = state.nodes.get(selectedPageId);
if (!node) return null;
return (
<>
<ChevronRight className="h-3 w-3 shrink-0" />
<span className="shrink-0 text-foreground font-medium truncate max-w-40">
{node.caption}
</span>
</>
);
})()}
</div>
);
}
@@ -1,305 +0,0 @@
"use client";
import { LayoutGrid, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
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 { RootTab } from "@/types/catalog";
import { BreadcrumbBar } from "./breadcrumb-bar";
import { InlineEditor, type PageUpdateData } from "./inline-editor";
import { RootTabs } from "./root-tabs";
import { SortableTree } from "./sortable-tree";
import { type CatalogStatsTotals, StatsBar } from "./stats-bar";
import {
type CatalogManagerMode,
CatalogManagerProvider,
catalogTreeQs,
useCatalogManager,
} from "./use-catalog-store";
/**
* 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 = [],
catalogType = "normal",
canEdit = false,
totals = null,
}: {
initialRoots?: RootTab[];
catalogType?: CatalogManagerMode;
canEdit?: boolean;
totals?: CatalogStatsTotals | null;
}) {
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
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 () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
};
}, [open]);
const title = catalogType === "bc" ? "BC Visual Manager" : "Visual Manager";
return (
<>
<Button
type="button"
variant="outline"
className="gap-2"
onClick={() => setOpen(true)}
>
<LayoutGrid className="h-4 w-4" />
{title}
</Button>
{mounted && open
? createPortal(
<div className="fixed inset-0 z-[200]">
<div className="absolute inset-0 bg-black/70" />
<div
role="dialog"
aria-modal="true"
aria-labelledby="visual-catalog-manager-title"
className="fixed top-[2.5vh] left-[1vw] z-[201] flex h-[95vh] max-h-[95vh] w-[98vw] max-w-[98vw] flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)] shadow-2xl"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<CatalogManagerProvider
catalogType={catalogType}
canEdit={canEdit}
>
<CatalogManagerInner
open={open}
onOpenChange={setOpen}
initialRoots={initialRoots}
totals={totals}
/>
</CatalogManagerProvider>
</div>
</div>,
document.body,
)
: null}
</>
);
}
function CatalogManagerInner({
open,
onOpenChange,
initialRoots,
totals,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
initialRoots: RootTab[];
totals?: CatalogStatsTotals | null;
}) {
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
const catQs = catalogTreeQs(catalogType);
const loadRootPages = useCallback(async () => {
dispatch({ type: "SET_LOADING_ROOT", loading: true });
try {
const res = await fetch(`/api/admin/catalog/tree?parentId=-1${catQs}`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Failed to load roots");
const pages: RootTab[] = (data.pages || []).map(
(p: Record<string, unknown>) => ({
id: Number(p.id),
caption: String(p.caption ?? ""),
iconImage: Number(p.iconImage) || 0,
enabled: String(p.enabled ?? "0"),
orderNum: Number(p.orderNum) || 0,
}),
);
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 {
toast.error("Failed to load catalog root pages");
dispatch({ type: "SET_LOADING_ROOT", loading: false });
}
}, [dispatch, state.activeTabId, catQs]);
// Seed from SSR immediately, then refresh from API.
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 });
}
}
void loadRootPages();
}, [
open,
initialRoots,
state.rootPages.length,
state.activeTabId,
dispatch,
loadRootPages,
]);
useEffect(() => {
if (!open) dispatch({ type: "ON_DIALOG_CLOSE" });
}, [open, dispatch]);
const handleTabClick = (id: number) => {
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
return;
}
dispatch({ type: "SET_ACTIVE_TAB", id });
};
const handleCreateRoot = async () => {
if (!canEdit) return;
const caption = prompt("Root page name:");
if (!caption?.trim()) return;
try {
const result =
catalogType === "bc"
? await createBcPage({
parentId: -1,
caption: caption.trim(),
})
: await createCatalogPage({
parentId: -1,
caption: caption.trim(),
});
if (!result.ok) throw new Error(result.error);
await loadRootPages();
toast.success("Root page created");
} catch {
toast.error("Failed to create root page");
}
};
const handleSelectPage = (id: number) => {
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
return;
}
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;
}
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) {
const pages = [...state.rootPages];
pages[rootIdx] = {
...pages[rootIdx],
caption: updated.caption,
enabled: updated.enabled,
};
dispatch({ type: "SET_ROOT_PAGES", pages });
}
};
const heading =
catalogType === "bc"
? "BC Visual Catalog Manager"
: "Visual Catalog Manager";
return (
<TooltipProvider delayDuration={300}>
<div
className="flex shrink-0 items-center justify-between gap-2 border-b border-[var(--admin-border)] px-4 py-2.5"
style={{ backgroundColor: "var(--admin-surface)" }}
>
<h2
id="visual-catalog-manager-title"
className="flex items-center gap-2 text-base font-semibold leading-none"
>
<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>
</div>
<BreadcrumbBar />
<RootTabs
tabs={state.rootPages}
activeTabId={state.activeTabId}
onTabClick={handleTabClick}
onEditTab={handleEditTab}
onCreateRoot={handleCreateRoot}
loading={state.loadingRoot}
/>
<div
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"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<InlineEditor
pageId={state.selectedPageId}
onSaved={handleEditorSaved}
/>
</div>
</div>
<StatsBar totals={totals} />
</TooltipProvider>
);
}
@@ -1,93 +0,0 @@
"use client";
import { ImageOff, Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { CATALOGUE_ASSET_BASE_PATH } from "@/lib/catalog-assets";
interface ImagePreviewProps {
value: string;
basePath?: string;
height?: number;
className?: string;
}
export function ImagePreview({
value,
basePath = CATALOGUE_ASSET_BASE_PATH,
height = 60,
className,
}: ImagePreviewProps) {
const [src, setSrc] = useState("");
const [status, setStatus] = useState<"idle" | "loading" | "loaded" | "error">(
"idle",
);
const [fallback, setFallback] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!value.trim()) {
setSrc("");
setStatus("idle");
setFallback(false);
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setFallback(false);
const url = `${basePath}/${value.trim()}.png`;
setSrc(url);
setStatus("loading");
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [value, basePath]);
function handleError() {
if (!fallback) {
// Try .gif as fallback
setFallback(true);
setSrc(`${basePath}/${value.trim()}.gif`);
setStatus("loading");
} else {
setStatus("error");
}
}
if (status === "idle" || !src) {
return (
<div
className={`flex items-center justify-center rounded border border-dashed bg-muted/30 text-muted-foreground ${className || ""}`}
style={{ height }}
>
<ImageOff className="h-4 w-4" />
</div>
);
}
return (
<div
className={`relative flex items-center justify-center rounded border bg-muted/30 overflow-hidden ${className || ""}`}
style={{ height }}
>
{status === "loading" && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground absolute" />
)}
<img
src={src}
alt=""
className="max-h-full max-w-full object-contain"
style={{ imageRendering: "pixelated" }}
onLoad={() => setStatus("loaded")}
onError={handleError}
/>
{status === "error" && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/60">
<ImageOff className="h-4 w-4 text-muted-foreground" />
</div>
)}
</div>
);
}
@@ -1,775 +0,0 @@
"use client";
import {
AlertCircle,
ChevronDown,
ChevronRight,
ExternalLink,
Languages,
Loader2,
Package,
Save,
Settings,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { updateCatalogPage } from "@/actions/catalog";
import { updateBcPage } from "@/actions/catalog-bc";
import {
type CatalogItemData,
CatalogItemsTable,
} from "@/app/admin/catalog/[id]/catalog-items-table";
import { CatalogTranslateTab } from "@/app/admin/catalog/[id]/catalog-translate-tab";
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
import { IconPicker } from "@/components/admin/catalog/icon-picker";
import { ItemsShopPreview } from "@/components/admin/catalog/items-shop-preview";
import { QuickAddFurni } from "@/components/admin/catalog/quick-add-furni";
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
CATALOG_LAYOUTS,
type CatalogLayout,
LAYOUT_DESCRIPTIONS,
} from "@/lib/catalog-layouts";
import { cn } from "@/lib/utils";
import { ImagePreview } from "./image-preview";
import { catalogTreeQs, useCatalogManager } from "./use-catalog-store";
interface PageDetail {
id: number;
caption: string;
parentId: number;
pageLayout: string;
enabled: string;
visible: string;
minRank: number;
clubOnly: string;
orderNum: number;
iconImage: number;
iconColor: number;
pageHeadline: string;
pageTeaser: string;
pageSpecial: string | null;
pageText1: string | null;
pageText2: string | null;
pageTextDetails: string | null;
pageTextTeaser: string | null;
itemCount?: number;
}
interface ItemsData {
items: CatalogItemData[];
baseItems: {
id: number;
publicName: string;
itemName: string;
spriteId: number;
type: string;
}[];
catalogNameMap: Record<number, string>;
furniDataIdList: number[];
furniDescriptionMap: Record<number, string>;
furniRevisionMap: Record<number, { classname: string; revision: number }>;
interactionTypes: string[];
allPages: { id: number; caption: string }[];
gamedataHotel?: string;
}
export interface PageUpdateData {
id: number;
caption: string;
enabled: string;
visible: string;
pageLayout: string;
iconImage: number;
}
interface InlineEditorProps {
pageId: number | null;
onSaved: (updated: PageUpdateData) => void;
}
type FormState = Record<string, string | number | null>;
export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
const { dispatch, catalogType, canEdit } = useCatalogManager();
const catQs = catalogTreeQs(catalogType);
const pageHref =
catalogType === "bc"
? `/admin/catalog/builder-club/${pageId}`
: `/admin/catalog/${pageId}`;
const [page, setPage] = useState<PageDetail | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<FormState>({});
const [original, setOriginal] = useState<FormState>({});
const [showTexts, setShowTexts] = useState(false);
const [activeTab, setActiveTab] = useState("settings");
const [showPreview, setShowPreview] = useState(false);
// Items/translate data (lazy loaded)
const [itemsData, setItemsData] = useState<ItemsData | null>(null);
const [itemsLoading, setItemsLoading] = useState(false);
const [itemsLoaded, setItemsLoaded] = useState(false);
// ── Dirty tracking ──────────────────────────────────────────
const isDirty = useMemo(() => {
return Object.keys(form).some((k) => form[k] !== original[k]);
}, [form, original]);
useEffect(() => {
dispatch({ type: "SET_DIRTY", dirty: isDirty });
}, [isDirty, dispatch]);
// ── Ctrl+S handler ──────────────────────────────────────────
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
if (canEdit && isDirty && !saving && page) handleSave();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
// biome-ignore lint/correctness/useExhaustiveDependencies: handleSave is a stable callback from parent
}, [canEdit, isDirty, saving, page, handleSave]);
const loadPage = useCallback(
async (id: number) => {
setLoading(true);
try {
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}${catQs}`);
if (!res.ok) throw new Error();
const data = await res.json();
const p = data.page as PageDetail;
setPage(p);
const formData: FormState = {
caption: p.caption,
pageLayout: p.pageLayout,
enabled: String(p.enabled),
visible: String(p.visible),
minRank: p.minRank ?? 0,
clubOnly: String(p.clubOnly ?? "0"),
orderNum: p.orderNum,
iconImage: p.iconImage,
iconColor: p.iconColor,
pageHeadline: p.pageHeadline || "",
pageTeaser: p.pageTeaser || "",
pageSpecial: p.pageSpecial || "",
pageText1: p.pageText1 || "",
pageText2: p.pageText2 || "",
pageTextDetails: p.pageTextDetails || "",
pageTextTeaser: p.pageTextTeaser || "",
};
setForm(formData);
setOriginal(formData);
} catch {
toast.error("Failed to load page");
setPage(null);
} finally {
setLoading(false);
}
},
[catQs],
);
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 reloadItems = useCallback(() => {
if (pageId && pageId > 0) loadItemsData(pageId);
}, [pageId, loadItemsData]);
useEffect(() => {
if (pageId && pageId > 0) {
loadPage(pageId);
setItemsData(null);
setItemsLoaded(false);
setActiveTab("settings");
setShowPreview(false);
} else {
setPage(null);
setForm({});
setOriginal({});
setItemsData(null);
setItemsLoaded(false);
}
}, [pageId, loadPage]);
// Lazy-load items (shop catalog only — BC items live on the detail page)
useEffect(() => {
if (catalogType === "bc") return;
if (
(activeTab === "items" || activeTab === "translate") &&
!itemsLoaded &&
!itemsLoading &&
pageId &&
pageId > 0
) {
loadItemsData(pageId);
}
}, [
activeTab,
itemsLoaded,
itemsLoading,
pageId,
loadItemsData,
catalogType,
]);
function update(key: string, value: string | number | null) {
if (!canEdit) return;
setForm((prev) => ({ ...prev, [key]: value }));
}
async function handleSave() {
if (!page || !canEdit) return;
setSaving(true);
try {
const result =
catalogType === "bc"
? await updateBcPage({ id: page.id, ...form })
: await updateCatalogPage({ id: page.id, ...form });
if (!result.ok) throw new Error(result.error || "Failed to save");
toast.success("Page updated & catalog synced");
setOriginal({ ...form });
setPage((prev) =>
prev
? {
...prev,
caption: String(form.caption || prev.caption),
enabled: String(form.enabled ?? prev.enabled),
visible: String(form.visible ?? prev.visible),
pageLayout: String(form.pageLayout || prev.pageLayout),
iconImage: (form.iconImage as number) ?? prev.iconImage,
}
: prev,
);
onSaved({
id: page.id,
caption: String(form.caption || page.caption),
enabled: String(form.enabled ?? page.enabled),
visible: String(form.visible ?? page.visible),
pageLayout: String(form.pageLayout || page.pageLayout),
iconImage: (form.iconImage as number) ?? page.iconImage,
});
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save");
} finally {
setSaving(false);
}
}
if (!pageId || pageId <= 0) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
Select a page from the tree to edit
</div>
);
}
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!page) return null;
const layoutColor =
LAYOUT_COLORS[String(form.pageLayout)] || "bg-muted text-muted-foreground";
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b shrink-0">
<div className="flex items-center gap-2">
<CatalogIcon
iconImage={(form.iconImage as number) ?? page.iconImage}
size={24}
/>
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">
{(form.caption as string) || page.caption}
</h3>
<Badge
className={cn(
"h-4 px-1 text-[8px] font-mono border-0",
layoutColor,
)}
>
{form.pageLayout as string}
</Badge>
{isDirty && (
<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" />
unsaved
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
#{page.id} &middot; Parent:{" "}
{page.parentId === -1 ? "Root" : `#${page.parentId}`}
{page.itemCount !== undefined && ` · ${page.itemCount} items`}
</p>
</div>
</div>
<Link href={pageHref} target="_blank">
<Button variant="ghost" size="sm" className="h-7 text-xs gap-1">
<ExternalLink className="h-3 w-3" />
Full editor
</Button>
</Link>
</div>
{/* Dirty banner */}
{isDirty && (
<div className="flex items-center gap-2 px-4 py-1.5 bg-[var(--admin-warning-subtle)] border-b border-[var(--admin-warning-border)] text-xs text-[var(--admin-warning)] shrink-0">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">
Unsaved changes — press{" "}
<kbd className="rounded border bg-background px-1 font-mono text-[10px]">
Ctrl+S
</kbd>{" "}
to save
</span>
<Button
size="sm"
variant="outline"
className="h-6 text-[11px] px-2"
onClick={handleSave}
disabled={saving || !canEdit}
>
{saving ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Save className="mr-1 h-3 w-3" />
)}
Save
</Button>
</div>
)}
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 flex flex-col min-h-0"
>
<TabsList className="mx-4 mt-2 shrink-0">
<TabsTrigger value="settings" className="gap-1.5 text-xs">
<Settings className="h-3.5 w-3.5" />
Settings
{isDirty && (
<span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-[var(--admin-warning)]" />
)}
</TabsTrigger>
<TabsTrigger value="items" className="gap-1.5 text-xs">
<Package className="h-3.5 w-3.5" />
Items
{itemsData && (
<Badge variant="secondary" className="h-4 px-1 text-[9px] ml-0.5">
{itemsData.items.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="translate" className="gap-1.5 text-xs">
<Languages className="h-3.5 w-3.5" />
Translate
{itemsData &&
(() => {
const furniSet = new Set(itemsData.furniDataIdList);
const missing = itemsData.baseItems.filter(
(b) => !furniSet.has(b.id),
).length;
return missing > 0 ? (
<span className="ml-0.5 rounded-full bg-[var(--admin-warning)] text-[var(--admin-warning-foreground)] text-[9px] px-1 py-0.5 leading-none font-medium">
{missing}
</span>
) : null;
})()}
</TabsTrigger>
</TabsList>
{/* ── Settings tab ──────────────────────────────── */}
<TabsContent
value="settings"
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
>
<div className="space-y-4">
{/* Caption + Layout */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Caption</Label>
<Input
value={String(form.caption || "")}
onChange={(e) => update("caption", e.target.value)}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Layout</Label>
<Select
value={String(form.pageLayout || "")}
onValueChange={(v) => update("pageLayout", v)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATALOG_LAYOUTS.map((l) => {
const color =
LAYOUT_COLORS[l] || "bg-muted text-muted-foreground";
return (
<SelectItem key={l} value={l}>
<span className="flex items-center gap-2">
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
color,
)}
/>
{l}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
{LAYOUT_DESCRIPTIONS[
String(form.pageLayout) as CatalogLayout
] ?? "Custom layout"}
</p>
</div>
</div>
{/* Order + Rank + Icon */}
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<Label className="text-xs">Order</Label>
<Input
type="number"
value={form.orderNum as number}
onChange={(e) =>
update("orderNum", parseInt(e.target.value, 10) || 0)
}
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>
<div className="space-y-1.5">
<Label className="text-xs">Icon</Label>
<IconPicker
value={(form.iconImage as number) ?? 0}
onChange={(v) => update("iconImage", v)}
/>
</div>
</div>
{/* Toggles */}
<div className="rounded-md border p-3 space-y-3">
<ToggleRow
id="ie-enabled"
label="Enabled"
description="Active in the catalog"
checked={form.enabled === "1"}
onChange={(v) => update("enabled", v ? "1" : "0")}
/>
<ToggleRow
id="ie-visible"
label="Visible"
description="Shown in navigation"
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")}
/>
</div>
{/* Headline image picker */}
<div className="space-y-1.5">
<Label className="text-xs">Headline</Label>
<CatalogImagePicker
value={String(form.pageHeadline || "")}
onChange={(v) => update("pageHeadline", v)}
type="header"
label="Headline image"
/>
<ImagePreview
value={String(form.pageHeadline || "")}
height={50}
/>
</div>
{/* Teaser image picker */}
<div className="space-y-1.5">
<Label className="text-xs">Teaser</Label>
<CatalogImagePicker
value={String(form.pageTeaser || "")}
onChange={(v) => update("pageTeaser", v)}
type="teaser"
label="Teaser image"
/>
<ImagePreview value={String(form.pageTeaser || "")} height={50} />
</div>
{/* Collapsible text fields */}
<button
type="button"
onClick={() => setShowTexts(!showTexts)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{showTexts ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
Text fields
{(form.pageText1 || form.pageText2 || form.pageTextDetails) && (
<Badge variant="secondary" className="h-4 px-1 text-[9px]">
has content
</Badge>
)}
</button>
{showTexts && (
<div className="space-y-3 pl-2 border-l-2 border-border/40">
<div className="space-y-1.5">
<Label className="text-xs">Text 1</Label>
<Textarea
rows={2}
value={String(form.pageText1 || "")}
onChange={(e) =>
update("pageText1", e.target.value || null)
}
className="text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Text 2</Label>
<Textarea
rows={2}
value={String(form.pageText2 || "")}
onChange={(e) =>
update("pageText2", e.target.value || null)
}
className="text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Text Details</Label>
<Textarea
rows={2}
value={String(form.pageTextDetails || "")}
onChange={(e) =>
update("pageTextDetails", e.target.value || null)
}
className="text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Special</Label>
<Input
value={String(form.pageSpecial || "")}
onChange={(e) =>
update("pageSpecial", e.target.value || null)
}
className="h-8 text-sm"
/>
</div>
</div>
)}
{/* Save button */}
<div className="pt-2">
<Button
onClick={handleSave}
disabled={saving || !isDirty || !canEdit}
className="w-full"
>
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save & Sync
</Button>
</div>
</div>
</TabsContent>
{/* ── Items tab ─────────────────────────────────── */}
<TabsContent
value="items"
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
>
{catalogType === "bc" ? (
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground space-y-3">
<p>BC items are managed on the full Builder Club page editor.</p>
<Link href={pageHref} target="_blank">
<Button variant="outline" size="sm" className="gap-1">
<ExternalLink className="h-3 w-3" />
Open BC page editor
</Button>
</Link>
</div>
) : itemsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : itemsData ? (
<div className="space-y-3">
{/* Toolbar */}
<div className="flex items-center gap-2 flex-wrap">
{canEdit && (
<>
<QuickAddFurni
pageId={page.id}
furniRevisionMap={itemsData.furniRevisionMap}
/>
<BulkImportItems
pageId={page.id}
onImported={reloadItems}
/>
</>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShowPreview(!showPreview)}
className="ml-auto"
>
{showPreview ? "Hide Preview" : "Shop Preview"}
</Button>
</div>
{/* Shop preview (togglable) */}
{showPreview && (
<ItemsShopPreview
items={itemsData.items}
furniRevisionMap={itemsData.furniRevisionMap}
layout={page.pageLayout}
canEdit={canEdit}
/>
)}
{/* Items table */}
<CatalogItemsTable
items={itemsData.items}
pageId={page.id}
pageLayout={page.pageLayout}
canEdit={canEdit}
interactionTypes={itemsData.interactionTypes}
furniRevisionMap={itemsData.furniRevisionMap}
allPages={itemsData.allPages}
/>
</div>
) : null}
</TabsContent>
{/* ── Translate tab ─────────────────────────────── */}
<TabsContent
value="translate"
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
>
{catalogType === "bc" ? (
<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 ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : itemsData ? (
<CatalogTranslateTab
baseItems={itemsData.baseItems}
catalogNameMap={itemsData.catalogNameMap}
canEdit={canEdit}
furniDataIdList={itemsData.furniDataIdList}
furniDescriptionMap={itemsData.furniDescriptionMap}
furniRevisionMap={itemsData.furniRevisionMap}
gamedataHotel={itemsData.gamedataHotel}
/>
) : null}
</TabsContent>
</Tabs>
</div>
);
}
// ── Toggle row (inline) ─────────────────────────────────────
function ToggleRow({
id,
label,
description,
checked,
onChange,
}: {
id: string;
label: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<Label htmlFor={id} className="text-xs font-medium cursor-pointer">
{label}
</Label>
<p className="text-[10px] text-muted-foreground">{description}</p>
</div>
<Switch id={id} checked={checked} onCheckedChange={onChange} size="sm" />
</div>
);
}
@@ -1,313 +0,0 @@
"use client";
import {
closestCenter,
DndContext,
type DragEndEvent,
DragOverlay,
type DragStartEvent,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Loader2, Pencil, Plus, Power, PowerOff, Trash2 } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import {
deleteTreePage,
reorderTreePage,
toggleCatalogPage,
} from "@/actions/catalog";
import {
deleteBcTreePage,
reorderBcTreePage,
toggleBcPage,
} from "@/actions/catalog-bc";
import { CatalogIcon } from "@/components/admin/catalog-tree";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { Button } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { cn } from "@/lib/utils";
import { useCatalogManager } from "./use-catalog-store";
export type { RootTab } from "@/types/catalog";
import type { RootTab } from "@/types/catalog";
interface RootTabsProps {
tabs: RootTab[];
activeTabId: number | null;
onTabClick: (id: number) => void | Promise<void>;
onEditTab: (id: number) => void | Promise<void>;
onCreateRoot: () => void;
loading?: boolean;
}
export function RootTabs({
tabs,
activeTabId,
onTabClick,
onEditTab,
onCreateRoot,
loading,
}: RootTabsProps) {
const { catalogType, canEdit } = useCatalogManager();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const [activeDragId, setActiveDragId] = useState<number | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveDragId(event.active.id as number);
}, []);
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
setActiveDragId(null);
if (!over || active.id === over.id) return;
const oldIndex = tabs.findIndex((t) => t.id === active.id);
const newIndex = tabs.findIndex((t) => t.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
if (!canEdit) 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");
} catch {
toast.error("Failed to reorder tabs");
}
},
[tabs, canEdit, catalogType],
);
const handleToggle = async (tab: RootTab) => {
if (!canEdit) return;
try {
if (catalogType === "bc") {
await toggleBcPage({ id: tab.id, field: "enabled" });
} else {
await toggleCatalogPage({ id: tab.id, action: "toggleEnabled" });
}
toast.success(
`${tab.caption} ${tab.enabled === "1" ? "disabled" : "enabled"}`,
);
} catch {
toast.error("Failed to toggle");
}
};
const handleDelete = async (tab: RootTab) => {
if (!canEdit) return;
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.`,
confirmLabel: "Delete",
});
if (!ok) return;
try {
if (catalogType === "bc") {
await deleteBcTreePage({ pageId: tab.id, mode: "reparent" });
} else {
await deleteTreePage({ pageId: tab.id, mode: "reparent" });
}
toast.success(`"${tab.caption}" deleted`);
} catch {
toast.error("Failed to delete");
}
};
const draggedTab = activeDragId
? tabs.find((t) => t.id === activeDragId)
: null;
return (
<div
className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--admin-border)] px-2 py-1.5"
style={{ backgroundColor: "var(--admin-surface)" }}
>
{confirmDialog}
{loading && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground mx-2" />
)}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={tabs.map((t) => t.id)}
strategy={horizontalListSortingStrategy}
>
{tabs.map((tab) => (
<SortableRootTab
key={tab.id}
tab={tab}
isActive={activeTabId === tab.id}
onClick={() => onTabClick(tab.id)}
onEdit={() => onEditTab(tab.id)}
onToggle={() => handleToggle(tab)}
onDelete={() => handleDelete(tab)}
/>
))}
</SortableContext>
<DragOverlay>
{draggedTab && (
<TabButton
tab={draggedTab}
isActive={activeTabId === draggedTab.id}
isOverlay
/>
)}
</DragOverlay>
</DndContext>
{canEdit && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 ml-1"
title="New root page"
onClick={onCreateRoot}
>
<Plus className="h-3.5 w-3.5" />
</Button>
)}
</div>
);
}
// ── Sortable root tab ─────────────────────────────────────────
function SortableRootTab({
tab,
isActive,
onClick,
onEdit,
onToggle,
onDelete,
}: {
tab: RootTab;
isActive: boolean;
onClick: () => void;
onEdit: () => void;
onToggle: () => void;
onDelete: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: tab.id,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.3 : 1,
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<TabButton
tab={tab}
isActive={isActive}
onClick={onClick}
onDoubleClick={onEdit}
/>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-44">
<ContextMenuItem onClick={onEdit}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit page
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={onToggle}>
{tab.enabled === "1" ? (
<PowerOff className="mr-2 h-3.5 w-3.5" />
) : (
<Power className="mr-2 h-3.5 w-3.5" />
)}
{tab.enabled === "1" ? "Disable" : "Enable"}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
className="text-destructive focus:text-destructive"
onClick={onDelete}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
// ── Tab button render ─────────────────────────────────────────
function TabButton({
tab,
isActive,
isOverlay,
onClick,
onDoubleClick,
}: {
tab: RootTab;
isActive: boolean;
isOverlay?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
onDoubleClick={onDoubleClick}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors whitespace-nowrap shrink-0",
isActive
? "bg-primary text-primary-foreground shadow-sm"
: "hover:bg-accent text-muted-foreground hover:text-foreground",
tab.enabled !== "1" && "opacity-50",
isOverlay && "bg-card shadow-lg border",
)}
>
<CatalogIcon iconImage={tab.iconImage} size={16} />
<span className="max-w-32 truncate">{tab.caption}</span>
</button>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,70 +0,0 @@
"use client";
import { FolderTree, Keyboard, Package, ToggleRight } from "lucide-react";
import { useCatalogManager } from "./use-catalog-store";
export interface CatalogStatsTotals {
totalPages: number;
enabledPages: number;
totalItems: number;
}
export function StatsBar({ totals }: { totals?: CatalogStatsTotals | null }) {
const { state } = useCatalogManager();
const selected = state.multiSelectedIds.size;
const rootTabs = state.rootPages.length;
const pages = totals?.totalPages ?? state.nodes.size;
const enabled = totals?.enabledPages ?? null;
const items = totals?.totalItems ?? null;
const loadedOnly = !totals;
return (
<div className="flex items-center justify-between gap-4 px-4 py-1.5 border-t bg-muted/30 text-[11px] text-muted-foreground shrink-0">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
<FolderTree className="h-3 w-3" />
{rootTabs} root
</span>
<span>
{pages} pages
{loadedOnly ? " loaded" : ""}
</span>
{enabled !== null && (
<span className="flex items-center gap-1">
<ToggleRight className="h-3 w-3" />
{enabled} enabled
</span>
)}
{items !== null && (
<span className="flex items-center gap-1">
<Package className="h-3 w-3" />
{items} items
</span>
)}
{selected > 0 && (
<span className="text-primary font-medium">{selected} selected</span>
)}
</div>
<div className="flex items-center gap-1">
<Keyboard className="h-3 w-3" />
<span>
<kbd className="rounded border bg-background px-1 font-mono text-[9px]">
Ctrl+S
</kbd>{" "}
save
<span className="mx-1">·</span>
<kbd className="rounded border bg-background px-1 font-mono text-[9px]">
/
</kbd>{" "}
search
<span className="mx-1">·</span>
<kbd className="rounded border bg-background px-1 font-mono text-[9px]">
Ctrl+K
</kbd>{" "}
quick add
</span>
</div>
</div>
);
}
@@ -1,336 +0,0 @@
"use client";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ChevronRight,
Copy as CopyIcon,
ExternalLink,
Eye,
EyeOff,
EyeOff as EyeOffIcon,
Files,
GripVertical,
Loader2,
Pencil,
Plus,
Power,
PowerOff,
Trash2,
} from "lucide-react";
import React from "react";
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
import { Badge } from "@/components/ui/badge";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { translateCaption } from "@/lib/catalog-translations";
import { cn } from "@/lib/utils";
export interface FlatTreeNode {
id: number;
caption: string;
parentId: number;
enabled: string;
visible: string;
iconImage: number;
iconColor: number;
pageLayout: string;
childCount: number;
itemCount: number;
depth: number;
isExpanded: boolean;
}
export interface TreeItemProps {
node: FlatTreeNode;
sortMode: boolean;
isSelected: boolean;
isFocused: boolean;
isMultiSelected: boolean;
isAncestorOfSelected?: boolean;
isOverlay?: boolean;
onToggleExpand: (id: number) => void;
onSelect: (id: number, e?: React.MouseEvent) => void;
onAddSubpage: (parentId: number) => void;
onToggleEnabled: (id: number, currentEnabled: string) => void;
onToggleVisible: (id: number, currentVisible: string) => void;
onDuplicate: (id: number) => void;
onDelete: (id: number) => void;
onOpenFull: (id: number) => void;
isLoading?: boolean;
}
export function SortableTreeItem(props: TreeItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: props.node.id,
disabled: !props.sortMode,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style}>
<TreeItemContent
{...props}
isDragging={isDragging}
dragHandleProps={
props.sortMode ? { ...attributes, ...listeners } : undefined
}
/>
</div>
);
}
export const TreeItemContent = React.memo(
function TreeItemContent({
node,
sortMode,
isSelected,
isFocused,
isMultiSelected,
isAncestorOfSelected,
isOverlay,
isDragging,
dragHandleProps,
onToggleExpand,
onSelect,
onAddSubpage,
onToggleEnabled,
onToggleVisible,
onDuplicate,
onDelete,
onOpenFull,
isLoading,
}: TreeItemProps & {
isDragging?: boolean;
dragHandleProps?: Record<string, unknown>;
}) {
const isDisabled = node.enabled !== "1";
const isHidden = node.visible !== "1";
const italianName = translateCaption(node.caption);
const layoutColor =
LAYOUT_COLORS[node.pageLayout] || "bg-muted text-muted-foreground";
const content = (
// biome-ignore lint/a11y/useSemanticElements: wrapped by ContextMenuTrigger asChild (Radix); a real <button> would conflict with sortable handle inside
<div
role="button"
tabIndex={sortMode ? -1 : 0}
className={cn(
"flex items-center gap-1 rounded-md px-1.5 py-1 group transition-colors",
!sortMode && "cursor-pointer",
isSelected && !isDragging && "bg-primary/10 ring-1 ring-primary/30",
isMultiSelected &&
!isSelected &&
!isDragging &&
"bg-primary/5 ring-1 ring-primary/15",
isFocused &&
!isSelected &&
!isMultiSelected &&
"ring-2 ring-[var(--admin-focus-ring)]/40",
!isSelected &&
!isMultiSelected &&
!isFocused &&
!isDragging &&
"hover:bg-accent/60",
isAncestorOfSelected &&
!isSelected &&
!isMultiSelected &&
"border-l-2 border-l-primary/30",
isDisabled && "opacity-40",
isDragging && "opacity-30",
isOverlay && "bg-card shadow-lg border opacity-100",
)}
style={{ paddingLeft: isOverlay ? 8 : `${node.depth * 20 + 8}px` }}
onClick={(e) => !sortMode && onSelect(node.id, e)}
onKeyDown={(e) => {
if (!sortMode && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
onSelect(node.id, e as unknown as React.MouseEvent);
}
}}
>
{/* Sort mode grip */}
{sortMode && (
<div
className="flex items-center justify-center h-5 w-5 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground"
{...dragHandleProps}
>
<GripVertical className="h-3.5 w-3.5" />
</div>
)}
{/* Expand/collapse */}
{node.childCount > 0 ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleExpand(node.id);
}}
className="flex items-center justify-center h-5 w-5 shrink-0 rounded hover:bg-accent"
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
) : (
<ChevronRight
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-150",
node.isExpanded && "rotate-90",
)}
/>
)}
</button>
) : (
<span className="h-5 w-5 shrink-0" />
)}
{/* Icon */}
<CatalogIcon iconImage={node.iconImage} size={18} />
{/* Caption */}
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">
{italianName ?? node.caption}
</span>
{italianName && (
<span className="text-[10px] text-muted-foreground truncate block leading-tight">
{node.caption}
</span>
)}
</div>
{/* Status indicators */}
<div className="flex items-center gap-0.5 shrink-0">
{isHidden && <EyeOff className="h-3 w-3 text-muted-foreground" />}
{isDisabled && (
<Badge variant="destructive" className="h-4 px-1 text-[9px]">
OFF
</Badge>
)}
{node.itemCount > 0 && (
<Badge
variant="secondary"
className="h-4 px-1 text-[9px] font-mono"
>
{node.itemCount}
</Badge>
)}
<Badge
className={cn(
"h-4 px-1 text-[8px] font-mono opacity-0 group-hover:opacity-100 transition-opacity border-0",
layoutColor,
)}
>
{node.pageLayout.replace("default_3x3", "3x3")}
</Badge>
</div>
</div>
);
// In sort mode or overlay, no context menu
if (sortMode || isOverlay) return content;
return (
<ContextMenu>
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
<ContextMenuContent className="w-52">
<ContextMenuLabel className="flex items-center gap-2">
<CatalogIcon iconImage={node.iconImage} size={14} />
{node.caption}
<span className="text-muted-foreground font-normal ml-auto">
#{node.id}
</span>
</ContextMenuLabel>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onSelect(node.id)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</ContextMenuItem>
<ContextMenuItem onClick={() => onOpenFull(node.id)}>
<ExternalLink className="mr-2 h-3.5 w-3.5" />
Open full editor
</ContextMenuItem>
<ContextMenuItem
onClick={() => navigator.clipboard.writeText(String(node.id))}
>
<CopyIcon className="mr-2 h-3.5 w-3.5" />
Copy ID
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onAddSubpage(node.id)}>
<Plus className="mr-2 h-3.5 w-3.5" />
Add subpage
</ContextMenuItem>
<ContextMenuItem onClick={() => onDuplicate(node.id)}>
<Files className="mr-2 h-3.5 w-3.5" />
Duplicate page
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => onToggleEnabled(node.id, node.enabled)}
>
{isDisabled ? (
<Power className="mr-2 h-3.5 w-3.5" />
) : (
<PowerOff className="mr-2 h-3.5 w-3.5" />
)}
{isDisabled ? "Enable" : "Disable"}
</ContextMenuItem>
<ContextMenuItem
onClick={() => onToggleVisible(node.id, node.visible)}
>
{isHidden ? (
<Eye className="mr-2 h-3.5 w-3.5" />
) : (
<EyeOffIcon className="mr-2 h-3.5 w-3.5" />
)}
{isHidden ? "Show" : "Hide"}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(node.id)}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
},
(prev, next) => {
return (
prev.node.id === next.node.id &&
prev.node.caption === next.node.caption &&
prev.node.enabled === next.node.enabled &&
prev.node.visible === next.node.visible &&
prev.node.itemCount === next.node.itemCount &&
prev.node.isExpanded === next.node.isExpanded &&
prev.node.depth === next.node.depth &&
prev.isSelected === next.isSelected &&
prev.isFocused === next.isFocused &&
prev.isMultiSelected === next.isMultiSelected &&
prev.isAncestorOfSelected === next.isAncestorOfSelected &&
prev.sortMode === next.sortMode &&
prev.isLoading === next.isLoading &&
prev.isDragging === next.isDragging
);
},
);
@@ -1,361 +0,0 @@
"use client";
import {
createContext,
type Dispatch,
type ReactNode,
useContext,
useReducer,
} from "react";
import type { RootTab, TreeNodeData, TreeNodeUpdate } from "@/types/catalog";
// ── State ─────────────────────────────────────────────────────
export interface CatalogManagerState {
// Root tabs
rootPages: RootTab[];
activeTabId: number | null;
loadingRoot: boolean;
// Tree
nodes: Map<number, TreeNodeData>;
childrenMap: Map<number, number[]>;
expandedIds: Set<number>;
loadingIds: Set<number>;
selectedPageId: number | null;
multiSelectedIds: Set<number>;
focusedId: number | null;
sortMode: boolean;
searchQuery: string;
searchResults: TreeNodeData[] | null;
searching: boolean;
// Editor
editorDirty: boolean;
// Breadcrumb
ancestors: Array<{ id: number; caption: string }>;
}
const initialState: CatalogManagerState = {
rootPages: [],
activeTabId: null,
loadingRoot: false,
nodes: new Map(),
childrenMap: new Map(),
expandedIds: new Set(),
loadingIds: new Set(),
selectedPageId: null,
multiSelectedIds: new Set(),
focusedId: null,
sortMode: false,
searchQuery: "",
searchResults: null,
searching: false,
editorDirty: false,
ancestors: [],
};
// ── Actions ───────────────────────────────────────────────────
export type CatalogAction =
| { type: "SET_ROOT_PAGES"; pages: RootTab[] }
| { type: "SET_LOADING_ROOT"; loading: boolean }
| { type: "SET_ACTIVE_TAB"; id: number | null }
| { type: "SELECT_PAGE"; id: number | null }
| { type: "TOGGLE_EXPAND"; id: number }
| { type: "SET_EXPANDED"; id: number; expanded: boolean }
| { type: "EXPAND_PATH"; ids: number[] }
| { type: "COLLAPSE_ALL" }
| { type: "SET_NODES"; nodes: TreeNodeData[] }
| { type: "SET_FULL_TREE"; nodes: TreeNodeData[] }
| { type: "SET_CHILDREN"; parentId: number; children: TreeNodeData[] }
| { type: "UPDATE_NODE"; update: TreeNodeUpdate }
| { type: "REMOVE_NODE"; id: number }
| { type: "SET_LOADING_IDS"; ids: Set<number> }
| { type: "SET_SEARCH_QUERY"; query: string }
| { type: "SET_SEARCH_RESULTS"; results: TreeNodeData[] | null }
| { type: "SET_SEARCHING"; searching: boolean }
| { type: "TOGGLE_SORT_MODE" }
| { type: "TOGGLE_MULTI_SELECT"; id: number }
| { type: "RANGE_SELECT"; from: number; to: number; flatIds: number[] }
| { type: "CLEAR_MULTI_SELECT" }
| { type: "SET_FOCUSED"; id: number | null }
| { type: "SET_DIRTY"; dirty: boolean }
| { type: "SET_ANCESTORS"; ancestors: Array<{ id: number; caption: string }> }
| { type: "ON_DIALOG_CLOSE" }
| { type: "CLEAR_TREE" };
// ── Reducer ───────────────────────────────────────────────────
function reducer(
state: CatalogManagerState,
action: CatalogAction,
): CatalogManagerState {
switch (action.type) {
case "SET_ROOT_PAGES":
return { ...state, rootPages: action.pages, loadingRoot: false };
case "SET_LOADING_ROOT":
return { ...state, loadingRoot: action.loading };
case "SET_ACTIVE_TAB":
return {
...state,
activeTabId: action.id,
selectedPageId: null,
multiSelectedIds: new Set(),
focusedId: null,
ancestors: [],
};
case "SELECT_PAGE":
return {
...state,
selectedPageId: action.id,
multiSelectedIds: new Set(),
focusedId: action.id,
};
case "TOGGLE_EXPAND": {
const next = new Set(state.expandedIds);
if (next.has(action.id)) next.delete(action.id);
else next.add(action.id);
return { ...state, expandedIds: next };
}
case "SET_EXPANDED": {
const next = new Set(state.expandedIds);
if (action.expanded) next.add(action.id);
else next.delete(action.id);
return { ...state, expandedIds: next };
}
case "EXPAND_PATH": {
const next = new Set(state.expandedIds);
for (const id of action.ids) next.add(id);
return { ...state, expandedIds: next };
}
case "COLLAPSE_ALL":
return { ...state, expandedIds: new Set() };
case "SET_NODES": {
const next = new Map(state.nodes);
for (const n of action.nodes) next.set(n.id, n);
return { ...state, nodes: next };
}
case "SET_FULL_TREE": {
const nodes = new Map<number, TreeNodeData>();
const childrenMap = new Map<number, number[]>();
for (const raw of action.nodes) {
const id = Number(raw.id);
const parentId = Number(raw.parentId);
if (!Number.isFinite(id)) continue;
const n: TreeNodeData = {
...raw,
id,
parentId: Number.isFinite(parentId) ? parentId : -1,
enabled: String(raw.enabled ?? "0"),
visible: String(raw.visible ?? "0"),
childCount: Number(raw.childCount) || 0,
itemCount: Number(raw.itemCount) || 0,
iconImage: Number(raw.iconImage) || 0,
iconColor: Number(raw.iconColor) || 0,
orderNum: Number(raw.orderNum) || 0,
};
nodes.set(id, n);
const siblings = childrenMap.get(n.parentId) || [];
siblings.push(id);
childrenMap.set(n.parentId, siblings);
}
// Roots may be parent_id -1 or 0 — mirror both keys so tabs resolve children.
const underNeg1 = childrenMap.get(-1) || [];
const under0 = childrenMap.get(0) || [];
if (underNeg1.length || under0.length) {
const merged = [...new Set([...underNeg1, ...under0])];
childrenMap.set(-1, merged);
childrenMap.set(0, merged);
}
for (const [parentId, ids] of childrenMap) {
ids.sort(
(a, b) =>
(nodes.get(a)?.orderNum ?? 0) - (nodes.get(b)?.orderNum ?? 0),
);
childrenMap.set(parentId, ids);
}
return { ...state, nodes, childrenMap };
}
case "SET_CHILDREN": {
const nextNodes = new Map(state.nodes);
const nextChildren = new Map(state.childrenMap);
const childIds: number[] = [];
for (const child of action.children) {
nextNodes.set(child.id, child);
childIds.push(child.id);
}
nextChildren.set(action.parentId, childIds);
return { ...state, nodes: nextNodes, childrenMap: nextChildren };
}
case "UPDATE_NODE": {
const existing = state.nodes.get(action.update.id);
if (!existing) return state;
const nextNodes = new Map(state.nodes);
nextNodes.set(action.update.id, {
...existing,
...(action.update.caption !== undefined && {
caption: action.update.caption,
}),
...(action.update.enabled !== undefined && {
enabled: action.update.enabled,
}),
...(action.update.visible !== undefined && {
visible: action.update.visible,
}),
...(action.update.pageLayout !== undefined && {
pageLayout: action.update.pageLayout,
}),
...(action.update.iconImage !== undefined && {
iconImage: action.update.iconImage,
}),
});
return { ...state, nodes: nextNodes };
}
case "REMOVE_NODE": {
const nextNodes = new Map(state.nodes);
nextNodes.delete(action.id);
const nextChildren = new Map(state.childrenMap);
// Remove from parent's children list
for (const [parentId, children] of nextChildren) {
const idx = children.indexOf(action.id);
if (idx !== -1) {
nextChildren.set(
parentId,
children.filter((cid) => cid !== action.id),
);
}
}
// Remove this node's children entry
nextChildren.delete(action.id);
const nextSelected =
state.selectedPageId === action.id ? null : state.selectedPageId;
return {
...state,
nodes: nextNodes,
childrenMap: nextChildren,
selectedPageId: nextSelected,
};
}
case "SET_LOADING_IDS":
return { ...state, loadingIds: action.ids };
case "SET_SEARCH_QUERY":
return { ...state, searchQuery: action.query };
case "SET_SEARCH_RESULTS":
return { ...state, searchResults: action.results, searching: false };
case "SET_SEARCHING":
return { ...state, searching: action.searching };
case "TOGGLE_SORT_MODE":
return {
...state,
sortMode: !state.sortMode,
multiSelectedIds: new Set(),
};
case "TOGGLE_MULTI_SELECT": {
const next = new Set(state.multiSelectedIds);
if (next.has(action.id)) next.delete(action.id);
else next.add(action.id);
return { ...state, multiSelectedIds: next, focusedId: action.id };
}
case "RANGE_SELECT": {
const { flatIds, from, to } = action;
const fromIdx = flatIds.indexOf(from);
const toIdx = flatIds.indexOf(to);
if (fromIdx === -1 || toIdx === -1) return state;
const lo = Math.min(fromIdx, toIdx);
const hi = Math.max(fromIdx, toIdx);
const next = new Set(state.multiSelectedIds);
for (let i = lo; i <= hi; i++) next.add(flatIds[i]);
return { ...state, multiSelectedIds: next, focusedId: action.to };
}
case "CLEAR_MULTI_SELECT":
return { ...state, multiSelectedIds: new Set() };
case "SET_FOCUSED":
return { ...state, focusedId: action.id };
case "SET_DIRTY":
return { ...state, editorDirty: action.dirty };
case "SET_ANCESTORS":
return { ...state, ancestors: action.ancestors };
case "ON_DIALOG_CLOSE":
return {
...state,
selectedPageId: null,
editorDirty: false,
multiSelectedIds: new Set(),
ancestors: [],
};
case "CLEAR_TREE":
return {
...state,
nodes: new Map(),
childrenMap: new Map(),
expandedIds: new Set(),
loadingIds: new Set(),
selectedPageId: null,
multiSelectedIds: new Set(),
focusedId: null,
searchQuery: "",
searchResults: null,
ancestors: [],
};
default:
return state;
}
}
// ── Context ───────────────────────────────────────────────────
export type CatalogManagerMode = "normal" | "bc";
interface CatalogManagerContextValue {
state: CatalogManagerState;
dispatch: Dispatch<CatalogAction>;
catalogType: CatalogManagerMode;
canEdit: boolean;
}
const CatalogManagerContext = createContext<CatalogManagerContextValue | null>(
null,
);
export function CatalogManagerProvider({
children,
catalogType = "normal",
canEdit = false,
}: {
children: ReactNode;
catalogType?: CatalogManagerMode;
canEdit?: boolean;
}) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<CatalogManagerContext.Provider
value={{ state, dispatch, catalogType, canEdit }}
>
{children}
</CatalogManagerContext.Provider>
);
}
export function useCatalogManager() {
const ctx = useContext(CatalogManagerContext);
if (!ctx)
throw new Error(
"useCatalogManager must be used within CatalogManagerProvider",
);
return ctx;
}
/** Append to tree API query strings when in Builder Club mode. */
export function catalogTreeQs(catalogType: CatalogManagerMode): string {
return catalogType === "bc" ? "&catalog=bc" : "";
}