diff --git a/frontend/src/components/common/RemoveLinksModal.tsx b/frontend/src/components/common/RemoveLinksModal.tsx new file mode 100644 index 0000000..b2e9964 --- /dev/null +++ b/frontend/src/components/common/RemoveLinksModal.tsx @@ -0,0 +1,91 @@ +import { X } from 'lucide-react' +import { useEffect, useState } from 'react' + +interface LinkedNodeItem { + path: string + title: string +} + +interface RemoveLinksModalProps { + isOpen: boolean + linkedNodes: LinkedNodeItem[] + onClose: () => void + onRemoveSelected: (selectedPaths: string[]) => Promise | void + initialSelected?: string[] +} + +export default function RemoveLinksModal({ isOpen, linkedNodes, onClose, onRemoveSelected, initialSelected = [] }: RemoveLinksModalProps) { + const [selected, setSelected] = useState>(new Set(initialSelected)) + const [busy, setBusy] = useState(false) + + useEffect(() => { + // Reset selection when the modal is opened + if (isOpen) { + setSelected(new Set(initialSelected)) + } + }, [isOpen]) + + if (!isOpen) return null + + return ( +
+
e.stopPropagation()}> +
+

Remove links

+ +
+
+

Select links to remove from this node:

+
+
    + {linkedNodes.length === 0 && ( +
  • No links
  • + )} + {linkedNodes.map(ln => ( +
  • + { + setSelected(prev => { + const next = new Set(prev) + if (e.target.checked) next.add(ln.path); else next.delete(ln.path) + return next + }) + }} + /> +
    +
    {ln.title}
    +
    {ln.path}
    +
    +
  • + ))} +
+
+
+
+
{selected.size} selected
+
+ + +
+
+
+
+ ) +} + + diff --git a/frontend/src/components/graph/NodeContextMenu.tsx b/frontend/src/components/graph/NodeContextMenu.tsx index 6607cc1..cb57ed2 100644 --- a/frontend/src/components/graph/NodeContextMenu.tsx +++ b/frontend/src/components/graph/NodeContextMenu.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { desktopTemplatesApi } from '../../api/desktop-templates' import { templatesApi, Template } from '../../api/templates' import { useProjectStore } from '../../store/projectStore' -import { Plus, Trash2, Edit, Link, FolderPlus, CheckSquare, Paperclip, Loader2, CheckCircle, Lock, Unlock, ChevronDown, ChevronRight } from 'lucide-react' +import { Plus, Trash2, Edit, Link, Unlink, FolderPlus, CheckSquare, Paperclip, Loader2, CheckCircle, Lock, Unlock, ChevronDown, ChevronRight } from 'lucide-react' interface NodeContextMenuProps { x: number @@ -28,9 +28,10 @@ interface NodeContextMenuProps { isLocked?: boolean onToggleCollapse?: (nodeId: string) => void isCollapsed?: boolean + onRemoveLinks?: (nodeId: string) => void } -function NodeContextMenu({ x, y, nodeId, edgeId, isFolder, hasTask, onCreateNode, onDeleteNode, onCreateChildNode, onCreateChildFolder, onEditNode, onSeeTask, onUnlinkEdge, onAttachFiles, onUploadFiles, onDeleteMultiple, multiCount = 0, onClose, onToggleTrackTask, onToggleLock, isLocked, onToggleCollapse, isCollapsed }: NodeContextMenuProps) { +function NodeContextMenu({ x, y, nodeId, edgeId, isFolder, hasTask, onCreateNode, onDeleteNode, onCreateChildNode, onCreateChildFolder, onEditNode, onSeeTask, onUnlinkEdge, onAttachFiles, onUploadFiles, onDeleteMultiple, multiCount = 0, onClose, onToggleTrackTask, onToggleLock, isLocked, onToggleCollapse, isCollapsed, onRemoveLinks }: NodeContextMenuProps) { const menuRef = useRef(null) const { currentProject, currentProjectPath } = useProjectStore() const [templates, setTemplates] = useState([]) @@ -245,6 +246,14 @@ function NodeContextMenu({ x, y, nodeId, edgeId, isFolder, hasTask, onCreateNode Create Link + + {onToggleTrackTask && ( - -
-

Select links to remove from this node:

-
-
    - {linkedNodes.length === 0 && ( -
  • No links
  • - )} - {linkedNodes.map(ln => ( -
  • - { - setRemoveLinksSelected(prev => { - const next = new Set(prev) - if (e.target.checked) next.add(ln.path); else next.delete(ln.path) - return next - }) - }} - /> -
    -
    {ln.title}
    -
    {ln.path}
    -
    -
  • - ))} -
-
-
-
- - -
- - - )} + ({ path: ln.path, title: ln.title }))} + onClose={() => { setRemoveLinksOpen(false); setRemoveLinksSelected(new Set()) }} + onRemoveSelected={async (paths) => { + try { + const store = useNodeStore.getState() + const current = resolvedNodePathRef.current + if (!current) return + for (const p of paths) { + await store.removeSoftLink(current, p) + } + try { await store.loadNodes() } catch {} + setLinksExpanded(true) + await refreshCurrentEditorContent() + } finally { + setRemoveLinksOpen(false) + setRemoveLinksSelected(new Set()) + } + }} + /> {/* Hidden file input for attachments */} | null>(null) const [ctrlMetaPressed, setCtrlMetaPressed] = useState(false) + const [removeLinksOpen, setRemoveLinksOpen] = useState(false) + const [removeLinksNodePath, setRemoveLinksNodePath] = useState(null) const [hideUploads, setHideUploads] = useState(() => { try { const raw = localStorage.getItem(STORAGE_KEYS.GRAPH_HIDE_UPLOADS) @@ -1072,6 +1075,12 @@ function GraphView() { [navigate, verbweaverNodes, addEditorTab] ) + const openRemoveLinksForNode = useCallback((nodePath: string) => { + setRemoveLinksNodePath(nodePath) + setRemoveLinksOpen(true) + setContextMenu(null) + }, []) + // Handle deleting edge const handleDeleteEdge = useCallback( async (edgeId: string) => { @@ -1829,6 +1838,7 @@ function GraphView() { const id = contextMenu.nodeId || '' return !!graphCollapsed[id] })()} + onRemoveLinks={(nodeId) => openRemoveLinksForNode(nodeId)} onUploadFiles={() => { const input = document.getElementById('graph-canvas-upload-input') as HTMLInputElement | null input?.click() @@ -1965,6 +1975,41 @@ function GraphView() { parentPath={parentPathForNewNode} /> + {/* Remove Links Modal for Mind Map */} + { + if (!removeLinksNodePath) return [] + const source = verbweaverNodes.get(removeLinksNodePath) + if (!source || !Array.isArray(source.softLinks)) return [] + const results: { path: string; title: string }[] = [] + for (const targetId of source.softLinks) { + const target = Array.from(verbweaverNodes.values()).find(n => n.metadata?.id === targetId) + if (target) results.push({ path: target.path, title: target.metadata?.title || target.name }) + } + // De-duplicate by path + const uniq = new Map() + results.forEach(r => uniq.set(r.path, r)) + return Array.from(uniq.values()).sort((a, b) => a.title.localeCompare(b.title)) + })()} + onClose={() => { setRemoveLinksOpen(false); setRemoveLinksNodePath(null) }} + onRemoveSelected={async (paths) => { + try { + if (!removeLinksNodePath) return + for (const p of paths) { + await removeSoftLink(removeLinksNodePath, p) + } + await loadNodes() + toast.success('Link(s) removed') + } catch (e) { + toast.error('Failed to remove link(s)') + } finally { + setRemoveLinksOpen(false) + setRemoveLinksNodePath(null) + } + }} + /> + {/* Hidden file input for attachments */} {attachTarget && (