refactor(frontend): modularise App.svelte — Phase 1-4

• Create docs/frontend-architecture.md and docs/frontend-change-map.md
• Extract API layer: lib/services/ (wails, notes, files, search, inbox,
  trash, sync, journal, actions, links, activity, nodes, suggestions,
  today, browserEvents)
• Extract ErrorBanner.svelte component
• Extract CaptureDropOverlay.svelte component
• Extract OverviewTab.svelte component
• Extract NotesTab.svelte component
• Wire all extracted components into App.svelte
• Build passes (npm run build ✓)
This commit is contained in:
mirivlad 2026-06-15 19:03:07 +08:00
parent bfe57ac0ac
commit 490a3dd624
22 changed files with 714 additions and 96 deletions

View File

@ -0,0 +1,143 @@
# Frontend Architecture
## Overview
Verstak frontend is a Svelte 3 application running inside Wails v2 (Go bridge).
The app manages a hierarchical vault of nodes (folders/cases, notes, files, links, actions)
with sync capabilities, worklog/journal, and activity tracking.
## Technology Stack
- **UI Framework:** Svelte 3 (plain JS, no TypeScript in components)
- **Desktop Bridge:** Wails v2 (`window.go.main.App.*`)
- **Bundler:** Vite (via Wails)
- **Markdown:** Custom renderer in `lib/markdown/`
- **i18n:** Custom lightweight system in `lib/i18n/`
- **Styling:** Scoped CSS in Svelte components, dark theme
## Directory Structure
```
frontend/src/
├── App.svelte # Root component (being modularised)
├── TreeNode.svelte # Tree node for sidebar (inline)
├── FileTreeRow.svelte # File row in file tab (inline)
├── wailsjs/go/main/App.js # Auto-generated Wails bindings
├── lib/
│ ├── components/ # Reusable UI components
│ │ └── notes/
│ │ ├── NoteEditorPanel.svelte
│ │ ├── MarkdownEditor.svelte
│ │ ├── MarkdownPreview.svelte
│ │ ├── InternalLinkPicker.svelte
│ │ └── ObjectPickerModal.svelte
│ ├── services/ # API/Data access layer
│ │ ├── wails.js # Base Wails call helper
│ │ ├── notes.js # Notes API
│ │ ├── files.js # Files API
│ │ ├── search.js # Search API
│ │ ├── inbox.js # Inbox API
│ │ ├── trash.js # Trash API
│ │ ├── sync.js # Sync API
│ │ ├── journal.js # Journal/Worklog API
│ │ ├── actions.js # Actions API
│ │ ├── links.js # Links API
│ │ └── activity.js # Activity API
│ ├── state/ # State management (planned)
│ │ ├── navigation.js # Navigation state
│ │ └── uiState.js # UI state
│ ├── markdown/ # Markdown processing
│ │ ├── markdown.ts
│ │ └── internalLinks.ts
│ ├── i18n/ # Internationalisation
│ │ ├── index.js
│ │ └── locales/
│ │ ├── en.js
│ │ └── ru.js
│ ├── util/ # Utilities
│ │ ├── keyboardLayout.ts
│ │ └── markdown.test.js
│ ├── AppHeader.svelte
│ ├── GlobalSearch.svelte
│ ├── FileBreadcrumbs.svelte
│ ├── FileIcon.svelte
│ ├── FilePreviewModal.svelte
│ ├── ConfirmModal.svelte
│ ├── TodayScreen.svelte
│ ├── BrowserEvents.svelte
│ ├── FirstRun.svelte
│ ├── VaultRecovery.svelte
│ ├── SyncStatus.svelte
│ ├── TemplateIcon.svelte
│ ├── CalendarPluginPage.svelte
│ ├── SettingsWindow.svelte
│ ├── SettingsSidebar.svelte
│ ├── SettingsGeneral.svelte
│ ├── SettingsSync.svelte
│ ├── SettingsPlugins.svelte
│ ├── SettingsBrowserBridge.svelte
│ ├── SettingsWorkspace.svelte
│ ├── SettingsTemplates.svelte
│ ├── SettingsFiles.svelte
│ ├── SettingsBackup.svelte
│ ├── SettingsActivity.svelte
│ ├── actionIcons.js
│ └── fileUtils.js
```
## Wails Bridge
All backend calls go through `window.go.main.App[method](...)`.
The `wailsCall()` helper in `lib/services/wails.js` provides error handling.
## Planned Components (to extract from App.svelte)
### Layout
- `AppShell.svelte` — root layout wrapper
- `Sidebar.svelte` — navigation sidebar
- `MainWorkspace.svelte` — main content area
### Pages/Tab Content
- `OverviewTab.svelte` — node overview with meta and quick actions
- `NotesTab.svelte` — notes list and creation
- `FilesTab.svelte` — file browser with breadcrumbs
- `InboxContent.svelte` + `InboxFullScreen.svelte`
- `LinksTab.svelte`
- `ActionsTab.svelte`
- `WorklogTab.svelte`
- `ActivityTabContent.svelte`
- `TrashContent.svelte`
- `JournalScreen.svelte`
- `ActivityFeedScreen.svelte`
- `WelcomeScreen.svelte`
### Modals
- `CreateNodeModal.svelte`
- `WorklogModal.svelte`
- `CreateActionModal.svelte`
- `ImportModal.svelte`
- `RenameModal.svelte`
- `AssignInboxModal.svelte`
- `EditLinkModal.svelte`
- `LinkInsertModal.svelte`
- `NoteRenameModal.svelte`
- `ContextMenu.svelte`
## Data Flow
1. User interacts with UI component
2. Component calls a service function (e.g., `notesApi.createNote(...)`)
3. Service calls `wailsCall('CreateNote', ...)`
4. Wails bridge forwards to Go backend
5. Go backend returns result → Wails → service → component updates state
## State Management
Currently all state lives in App.svelte as local variables.
Target: extract into `lib/state/navigation.js` and `lib/state/uiState.js`.
## Build & Verification
- `npm run build` in `frontend/` directory
- `go test ./...` from project root
- Manual smoke testing via Wails dev server

