Files
EpicNext-Cms/src/components/admin/catalog-manager/root-tabs.tsx
T
SimoandCursor 8d1cf86105 feat(catalog): SSR stats in Visual Manager, honest deletes, newPageParent
Pass DB totals into StatsBar, mirror Catalog Structure delete copy, and open New Page with ?newPageParent= for shop and BC.

Co-authored-by: Cursor <[email protected]>
2026-07-18 21:14:55 +02:00

314 lines
7.8 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 {
deleteBcTreePage,
reorderBcTreePage,
toggleBcPage,
} from "@/actions/catalog-bc";
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";
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>
);
}