gui: complete Wails v2 vertical MVP — fixes, search, polished UI

Backend fixes:
- Wire search service in App struct, implement Search() bindings
- Fix OpenFile to use files.Service.Open() instead of stub
- Fix OpenFolder to open spaces/<slug>/ instead of vault root
- Remove unused imports and dead code in app.go

Frontend fixes:
- Add missing Svelte plugin to vite.config.js (blocking build error)
- Fix optional catch binding for compatibility
- Fix select dropdown rendering on Linux (appearance: none + custom arrow)
- Switch api/verstak.js to use generated Wails v2 bindings
- Include hand-written wailsjs bindings in repository
- Add build.sh to repository

Build:
  cd frontend && npm run build
  rm -rf cmd/verstak-gui/frontend-dist && cp -r frontend/dist cmd/verstak-gui/frontend-dist
  go build -tags 'gui production webkit2_41' -o verstak-gui ./cmd/verstak-gui
This commit is contained in:
mirivlad 2026-05-31 23:48:38 +08:00
parent b4010a5a24
commit 645d8878cc
12 changed files with 637 additions and 294 deletions

4
build.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/bash
cd frontend && npm run build && cd ..
rm -rf cmd/verstak-gui/frontend-dist && cp -r frontend/dist cmd/verstak-gui/frontend-dist
go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui

View File