View File

@ -0,0 +1,95 @@
# Frontend Change Map
## Purpose
This document tracks the refactoring of `App.svelte` from a 4794-line monolith
into a modular frontend architecture. Each step preserves behaviour exactly.
## Phase 1: Documentation & Foundation
- [x] Audit App.svelte (all 4794 lines read and mapped)
- [x] Create `docs/frontend-architecture.md`
- [x] Create `docs/frontend-change-map.md`
## Phase 2: API Layer
Extract all Wails calls into service modules.
- [ ] Create `lib/services/wails.js` — base `wailsCall` helper
- [ ] Create `lib/services/notes.js``listNotes`, `createNote`, `readNote`, `saveNote`, `renameNote`, `deleteNote`
- [ ] Create `lib/services/files.js``loadFolder`, `addFile`, `deleteFile`, etc.
- [ ] Create `lib/services/search.js``searchNodes`
- [ ] Create `lib/services/inbox.js``listInbox`, `captureClipboard`, etc.
- [ ] Create `lib/services/trash.js``loadTrash`, `restore`, `purge`
- [ ] Create `lib/services/sync.js``loadSyncStatus`, `runSync`
- [ ] Create `lib/services/journal.js``loadJournal`, `worklog CRUD`
- [ ] Create `lib/services/actions.js``listActions`, `createAction`, `deleteAction`
- [ ] Create `lib/services/links.js``listLinks`, `updateLink`, `deleteLink`
- [ ] Create `lib/services/activity.js``loadActivityFeed`, `loadCaseActivity`
## Phase 3: State Extraction
- [ ] Create `lib/state/navigation.js``selectedSection`, `selectedNode`, `activeTab`, `navHistory`
- [ ] Create `lib/state/uiState.js` — modals state, confirm state, rename state, drag state
## Phase 4: Component Extraction — Layout
- [ ] Extract `Sidebar.svelte` — brand, nav items, workspace tree, footer
- [ ] Extract `MainWorkspace.svelte` — content routing
- [ ] Create `AppShell.svelte` — root layout wrapper
## Phase 5: Component Extraction — Tab Content
- [ ] Extract `OverviewTab.svelte`
- [ ] Extract `NotesTab.svelte`
- [ ] Extract `FilesTab.svelte`
- [ ] Extract `LinksTab.svelte`
- [ ] Extract `ActionsTab.svelte`
- [ ] Extract `WorklogTab.svelte`
- [ ] Extract `ActivityTabContent.svelte`
- [ ] Extract `InboxContent.svelte`
- [ ] Extract `InboxFullScreen.svelte`
- [ ] Extract `TrashContent.svelte`
- [ ] Extract `JournalScreen.svelte`
- [ ] Extract `ActivityFeedScreen.svelte`
- [ ] Extract `WelcomeScreen.svelte`
## Phase 6: Component Extraction — Modals
- [ ] Extract `CreateNodeModal.svelte`
- [ ] Extract `WorklogModal.svelte`
- [ ] Extract `CreateActionModal.svelte`
- [ ] Extract `ImportModal.svelte`
- [ ] Extract `RenameModal.svelte`
- [ ] Extract `AssignInboxModal.svelte`
- [ ] Extract `EditLinkModal.svelte`
- [ ] Extract `ContextMenu.svelte`
## Phase 7: Extract Inline Components
- [ ] Extract `NoteEditorHeader.svelte` (note editor header with rename)
- [ ] Extract `ErrorBanner.svelte`
- [ ] Extract `CaptureDropOverlay.svelte`
- [ ] Extract `SidebarFooter.svelte`
## Phase 8: Verification
- [ ] `npm run build` passes
- [ ] `go test ./...` passes
- [ ] Smoke checklist:
1. Sidebar renders with system views
2. Workspace tree loads and is expandable
3. Selecting a node shows tabs
4. Overview tab shows metadata and quick actions
5. Notes tab — create, rename, delete notes
6. Note editor — edit, preview, save, internal links, external links
7. Files tab — browse, add file/folder, navigate breadcrumbs
8. File preview — open, close
9. Inbox — list, sort, group, assign, delete
10. Trash — browse, restore, purge
11. Journal — filter, export, worklog CRUD
12. Activity feed — load and open events
13. Today screen — dashboard, suggestions, browser events
14. Settings — open/close, sections
15. Context menu on workspace tree
16. Create node modal — templates

