style: format code biome
Local Build and Deploy / deploy (push) Failing after 46s

This commit is contained in:
openhands committed 2026-07-13 21:57:41 +02:00
1 parent 8efd032cc6
commit df38dccbf1
735 files changed
+128321 -120870

No files matched your search

@@ -5,54 +5,58 @@ import { Fragment, useEffect } from "react";
import { useCatalogManager } from "./use-catalog-store";
export function BreadcrumbBar() {
const { state, dispatch } = useCatalogManager();
const { selectedPageId, ancestors } = state;
const { state, dispatch } = useCatalogManager();
const { selectedPageId, ancestors } = state;
useEffect(() => {
if (!selectedPageId || selectedPageId <= 0) {
dispatch({ type: "SET_ANCESTORS", ancestors: [] });
return;
}
const ac = new AbortController();
fetch(`/api/admin/catalog/tree?ancestors=${selectedPageId}`, { 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]);
useEffect(() => {
if (!selectedPageId || selectedPageId <= 0) {
dispatch({ type: "SET_ANCESTORS", ancestors: [] });
return;
}
const ac = new AbortController();
fetch(`/api/admin/catalog/tree?ancestors=${selectedPageId}`, {
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]);
if (!selectedPageId || ancestors.length === 0) return null;
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>
);
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>
);
}
@@ -5,7 +5,13 @@ import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { createCatalogPage } from "@/actions/catalog";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { RootTab } from "@/types/catalog";
import { BreadcrumbBar } from "./breadcrumb-bar";
@@ -16,158 +22,176 @@ import { StatsBar } from "./stats-bar";
import { CatalogManagerProvider, useCatalogManager } from "./use-catalog-store";
export function CatalogManagerDialog() {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2">
<LayoutGrid className="h-4 w-4" />
Visual Manager
</Button>
</DialogTrigger>
<DialogContent
className="max-w-full w-[98vw] h-[95vh] max-h-[95vh] p-0 flex flex-col gap-0 sm:max-w-[98vw]"
onInteractOutside={(e) => e.preventDefault()}
>
<CatalogManagerProvider>
<CatalogManagerInner open={open} onOpenChange={setOpen} />
</CatalogManagerProvider>
</DialogContent>
</Dialog>
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2">
<LayoutGrid className="h-4 w-4" />
Visual Manager
</Button>
</DialogTrigger>
<DialogContent
className="max-w-full w-[98vw] h-[95vh] max-h-[95vh] p-0 flex flex-col gap-0 sm:max-w-[98vw]"
onInteractOutside={(e) => e.preventDefault()}
>
<CatalogManagerProvider>
<CatalogManagerInner open={open} onOpenChange={setOpen} />
</CatalogManagerProvider>
</DialogContent>
</Dialog>
);
}
function CatalogManagerInner({
open,
// biome-ignore lint/correctness/noUnusedFunctionParameters: part of the dialog public API
onOpenChange,
open,
// biome-ignore lint/correctness/noUnusedFunctionParameters: part of the dialog public API
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { state, dispatch } = useCatalogManager();
const { state, dispatch } = useCatalogManager();
// Load root pages when dialog opens
const loadRootPages = useCallback(async () => {
dispatch({ type: "SET_LOADING_ROOT", loading: true });
try {
const res = await fetch("/api/admin/catalog/tree?parentId=-1");
if (!res.ok) throw new Error();
const data = await res.json();
const pages: RootTab[] = (data.pages || []).map((p: Record<string, unknown>) => ({
id: p.id as number,
caption: p.caption as string,
iconImage: p.iconImage as number,
enabled: String(p.enabled),
orderNum: (p.orderNum as number) || 0,
}));
dispatch({ type: "SET_ROOT_PAGES", pages });
// Load root pages when dialog opens
const loadRootPages = useCallback(async () => {
dispatch({ type: "SET_LOADING_ROOT", loading: true });
try {
const res = await fetch("/api/admin/catalog/tree?parentId=-1");
if (!res.ok) throw new Error();
const data = await res.json();
const pages: RootTab[] = (data.pages || []).map(
(p: Record<string, unknown>) => ({
id: p.id as number,
caption: p.caption as string,
iconImage: p.iconImage as number,
enabled: String(p.enabled),
orderNum: (p.orderNum as number) || 0,
}),
);
dispatch({ type: "SET_ROOT_PAGES", pages });
// Auto-select first tab if none or missing
if (pages.length > 0 && (!state.activeTabId || !pages.find((p) => p.id === state.activeTabId))) {
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]);
// Auto-select first tab if none or missing
if (
pages.length > 0 &&
(!state.activeTabId || !pages.find((p) => p.id === state.activeTabId))
) {
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]);
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (open) loadRootPages();
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (open) loadRootPages();
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
// Handle dialog close: preserve tree state, clear selection
useEffect(() => {
if (!open) dispatch({ type: "ON_DIALOG_CLOSE" });
}, [open, dispatch]);
// Handle dialog close: preserve tree state, clear selection
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 });
dispatch({ type: "CLEAR_TREE" });
};
const handleTabClick = (id: number) => {
if (state.editorDirty && !confirm("You have unsaved changes. Discard?"))
return;
dispatch({ type: "SET_ACTIVE_TAB", id });
dispatch({ type: "CLEAR_TREE" });
};
const handleCreateRoot = async () => {
const caption = prompt("Root page name:");
if (!caption?.trim()) return;
try {
const result = 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 handleCreateRoot = async () => {
const caption = prompt("Root page name:");
if (!caption?.trim()) return;
try {
const result = 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 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) => {
// First ensure this tab is active, then select it in the editor
if (state.activeTabId !== id) {
dispatch({ type: "SET_ACTIVE_TAB", id });
dispatch({ type: "CLEAR_TREE" });
}
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) return;
dispatch({ type: "SELECT_PAGE", id });
};
const handleEditTab = (id: number) => {
// First ensure this tab is active, then select it in the editor
if (state.activeTabId !== id) {
dispatch({ type: "SET_ACTIVE_TAB", id });
dispatch({ type: "CLEAR_TREE" });
}
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 handleEditorSaved = (updated: PageUpdateData) => {
dispatch({ type: "UPDATE_NODE", update: { ...updated } });
dispatch({ type: "SET_DIRTY", dirty: false });
// Update root tabs if root page
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 });
}
};
// Update root tabs if root page
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 });
}
};
return (
<TooltipProvider delayDuration={300}>
{/* Header */}
<DialogHeader className="px-4 py-2.5 border-b shrink-0">
<DialogTitle className="text-base flex items-center gap-2">
<LayoutGrid className="h-4 w-4" />
Visual Catalog Manager
</DialogTitle>
</DialogHeader>
return (
<TooltipProvider delayDuration={300}>
{/* Header */}
<DialogHeader className="px-4 py-2.5 border-b shrink-0">
<DialogTitle className="text-base flex items-center gap-2">
<LayoutGrid className="h-4 w-4" />
Visual Catalog Manager
</DialogTitle>
</DialogHeader>
{/* Breadcrumb */}
<BreadcrumbBar />
{/* Breadcrumb */}
<BreadcrumbBar />
{/* Root tabs */}
<RootTabs
tabs={state.rootPages}
activeTabId={state.activeTabId}
onTabClick={handleTabClick}
onEditTab={handleEditTab}
onCreateRoot={handleCreateRoot}
loading={state.loadingRoot}
/>
{/* Root tabs */}
<RootTabs
tabs={state.rootPages}
activeTabId={state.activeTabId}
onTabClick={handleTabClick}
onEditTab={handleEditTab}
onCreateRoot={handleCreateRoot}
loading={state.loadingRoot}
/>
{/* Main content: tree + editor */}
<div className="flex flex-1 min-h-0">
<SortableTree
activeTabId={state.activeTabId}
selectedPageId={state.selectedPageId}
onSelectPage={handleSelectPage}
onTreeMutation={loadRootPages}
nodeUpdate={null}
/>
<InlineEditor pageId={state.selectedPageId} onSaved={handleEditorSaved} />
</div>
{/* Main content: tree + editor */}
<div className="flex flex-1 min-h-0">
<SortableTree
activeTabId={state.activeTabId}
selectedPageId={state.selectedPageId}
onSelectPage={handleSelectPage}
onTreeMutation={loadRootPages}
nodeUpdate={null}
/>
<InlineEditor
pageId={state.selectedPageId}
onSaved={handleEditorSaved}
/>
</div>
{/* Stats bar */}
<StatsBar />
</TooltipProvider>
);
{/* Stats bar */}
<StatsBar />
</TooltipProvider>
);
}
@@ -4,85 +4,89 @@ import { ImageOff, Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
interface ImagePreviewProps {
value: string;
basePath?: string;
height?: number;
className?: string;
value: string;
basePath?: string;
height?: number;
className?: string;
}
export function ImagePreview({
value,
basePath = "/swf/c_images/catalogue",
height = 60,
className,
value,
basePath = "/swf/c_images/catalogue",
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);
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;
}
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);
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]);
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");
}
}
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>
);
}
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>
);
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>
);
}
File diff suppressed because it is too large. Load diff
+248 -184
View File
@@ -1,31 +1,44 @@
"use client";
import {
closestCenter,
DndContext,
type DragEndEvent,
DragOverlay,
type DragStartEvent,
PointerSensor,
useSensor,
useSensors,
closestCenter,
DndContext,
type DragEndEvent,
DragOverlay,
type DragStartEvent,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { arrayMove, horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable";
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 {
deleteTreePage,
reorderTreePage,
toggleCatalogPage,
} from "@/actions/catalog";
import { CatalogIcon } from "@/components/admin/catalog-tree";
import { Button } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export type { RootTab } from "@/types/catalog";
@@ -33,199 +46,250 @@ export type { RootTab } from "@/types/catalog";
import type { RootTab } from "@/types/catalog";
interface RootTabsProps {
tabs: RootTab[];
activeTabId: number | null;
onTabClick: (id: number) => void;
onEditTab: (id: number) => void;
onCreateRoot: () => void;
loading?: boolean;
tabs: RootTab[];
activeTabId: number | null;
onTabClick: (id: number) => void;
onEditTab: (id: number) => void;
onCreateRoot: () => void;
loading?: boolean;
}
export function RootTabs({ tabs, activeTabId, onTabClick, onEditTab, onCreateRoot, loading }: RootTabsProps) {
const [activeDragId, setActiveDragId] = useState<number | null>(null);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
export function RootTabs({
tabs,
activeTabId,
onTabClick,
onEditTab,
onCreateRoot,
loading,
}: RootTabsProps) {
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 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 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;
const oldIndex = tabs.findIndex((t) => t.id === active.id);
const newIndex = tabs.findIndex((t) => t.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const newOrder = arrayMove(tabs, oldIndex, newIndex);
const newOrder = arrayMove(tabs, oldIndex, newIndex);
// Batch reorder
try {
const results = await Promise.allSettled(
newOrder.map((tab, idx) => reorderTreePage({ 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],
);
// Batch reorder
try {
const results = await Promise.allSettled(
newOrder.map((tab, idx) =>
reorderTreePage({ 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],
);
const handleToggle = async (tab: RootTab) => {
try {
await toggleCatalogPage({ id: tab.id, action: "toggleEnabled" });
toast.success(`${tab.caption} ${tab.enabled === "1" ? "disabled" : "enabled"}`);
} catch {
toast.error("Failed to toggle");
}
};
const handleToggle = async (tab: RootTab) => {
try {
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 (!confirm(`Delete root page "${tab.caption}"? Children will be reparented.`)) return;
try {
await deleteTreePage({ pageId: tab.id, mode: "reparent" });
toast.success(`"${tab.caption}" deleted`);
} catch {
toast.error("Failed to delete");
}
};
const handleDelete = async (tab: RootTab) => {
if (
!confirm(
`Delete root page "${tab.caption}"? Children will be reparented.`,
)
)
return;
try {
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;
const draggedTab = activeDragId
? tabs.find((t) => t.id === activeDragId)
: null;
return (
<div className="flex items-center gap-1 border-b bg-muted/30 px-2 py-1.5 overflow-x-auto shrink-0">
{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>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 ml-1" onClick={onCreateRoot}>
<Plus className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>New root page</p>
</TooltipContent>
</Tooltip>
</div>
);
return (
<div className="flex items-center gap-1 border-b bg-muted/30 px-2 py-1.5 overflow-x-auto shrink-0">
{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>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 ml-1"
onClick={onCreateRoot}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>New root page</p>
</TooltipContent>
</Tooltip>
</div>
);
}
// ── Sortable root tab ─────────────────────────────────────────
function SortableRootTab({
tab,
isActive,
onClick,
onEdit,
onToggle,
onDelete,
tab,
isActive,
onClick,
onEdit,
onToggle,
onDelete,
}: {
tab: RootTab;
isActive: boolean;
onClick: () => void;
onEdit: () => void;
onToggle: () => void;
onDelete: () => void;
tab: RootTab;
isActive: boolean;
onClick: () => void;
onEdit: () => void;
onToggle: () => void;
onDelete: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id,
});
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,
};
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>
);
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,
isActive,
isOverlay,
onClick,
onDoubleClick,
}: {
tab: RootTab;
isActive: boolean;
isOverlay?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
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>
);
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
@@ -5,50 +5,61 @@ import { useMemo } from "react";
import { useCatalogManager } from "./use-catalog-store";
export function StatsBar() {
const { state } = useCatalogManager();
const { state } = useCatalogManager();
const stats = useMemo(() => {
const nodes = Array.from(state.nodes.values());
const enabled = nodes.filter((n) => n.enabled === "1").length;
const totalItems = nodes.reduce((sum, n) => sum + n.itemCount, 0);
return {
rootTabs: state.rootPages.length,
totalPages: nodes.length,
enabled,
totalItems,
};
}, [state.nodes, state.rootPages]);
const stats = useMemo(() => {
const nodes = Array.from(state.nodes.values());
const enabled = nodes.filter((n) => n.enabled === "1").length;
const totalItems = nodes.reduce((sum, n) => sum + n.itemCount, 0);
return {
rootTabs: state.rootPages.length,
totalPages: nodes.length,
enabled,
totalItems,
};
}, [state.nodes, state.rootPages]);
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" />
{stats.rootTabs} root
</span>
<span>{stats.totalPages} pages</span>
<span className="flex items-center gap-1">
<ToggleRight className="h-3 w-3" />
{stats.enabled} enabled
</span>
<span className="flex items-center gap-1">
<Package className="h-3 w-3" />
{stats.totalItems} items
</span>
{state.multiSelectedIds.size > 0 && (
<span className="text-primary font-medium">{state.multiSelectedIds.size} 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>
);
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" />
{stats.rootTabs} root
</span>
<span>{stats.totalPages} pages</span>
<span className="flex items-center gap-1">
<ToggleRight className="h-3 w-3" />
{stats.enabled} enabled
</span>
<span className="flex items-center gap-1">
<Package className="h-3 w-3" />
{stats.totalItems} items
</span>
{state.multiSelectedIds.size > 0 && (
<span className="text-primary font-medium">
{state.multiSelectedIds.size} 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>
);
}
+302 -258
View File
@@ -3,290 +3,334 @@
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,
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,
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;
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;
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 {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: props.node.id,
disabled: !props.sortMode,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
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>
);
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";
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>
)}
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" />
)}
{/* 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} />
{/* 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>
{/* 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>
);
{/* 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;
// 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
);
},
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,264 +1,300 @@
"use client";
import { createContext, type Dispatch, type ReactNode, useContext, useReducer } from "react";
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;
// 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;
// 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;
// Editor
editorDirty: boolean;
// Breadcrumb
ancestors: Array<{ id: number; caption: string }>;
// 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: [],
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_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" };
| { 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_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,
};
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 "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_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_NODES": {
const next = new Map(state.nodes);
for (const n of action.nodes) next.set(n.id, n);
return { ...state, nodes: next };
}
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 "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 "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 "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 "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: [],
};
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;
}
default:
return state;
}
}
// ── Context ───────────────────────────────────────────────────
interface CatalogManagerContextValue {
state: CatalogManagerState;
dispatch: Dispatch<CatalogAction>;
state: CatalogManagerState;
dispatch: Dispatch<CatalogAction>;
}
const CatalogManagerContext = createContext<CatalogManagerContextValue | null>(null);
const CatalogManagerContext = createContext<CatalogManagerContextValue | null>(
null,
);
export function CatalogManagerProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<CatalogManagerContext.Provider value={{ state, dispatch }}>{children}</CatalogManagerContext.Provider>
);
const [state, dispatch] = useReducer(reducer, initialState);
return (
<CatalogManagerContext.Provider value={{ state, dispatch }}>
{children}
</CatalogManagerContext.Provider>
);
}
export function useCatalogManager() {
const ctx = useContext(CatalogManagerContext);
if (!ctx) throw new Error("useCatalogManager must be used within CatalogManagerProvider");
return ctx;
const ctx = useContext(CatalogManagerContext);
if (!ctx)
throw new Error(
"useCatalogManager must be used within CatalogManagerProvider",
);
return ctx;
}