251 lines
7.3 KiB
TypeScript
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>
|
|
)
|
|
}
|