fix: rename node not found, A11y warnings cleanup

- fix renameId cleared before RenameNode API call (submitRename)
- add role, tabindex, keydown handlers to all interactive divs
- associate labels with inputs (wrap in label + .label-text)
- remove autofocus, unused CSS selectors, old root build.sh
- update frontend-dist assets
This commit is contained in:
mirivlad 2026-06-03 08:55:38 +08:00
parent 23b3d07071
commit 7d81250ebd
9 changed files with 105 additions and 76 deletions

View File

@ -1,26 +0,0 @@
#!/bin/bash
set -e
# Load NVM for Node.js
export NVM_DIR="${NVM_DIR:-$HOME/.config/nvm}"
if [ -s "$NVM_DIR/nvm.sh" ]; then
. "$NVM_DIR/nvm.sh"
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
. "$HOME/.nvm/nvm.sh"
fi
BUILD_DIR="build"
mkdir -p "$BUILD_DIR"
echo "==> Building frontend..."
cd frontend && npm run build && cd ..
cp -r frontend/dist/* cmd/verstak-gui/frontend-dist/
echo "==> Building GUI binary..."
go build -tags "webkit2_41 desktop production" -ldflags="-s -w" -o "$BUILD_DIR/verstak-gui-linux-amd64" ./cmd/verstak-gui/
echo "==> Building server binary..."
go build -ldflags="-s -w" -o "$BUILD_DIR/verstak-server-linux-amd64" ./cmd/verstak-server/
echo "==> Done. Binaries in $BUILD_DIR/:"
ls -lh "$BUILD_DIR/"

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-BH7waEiY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BzI_Zj56.css">
<script type="module" crossorigin src="/assets/main-CWWXp5bW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BBKDbfa7.css">
</head>
<body>
<div id="app"></div>

View File

@ -89,6 +89,7 @@
let clipboard = { items: [], mode: 'copy' }
let selectedIds = []
let dragIds = []
let dropRootValid = false
let showConfirm = false
let confirmTitle = ''
@ -565,11 +566,17 @@
return
}
showRename = false
const id = renameId
renameId = ''
try {
await wailsCall('RenameNode', renameId, name)
const parentId = currentFolderId || selectedNode.id
await loadFolder(parentId)
await wailsCall('RenameNode', id, name)
if (selectedNode && selectedNode.id === id) {
selectedNode = { ...selectedNode, title: name }
}
await reloadTreePreservingExpanded()
if (currentFolderId) {
await loadFolder(currentFolderId)
}
} catch (e) {
error = String(e)
}
@ -729,11 +736,11 @@
function handleDragOverRoot(e) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
e.currentTarget.classList.add('drop-valid')
dropRootValid = true
}
function handleDragLeaveRoot(e) {
e.currentTarget.classList.remove('drop-valid')
dropRootValid = false
}
// ===== Node operations from context menu =====
@ -1172,6 +1179,15 @@
}
syncLoading = false
}
function onKeyActivate(fn) {
return (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
fn()
}
}
}
</script>
<div class="app">
@ -1198,6 +1214,8 @@
</div>
{#if workspaceTree.length > 0}
<div class="workspace-tree-area"
class:drop-valid={dropRootValid}
role="region" aria-label={t('nav.workspace')}
on:dragover|preventDefault={handleDragOverRoot}
on:dragleave={handleDragLeaveRoot}
on:drop={handleDropRoot}>
@ -1252,7 +1270,7 @@
</header>
{#if error}
<div class="error-banner" on:click={() => error = ''}>
<div class="error-banner" role="button" tabindex="0" on:click={() => error = ''} on:keydown={onKeyActivate(() => error = '')}>
{error}
<button class="dismiss-btn" on:click|stopPropagation={() => error = ''} aria-label="Dismiss">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
@ -1315,7 +1333,7 @@
<div class="recent-section">
<h3>{t('overview.recentNotes')}</h3>
{#each notes.slice(0, 5) as note}
<div class="recent-note" on:click={() => openNote(note)}>
<div class="recent-note" role="button" tabindex="0" on:click={() => openNote(note)} on:keydown={onKeyActivate(() => openNote(note))}>
<span>{note.title}</span><span class="recent-date">{formatDate(note.createdAt)}</span>
</div>
{/each}
@ -1351,7 +1369,7 @@
{:else}
<div class="notes-list">
{#each notes as note}
<div class="note-card" on:click={() => openNote(note)}>
<div class="note-card" role="button" tabindex="0" on:click={() => openNote(note)} on:keydown={onKeyActivate(() => openNote(note))}>
<div class="note-card-title">{note.title}</div>
<div class="note-card-date">{formatDate(note.createdAt)}</div>
</div>
@ -1620,15 +1638,15 @@
{/if}
{#if showCreateNode}
<div class="modal-overlay" on:click|self={cancelCreateNode}>
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelCreateNode} on:keydown={onKeyActivate(cancelCreateNode)}>
<div class="modal modal-create">
<h3>{t('nav.createNode')}</h3>
{#if createInNode}
<div class="create-context">{t('nav.createInside')} «{createInNode.title}»</div>
{/if}
<div class="form-group">
<label>{t('template.select')}</label>
<div class="template-cards">
<span class="form-label">{t('template.select')}</span>
<div class="template-cards" role="group" aria-label={t('template.select')}>
<button class="template-card" class:selected={createWithTemplate === null}
on:click={() => createWithTemplate = null}>
<TemplateIcon kind="folder" size={24} />
@ -1650,9 +1668,10 @@
</div>
</div>
<div class="form-group">
<label>{t('common.name')}</label>
<label><span class="label-text">{t('common.name')}</span>
<input type="text" placeholder={t('case.namePlaceholder')} bind:value={newNodeTitle}
on:keydown={(e) => e.key === 'Enter' && submitCreateNode()} autofocus />
on:keydown={(e) => e.key === 'Enter' && submitCreateNode()} />
</label>
</div>
<div class="modal-actions">
<button class="btn btn-primary" on:click={submitCreateNode}
@ -1664,7 +1683,7 @@
{/if}
{#if contextMenu.visible}
<div class="context-menu-backdrop" on:click={closeContextMenu} on:contextmenu|preventDefault={closeContextMenu}>
<div class="context-menu-backdrop" role="button" tabindex="0" on:click={closeContextMenu} on:contextmenu|preventDefault={closeContextMenu} on:keydown={onKeyActivate(closeContextMenu)}>
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px">
{#if contextMenu.node && ['folder','project','client','document','recipe'].includes(contextMenu.node.type)}
<div class="context-menu-section">{t('common.create')}</div>
@ -1696,27 +1715,30 @@
{/if}
{#if showCreateAction}
<div class="modal-overlay" on:click|self={cancelCreateAction}>
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelCreateAction} on:keydown={onKeyActivate(cancelCreateAction)}>
<div class="modal">
<h3>{t('action.newAction')}</h3>
<div class="form-group">
<label>{t('common.name')}</label>
<label><span class="label-text">{t('common.name')}</span>
<input type="text" placeholder={t('action.namePlaceholder')} bind:value={newActionTitle}
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} autofocus />
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} />
</label>
</div>
<div class="form-group">
<label>{t('common.type')}</label>
<label><span class="label-text">{t('common.type')}</span>
<select bind:value={newActionKind}>
{#each actionKinds as k}
<option value={k.id}>{k.label}</option>
{/each}
</select>
</label>
</div>
<div class="form-group">
<label>{newActionKind === 'open_url' ? t('action.dataUrl') : newActionKind === 'open_folder' || newActionKind === 'open_file' ? t('action.dataPath') : t('action.dataCommand')}</label>
<label><span class="label-text">{newActionKind === 'open_url' ? t('action.dataUrl') : newActionKind === 'open_folder' || newActionKind === 'open_file' ? t('action.dataPath') : t('action.dataCommand')}</span>
<input type="text" placeholder={newActionKind === 'open_url' ? t('action.urlPlaceholder') : newActionKind === 'open_folder' || newActionKind === 'open_file' ? t('action.pathPlaceholder') : t('action.commandPlaceholder')}
bind:value={newActionData}
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} />
</label>
</div>
<div class="modal-actions">
<button class="btn btn-primary" on:click={submitCreateAction}>{t('common.create')}</button>
@ -1727,7 +1749,7 @@
{/if}
{#if showImportDialog && importSummary}
<div class="modal-overlay" on:click|self={cancelImport}>
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelImport} on:keydown={onKeyActivate(cancelImport)}>
<div class="modal">
<h3>{t('file.importTitle')} «{selectedNode ? selectedNode.title : ''}»</h3>
<div class="import-summary">
@ -1751,13 +1773,14 @@
{/if}
{#if showRename}
<div class="modal-overlay" on:click|self={cancelRename}>
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelRename} on:keydown={onKeyActivate(cancelRename)}>
<div class="modal">
<h3>{t('rename.title')}</h3>
<div class="form-group">
<label>{t('common.newName')}</label>
<label><span class="label-text">{t('common.newName')}</span>
<input type="text" bind:value={renameValue}
on:keydown={onRenameKeydown} />
</label>
</div>
{#if renameError}
<div class="rename-error">{renameError}</div>
@ -1782,7 +1805,7 @@
{/if}
{#if showSettings}
<div class="modal-overlay" on:click|self={closeSettings}>
<div class="modal-overlay" role="button" tabindex="0" on:click|self={closeSettings} on:keydown={onKeyActivate(closeSettings)}>
<div class="modal modal-sync">
<h3>{t('sync.settings')}</h3>
{#if syncStatus}
@ -1824,16 +1847,19 @@
</div>
{:else}
<div class="form-group">
<label>{t('sync.serverUrl')}</label>
<label><span class="label-text">{t('sync.serverUrl')}</span>
<input type="text" placeholder={t('sync.serverUrlPlaceholder')} bind:value={syncServerUrl} />
</label>
</div>
<div class="form-group">
<label>{t('sync.username')}</label>
<label><span class="label-text">{t('sync.username')}</span>
<input type="text" placeholder={t('sync.usernamePlaceholder')} bind:value={syncUsername} />
</label>
</div>
<div class="form-group">
<label>{t('sync.password')}</label>
<label><span class="label-text">{t('sync.password')}</span>
<input type="password" placeholder={t('sync.passwordPlaceholder')} bind:value={syncPassword} />
</label>
</div>
<div class="modal-actions" style="margin-top:12px">
<button class="btn" on:click={testConnection} disabled={syncLoading || !syncServerUrl}>{t('sync.test')}</button>
@ -1843,8 +1869,9 @@
<div style="margin-top:16px;padding-top:16px;border-top:1px solid #2a2a3c">
<div class="form-group">
<label>{t('sync.autoSync')}</label>
<label><span class="label-text">{t('sync.autoSync')}</span>
<input type="number" placeholder="0" bind:value={syncInterval} min="0" />
</label>
</div>
<button class="btn" on:click={saveSyncInterval} disabled={syncLoading}>{t('sync.saveInterval')}</button>
</div>
@ -1884,16 +1911,6 @@
.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:hover { color: #ccc; }
/* Tree items in sidebar */
.tree-item { display: flex; align-items: center; padding: 4px 8px 4px 0; cursor: default; font-size: 13px; color: #ccc; }
.tree-item:hover { background: #222233; }
.tree-item.selected { background: #2a2a4a; color: #fff; font-weight: 500; }
.tree-toggle { background: none; border: none; color: #666; cursor: pointer; padding: 2px 4px; font-size: 10px; width: 20px; text-align: center; flex-shrink: 0; font-family: inherit; line-height: 1; }
.tree-toggle:hover { color: #888; }
.tree-arrow { display: inline-block; }
.tree-spacer { display: inline-block; width: 12px; }
.tree-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 2px 4px; cursor: pointer; }
/* Context menu */
.context-menu-backdrop { position: fixed; inset: 0; z-index: 200; }
.context-menu { position: fixed; background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 4px; min-width: 180px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); }
@ -1999,7 +2016,6 @@
.empty-state .empty-icon { margin-bottom: 12px; color: #444; }
.empty-state .hint { font-size: 12px; color: #555; margin-top: 6px; }
.empty-state .empty-actions { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
.empty-note { font-size: 12px; color: #444; margin-top: 16px; }
/* Welcome */
.welcome { padding: 48px 24px; text-align: center; }
@ -2007,16 +2023,13 @@
.welcome p { color: #666; font-size: 14px; }
.error-text { color: #ff8888; }
/* FAB */
.fab { position: fixed; bottom: 24px; right: 24px; width: 56px; height: 56px; border-radius: 50%; background: #6366f1; color: #fff; font-size: 28px; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); }
.fab:hover { background: #4f46e5; }
/* Modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; }
.modal { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 12px; padding: 24px; width: 400px; max-width: 90vw; }
.modal h3 { font-size: 18px; margin-bottom: 16px; }
.form-group { margin-bottom: 12px; }
.form-group label { display: block; font-size: 12px; color: #666; margin-bottom: 4px; }
.form-group label { display: block; }
.form-group .label-text, .form-group .form-label { display: block; font-size: 12px; color: #666; margin-bottom: 4px; }
.form-group input, .form-group select { width: 100%; padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; }
.form-group select { appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; padding-right: 32px; }
.form-group input:focus, .form-group select:focus { outline: none; border-color: #6366f1; }

View File

@ -114,6 +114,13 @@
menuY = Math.min(e.clientY, window.innerHeight - 320)
menuOpen = true
}
function handleRowKeydown(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleClick(e)
}
}
</script>
<svelte:window on:click={closeMenu}/>
@ -124,6 +131,7 @@
tabindex="0"
draggable="true"
on:click={handleClick}
on:keydown={handleRowKeydown}
on:contextmenu={oncontextmenu}
on:dragstart={handleDragStart}
on:dragover={handleDragOver}
@ -194,7 +202,7 @@
{#if menuOpen}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="menu-backdrop" on:click|stopPropagation={closeMenu} role="presentation"></div>
<div class="menu" style="left: {menuX}px; top: {menuY}px; position: fixed;" on:click|stopPropagation role="menu">
<div class="menu" style="left: {menuX}px; top: {menuY}px; position: fixed;" on:click|stopPropagation on:keydown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); closeMenu() } }} role="menu" tabindex="-1">
<button class="menu-item" on:click={handleOpen} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{t('common.open')}

View File

@ -201,11 +201,33 @@
if (shouldShowToggle(node) && onToggle) onToggle(node.id)
}
function handleRowKeydown(e, node) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (onSelect) onSelect(node)
}
}
function handleIconClick(e, node) {
e.stopPropagation()
if (shouldShowToggle(node) && onToggle) onToggle(node.id)
}
function handleIconKeydown(e, node) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
if (shouldShowToggle(node) && onToggle) onToggle(node.id)
}
}
function handleLabelKeydown(e, node) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (onSelect) onSelect(node)
}
}
function computeDropInfo(allNodes, draggedId, pMap) {
const info = {}
function walk(list) {
@ -232,11 +254,14 @@
class:drop-invalid={dragOverNodeId === node.id && !dropAllowed[node.id]}
style="padding-left: {level * 16 + 4}px"
draggable="true"
role="button"
tabindex="0"
on:dragstart={(e) => handleDragStart(e, node)}
on:dragover={(e) => handleDragOver(e, node)}
on:dragleave={(e) => handleDragLeave(e, node)}
on:drop={(e) => handleDrop(e, node)}
on:click={(e) => handleRowClick(e, node)}
on:keydown={(e) => handleRowKeydown(e, node)}
on:dblclick={(e) => handleRowDblClick(e, node)}
on:contextmenu|preventDefault={(e) => onContextMenu && onContextMenu(e, node)}>
{#if shouldShowToggle(node)}
@ -246,10 +271,15 @@
{:else}
<span class="tree-toggle-placeholder"></span>
{/if}
<span class="tree-icon" on:click={(e) => handleIconClick(e, node)} on:dblclick|stopPropagation>
<span class="tree-icon" role="button" tabindex="-1"
on:click={(e) => handleIconClick(e, node)}
on:keydown={(e) => handleIconKeydown(e, node)}
on:dblclick|stopPropagation>
<TemplateIcon kind={iconKind(node)} size={16} />
</span>
<span class="tree-label" on:click|stopPropagation={() => onSelect && onSelect(node)}>
<span class="tree-label" role="button" tabindex="-1"
on:click|stopPropagation={() => onSelect && onSelect(node)}
on:keydown={(e) => handleLabelKeydown(e, node)}>
{node.title}
</span>
</div>

View File

@ -10,7 +10,7 @@
const dispatch = createEventDispatcher()
</script>
<div class="overlay" on:click|self={() => dispatch('cancel')} role="dialog" aria-modal="true" aria-label={title}>
<div class="overlay" on:click|self={() => dispatch('cancel')} on:keydown={(e) => { if (e.key === 'Escape') { e.preventDefault(); dispatch('cancel') } }} role="presentation">
<div class="modal">
<h3>{title}</h3>
<p class="message">{message}</p>

View File

@ -35,7 +35,7 @@
})
</script>
<div class="overlay" on:click|self={() => dispatch('close')} role="dialog" aria-modal="true" aria-label={`Preview: ${item.name}`}>
<div class="overlay" on:click|self={() => dispatch('close')} on:keydown={(e) => { if (e.key === 'Escape') { e.preventDefault(); dispatch('close') } }} role="presentation">
<div class="modal">
<header class="preview-header">
<div class="preview-title">