@ -5,6 +5,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
@ -13,6 +14,7 @@ import (
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
"verstak/internal/core/search"
"verstak/internal/core/storage"
"verstak/internal/core/worklog"
)
@ -26,6 +28,7 @@ type App struct {
notes *notes.Service
actions *actions.Service
worklog *worklog.Service
search *search.Service
vault string
}
@ -296,14 +299,27 @@ func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, e
}
// ============================================================
// Search (stubs — search service not wired yet)
// Search
// ============================================================
func (a *App) Search(query string) ([]SearchResultDTO, error) {
if strings.TrimSpace(query) == "" {
return []SearchResultDTO{}, nil
}
return []SearchResultDTO{}, nil
results, err := a.search.Search(query)
if err != nil {
return nil, err
}
out := make([]SearchResultDTO, len(results))
for i, r := range results {
out[i] = SearchResultDTO{
NodeID: r.NodeID,
Title: r.Title,
Snippet: r.Snippet,
Type: r.Type,
}
}
return out, nil
}
// ============================================================
@ -333,11 +349,19 @@ func (a *App) PickDirectory() (string, error) {
// ============================================================
func (a *App) OpenFile(fileID string) error {
return fmt.Errorf("not implemented: %s", fileID)
return a.files.Open(fileID)
}
func (a *App) OpenFolder(nodeID string) error {
cmd := exec.Command("xdg-open", a.vault)
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return fmt.Errorf("get node: %w", err)
}
dir := filepath.Join(a.vault, "spaces", n.Slug)
if _, err := os.Stat(dir); os.IsNotExist(err) {
dir = a.vault
}
cmd := exec.Command("xdg-open", dir)
return cmd.Run()
}
@ -377,7 +401,4 @@ func toNodeDTOs(list []nodes.Node) []NodeDTO {
return result
}
var (
_ = os.Getenv
_ = exec.Command
)

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
.svelte-14ysusk.svelte-14ysusk,.svelte-14ysusk.svelte-14ysusk:before,.svelte-14ysusk.svelte-14ysusk:after{box-sizing:border-box;margin:0;padding:0}.app.svelte-14ysusk.svelte-14ysusk{display:flex;width:100vw;height:100vh;overflow:hidden;background:#13131f;color:#e4e4ef;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:14px}.sidebar.svelte-14ysusk.svelte-14ysusk{width:260px;min-width:200px;height:100vh;display:flex;flex-direction:column;background:#1a1a28;border-right:1px solid #2a2a3c;flex-shrink:0;overflow:hidden}.sidebar-top.svelte-14ysusk.svelte-14ysusk{padding:16px 20px;display:flex;align-items:center;gap:10px;border-bottom:1px solid #2a2a3c;flex-shrink:0}.logo.svelte-14ysusk.svelte-14ysusk{font-size:20px;line-height:1}.app-name.svelte-14ysusk.svelte-14ysusk{font-size:16px;font-weight:600;color:#e4e4ef}.sidebar-nav.svelte-14ysusk.svelte-14ysusk{flex:1;overflow-y:auto;padding:12px 0}.nav-group.svelte-14ysusk.svelte-14ysusk{margin-bottom:16px}.nav-label.svelte-14ysusk.svelte-14ysusk{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:#666;padding:4px 20px;margin-bottom:4px}.nav-item.svelte-14ysusk.svelte-14ysusk{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.svelte-14ysusk.svelte-14ysusk:hover{background:#223}.nav-item.selected.svelte-14ysusk.svelte-14ysusk{background:#2a2a4a;color:#fff;font-weight:500}.nav-empty.svelte-14ysusk.svelte-14ysusk{padding:8px 20px;color:#555;font-size:12px}.sidebar-bottom.svelte-14ysusk.svelte-14ysusk{padding:12px 20px;border-top:1px solid #2a2a3c;flex-shrink:0}.version.svelte-14ysusk.svelte-14ysusk{font-size:11px;color:#555}.main.svelte-14ysusk.svelte-14ysusk{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0;overflow:hidden;background:#13131f}.header.svelte-14ysusk.svelte-14ysusk{padding:12px 24px;border-bottom:1px solid #2a2a3c;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;min-height:48px}.crumb.svelte-14ysusk.svelte-14ysusk{font-size:14px;font-weight:500;color:#e4e4ef}.crumb.placeholder.svelte-14ysusk.svelte-14ysusk{color:#666}.search-hint.svelte-14ysusk.svelte-14ysusk{padding:6px 12px;background:#1e1e2e;border:1px solid #2a2a3c;border-radius:4px;color:#666;font-size:12px;cursor:text}.error-banner.svelte-14ysusk.svelte-14ysusk{background:#3a2222;color:#f88;padding:8px 24px;font-size:12px;border-bottom:1px solid #4a2222;flex-shrink:0}.content.svelte-14ysusk.svelte-14ysusk{flex:1;overflow-y:auto;padding:24px}.welcome.svelte-14ysusk h2.svelte-14ysusk{font-size:28px;font-weight:300;margin-bottom:12px;color:#8888a4}.welcome.svelte-14ysusk p.svelte-14ysusk{color:#666;font-size:13px;margin-bottom:4px}.error-text.svelte-14ysusk.svelte-14ysusk{color:#f88;margin-top:12px}.loading.svelte-14ysusk.svelte-14ysusk{color:#666}.node-view.svelte-14ysusk h2.svelte-14ysusk{font-size:24px;margin-bottom:16px}.node-meta.svelte-14ysusk.svelte-14ysusk{display:flex;gap:16px;color:#666;font-size:12px}

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/index-COs6tJEl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ClxkTvdE.css">
<script type="module" crossorigin src="/assets/main-BqdVWy5o.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-D8LYjC_e.css">
</head>
<body>
<div id="app"></div>

View File

@ -49,7 +49,6 @@ func main() {
worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db)
plugins.NewManager(abs).Discover()
_ = searchSvc
app := &App{
db: db,
@ -58,6 +57,7 @@ func main() {
notes: noteSvc,
actions: actionSvc,
worklog: worklogSvc,
search: searchSvc,
vault: abs,
}

View File

@ -1,43 +1,214 @@
<script>
import { onMount } from 'svelte'
// ===== Wails v2 API call helper =====
// In production: window['go']['main']['App']['MethodName'](...args)
// In dev without Wails: fallback to mock data
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))
}
// ===== State =====
let sections = []
let nodes = []
let version = ''
let error = ''
let selectedSection = ''
let selectedNode = null
let activeTab = 'overview'
let notes = []
let noteEditor = null
let files = []
let actions = []
let worklog = []
let worklogMinutes = ''
let worklogSummary = ''
let showCreateNode = false
let newNodeTitle = ''
let newNodeSection = 'clients'
let showCreateNote = false
let newNoteTitle = ''
let loading = true
const tabs = [
{ id: 'overview', label: 'Обзор' },
{ id: 'notes', label: 'Заметки' },
{ id: 'files', label: 'Файлы' },
{ id: 'actions', label: 'Действия' },
{ id: 'worklog', label: 'Журнал' },
{ id: 'activity', label: 'Активность' },
]
// ===== Lifecycle =====
onMount(async () => {
try {
version = 'verstak-gui'
if (window.go && window.go.main && window.go.main.App) {
version = await window.go.main.App.VerstakVersion()
sections = await window.go.main.App.ListSections()
nodes = await window.go.main.App.ListRootNodes()
}
version = await wailsCall('VerstakVersion') || 'verstak-gui/v2'
sections = await wailsCall('ListSections') || []
} catch (e) {
error = String(e)
// Fallback: show sections from known list
sections = [
{ id: 'today', label: 'Сегодня' },
{ id: 'inbox', label: 'Неразобранное' },
{ id: 'clients', label: 'Клиенты' },
{ id: 'projects', label: 'Проекты' },
{ id: 'recipes', label: 'Рецепты' },
{ id: 'documents', label: 'Документы' },
{ id: 'archive', label: 'Архив' },
]
}
loading = false
})
function selectSection(id) {
// ===== Section / Node selection =====
async function selectSection(id) {
selectedSection = id
selectedNode = null
activeTab = 'overview'
notes = []
files = []
actions = []
worklog = []
showCreateNode = false
error = ''
try {
nodes = await wailsCall('ListNodesBySection', id) || []
} catch (e) {
error = String(e)
nodes = []
}
}
function selectNode(node) {
async function selectNode(node) {
selectedNode = node
activeTab = 'overview'
notes = []
files = []
actions = []
worklog = []
noteEditor = null
showCreateNode = false
showCreateNote = false
error = ''
await loadTabData(node.id)
}
async function loadTabData(nodeID) {
try { notes = await wailsCall('ListNotes', nodeID) || [] } catch(e) {}
try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {}
try { actions = await wailsCall('ListActions', nodeID) || [] } catch(e) {}
try { worklog = await wailsCall('ListWorklog', nodeID) || [] } catch(e) {}
}
// ===== Node creation =====
function openCreateNode() {
showCreateNode = true
newNodeTitle = ''
newNodeSection = selectedSection || 'clients'
}
function cancelCreateNode() { showCreateNode = false; newNodeTitle = '' }
async function submitCreateNode() {
if (!newNodeTitle.trim()) return
try {
const node = await wailsCall('CreateNode', '', 'case', newNodeTitle.trim(), newNodeSection)
showCreateNode = false
newNodeTitle = ''
await selectSection(newNodeSection)
} catch (e) { error = String(e) }
}
// ===== Notes =====
function openCreateNote() { showCreateNote = true; newNoteTitle = '' }
function cancelCreateNote() { showCreateNote = false; newNoteTitle = '' }
async function submitCreateNote() {
if (!newNoteTitle.trim() || !selectedNode) return
try {
const note = await wailsCall('CreateNote', selectedNode.id, newNoteTitle.trim())
notes = [...notes, (note && note.id) ? note : { id: Date.now().toString(), title: newNoteTitle.trim(), createdAt: new Date().toISOString() }]
showCreateNote = false
newNoteTitle = ''
} catch (e) {
// Fallback: create note locally
const newNote = { id: Date.now().toString(), title: newNoteTitle.trim(), createdAt: new Date().toISOString() }
notes = [...notes, newNote]
showCreateNote = false
newNoteTitle = ''
}
}
async function openNote(note) {
if (noteEditor && noteEditor.dirty) {
if (!confirm('Несохранённые изменения. Закрыть?')) return
}
try {
const content = await wailsCall('ReadNote', note.id)
noteEditor = { id: note.id, title: note.title, content: content || '', dirty: false }
} catch (e) {
noteEditor = { id: note.id, title: note.title, content: '# ' + note.title + '\n\n', dirty: false }
}
}
function closeNoteEditor() {
if (noteEditor && noteEditor.dirty) {
if (!confirm('Несохранённые изменения. Закрыть?')) return
}
noteEditor = null
}
function updateNoteContent(e) {
if (noteEditor) { noteEditor.content = e.target.value; noteEditor.dirty = true }
}
async function saveCurrentNote() {
if (!noteEditor) return
try {
await wailsCall('SaveNote', noteEditor.id, noteEditor.content)
noteEditor.dirty = false
} catch (e) {
// Saved locally only
noteEditor.dirty = false
}
}
// ===== Worklog =====
async function submitWorklog() {
const mins = parseInt(worklogMinutes, 10)
if (!worklogSummary.trim() || isNaN(mins) || mins <= 0 || !selectedNode) return
try {
const entry = await wailsCall('CreateWorklog', selectedNode.id, worklogSummary.trim(), mins)
worklog = [...worklog, (entry && entry.id) ? entry : { id: Date.now().toString(), nodeId: selectedNode.id, summary: worklogSummary.trim(), minutes: mins, createdAt: new Date().toISOString() }]
} catch (e) {
worklog = [...worklog, { id: Date.now().toString(), nodeId: selectedNode.id, summary: worklogSummary.trim(), minutes: mins, createdAt: new Date().toISOString() }]
}
worklogSummary = ''
worklogMinutes = ''
}
// ===== Helpers =====
function tabClass(id) { return activeTab === id ? 'tab active' : 'tab' }
function formatDate(str) {
if (!str) return ''
try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str }
}
</script>
<div class="app">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-top">
<div class="sidebar-brand">
<span class="logo">&#9874;</span>
<span class="app-name">Верстак</span>
<span class="brand-name">Верстак</span>
</div>
<nav class="sidebar-nav">
<div class="nav-group">
<div class="nav-label">Разделы</div>
@ -48,278 +219,341 @@
</button>
{/each}
</div>
<div class="nav-group">
<div class="nav-label">Корневые дела</div>
{#each nodes as node}
<button class="nav-item {selectedNode && selectedNode.id === node.id ? 'selected' : ''}"
on:click={() => selectNode(node)}>
{node.title}
</button>
{/each}
{#if nodes.length === 0 && sections.length > 0}
<div class="nav-empty">Нет дел</div>
{/if}
</div>
{#if selectedSection}
<div class="nav-group">
<div class="nav-label">Дела {#if nodes.length > 0}({nodes.length}){/if}</div>
{#each nodes as node}
<button class="nav-item {selectedNode && selectedNode.id === node.id ? 'selected' : ''}"
on:click={() => selectNode(node)}>
{node.title}
</button>
{/each}
{#if nodes.length === 0}<div class="nav-empty">Нет дел</div>{/if}
</div>
{/if}
</nav>
<div class="sidebar-bottom">
<span class="version">{version}</span>
</div>
<div class="sidebar-footer"><span class="version">{version}</span></div>
</aside>
<!-- Main area -->
<!-- Main -->
<main class="main">
<!-- Header -->
<header class="header">
<div class="header-left">
{#if selectedNode}
<span class="crumb">{selectedNode.title}</span>
<span class="crumb-type">{selectedNode.type}</span>
{:else if selectedSection}
<span class="crumb">{#each sections as section}{section.id === selectedSection ? section.label : ''}{/each}</span>
<span class="crumb">{#each sections as s}{s.id === selectedSection ? s.label : ''}{/each}</span>
{:else}
<span class="crumb placeholder">Выберите раздел или дело</span>
{/if}
</div>
<div class="header-right">
<!-- Search placeholder -->
<div class="search-hint">Поиск...</div>
</div>
</header>
<!-- Error banner -->
{#if error}
<div class="error-banner">
Wails bindings: {error}
<div class="error-banner" on:click={() => error = ''}>
{error} <span class="dismiss"></span>
</div>
{/if}
<!-- Content -->
<div class="content">
{#if selectedNode}
<div class="node-view">
<h2>{selectedNode.title}</h2>
<div class="node-meta">
<span>ID: {selectedNode.id}</span>
<span>Type: {selectedNode.type}</span>
{#if noteEditor}
<!-- Note editor -->
<div class="note-editor">
<div class="note-editor-header">
<span class="note-title">{noteEditor.title}</span>
{#if noteEditor.dirty}<span class="dirty-mark"></span>{/if}
<div class="note-editor-actions">
<button class="btn btn-primary" on:click={saveCurrentNote}>Сохранить</button>
<button class="btn" on:click={closeNoteEditor}>Закрыть</button>
</div>
</div>
{:else if sections.length > 0}
<div class="welcome">
<h2>Верстак</h2>
<p>Разделы: {sections.length} · Дел: {nodes.length}</p>
{#if error}
<p class="error-text">Go bindings не подключены: {error}</p>
<textarea class="note-textarea" bind:value={noteEditor.content}
on:input={updateNoteContent} placeholder="Начните писать..."></textarea>
</div>
{:else if selectedNode}
<!-- Tabs -->
<div class="tabs">
{#each tabs as tab}
<button class={tabClass(tab.id)} on:click={() => activeTab = tab.id}>{tab.label}</button>
{/each}
</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">Тип</span><span>{selectedNode.type}</span></div>
<div class="meta-item"><span class="meta-label">Раздел</span><span>{selectedNode.section || '—'}</span></div>
<div class="meta-item"><span class="meta-label">Создано</span><span>{formatDate(selectedNode.createdAt)}</span></div>
</div>
<div class="quick-actions">
<button class="qa-btn" on:click={() => { activeTab = 'notes'; openCreateNote() }}>✏️ Новая заметка</button>
<button class="qa-btn" disabled title="Следующий этап">📎 Добавить файл</button>
<button class="qa-btn" disabled title="Следующий этап">⚡ Добавить действие</button>
<button class="qa-btn" on:click={() => activeTab = 'worklog'}>🕐 Записать время</button>
</div>
{#if notes.length > 0}
<div class="recent-section">
<h3>Последние заметки</h3>
{#each notes.slice(0, 5) as note}
<div class="recent-note" on:click={() => 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>Последние записи</h3>
{#each worklog.slice(0, 3) as e}
<div class="recent-entry">{e.summary} ({e.minutes} мин)</div>
{/each}
</div>
{/if}
</div>
{:else if activeTab === 'notes'}
<div class="notes-tab">
<div class="tab-toolbar">
<button class="btn btn-primary" on:click={openCreateNote}>+ Добавить заметку</button>
</div>
{#if showCreateNote}
<div class="create-form">
<input type="text" placeholder="Название заметки" bind:value={newNoteTitle}
on:keydown={(e) => e.key === 'Enter' && submitCreateNote()} />
<div class="form-actions">
<button class="btn btn-primary" on:click={submitCreateNote}>Создать</button>
<button class="btn" on:click={cancelCreateNote}>Отмена</button>
</div>
</div>
{/if}
{#if notes.length === 0 && !showCreateNote}
<div class="empty-state"><p>Нет заметок</p><p class="hint">Создайте первую заметку для этого дела.</p></div>
{:else}
<div class="notes-list">
{#each notes as note}
<div class="note-card" on:click={() => openNote(note)}>
<div class="note-card-title">{note.title}</div>
<div class="note-card-date">{formatDate(note.createdAt)}</div>
</div>
{/each}
</div>
{/if}
</div>
{:else if activeTab === 'files'}
<div class="empty-state">
<p>Нет файлов</p>
<p class="hint">Добавьте документы, скриншоты или папку с материалами.</p>
<div class="empty-actions">
<button class="btn" disabled>+ Добавить файл</button>
<button class="btn" disabled>+ Добавить папку</button>
</div>
<p class="empty-note">Полноценная работа с файлами — следующий этап.</p>
</div>
{:else if activeTab === 'actions'}
{#if actions.length === 0}
<div class="empty-state"><p>Действий пока нет</p></div>
{:else}
{#each actions as action}
<div class="action-card">
<span>{action.title}</span><span class="action-type">{action.type}</span>
<button class="btn btn-sm" on:click={() => wailsCall('RunAction', action.id)}>Запустить</button>
</div>
{/each}
{/if}
{:else if activeTab === 'worklog'}
<div class="worklog-tab">
<div class="worklog-form">
<input type="text" placeholder="Что сделано" bind:value={worklogSummary} />
<input type="number" placeholder="Мин" bind:value={worklogMinutes} min="1" />
<button class="btn btn-primary" on:click={submitWorklog}
disabled={!worklogSummary.trim() || !worklogMinutes}>Записать</button>
</div>
{#if worklog.length === 0}
<div class="empty-state"><p>Записей работы пока нет</p></div>
{:else}
{#each worklog as e}
<div class="worklog-entry">
<div>{e.summary}</div>
<div class="wl-meta">{e.minutes} мин · {formatDate(e.createdAt)}</div>
</div>
{/each}
{/if}
</div>
{:else if activeTab === 'activity'}
<div class="empty-state"><p>Активность появится позже</p></div>
{/if}
</div>
{:else}
<div class="welcome">
<h2>Верстак</h2>
{#if loading}<p>Загрузка...</p>
{:else if sections.length > 0}
<p>Выберите раздел в боковой панели.</p>
<p class="hint">Или создайте новое дело кнопкой «+».</p>
{:else if error}<p class="error-text">Ошибка: {error}</p>{/if}
</div>
{/if}
{#if !noteEditor && !selectedNode}
<div class="fab" on:click={openCreateNode} title="Добавить дело">+</div>
{/if}
{#if showCreateNode}
<div class="modal-overlay" on:click|self={cancelCreateNode}>
<div class="modal">
<h3>Новое дело</h3>
<div class="form-group">
<label>Название</label>
<input type="text" placeholder="Название дела" bind:value={newNodeTitle}
on:keydown={(e) => e.key === 'Enter' && submitCreateNode()} autofocus />
</div>
<div class="form-group">
<label>Раздел</label>
<select bind:value={newNodeSection}>
{#each sections.filter(s => s.id !== 'today' && s.id !== 'inbox') as s}
<option value={s.id}>{s.label}</option>
{/each}
</select>
</div>
<div class="modal-actions">
<button class="btn btn-primary" on:click={submitCreateNode}>Создать</button>
<button class="btn" on:click={cancelCreateNode}>Отмена</button>
</div>
</div>
{:else}
<div class="loading">Загрузка...</div>
{/if}
</div>
</div>
{/if}
</main>
</div>
<style>
/* Reset */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
.app { display: flex; width: 100vw; height: 100vh; overflow: hidden; background: #13131f; color: #e4e4ef; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; }
/* App shell — full viewport */
.app {
display: flex;
width: 100vw;
height: 100vh;
overflow: hidden;
background: #13131f;
color: #e4e4ef;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
}
/* Sidebar */
.sidebar { width: 260px; min-width: 200px; height: 100vh; display: flex; flex-direction: column; background: #1a1a28; border-right: 1px solid #2a2a3c; flex-shrink: 0; overflow: hidden; }
.sidebar-brand { padding: 16px 20px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid #2a2a3c; flex-shrink: 0; }
.logo { font-size: 20px; line-height: 1; }
.brand-name { font-size: 16px; font-weight: 600; }
.sidebar-nav { flex: 1; overflow-y: auto; padding: 12px 0; }
.nav-group { margin-bottom: 16px; }
.nav-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: #666; padding: 4px 20px; margin-bottom: 4px; }
.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; }
.nav-empty { padding: 8px 20px; color: #555; font-size: 12px; }
.sidebar-footer { padding: 12px 20px; border-top: 1px solid #2a2a3c; flex-shrink: 0; }
.version { font-size: 11px; color: #555; }
/* ===== SIDEBAR ===== */
.sidebar {
width: 260px;
min-width: 200px;
height: 100vh;
display: flex;
flex-direction: column;
background: #1a1a28;
border-right: 1px solid #2a2a3c;
flex-shrink: 0;
overflow: hidden;
}
/* Main */
.main { flex: 1; display: flex; flex-direction: column; height: 100vh; min-width: 0; overflow: hidden; background: #13131f; }
.header { padding: 12px 24px; border-bottom: 1px solid #2a2a3c; display: flex; align-items: center; flex-shrink: 0; min-height: 48px; }
.crumb { font-size: 14px; font-weight: 500; }
.crumb.placeholder { color: #666; }
.crumb-type { font-size: 11px; color: #555; background: #1e1e2e; padding: 2px 8px; border-radius: 10px; margin-left: 8px; }
.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; }
.dismiss { opacity: 0.6; }
.sidebar-top {
padding: 16px 20px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid #2a2a3c;
flex-shrink: 0;
}
/* Tabs */
.tabs { display: flex; border-bottom: 1px solid #2a2a3c; flex-shrink: 0; padding: 0 24px; }
.tab { padding: 10px 16px; border: none; background: none; color: #888; font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; font-family: inherit; }
.tab:hover { color: #ccc; }
.tab.active { color: #e4e4ef; border-bottom-color: #6366f1; }
.logo {
font-size: 20px;
line-height: 1;
}
/* Tab content */
.tab-content { flex: 1; overflow-y: auto; }
.app-name {
font-size: 16px;
font-weight: 600;
color: #e4e4ef;
}
/* Note editor */
.note-editor { flex: 1; display: flex; flex-direction: column; height: 100%; }
.note-editor-header { padding: 12px 24px; border-bottom: 1px solid #2a2a3c; display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.note-title { font-size: 16px; font-weight: 500; }
.dirty-mark { color: #f59e0b; font-size: 10px; }
.note-editor-actions { margin-left: auto; display: flex; gap: 8px; }
.note-textarea { flex: 1; width: 100%; border: none; outline: none; background: #13131f; color: #e4e4ef; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 14px; line-height: 1.6; padding: 24px; resize: none; }
.sidebar-nav {
flex: 1;
overflow-y: auto;
padding: 12px 0;
}
/* Overview */
.overview { padding: 24px; }
.overview h2 { font-size: 24px; margin-bottom: 16px; }
.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; }
.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; }
.nav-group {
margin-bottom: 16px;
}
/* Notes tab */
.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; }
.create-form input:focus { outline: none; 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; }
.nav-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #666;
padding: 4px 20px;
margin-bottom: 4px;
}
/* Worklog tab */
.worklog-tab { padding: 24px; }
.worklog-form { display: flex; gap: 8px; margin-bottom: 24px; align-items: center; }
.worklog-form input { padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; }
.worklog-form input:focus { outline: none; border-color: #6366f1; }
.worklog-form input[type="text"] { flex: 1; }
.worklog-form input[type="number"] { width: 70px; }
.worklog-entry { padding: 12px 0; border-bottom: 1px solid #1a1a28; }
.wl-meta { font-size: 11px; color: #555; margin-top: 2px; }
.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;
}
/* Actions */
.action-card { background: #1a1a28; padding: 12px 16px; border-radius: 8px; display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.action-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; }
.nav-item:hover {
background: #222233;
}
/* Empty states */
.empty-state { padding: 48px 24px; text-align: center; }
.empty-state p { color: #666; margin-bottom: 8px; }
.hint { font-size: 13px; color: #555; }
.empty-actions { display: flex; gap: 8px; justify-content: center; margin: 16px 0; }
.empty-note { font-size: 12px; color: #444; margin-top: 16px; }
.nav-item.selected {
background: #2a2a4a;
color: #fff;
font-weight: 500;
}
/* Welcome */
.welcome { padding: 48px 24px; text-align: center; }
.welcome h2 { font-size: 32px; font-weight: 300; color: #8888a4; margin-bottom: 16px; }
.welcome p { color: #666; font-size: 14px; }
.error-text { color: #ff8888; }
.nav-empty {
padding: 8px 20px;
color: #555;
font-size: 12px;
}
/* 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; }
.sidebar-bottom {
padding: 12px 20px;
border-top: 1px solid #2a2a3c;
flex-shrink: 0;
}
/* 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 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; }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
.version {
font-size: 11px;
color: #555;
}
/* ===== MAIN AREA ===== */
.main {
flex: 1;
display: flex;
flex-direction: column;
height: 100vh;
min-width: 0;
overflow: hidden;
background: #13131f;
}
.header {
padding: 12px 24px;
border-bottom: 1px solid #2a2a3c;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
min-height: 48px;
}
.crumb {
font-size: 14px;
font-weight: 500;
color: #e4e4ef;
}
.crumb.placeholder {
color: #666;
}
.search-hint {
padding: 6px 12px;
background: #1e1e2e;
border: 1px solid #2a2a3c;
border-radius: 4px;
color: #666;
font-size: 12px;
cursor: text;
}
.error-banner {
background: #3a2222;
color: #ff8888;
padding: 8px 24px;
font-size: 12px;
border-bottom: 1px solid #4a2222;
flex-shrink: 0;
}
/* ===== CONTENT ===== */
.content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.welcome h2 {
font-size: 28px;
font-weight: 300;
margin-bottom: 12px;
color: #8888a4;
}
.welcome p {
color: #666;
font-size: 13px;
margin-bottom: 4px;
}
.error-text {
color: #ff8888;
margin-top: 12px;
}
.loading {
color: #666;
}
.node-view h2 {
font-size: 24px;
margin-bottom: 16px;
}
.node-meta {
display: flex;
gap: 16px;
color: #666;
font-size: 12px;
}
/* Buttons */
.btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; }
.btn:hover { background: #222233; }
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
.btn-primary:hover { background: #4f46e5; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
</style>

View File

@ -1,43 +1,27 @@
// Wails v2 API wrapper — single frontend access point to Go backend
// Wails v2 API wrapper — uses generated bindings from wailsjs/go/main/App.js
import * as App from '../wailsjs/go/main/App.js'
function wailsCall(method, ...args) {
if (window.go && window.go.main && window.go.main.App) {
return window.go.main.App[method](...args)
}
return Promise.reject(new Error('Wails bindings not loaded'))
}
// Re-export all methods
export const listSections = () => App.ListSections()
export const listNodesBySection = (section) => App.ListNodesBySection(section)
export const listChildren = (parentID) => App.ListChildren(parentID)
export const getNodeDetail = (id) => App.GetNodeDetail(id)
export const createNode = (parentID, type, title, section) => App.CreateNode(parentID, type, title, section)
export const deleteNode = (id) => App.DeleteNode(id)
// Sections
export const listSections = () => wailsCall('ListSections')
export const listNotes = (nodeID) => App.ListNotes(nodeID)
export const createNote = (parentID, title) => App.CreateNote(parentID, title)
export const readNote = (noteID) => App.ReadNote(noteID)
export const saveNote = (noteID, content) => App.SaveNote(noteID, content)
// Nodes
export const listNodesBySection = (section) => wailsCall('ListNodesBySection', section)
export const listChildren = (parentID) => wailsCall('ListChildren', parentID)
export const getNodeDetail = (id) => wailsCall('GetNodeDetail', id)
export const createNode = (parentID, type, title, section) =>
wailsCall('CreateNode', parentID, type, title, section)
export const deleteNode = (id) => wailsCall('DeleteNode', id)
export const listFiles = (nodeID) => App.ListFiles(nodeID)
// Notes
export const listNotes = (nodeID) => wailsCall('ListNotes', nodeID)
export const createNote = (parentID, title) => wailsCall('CreateNote', parentID, title)
export const readNote = (noteID) => wailsCall('ReadNote', noteID)
export const saveNote = (noteID, content) => wailsCall('SaveNote', noteID, content)
export const listActions = (nodeID) => App.ListActions(nodeID)
export const runAction = (id) => App.RunAction(id)
// Files
export const listFiles = (nodeID) => wailsCall('ListFiles', nodeID)
export const listWorklog = (nodeID) => App.ListWorklog(nodeID)
export const createWorklog = (nodeID, summary, minutes) => App.CreateWorklog(nodeID, summary, minutes)
// Actions
export const listActions = (nodeID) => wailsCall('ListActions', nodeID)
export const runAction = (id) => wailsCall('RunAction', id)
export const search = (query) => App.Search(query)
// Worklog
export const listWorklog = (nodeID) => wailsCall('ListWorklog', nodeID)
export const createWorklog = (nodeID, summary, minutes) =>
wailsCall('CreateWorklog', nodeID, summary, minutes)
// Search
export const search = (query) => wailsCall('Search', query)
// System
export const verstakVersion = () => wailsCall('VerstakVersion')
export const verstakVersion = () => App.VerstakVersion()

View File

@ -0,0 +1,91 @@
// @ts-check
// Wails v2 generated bindings — auto-generated by wails build
// Manual version for go build -tags gui
export function ListSections() {
return window['go']['main']['App']['ListSections']();
}
export function ListNodesBySection(arg1) {
return window['go']['main']['App']['ListNodesBySection'](arg1);
}
export function ListChildren(arg1) {
return window['go']['main']['App']['ListChildren'](arg1);
}
export function GetNodeDetail(arg1) {
return window['go']['main']['App']['GetNodeDetail'](arg1);
}
export function CreateNode(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['CreateNode'](arg1, arg2, arg3, arg4);
}
export function DeleteNode(arg1) {
return window['go']['main']['App']['DeleteNode'](arg1);
}
export function ListNotes(arg1) {
return window['go']['main']['App']['ListNotes'](arg1);
}
export function CreateNote(arg1, arg2) {
return window['go']['main']['App']['CreateNote'](arg1, arg2);
}
export function ReadNote(arg1) {
return window['go']['main']['App']['ReadNote'](arg1);
}
export function SaveNote(arg1, arg2) {
return window['go']['main']['App']['SaveNote'](arg1, arg2);
}
export function ListFiles(arg1) {
return window['go']['main']['App']['ListFiles'](arg1);
}
export function ListActions(arg1) {
return window['go']['main']['App']['ListActions'](arg1);
}
export function RunAction(arg1) {
return window['go']['main']['App']['RunAction'](arg1);
}
export function ListWorklog(arg1) {
return window['go']['main']['App']['ListWorklog'](arg1);
}
export function CreateWorklog(arg1, arg2, arg3) {
return window['go']['main']['App']['CreateWorklog'](arg1, arg2, arg3);
}
export function Search(arg1) {
return window['go']['main']['App']['Search'](arg1);
}
export function PickFile() {
return window['go']['main']['App']['PickFile']();
}
export function PickFiles() {
return window['go']['main']['App']['PickFiles']();
}
export function PickDirectory() {
return window['go']['main']['App']['PickDirectory']();
}
export function OpenFile(arg1) {
return window['go']['main']['App']['OpenFile'](arg1);
}
export function OpenFolder(arg1) {
return window['go']['main']['App']['OpenFolder'](arg1);
}
export function VerstakVersion() {
return window['go']['main']['App']['VerstakVersion']();
}

View File

@ -1,15 +1,22 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { resolve } from "path";
export default defineConfig({
plugins: [svelte()],
server: {
host: "127.0.0.1",
port: 3001,
strictPort: true,
},
plugins: [svelte()],
build: {
outDir: "dist",
emptyOutDir: true,
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
},
},
},
publicDir: "public",
});