Files
EpicNext-Cms/src/components/admin/catalog-manager/root-tabs.tsx
T
Simo 8d37ae9ae0
Remote Build and Deploy / deploy (push) Has been cancelled
style: normalize restored source endings
2026-07-11 21:19:54 +02:00

251 lines
7.3 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 { CatalogIcon } from '@/components/admin/catalog-tree'
import { Button } from '@/components/ui/button'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
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
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 } }))
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) => {
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
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: 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>
)
}