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;
}
</style>
<script type="module" crossorigin src="/assets/main-BLPyZvHh.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-DxSaNTHX.css">
<script type="module" crossorigin src="/assets/main-CBFn_7kC.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-C49DuBq6.css">
</head>
<body>
<div id="app"></div>

View File

@ -641,7 +641,14 @@
const createdId = created ? created.id : null
createInNode = null
createWithTemplate = undefined
workspaceTree = await wailsCall('ListWorkspaceTree') || workspaceTree
if (parentID) {
expanded = { ...expanded, [parentID]: true }
const children = await wailsCall('ListChildren', parentID) || []
setNodeChildren(workspaceTree, parentID, children)
workspaceTree = [...workspaceTree]
} else {
workspaceTree = await wailsCall('ListWorkspaceTree') || workspaceTree
}
if (createdId) {
const node = await wailsCall('GetNodeDetail', createdId)
if (node) {
@ -662,8 +669,62 @@
}
// ===== Tree expand/collapse =====
function toggleExpand(nodeId) {
expanded = { ...expanded, [nodeId]: !expanded[nodeId] }
async function toggleExpand(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 =====
@ -1076,14 +1137,19 @@
<button class="nav-add-btn" on:click={openCreateRoot} title={t('common.create')}>+</button>
</div>
{#if workspaceTree.length > 0}
<TreeNode
nodes={workspaceTree}
{expanded}
selectedNodeId={selectedNode?.id || ''}
onSelect={selectNode}
onToggle={toggleExpand}
onContextMenu={handleContextMenu}
/>
<div class="workspace-tree-area"
on:dragover|preventDefault={handleDragOverRoot}
on:drop={handleDropRoot}>
<TreeNode
nodes={workspaceTree}
{expanded}
selectedNodeId={selectedNode?.id || ''}
onSelect={selectNode}
onToggle={toggleExpand}
onContextMenu={handleContextMenu}
onDrop={handleNodeDrop}
/>
</div>
{:else}
<div class="nav-empty">{t('nav.noNodes')}</div>
{/if}
@ -1162,7 +1228,7 @@
<div class="overview">
<h2>{selectedNode.title}</h2>
<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.created')}</span><span>{formatDate(selectedNode.createdAt)}</span></div>
</div>
@ -1495,7 +1561,7 @@
{#if showCreateNode}
<div class="modal-overlay" on:click|self={cancelCreateNode}>
<div class="modal modal-create">
<h3>{createInNode ? t('nav.createInside') : t('case.new')}</h3>
<h3>{t('nav.createNode')}</h3>
{#if createInNode}
<div class="create-context">{t('nav.createInside')} «{createInNode.title}»</div>
{/if}
@ -1539,7 +1605,7 @@
{#if contextMenu.visible}
<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">
{#if contextMenu.node}
{#if contextMenu.node && ['folder','project','client','document','recipe'].includes(contextMenu.node.type)}
<div class="context-menu-section">{t('common.create')}</div>
{#each (enabledTemplates.length > 0 ? enabledTemplates : [{ id: '', title: 'template.optionNone', icon: 'folder' }]) as 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:hover { background: #222233; }
.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-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; }

View File

@ -8,6 +8,7 @@
export let onSelect
export let onToggle
export let onContextMenu
export let onDrop
function iconKind(node) {
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 === 'recipe' || node.template_id === 'recipe.default') return 'recipe'
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 === 'file') return 'file'
return 'generic'
}
function hasChildren(node) {
return node.children && node.children.length > 0
function isContainer(node) {
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>
@ -29,24 +78,32 @@
<div class="tree-item"
class:selected={selectedNodeId === node.id}
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)}>
{#if hasChildren(node)}
<button class="tree-toggle" on:click={() => onToggle && onToggle(node.id)}
{#if isContainer(node)}
<button class="tree-toggle" on:click|stopPropagation={() => onToggle && onToggle(node.id)}
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>
{:else}
<span class="tree-toggle-placeholder"></span>
{/if}
<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()}>
{node.title}
</span>
</div>
{#if expanded[node.id] && hasChildren(node)}
<svelte:self nodes={node.children} {expanded} {selectedNodeId} level={level + 1}
{onSelect} {onToggle} {onContextMenu} />
{#if expanded[node.id] && hasLoadedChildren(node)}
<svelte:self nodes={node.children || []} {expanded} {selectedNodeId} level={level + 1}
{onSelect} {onToggle} {onContextMenu} {onDrop} />
{/if}
{/each}

View File

@ -32,6 +32,11 @@
{: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"/>
<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}
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>

View File

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

View File

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