Local Build and Deploy / deploy (push) Successful in 55s
Use an opaque admin-canvas portal and lazy parentId fetches instead of mode=full, which fails on large catalogs. Search no longer loads the entire tree. Co-authored-by: Cursor <[email protected]>
292 lines
7.1 KiB
TypeScript
292 lines
7.1 KiB
TypeScript
"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 { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
|
import { CatalogIcon } from "@/components/admin/catalog-tree";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuSeparator,
|
|
ContextMenuTrigger,
|
|
} from "@/components/ui/context-menu";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
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 { 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;
|
|
|
|
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],
|
|
);
|
|
|
|
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) => {
|
|
const ok = await confirm({
|
|
title: "Delete root page",
|
|
description: `Delete root page "${tab.caption}"? Children will be reparented.`,
|
|
confirmLabel: "Delete",
|
|
});
|
|
if (!ok) 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;
|
|
|
|
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>
|
|
<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>
|
|
);
|
|
}
|