fix: restore complete admin feature dependencies
This commit is contained in:
1 parent
5b4228261a
commit
0cd753c735
115 files changed
+18565
-4930
No files matched your search
@@ -0,0 +1,304 @@
|
||||
'use client'
|
||||
|
||||
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,
|
||||
} 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,
|
||||
} 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function SortableTreeItem(props: TreeItemProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: props.node.id,
|
||||
disabled: !props.sortMode,
|
||||
})
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
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-blue-500/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" />
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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
|
||||
|
||||
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
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user