feat(housekeeping): cut over administration to ase
This commit is contained in:
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} · 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" : "";
|
||||
}
|
||||
Reference in new issue
Block a user