diff options
Diffstat (limited to 'lib/menu-v2/components/menu-tree.tsx')
| -rw-r--r-- | lib/menu-v2/components/menu-tree.tsx | 282 |
1 files changed, 282 insertions, 0 deletions
diff --git a/lib/menu-v2/components/menu-tree.tsx b/lib/menu-v2/components/menu-tree.tsx new file mode 100644 index 00000000..7d3ab077 --- /dev/null +++ b/lib/menu-v2/components/menu-tree.tsx @@ -0,0 +1,282 @@ +"use client"; + +import { useCallback } from "react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + ChevronRight, + ChevronDown, + ChevronUp, + Folder, + FolderOpen, + File, + Pencil, + Plus, + ArrowUpDown, + EyeOff, +} from "lucide-react"; +import type { MenuTreeNode } from "../types"; + +interface MenuTreeProps { + nodes: MenuTreeNode[]; + onEdit: (node: MenuTreeNode) => void; + onMoveUp: (nodeId: number) => void; + onMoveDown: (nodeId: number) => void; + onMoveTo: (node: MenuTreeNode) => void; + onAddGroup: (parentId: number) => void; + expandedIds: Set<number>; + onExpandedIdsChange: (ids: Set<number>) => void; + isPending?: boolean; +} + +interface TreeItemProps { + node: MenuTreeNode; + depth: number; + isFirst: boolean; + isLast: boolean; + onEdit: (node: MenuTreeNode) => void; + onMoveUp: (nodeId: number) => void; + onMoveDown: (nodeId: number) => void; + onMoveTo: (node: MenuTreeNode) => void; + onAddGroup: (parentId: number) => void; + isExpanded: boolean; + onToggleExpand: () => void; + isPending?: boolean; +} + +function TreeItem({ + node, + depth, + isFirst, + isLast, + onEdit, + onMoveUp, + onMoveDown, + onMoveTo, + onAddGroup, + isExpanded, + onToggleExpand, + isPending, +}: TreeItemProps) { + const isMenuGroup = node.nodeType === "menu_group"; + const isGroup = node.nodeType === "group"; + const isMenu = node.nodeType === "menu"; + const isTopLevel = node.parentId === null; + const hasChildren = node.children && node.children.length > 0; + const isExpandable = isMenuGroup || isGroup; + + // Move To is disabled for: + // - menu_group (always at top level, cannot be moved) + // - top-level menu (parentId === null, can only reorder with up/down) + const canMoveTo = !isMenuGroup && !isTopLevel; + + const getIcon = () => { + if (isMenuGroup || isGroup) { + return isExpanded ? ( + <FolderOpen className="h-4 w-4 text-amber-500" /> + ) : ( + <Folder className="h-4 w-4 text-amber-500" /> + ); + } + return <File className="h-4 w-4 text-slate-500" />; + }; + + const getTypeLabel = () => { + switch (node.nodeType) { + case "menu_group": return "Menu Group"; + case "group": return "Group"; + case "menu": return "Menu"; + default: return ""; + } + }; + + return ( + <div + className={cn( + "flex items-center gap-2 px-2 py-1.5 rounded-md border bg-background hover:bg-accent/50 transition-colors", + !node.isActive && "opacity-50 bg-muted/30 border-dashed" + )} + style={{ marginLeft: depth * 24 }} + > + {/* Expand/Collapse */} + {isExpandable ? ( + <button + onClick={onToggleExpand} + className="p-0.5 hover:bg-accent rounded shrink-0" + > + {isExpanded ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + )} + </button> + ) : ( + <div className="w-5 shrink-0" /> + )} + + {/* Icon */} + {getIcon()} + + {/* Title */} + <span className={cn( + "flex-1 text-sm font-medium truncate min-w-0", + !node.isActive && "line-through text-muted-foreground" + )}> + {node.titleKo} + {node.titleEn && ( + <span className="text-muted-foreground font-normal"> [{node.titleEn}]</span> + )} + </span> + + {/* Hidden indicator */} + {!node.isActive && ( + <EyeOff className="h-3.5 w-3.5 text-muted-foreground shrink-0" title="Hidden" /> + )} + + {/* Path (for menus) */} + {isMenu && node.menuPath && ( + <span className="text-xs text-muted-foreground truncate max-w-[150px] shrink-0"> + {node.menuPath} + </span> + )} + + {/* Type Badge */} + <Badge variant={node.isActive ? "default" : "secondary"} className="text-xs shrink-0"> + {getTypeLabel()} + </Badge> + + {/* Active indicator */} + <div + className={cn( + "w-2 h-2 rounded-full shrink-0", + node.isActive ? "bg-green-500" : "bg-gray-400" + )} + title={node.isActive ? "Visible" : "Hidden"} + /> + + {/* Actions */} + <div className="flex items-center gap-1 shrink-0"> + {/* Move Up */} + <Button + variant="ghost" + size="icon" + className="h-7 w-7" + onClick={() => onMoveUp(node.id)} + disabled={isFirst || isPending} + title="Move Up" + > + <ChevronUp className="h-4 w-4" /> + </Button> + + {/* Move Down */} + <Button + variant="ghost" + size="icon" + className="h-7 w-7" + onClick={() => onMoveDown(node.id)} + disabled={isLast || isPending} + title="Move Down" + > + <ChevronDown className="h-4 w-4" /> + </Button> + + {/* Move To (different parent) - disabled for top level nodes */} + <Button + variant="ghost" + size="icon" + className="h-7 w-7" + onClick={() => onMoveTo(node)} + disabled={!canMoveTo || isPending} + title={canMoveTo ? "Move To..." : "Cannot move top-level items"} + > + <ArrowUpDown className="h-4 w-4" /> + </Button> + + {/* Edit */} + <Button + variant="ghost" + size="icon" + className="h-7 w-7" + onClick={() => onEdit(node)} + disabled={isPending} + title="Edit" + > + <Pencil className="h-4 w-4" /> + </Button> + + {/* Add Sub-Group (for menu groups only) */} + {isMenuGroup && ( + <Button + variant="ghost" + size="icon" + className="h-7 w-7" + onClick={() => onAddGroup(node.id)} + disabled={isPending} + title="Add Sub-Group" + > + <Plus className="h-4 w-4" /> + </Button> + )} + </div> + </div> + ); +} + +export function MenuTree({ + nodes, + onEdit, + onMoveUp, + onMoveDown, + onMoveTo, + onAddGroup, + expandedIds, + onExpandedIdsChange, + isPending, +}: MenuTreeProps) { + const toggleExpand = useCallback((nodeId: number) => { + const next = new Set(expandedIds); + if (next.has(nodeId)) { + next.delete(nodeId); + } else { + next.add(nodeId); + } + onExpandedIdsChange(next); + }, [expandedIds, onExpandedIdsChange]); + + const renderTree = (nodeList: MenuTreeNode[], depth: number) => { + return nodeList.map((node, index) => { + const isExpanded = expandedIds.has(node.id); + const isExpandable = node.nodeType === "menu_group" || node.nodeType === "group"; + const hasChildren = node.children && node.children.length > 0; + + return ( + <div key={node.id} className="space-y-1"> + <TreeItem + node={node} + depth={depth} + isFirst={index === 0} + isLast={index === nodeList.length - 1} + onEdit={onEdit} + onMoveUp={onMoveUp} + onMoveDown={onMoveDown} + onMoveTo={onMoveTo} + onAddGroup={onAddGroup} + isExpanded={isExpanded} + onToggleExpand={() => toggleExpand(node.id)} + isPending={isPending} + /> + {isExpandable && isExpanded && hasChildren && ( + <div className="space-y-1"> + {renderTree(node.children!, depth + 1)} + </div> + )} + </div> + ); + }); + }; + + return <div className="space-y-1">{renderTree(nodes, 0)}</div>; +} + + |
