gui: drag-and-drop sidebar, tree expand, localization fixes

TreeNode.svelte:
- Native HTML5 drag-and-drop with move effect
- Lazy tree expand/collapse (arrow for container types only)
- Drop validation: no self-drop, container-only, descendant check
- 'case' icon kind added

App.svelte:
- toggleExpand loads children via ListChildren into tree
- handleNodeDrop calls MoveNode(draggedId, targetId), refreshes tree
- Root workspace area is a drop target (handleDropRoot)
- Overview section shows nodeKindLabel instead of raw type enum
- Context menu shows Create only for container types
- Create modal title uses 'Создать элемент'
- submitCreateNode expands parent after child creation

TemplateIcon.svelte: added 'case' icon (folder-like with dividers)
i18n: added nav.createNode key (ru+en)
This commit is contained in:
mirivlad 2026-06-03 03:18:04 +08:00
parent f022f46909
commit b2dcb116c9
10 changed files with 160 additions and 29 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -16,8 +16,8 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-BLPyZvHh.js"></script> <script type="module" crossorigin src="/assets/main-CBFn_7kC.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-DxSaNTHX.css"> <link rel="stylesheet" crossorigin href="/assets/main-C49DuBq6.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -641,7 +641,14 @@
const createdId = created ? created.id : null const createdId = created ? created.id : null
createInNode = null createInNode = null
createWithTemplate = undefined createWithTemplate = undefined
if (parentID) {
expanded = { ...expanded, [parentID]: true }
const children = await wailsCall('ListChildren', parentID) || []
setNodeChildren(workspaceTree, parentID, children)
workspaceTree = [...workspaceTree]
} else {
workspaceTree = await wailsCall('ListWorkspaceTree') || workspaceTree workspaceTree = await wailsCall('ListWorkspaceTree') || workspaceTree
}
if (createdId) { if (createdId) {
const node = await wailsCall('GetNodeDetail', createdId) const node = await wailsCall('GetNodeDetail', createdId)
if (node) { if (node) {
@ -662,8 +669,62 @@
} }
// ===== Tree expand/collapse ===== // ===== Tree expand/collapse =====
function toggleExpand(nodeId) { async function toggleExpand(nodeId) {
expanded = { ...expanded, [nodeId]: !expanded[nodeId] } const willExpand = !expanded[nodeId]
expanded = { ...expanded, [nodeId]: willExpand }
if (!willExpand) return
const children = await wailsCall('ListChildren', nodeId) || []
setNodeChildren(workspaceTree, nodeId, children)
workspaceTree = [...workspaceTree]
}
function setNodeChildren(tree, nodeId, children) {
for (const node of tree) {
if (node.id === nodeId) {
node.children = children
return true
}
if (node.children && setNodeChildren(node.children, nodeId, children)) {
return true
}
}
return false
}
// ===== Drag-and-drop =====
async function handleNodeDrop(draggedId, targetId) {
if (!draggedId || !targetId || draggedId === targetId) return
try {
const moved = await wailsCall('MoveNode', draggedId, targetId)
workspaceTree = await wailsCall('ListWorkspaceTree') || []
const refreshed = await wailsCall('GetNodeDetail', draggedId)
if (refreshed) {
selectedSection = ''
selectNode(refreshed)
}
} catch (e) { error = String(e) }
}
function handleDragOverRoot(e) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
async function handleDropRoot(e) {
e.preventDefault()
try {
const draggedId = e.dataTransfer.getData('text/plain')
if (!draggedId) return
const detail = await wailsCall('GetNodeDetail', draggedId)
if (!detail || !detail.parent_id) return
await wailsCall('MoveNode', draggedId, '')
workspaceTree = await wailsCall('ListWorkspaceTree') || []
const refreshed = await wailsCall('GetNodeDetail', draggedId)
if (refreshed) {
selectedSection = ''
selectNode(refreshed)
}
} catch (e) { error = String(e) }
} }
// ===== Node operations from context menu ===== // ===== Node operations from context menu =====
@ -1076,6 +1137,9 @@
<button class="nav-add-btn" on:click={openCreateRoot} title={t('common.create')}>+</button> <button class="nav-add-btn" on:click={openCreateRoot} title={t('common.create')}>+</button>
</div> </div>
{#if workspaceTree.length > 0} {#if workspaceTree.length > 0}
<div class="workspace-tree-area"
on:dragover|preventDefault={handleDragOverRoot}
on:drop={handleDropRoot}>
<TreeNode <TreeNode
nodes={workspaceTree} nodes={workspaceTree}
{expanded} {expanded}
@ -1083,7 +1147,9 @@
onSelect={selectNode} onSelect={selectNode}
onToggle={toggleExpand} onToggle={toggleExpand}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
onDrop={handleNodeDrop}
/> />
</div>
{:else} {:else}
<div class="nav-empty">{t('nav.noNodes')}</div> <div class="nav-empty">{t('nav.noNodes')}</div>
{/if} {/if}
@ -1162,7 +1228,7 @@
<div class="overview"> <div class="overview">
<h2>{selectedNode.title}</h2> <h2>{selectedNode.title}</h2>
<div class="meta-grid"> <div class="meta-grid">
<div class="meta-item"><span class="meta-label">{t('overview.type')}</span><span>{selectedNode.type}</span></div> <div class="meta-item"><span class="meta-label">{t('overview.type')}</span><span>{nodeKindLabel(selectedNode.type)}</span></div>
<div class="meta-item"><span class="meta-label">{t('overview.section')}</span><span>{selectedNode.section || '—'}</span></div> <div class="meta-item"><span class="meta-label">{t('overview.section')}</span><span>{selectedNode.section || '—'}</span></div>
<div class="meta-item"><span class="meta-label">{t('overview.created')}</span><span>{formatDate(selectedNode.createdAt)}</span></div> <div class="meta-item"><span class="meta-label">{t('overview.created')}</span><span>{formatDate(selectedNode.createdAt)}</span></div>
</div> </div>
@ -1495,7 +1561,7 @@
{#if showCreateNode} {#if showCreateNode}
<div class="modal-overlay" on:click|self={cancelCreateNode}> <div class="modal-overlay" on:click|self={cancelCreateNode}>
<div class="modal modal-create"> <div class="modal modal-create">
<h3>{createInNode ? t('nav.createInside') : t('case.new')}</h3> <h3>{t('nav.createNode')}</h3>
{#if createInNode} {#if createInNode}
<div class="create-context">{t('nav.createInside')} «{createInNode.title}»</div> <div class="create-context">{t('nav.createInside')} «{createInNode.title}»</div>
{/if} {/if}
@ -1539,7 +1605,7 @@
{#if contextMenu.visible} {#if contextMenu.visible}
<div class="context-menu-backdrop" on:click={closeContextMenu} on:contextmenu|preventDefault={closeContextMenu}> <div class="context-menu-backdrop" on:click={closeContextMenu} on:contextmenu|preventDefault={closeContextMenu}>
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px"> <div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px">
{#if contextMenu.node} {#if contextMenu.node && ['folder','project','client','document','recipe'].includes(contextMenu.node.type)}
<div class="context-menu-section">{t('common.create')}</div> <div class="context-menu-section">{t('common.create')}</div>
{#each (enabledTemplates.length > 0 ? enabledTemplates : [{ id: '', title: 'template.optionNone', icon: 'folder' }]) as tpl} {#each (enabledTemplates.length > 0 ? enabledTemplates : [{ id: '', title: 'template.optionNone', icon: 'folder' }]) as tpl}
<button class="context-menu-item" on:click={() => openCreateInNode(tpl)}> <button class="context-menu-item" on:click={() => openCreateInNode(tpl)}>
@ -1744,6 +1810,7 @@
.nav-item { display: block; width: 100%; padding: 8px 20px; border: none; background: none; color: #ccc; font-size: 13px; text-align: left; cursor: pointer; border-radius: 0; font-family: inherit; } .nav-item { display: block; width: 100%; padding: 8px 20px; border: none; background: none; color: #ccc; font-size: 13px; text-align: left; cursor: pointer; border-radius: 0; font-family: inherit; }
.nav-item:hover { background: #222233; } .nav-item:hover { background: #222233; }
.nav-item.selected { background: #2a2a4a; color: #fff; font-weight: 500; } .nav-item.selected { background: #2a2a4a; color: #fff; font-weight: 500; }
.workspace-tree-area { min-height: 32px; }
.nav-empty { padding: 8px 20px; color: #555; font-size: 12px; } .nav-empty { padding: 8px 20px; color: #555; font-size: 12px; }
.nav-label-row { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: #666; padding: 4px 20px; margin-bottom: 4px; display: flex; align-items: center; justify-content: space-between; } .nav-label-row { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: #666; padding: 4px 20px; margin-bottom: 4px; display: flex; align-items: center; justify-content: space-between; }
.nav-add-btn { background: none; border: none; color: #666; cursor: pointer; font-size: 16px; padding: 0 4px; font-family: inherit; line-height: 1; } .nav-add-btn { background: none; border: none; color: #666; cursor: pointer; font-size: 16px; padding: 0 4px; font-family: inherit; line-height: 1; }

View File

@ -8,6 +8,7 @@
export let onSelect export let onSelect
export let onToggle export let onToggle
export let onContextMenu export let onContextMenu
export let onDrop
function iconKind(node) { function iconKind(node) {
if (node.type === 'client' || node.template_id === 'client.default') return 'client' if (node.type === 'client' || node.template_id === 'client.default') return 'client'
@ -15,13 +16,61 @@
if (node.type === 'document' || node.template_id === 'document.default') return 'document' if (node.type === 'document' || node.template_id === 'document.default') return 'document'
if (node.type === 'recipe' || node.template_id === 'recipe.default') return 'recipe' if (node.type === 'recipe' || node.template_id === 'recipe.default') return 'recipe'
if (node.type === 'folder' || node.template_id === 'folder.default') return 'folder' if (node.type === 'folder' || node.template_id === 'folder.default') return 'folder'
if (node.type === 'case') return 'case'
if (node.type === 'note') return 'note' if (node.type === 'note') return 'note'
if (node.type === 'file') return 'file' if (node.type === 'file') return 'file'
return 'generic' return 'generic'
} }
function hasChildren(node) { function isContainer(node) {
return node.children && node.children.length > 0 return ['folder', 'project', 'client', 'document', 'recipe', 'case'].includes(node.type)
}
function hasLoadedChildren(node) {
return node.children !== undefined
}
function canDrop(target, draggedId) {
if (!target || !draggedId) return false
if (draggedId === target.id) return false
if (!isContainer(target)) return false
if (isDescendant(target, draggedId)) return false
return true
}
function isDescendant(node, ancestorId) {
if (!node.children) return false
for (const child of node.children) {
if (child.id === ancestorId) return true
if (isDescendant(child, ancestorId)) return true
}
return false
}
function getDraggedId(e) {
try { return e.dataTransfer.getData('text/plain') } catch { return '' }
}
function handleDragStart(e, node) {
e.stopPropagation()
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', node.id)
}
function handleDragOver(e, node) {
const id = getDraggedId(e)
if (!canDrop(node, id)) return
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
}
function handleDrop(e, node) {
e.preventDefault()
e.stopPropagation()
const draggedId = getDraggedId(e)
if (!canDrop(node, draggedId)) return
if (onDrop) onDrop(draggedId, node.id)
} }
</script> </script>
@ -29,24 +78,32 @@
<div class="tree-item" <div class="tree-item"
class:selected={selectedNodeId === node.id} class:selected={selectedNodeId === node.id}
style="padding-left: {level * 16 + 4}px" style="padding-left: {level * 16 + 4}px"
draggable="true"
on:dragstart={(e) => handleDragStart(e, node)}
on:dragover={(e) => handleDragOver(e, node)}
on:drop={(e) => handleDrop(e, node)}
on:contextmenu|preventDefault={(e) => onContextMenu && onContextMenu(e, node)}> on:contextmenu|preventDefault={(e) => onContextMenu && onContextMenu(e, node)}>
{#if hasChildren(node)} {#if isContainer(node)}
<button class="tree-toggle" on:click={() => onToggle && onToggle(node.id)} <button class="tree-toggle" on:click|stopPropagation={() => onToggle && onToggle(node.id)}
on:contextmenu|preventDefault={(e) => e.stopPropagation()}> on:contextmenu|preventDefault={(e) => e.stopPropagation()}>
<span class="tree-arrow">{expanded[node.id] ? '▾' : '▸'}</span> {#if expanded[node.id]}
<span class="tree-arrow"></span>
{:else}
<span class="tree-arrow"></span>
{/if}
</button> </button>
{:else} {:else}
<span class="tree-toggle-placeholder"></span> <span class="tree-toggle-placeholder"></span>
{/if} {/if}
<span class="tree-icon"><TemplateIcon kind={iconKind(node)} size={16} /></span> <span class="tree-icon"><TemplateIcon kind={iconKind(node)} size={16} /></span>
<span class="tree-label" on:click={() => onSelect && onSelect(node)} <span class="tree-label" on:click|stopPropagation={() => onSelect && onSelect(node)}
on:contextmenu|preventDefault={(e) => e.stopPropagation()}> on:contextmenu|preventDefault={(e) => e.stopPropagation()}>
{node.title} {node.title}
</span> </span>
</div> </div>
{#if expanded[node.id] && hasChildren(node)} {#if expanded[node.id] && hasLoadedChildren(node)}
<svelte:self nodes={node.children} {expanded} {selectedNodeId} level={level + 1} <svelte:self nodes={node.children || []} {expanded} {selectedNodeId} level={level + 1}
{onSelect} {onToggle} {onContextMenu} /> {onSelect} {onToggle} {onContextMenu} {onDrop} />
{/if} {/if}
{/each} {/each}

View File

@ -32,6 +32,11 @@
{:else if kind === 'file'} {:else if kind === 'file'}
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/> <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>
<polyline points="13 2 13 9 20 9"/> <polyline points="13 2 13 9 20 9"/>
{:else if kind === 'case'}
<rect x="3" y="5" width="18" height="14" rx="2"/>
<polyline points="3 10 21 10"/>
<line x1="8" y1="5" x2="8" y2="19"/>
<line x1="16" y1="5" x2="16" y2="19"/>
{:else} {:else}
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/> <line x1="12" y1="16" x2="12" y2="12"/>

View File

@ -15,6 +15,7 @@ export default {
'nav.noNodes': 'No nodes', 'nav.noNodes': 'No nodes',
'nav.openFolder': 'Open folder', 'nav.openFolder': 'Open folder',
'nav.createInside': 'Create inside', 'nav.createInside': 'Create inside',
'nav.createNode': 'Create element',
'nav.selectPrompt': 'Select a section or case', 'nav.selectPrompt': 'Select a section or case',
'nav.brand': 'Verstak', 'nav.brand': 'Verstak',

View File

@ -20,6 +20,7 @@ export default {
'nav.noNodes': 'Нет узлов', 'nav.noNodes': 'Нет узлов',
'nav.openFolder': 'Открыть папку', 'nav.openFolder': 'Открыть папку',
'nav.createInside': 'Создать внутри', 'nav.createInside': 'Создать внутри',
'nav.createNode': 'Создать элемент',
'tab.overview': 'Обзор', 'tab.overview': 'Обзор',
'tab.notes': 'Заметки', 'tab.notes': 'Заметки',