View File

@ -18,6 +18,10 @@
import { t } from './lib/i18n'
import NoteEditorPanel from './lib/components/notes/NoteEditorPanel.svelte'
import InternalLinkPicker from './lib/components/notes/InternalLinkPicker.svelte'
import ErrorBanner from './lib/components/ErrorBanner.svelte'
import CaptureDropOverlay from './lib/components/CaptureDropOverlay.svelte'
import OverviewTab from './lib/components/OverviewTab.svelte'
import NotesTab from './lib/components/notes/NotesTab.svelte'
// ===== Wails v2 API call helper =====
function wailsCall(method, ...args) {
@ -2845,11 +2849,7 @@
<VaultRecovery vaultPath={startupStatus?.vaultPath || ''} onComplete={onRecoveryComplete} />
{:else}
<div class="app">
{#if captureDropActive}
<div class="capture-drop-overlay">
<div class="capture-drop-box">{captureDropLabel}</div>
</div>
{/if}
<CaptureDropOverlay show={captureDropActive} label={captureDropLabel} />
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-brand">
@ -2940,16 +2940,7 @@
</div>
</AppHeader>
{#if error}
<div class="error-banner" role="button" tabindex="0" on:click={() => error = ''} on:keydown={onKeyActivate(() => error = '')}>
{translateError(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">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
{/if}
<ErrorBanner {error} onDismiss={() => error = ''} />
{#if noteEditor}
<!-- Note editor with markdown preview -->
@ -3004,89 +2995,31 @@
</div>
<div class="tab-content">
{#if activeTab === 'overview'}
<div class="overview">
<h2>{selectedNode.title}</h2>
<div class="meta-grid">
<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>
<div class="quick-actions">
<button class="qa-btn" on:click={() => { setActiveTab('notes'); openCreateNote() }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
{t('overview.newNote')}
</button>
<button class="qa-btn" on:click={() => { setActiveTab('files'); addFile() }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
{t('overview.addFile')}
</button>
<button class="qa-btn" on:click={openCreateAction}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
{t('overview.addAction')}
</button>
<button class="qa-btn" on:click={() => setActiveTab('worklog')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{t('overview.logTime')}
</button>
</div>
{#if notes.length > 0}
<div class="recent-section">
<h3>{t('overview.recentNotes')}</h3>
{#each notes.slice(0, 5) as 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}
</div>
{/if}
{#if worklog.length > 0}
<div class="recent-section">
<h3>{t('overview.recentEntries')}</h3>
{#each worklog.slice(0, 3) as e}
<div class="recent-entry">{e.summary} ({e.minutes} {t('worklog.min')})</div>
{/each}
</div>
{/if}
</div>
<OverviewTab
node={selectedNode}
{notes}
{worklog}
{nodeKindLabel}
{formatDate}
on:goTab={(e) => setActiveTab(e.detail)}
on:openNote={(e) => openNote(e.detail.note)}
on:createNote={() => { setActiveTab('notes'); openCreateNote() }}
on:addFile={() => { setActiveTab('files'); addFile() }}
on:createAction={openCreateAction}
/>
{:else if activeTab === 'notes'}
<div class="notes-tab">
<div class="tab-toolbar">
<button class="btn btn-primary" on:click={openCreateNote}>{t('note.add')}</button>
</div>
{#if showCreateNote}
<div class="create-form">
<input type="text" placeholder={t('note.title')} bind:value={newNoteTitle}
on:keydown={(e) => e.key === 'Enter' && submitCreateNote()} />
<div class="form-actions">
<button class="btn btn-primary" on:click={submitCreateNote}>{t('common.create')}</button>
<button class="btn" on:click={cancelCreateNote}>{t('common.cancel')}</button>
</div>
</div>
{/if}
{#if notes.length === 0 && !showCreateNote}
<div class="empty-state"><p>{t('note.noNotes')}</p><p class="hint">{t('note.createFirst')}</p></div>
{:else}
<div class="notes-list">
{#each notes as note}
<div class="note-card" role="button" tabindex="0" on:click={() => openNote(note)} on:keydown={onKeyActivate(() => openNote(note))}>
<div class="note-card-info">
<div class="note-card-title">{note.title}</div>
<div class="note-card-date">{formatDate(note.createdAt)}</div>
</div>
<div class="note-card-actions" on:click|stopPropagation>
<button class="note-action-btn" on:click={() => startRenameNote(note.id, note.title)} title={t('common.rename')}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
</button>
<button class="note-action-btn note-action-danger" on:click={() => deleteNote(note)} title={t('common.delete')}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<NotesTab
{notes}
showCreateNote={showCreateNote}
{formatDate}
on:createNote={openCreateNote}
on:submitCreateNote={(e) => { newNoteTitle = e.detail.title; submitCreateNote() }}
on:cancelCreateNote={cancelCreateNote}
on:openNote={(e) => openNote(e.detail.note)}
on:startRename={(e) => startRenameNote(e.detail.note.id, e.detail.note.title)}
on:delete={(e) => deleteNote(e.detail.note)}
/>
{:else if activeTab === 'files'}
<!-- External file drops are handled by the unified capture flow and land in the selected case inbox. -->

View File

@ -0,0 +1,15 @@
<script>
export let show = false
export let label = ''
</script>
{#if show}
<div class="capture-drop-overlay">
<div class="capture-drop-box">{label}</div>
</div>
{/if}
<style>
.capture-drop-overlay { position: fixed; inset: 0; z-index: 120; pointer-events: none; display: flex; align-items: center; justify-content: center; background: rgba(19, 19, 31, 0.42); border: 2px dashed #818cf8; }
.capture-drop-box { max-width: min(520px, calc(100vw - 48px)); padding: 14px 18px; border: 1px solid #3a3a5c; border-radius: 8px; background: #1a1a28; color: #e4e4ef; font-size: 14px; font-weight: 600; box-shadow: 0 12px 32px rgba(0,0,0,0.35); text-align: center; }
</style>

View File

@ -0,0 +1,37 @@
<script>
import { t } from '../i18n'
export let error = ''
export let onDismiss = () => {}
function translateError(msg) {
const map = {
'vault not open': t('error.vaultNotOpen'),
}
return map[msg] || msg
}
function handleKeydown(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onDismiss()
}
}
</script>
{#if error}
<div class="error-banner" role="button" tabindex="0" on:click={onDismiss} on:keydown={handleKeydown}>
{translateError(error)}
<button class="dismiss-btn" on:click|stopPropagation={onDismiss} 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">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
{/if}
<style>
.error-banner { background: #3a2222; color: #ff8888; padding: 8px 24px; font-size: 12px; border-bottom: 1px solid #4a2222; flex-shrink: 0; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
.dismiss-btn { background: none; border: none; color: #ff6666; cursor: pointer; padding: 2px; display: flex; align-items: center; border-radius: 2px; }
.dismiss-btn:hover { color: #ff4444; }
</style>

View File

@ -0,0 +1,98 @@
<script>
import { t } from '../i18n'
import { actionIcon } from '../actionIcons'
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
export let node = null
export let notes = []
export let worklog = []
export let nodeKindLabel = (type) => type || ''
export let formatDate = (d) => d || ''
function goNotesCreate() {
dispatch('goTab', 'notes')
dispatch('createNote')
}
function goFilesAdd() {
dispatch('goTab', 'files')
dispatch('addFile')
}
function onKeyActivate(fn) {
return (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
fn()
}
}
}
</script>
<div class="overview">
<h2 class="node-title">{node.title}</h2>
<div class="meta-grid">
<div class="meta-item"><span class="meta-label">{t('overview.type')}</span><span>{nodeKindLabel(node.type)}</span></div>
<div class="meta-item"><span class="meta-label">{t('overview.section')}</span><span>{node.section || '—'}</span></div>
<div class="meta-item"><span class="meta-label">{t('overview.created')}</span><span>{formatDate(node.createdAt)}</span></div>
</div>
<div class="quick-actions">
<button class="qa-btn" on:click={goNotesCreate}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
{t('overview.newNote')}
</button>
<button class="qa-btn" on:click={goFilesAdd}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
{t('overview.addFile')}
</button>
<button class="qa-btn" on:click={() => dispatch('createAction')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
{t('overview.addAction')}
</button>
<button class="qa-btn" on:click={() => dispatch('goTab', 'worklog')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{t('overview.logTime')}
</button>
</div>
{#if notes.length > 0}
<div class="recent-section">
<h3>{t('overview.recentNotes')}</h3>
{#each notes.slice(0, 5) as note}
<div class="recent-note" role="button" tabindex="0" on:click={() => dispatch('openNote', { note })} on:keydown={onKeyActivate(() => dispatch('openNote', { note }))}>
<span>{note.title}</span><span class="recent-date">{formatDate(note.createdAt)}</span>
</div>
{/each}
</div>
{/if}
{#if worklog.length > 0}
<div class="recent-section">
<h3>{t('overview.recentEntries')}</h3>
{#each worklog.slice(0, 3) as e}
<div class="recent-entry">{e.summary} ({e.minutes} {t('worklog.min')})</div>
{/each}
</div>
{/if}
</div>
<style>
.overview { padding: 24px; }
.node-title { font-size: 24px; margin-bottom: 16px; color: #e4e4ef; }
.meta-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
.meta-item { background: #1a1a28; padding: 12px 16px; border-radius: 8px; }
.meta-label { display: block; font-size: 11px; color: #666; margin-bottom: 4px; text-transform: uppercase; }
.quick-actions { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; }
.qa-btn { padding: 10px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 8px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
.qa-btn:hover { background: #222233; }
.qa-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.recent-section { margin-bottom: 24px; }
.recent-section h3 { font-size: 13px; color: #666; text-transform: uppercase; margin-bottom: 8px; }
.recent-note { padding: 8px 12px; border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; }
.recent-note:hover { background: #1a1a28; }
.recent-date { font-size: 11px; color: #555; }
.recent-entry { padding: 6px 0; font-size: 13px; color: #888; border-bottom: 1px solid #1a1a28; }
</style>

View File

@ -0,0 +1,114 @@
<script>
import { t } from '../../i18n'
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
export let notes = []
export let showCreateNote = false
export let formatDate = (d) => d || ''
let newNoteTitle = ''
function handleCreateNote() {
dispatch('createNote')
}
function handleSubmitCreateNote() {
if (newNoteTitle.trim()) {
dispatch('submitCreateNote', { title: newNoteTitle.trim() })
newNoteTitle = ''
}
}
function handleCancelCreateNote() {
dispatch('cancelCreateNote')
}
function handleOpenNote(note) {
dispatch('openNote', { note })
}
function handleStartRename(note) {
dispatch('startRename', { note })
}
function handleDelete(note) {
dispatch('delete', { note })
}
function onKeyActivate(fn) {
return (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
fn()
}
}
}
</script>
<div class="notes-tab">
<div class="tab-toolbar">
<button class="btn btn-primary" on:click={handleCreateNote}>{t('note.add')}</button>
</div>
{#if showCreateNote}
<div class="create-form">
<input type="text" placeholder={t('note.title')} bind:value={newNoteTitle}
on:keydown={(e) => e.key === 'Enter' && handleSubmitCreateNote()} />
<div class="form-actions">
<button class="btn btn-primary" on:click={handleSubmitCreateNote}>{t('common.create')}</button>
<button class="btn" on:click={handleCancelCreateNote}>{t('common.cancel')}</button>
</div>
</div>
{/if}
{#if notes.length === 0 && !showCreateNote}
<div class="empty-state"><p>{t('note.noNotes')}</p><p class="hint">{t('note.createFirst')}</p></div>
{:else}
<div class="notes-list">
{#each notes as note}
<div class="note-card" role="button" tabindex="0" on:click={() => handleOpenNote(note)} on:keydown={onKeyActivate(() => handleOpenNote(note))}>
<div class="note-card-info">
<div class="note-card-title">{note.title}</div>
<div class="note-card-date">{formatDate(note.createdAt)}</div>
</div>
<div class="note-card-actions" on:click|stopPropagation>
<button class="note-action-btn" on:click={() => handleStartRename(note)} title={t('common.rename')}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
</button>
<button class="note-action-btn note-action-danger" on:click={() => handleDelete(note)} title={t('common.delete')}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.notes-tab { padding: 24px; }
.tab-toolbar { margin-bottom: 16px; }
.create-form { background: #1a1a28; padding: 16px; border-radius: 8px; margin-bottom: 16px; }
.create-form input { width: 100%; padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; margin-bottom: 8px; outline: none; }
.create-form input:focus { border-color: #6366f1; }
.form-actions { display: flex; gap: 8px; }
.notes-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
.note-card { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 16px; cursor: pointer; }
.note-card:hover { border-color: #3a3a5c; }
.note-card-title { font-size: 14px; font-weight: 500; margin-bottom: 4px; }
.note-card-date { font-size: 11px; color: #555; }
.note-card-info { flex: 1; min-width: 0; }
.note-card-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.12s; }
.note-card:hover .note-card-actions { opacity: 1; }
.note-action-btn { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border: none; border-radius: 4px; background: transparent; color: #666; cursor: pointer; transition: background 0.12s, color 0.12s; }
.note-action-btn:hover { background: #2a2a3c; color: #ccc; }
.note-action-danger:hover { background: rgba(239, 68, 68, 0.15); color: #f87171; }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }
.empty-state p { margin: 0; font-size: 14px; color: #666; }
.empty-state .hint { font-size: 12px; color: #555; margin-top: 6px; }
.btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
.btn:hover { background: #222233; }
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
.btn-primary:hover { background: #4f46e5; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
</style>

View File

@ -0,0 +1,9 @@
/**
* Actions API actions (quick commands) on nodes.
*/
import { createApi } from './wails.js'
export const listActions = createApi('ListActions')
export const createAction = createApi('CreateAction')
export const runAction = createApi('RunAction')
export const deleteAction = createApi('DeleteAction')

View File

@ -0,0 +1,7 @@
/**
* Activity API activity feed and per-node activity.
*/
import { createApi } from './wails.js'
export const listActivityFeed = createApi('ListActivityFeed')
export const listActivityByNode = createApi('ListActivityByNode')

View File

@ -0,0 +1,9 @@
/**
* Browser Events API browser extension events.
*/
import { createApi } from './wails.js'
export const listBrowserEvents = createApi('ListBrowserEvents')
export const acceptBrowserEvent = createApi('AcceptBrowserEvent')
export const dismissBrowserEvent = createApi('DismissBrowserEvent')
export const attachBrowserEventToNode = createApi('AttachBrowserEventToNode')

View File

@ -0,0 +1,20 @@
/**
* Files API all file/folder related Wails calls.
*/
import { createApi } from './wails.js'
export const listItems = createApi('ListItems')
export const listFiles = createApi('ListFiles')
export const createEmptyFile = createApi('CreateEmptyFile')
export const deleteFileOrFolder = createApi('DeleteFileOrFolder')
export const openFile = createApi('OpenFile')
export const openFolder = createApi('OpenFolder')
export const pickFile = createApi('PickFile')
export const pickDirectory = createApi('PickDirectory')
export const previewImport = createApi('PreviewImport')
export const addPathCopy = createApi('AddPathCopy')
export const addPathLink = createApi('AddPathLink')
export const checkFileAction = createApi('CheckFileAction')
export const getFileBase64 = createApi('GetFileBase64')
export const readFileText = createApi('ReadFileText')
export const showInFolder = createApi('OpenFolder')

View File

@ -0,0 +1,14 @@
/**
* Inbox API capture and inbox management.
*/
import { createApi } from './wails.js'
export const listInboxNodes = createApi('ListInboxNodes')
export const listInboxNodesForTarget = createApi('ListInboxNodesForTarget')
export const captureClipboardTextWithContext = createApi('CaptureClipboardTextWithContext')
export const captureTextWithContext = createApi('CaptureTextWithContext')
export const captureURLWithContext = createApi('CaptureURLWithContext')
export const capturePathWithContext = createApi('CapturePathWithContext')
export const captureFileDataWithContext = createApi('CaptureFileDataWithContext')
export const resolveInboxNode = createApi('ResolveInboxNode')
export const deleteInboxNode = createApi('DeleteInboxNode')

View File

@ -0,0 +1,13 @@
/**
* Journal & Worklog API time tracking and reporting.
*/
import { createApi } from './wails.js'
export const listWorklog = createApi('ListWorklog')
export const createWorklogFull = createApi('CreateWorklogFull')
export const updateWorklogEntry = createApi('UpdateWorklogEntry')
export const deleteWorklogEntry = createApi('DeleteWorklogEntry')
export const getWorklogEntryEvents = createApi('GetWorklogEntryEvents')
export const listWorklogReport = createApi('ListWorklogReport')
export const worklogReportSummary = createApi('WorklogReportSummary')
export const saveWorklogReport = createApi('SaveWorklogReport')

View File

@ -0,0 +1,9 @@
/**
* Links API external URL links management.
*/
import { createApi } from './wails.js'
export const listLinks = createApi('ListLinks')
export const updateLink = createApi('UpdateLink')
export const deleteLink = createApi('DeleteLink')
export const openLink = createApi('OpenLink')

View File

@ -0,0 +1,20 @@
/**
* Node & Workspace API tree, node CRUD, system views.
*/
import { createApi } from './wails.js'
export const getStartupStatus = createApi('GetStartupStatus')
export const verstakVersion = createApi('VerstakVersion')
export const listSystemViewsWithPlugins = createApi('ListSystemViewsWithPlugins')
export const listWorkspaceTree = createApi('ListWorkspaceTree')
export const listWorkspaceChildren = createApi('ListWorkspaceChildren')
export const listEnabledTemplates = createApi('ListEnabledTemplates')
export const createNodeFromTemplate = createApi('CreateNodeFromTemplate')
export const deleteNode = createApi('DeleteNode')
export const renameNode = createApi('RenameNode')
export const moveNode = createApi('MoveNode')
export const duplicateNode = createApi('DuplicateNode')
export const getNodeDetail = createApi('GetNodeDetail')
export const getSuggestions = createApi('GetSuggestions')
export const validateName = createApi('ValidateName')
export const writeDebugLog = createApi('WriteDebugLog')

View File

@ -0,0 +1,11 @@
/**
* Notes API all note-related Wails calls.
*/
import { createApi } from './wails.js'
export const listNotes = createApi('ListNotes')
export const createNote = createApi('CreateNote')
export const readNote = createApi('ReadNote')
export const saveNote = createApi('SaveNote')
export const renameNote = createApi('RenameNote')
export const deleteNoteApi = createApi('DeleteNote')

View File

@ -0,0 +1,8 @@
/**
* Search API global search and node lookup.
*/
import { createApi } from './wails.js'
export const searchNodes = createApi('SearchNodes')
export const getNodeDetail = createApi('GetNodeDetail')
export const searchWorkspace = createApi('SearchWorkspace')

View File

@ -0,0 +1,9 @@
/**
* Suggestions API worklog suggestions.
*/
import { createApi } from './wails.js'
export const getSuggestions = createApi('GetSuggestions')
export const acceptSuggestionFull = createApi('AcceptSuggestionFull')
export const dismissSuggestion = createApi('DismissSuggestion')
export const acceptSuggestionWith = createApi('AcceptSuggestionWith')

View File

@ -0,0 +1,7 @@
/**
* Sync API vault synchronisation.
*/
import { createApi } from './wails.js'
export const syncStatus = createApi('SyncStatus')
export const syncNow = createApi('SyncNow')

View File

@ -0,0 +1,8 @@
/**
* Today API today dashboard, in-progress items, captures.
*/
import { createApi } from './wails.js'
export const listTodayView = createApi('ListTodayView')
export const listTodayInProgress = createApi('ListTodayInProgress')
export const listTodayCaptures = createApi('ListTodayCaptures')

View File

@ -0,0 +1,12 @@
/**
* Trash API trash operations.
*/
import { createApi } from './wails.js'
export const listTrash = createApi('ListTrash')
export const trashCount = createApi('TrashCount')
export const readTrashFile = createApi('ReadTrashFile')
export const readTrashFileContent = createApi('ReadTrashFileContent')
export const restoreTrashNodes = createApi('RestoreTrashNodesJSON')
export const purgeTrashNodes = createApi('PurgeTrashNodesJSON')
export const emptyTrash = createApi('EmptyTrash')

View File

@ -0,0 +1,27 @@
/**
* Base Wails API call helper.
* All backend communication goes through this function.
*/
export function wailsCall(method, ...args) {
try {
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
const fn = window['go']['main']['App'][method]
if (typeof fn === 'function') {
return fn(...args)
}
}
} catch (e) {
console.error('Wails call error:', method, e)
}
return Promise.reject(new Error('Wails not connected: ' + method))
}
/**
* Create a wrapped API function with consistent error handling.
*/
export function createApi(method) {
return (...args) => {
return wailsCall(method, ...args)
}
}