summaryrefslogtreecommitdiff
path: root/lib/menu-v2/components/menu-tree.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/menu-v2/components/menu-tree.tsx')
-rw-r--r--lib/menu-v2/components/menu-tree.tsx282
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>;
+}
+
+