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:
parent
f022f46909
commit
b2dcb116c9
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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export default {
|
|||
'nav.noNodes': 'Нет узлов',
|
||||
'nav.openFolder': 'Открыть папку',
|
||||
'nav.createInside': 'Создать внутри',
|
||||
'nav.createNode': 'Создать элемент',
|
||||
|
||||
'tab.overview': 'Обзор',
|
||||
'tab.notes': 'Заметки',
|
||||
|
|
|
|||
Loading…
Reference in New Issue