Author SHA1 Message Date
mirivlad b4010a5a24 gui: Wails v2 vertical MVP — bindings + UI
Go bindings (cmd/verstak-gui/app.go):
- Wire notes.Service, files.Service, actions.Service, worklog.Service
- CreateNote, ListNotes, ReadNote, SaveNote
- ListFiles, ListActions, RunAction
- ListWorklog, CreateWorklog
- Fix DTO mappings for core types

Frontend (frontend/src/):
- api/verstak.js: Wails v2 API wrapper (window.go.main.App)
- App.svelte: full rewrite with sidebar + 6 tabs + notes editor
- Section filtering → nodes by section
- Notes: create, open textarea editor, save, dirty tracking
- Quick actions, worklog entries, empty states
- Node creation modal with section select

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
  ./verstak-gui
2026-05-31 20:37:46 +08:00
mirivlad 4a8f4e3319 docs: mark Wails vertical MVP as current stage 2026-05-31 20:15:45 +08:00
mirivlad ee503c338f gui: fix layout — full viewport, dark theme, sidebar+main
Problem: UI appeared as narrow dark panel on white background with scrollbar.

Root causes:
- html/body had no reset — default browser margin/padding = white borders
- index.html referenced non-existent /style.css
- .app used height:100vh but no width:100vw or overflow:hidden
- sidebar used fixed width instead of flexbox

Fixed:
- index.html: added critical CSS reset (html/body/#app = 100%, margin:0, overflow:hidden, dark bg)
- index.html: removed /style.css ref, changed lang to ru, title to Верстак
- App.svelte: complete layout rewrite
  - .app = flex, 100vw x 100vh, overflow:hidden
  - sidebar = 260px width, full height, flex column
  - main = flex:1, full height, flex column (header + content)
  - sidebar nav with sections + nodes using <button> elements
  - content area fills remaining space
  - proper dark theme colors throughout
- Rebuilt frontend/dist and cmd/verstak-gui/frontend-dist
2026-05-31 19:54:07 +08:00
11 changed files with 856 additions and 105 deletions
+383
View File
@@ -0,0 +1,383 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"verstak/internal/core/actions"
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
"verstak/internal/core/storage"
"verstak/internal/core/worklog"
)
// App is the Wails v2 application adapter. It wraps core services.
type App struct {
ctx context.Context
db *storage.DB
nodes *nodes.Repository
files *files.Service
notes *notes.Service
actions *actions.Service
worklog *worklog.Service
vault string
}
// startup is called when the app starts. Store context.
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
}
// ============================================================
// DTOs
// ============================================================
type NodeDTO struct {
ID string `json:"id"`
ParentID string `json:"parentId"`
Title string `json:"title"`
Type string `json:"type"`
Section string `json:"section"`
Path string `json:"path"`
CreatedAt string `json:"createdAt"`
}
type SectionDTO struct {
ID string `json:"id"`
Label string `json:"label"`
}
type NoteDTO struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Format string `json:"format"`
CreatedAt string `json:"createdAt"`
}
type FileDTO struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
Mime string `json:"mime"`
IsDir bool `json:"isDir"`
Missing bool `json:"missing"`
}
type ActionDTO struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
Title string `json:"title"`
Type string `json:"type"`
Data string `json:"data"`
}
type WorklogDTO struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
Summary string `json:"summary"`
Minutes int `json:"minutes"`
CreatedAt string `json:"createdAt"`
}
type SearchResultDTO struct {
NodeID string `json:"nodeId"`
Title string `json:"title"`
Snippet string `json:"snippet"`
Type string `json:"type"`
}
// ============================================================
// Sections
// ============================================================
func (a *App) ListSections() []SectionDTO {
return []SectionDTO{
{ID: "today", Label: "Сегодня"},
{ID: "inbox", Label: "Неразобранное"},
{ID: "clients", Label: "Клиенты"},
{ID: "projects", Label: "Проекты"},
{ID: "recipes", Label: "Рецепты"},
{ID: "documents", Label: "Документы"},
{ID: "archive", Label: "Архив"},
}
}
// ============================================================
// Nodes
// ============================================================
func (a *App) ListNodesBySection(section string) ([]NodeDTO, error) {
list, err := a.nodes.ListRoots(false, section)
if err != nil {
return nil, err
}
return toNodeDTOs(list), nil
}
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
list, err := a.nodes.ListChildren(parentID, false)
if err != nil {
return nil, err
}
return toNodeDTOs(list), nil
}
func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) {
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return nil, err
}
dto := toNodeDTO(n)
return &dto, nil
}
func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, error) {
n, err := a.nodes.Create(parentID, nodeType, title, section)
if err != nil {
return nil, err
}
dto := toNodeDTO(n)
return &dto, nil
}
func (a *App) DeleteNode(id string) error {
return a.nodes.SoftDelete(id)
}
// ============================================================
// Notes
// ============================================================
// ListNotes returns note-type children of a node.
func (a *App) ListNotes(nodeID string) ([]NodeDTO, error) {
children, err := a.nodes.ListChildren(nodeID, false)
if err != nil {
return nil, err
}
var result []NodeDTO
for i := range children {
if children[i].Type == nodes.TypeNote {
result = append(result, toNodeDTO(&children[i]))
}
}
return result, nil
}
// CreateNote creates a note under a parent node.
func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
node, _, err := a.notes.Create(parentID, title, "")
if err != nil {
return nil, err
}
dto := toNodeDTO(node)
return &dto, nil
}
// ReadNote reads note content.
func (a *App) ReadNote(noteID string) (string, error) {
return a.notes.Read(noteID)
}
// SaveNote saves note content.
func (a *App) SaveNote(noteID, content string) error {
return a.notes.Save(noteID, content)
}
// ============================================================
// Files
// ============================================================
func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
records, err := a.files.ListByNode(nodeID)
if err != nil {
return nil, err
}
result := make([]FileDTO, len(records))
for i := range records {
isDir := records[i].MIME == "inode/directory"
missing := false
result[i] = FileDTO{
ID: records[i].ID,
NodeID: records[i].NodeID,
Name: records[i].Filename,
Path: records[i].Path,
Size: records[i].Size,
Mime: records[i].MIME,
IsDir: isDir,
Missing: missing,
}
}
return result, nil
}
// ============================================================
// Actions
// ============================================================
func (a *App) ListActions(nodeID string) ([]ActionDTO, error) {
list, err := a.actions.ListByNode(nodeID)
if err != nil {
return nil, err
}
result := make([]ActionDTO, len(list))
for i := range list {
data := list[i].Command
if list[i].URL != "" {
data = list[i].URL
}
result[i] = ActionDTO{
ID: list[i].ID,
NodeID: list[i].NodeID,
Title: list[i].Title,
Type: list[i].Kind,
Data: data,
}
}
return result, nil
}
func (a *App) RunAction(id string) error {
_, err := a.actions.Run(id)
return err
}
// ============================================================
// Worklog
// ============================================================
func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
list, err := a.worklog.ListByNode(nodeID)
if err != nil {
return nil, err
}
result := make([]WorklogDTO, len(list))
for i := range list {
mins := 0
if list[i].Minutes != nil {
mins = *list[i].Minutes
}
result[i] = WorklogDTO{
ID: list[i].ID,
NodeID: list[i].NodeID,
Summary: list[i].Summary,
Minutes: mins,
CreatedAt: list[i].CreatedAt.Format("2006-01-02T15:04:05Z"),
}
}
return result, nil
}
func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error) {
entry, err := a.worklog.Add(nodeID, summary, "", minutes, false, false)
if err != nil {
return nil, err
}
mins := 0
if entry.Minutes != nil {
mins = *entry.Minutes
}
dto := &WorklogDTO{
ID: entry.ID,
NodeID: entry.NodeID,
Summary: entry.Summary,
Minutes: mins,
CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
return dto, nil
}
// ============================================================
// Search (stubs — search service not wired yet)
// ============================================================
func (a *App) Search(query string) ([]SearchResultDTO, error) {
if strings.TrimSpace(query) == "" {
return []SearchResultDTO{}, nil
}
return []SearchResultDTO{}, nil
}
// ============================================================
// File Dialogs (Wails v2 Runtime)
// ============================================================
func (a *App) PickFile() (string, error) {
return wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: "Выберите файл",
})
}
func (a *App) PickFiles() ([]string, error) {
return wailsruntime.OpenMultipleFilesDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: "Выберите файлы",
})
}
func (a *App) PickDirectory() (string, error) {
return wailsruntime.OpenDirectoryDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: "Выберите папку",
})
}
// ============================================================
// System helpers
// ============================================================
func (a *App) OpenFile(fileID string) error {
return fmt.Errorf("not implemented: %s", fileID)
}
func (a *App) OpenFolder(nodeID string) error {
cmd := exec.Command("xdg-open", a.vault)
return cmd.Run()
}
func (a *App) VerstakVersion() string {
return "verstak-gui/v2"
}
// ============================================================
// Helpers
// ============================================================
func toNodeDTO(n *nodes.Node) NodeDTO {
parentID := ""
if n.ParentID != nil {
parentID = *n.ParentID
}
path := ""
if n.Path != nil {
path = *n.Path
}
return NodeDTO{
ID: n.ID,
ParentID: parentID,
Title: n.Title,
Type: n.Type,
Section: n.Section,
Path: path,
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
}
func toNodeDTOs(list []nodes.Node) []NodeDTO {
result := make([]NodeDTO, len(list))
for i := range list {
result[i] = toNodeDTO(&list[i])
}
return result
}
var (
_ = os.Getenv
_ = exec.Command
)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.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}
@@ -1 +0,0 @@
.svelte-10a60fp.svelte-10a60fp{box-sizing:border-box;margin:0;padding:0}.app.svelte-10a60fp.svelte-10a60fp{display:flex;flex-direction:column;height:100vh;background:#13131f;color:#e4e4ef;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}.topbar.svelte-10a60fp.svelte-10a60fp{padding:12px 20px;border-bottom:1px solid #2a2a3c;display:flex;align-items:center;gap:12px}.logo.svelte-10a60fp.svelte-10a60fp{font-size:20px}.topbar.svelte-10a60fp h1.svelte-10a60fp{font-size:18px;font-weight:600}.version.svelte-10a60fp.svelte-10a60fp{color:#666;font-size:12px;margin-left:auto}.error-banner.svelte-10a60fp.svelte-10a60fp{background:#422;color:#f88;padding:8px 20px;font-size:13px}.main.svelte-10a60fp.svelte-10a60fp{display:flex;flex:1;overflow:hidden}.sidebar.svelte-10a60fp.svelte-10a60fp{width:240px;border-right:1px solid #2a2a3c;padding:12px;overflow-y:auto;flex-shrink:0}.sidebar-header.svelte-10a60fp.svelte-10a60fp{font-size:11px;text-transform:uppercase;color:#666;margin-bottom:8px;padding:0 12px}.sidebar-item.svelte-10a60fp.svelte-10a60fp{padding:8px 12px;border-radius:6px;cursor:pointer;margin-bottom:2px;color:#ccc}.sidebar-item.svelte-10a60fp.svelte-10a60fp:hover{background:#1e1e2e}.sidebar-item.selected.svelte-10a60fp.svelte-10a60fp{background:#2a2a3c;color:#fff}.sidebar-empty.svelte-10a60fp.svelte-10a60fp{color:#666;font-size:12px;padding:8px 12px}.content.svelte-10a60fp.svelte-10a60fp{flex:1;padding:20px;overflow-y:auto}.welcome.svelte-10a60fp.svelte-10a60fp{color:#8888a4;font-size:14px;line-height:1.6}.error-text.svelte-10a60fp.svelte-10a60fp{color:#f88}.hint.svelte-10a60fp.svelte-10a60fp{color:#666;margin-top:8px}.loading.svelte-10a60fp.svelte-10a60fp{color:#888;font-size:14px}.node-view.svelte-10a60fp h2.svelte-10a60fp{font-size:24px;margin-bottom:16px}.node-view.svelte-10a60fp p.svelte-10a60fp{color:#888;font-size:13px;margin-bottom:8px}
+15 -5
View File
@@ -1,13 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/wails.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/style.css" />
<title>Wails + Svelte</title>
<script type="module" crossorigin src="/assets/index-C37GEF9W.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3NJ53hP.css">
<title>Верстак</title>
<style>
/* Critical reset — no white borders, full viewport */
html, body, #app {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/index-COs6tJEl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ClxkTvdE.css">
</head>
<body>
<div id="app"></div>
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"embed"
"log"
"os"
"path/filepath"
"verstak/internal/core/actions"
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
"verstak/internal/core/worklog"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
//go:embed all:frontend-dist
var assets embed.FS
func main() {
vaultPath := "."
if len(os.Args) > 1 {
vaultPath = os.Args[1]
}
abs, err := filepath.Abs(vaultPath)
if err != nil {
log.Fatal(err)
}
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
log.Fatalf("Open vault: %v", err)
}
defer db.Close()
// Init core services
nodeRepo := nodes.NewRepository(db)
fileSvc := files.NewService(db, abs)
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
actionSvc := actions.NewService(db)
worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db)
plugins.NewManager(abs).Discover()
_ = searchSvc
app := &App{
db: db,
nodes: nodeRepo,
files: fileSvc,
notes: noteSvc,
actions: actionSvc,
worklog: worklogSvc,
vault: abs,
}
err = wails.Run(&options.App{
Title: "Верстак",
Width: 1280,
Height: 800,
MinWidth: 800,
MinHeight: 600,
BackgroundColour: &options.RGBA{R: 19, G: 19, B: 31, A: 1},
AssetServer: &assetserver.Options{
Assets: assets,
},
OnStartup: app.startup,
Bind: []interface{}{app},
})
if err != nil {
log.Fatal(err)
}
}
+30 -2
View File
@@ -21,8 +21,8 @@
| 8 | FTS5 Search | ✅ выполнен |
| 9 | Section assignment + Sidebar filtering | ✅ выполнен |
| 10 | Plugin Manager (discovery + templates) | ✅ выполнен |
| 11 | **Wails Desktop GUI** | 🔄 в процессе — Wails v2 stable |
| 12 | **Files/Folders full workflow** | ⬜ не начат |
| 11 | **Wails Desktop GUI** | 🔄 Wails v2 vertical MVP |
| 12 | **Files/Folders full workflow** | ⬜ следующий этап после vertical MVP |
| 13 | **Drag-and-drop** | ⬜ не начат |
| 14 | **MVP stabilization** | ⬜ не начат |
| 15 | Sync Server Skeleton | 🔒 PAUSED |
@@ -47,10 +47,38 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
./verstak-gui
```
**GUI Build (Wails v2):**
```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
./verstak-gui
```
Или для dev режима: `wails dev` (требует Wails v2 CLI)
---
## Текущий этап: Wails v2 Vertical MVP
**Цель:** базовый рабочий desktop GUI для разделов → дел → заметок.
**Прогресс:**
- ✅ Wails v2 shell (window opens, no SIGSEGV)
- ✅ Layout fix (full viewport, dark theme, sidebar+main)
- 🔄 Notes bindings + UI
- 🔄 Tabs (Overview/Notes/Files/Actions/Worklog/Activity)
- 🔄 Node creation
- 🔄 Section filtering
**Пауза (не начинать до завершения vertical MVP):**
- Файлы/папки workflow
- Drag-and-drop
- Sync, plugins, Lua, browser extension, TUI
- Новые шаблоны, DokuWiki importer
---
## Выполненные шаги (1-10)
### ШАГ 1 — Git Init + Skeleton
+13 -3
View File
@@ -1,11 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/wails.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/style.css" />
<title>Wails + Svelte</title>
<title>Верстак</title>
<style>
/* Critical reset — no white borders, full viewport */
html, body, #app {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
background: #13131f;
}
</style>
</head>
<body>
<div id="app"></div>
+264 -68
View File
@@ -8,19 +8,9 @@
let selectedSection = ''
let selectedNode = null
// Wails v2 bindings — generated by wails
// For now, use window.wails or import from wailsjs
async function callGo(method, ...args) {
if (window.go && window.go.main && window.go.main.App) {
return window.go.main.App[method](...args)
}
throw new Error('Wails bindings not loaded')
}
onMount(async () => {
try {
version = 'verstak-gui'
// Try Wails bindings
if (window.go && window.go.main && window.go.main.App) {
version = await window.go.main.App.VerstakVersion()
sections = await window.go.main.App.ListSections()
@@ -33,7 +23,6 @@
function selectSection(id) {
selectedSection = id
// TODO: load nodes for section
}
function selectNode(node) {
@@ -42,88 +31,295 @@
</script>
<div class="app">
<!-- Top bar -->
<div class="topbar">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-top">
<span class="logo">&#9874;</span>
<h1>Верстак</h1>
<span class="app-name">Верстак</span>
</div>
<nav class="sidebar-nav">
<div class="nav-group">
<div class="nav-label">Разделы</div>
{#each sections as section}
<button class="nav-item {selectedSection === section.id ? 'selected' : ''}"
on:click={() => selectSection(section.id)}>
{section.label}
</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>
</nav>
<div class="sidebar-bottom">
<span class="version">{version}</span>
</div>
</aside>
<!-- Main area -->
<main class="main">
<!-- Header -->
<header class="header">
<div class="header-left">
{#if selectedNode}
<span class="crumb">{selectedNode.title}</span>
{:else if selectedSection}
<span class="crumb">{#each sections as section}{section.id === selectedSection ? section.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">
Error: {error}
Wails bindings: {error}
</div>
{/if}
<!-- Main content -->
<div class="main">
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-header">Разделы</div>
{#each sections as section}
<div class="sidebar-item {selectedSection === section.id ? 'selected' : ''}"
on:click={() => selectSection(section.id)}>
{section.label}
</div>
{/each}
<div class="sidebar-header" style="margin-top:16px">Корневые дела</div>
{#each nodes as node}
<div class="sidebar-item {selectedNode && selectedNode.id === node.id ? 'selected' : ''}"
on:click={() => selectNode(node)}>
{node.title}
</div>
{/each}
{#if nodes.length === 0 && sections.length > 0}
<div class="sidebar-empty">Нет дел. Создайте первое дело.</div>
{/if}
</div>
<!-- Content panel -->
<!-- Content -->
<div class="content">
{#if selectedNode}
<div class="node-view">
<h2>{selectedNode.title}</h2>
<p>ID: {selectedNode.id}</p>
<p>Type: {selectedNode.type}</p>
<div class="node-meta">
<span>ID: {selectedNode.id}</span>
<span>Type: {selectedNode.type}</span>
</div>
</div>
{:else if sections.length > 0}
<div class="welcome">
<p>Wails v2 Desktop GUI работает.</p>
<p>Sections: {sections.length}, Root nodes: {nodes.length}</p>
<h2>Верстак</h2>
<p>Разделы: {sections.length} · Дел: {nodes.length}</p>
{#if error}
<p class="error-text">Wails bindings error: {error}</p>
<p class="hint">Window opens but Go bindings not connected yet.</p>
<p class="error-text">Go bindings не подключены: {error}</p>
{/if}
</div>
{:else}
<div class="loading">Загрузка...</div>
{/if}
</div>
</div>
</main>
</div>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
.app { display: flex; flex-direction: column; height: 100vh; background: #13131f; color: #e4e4ef; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.topbar { padding: 12px 20px; border-bottom: 1px solid #2a2a3c; display: flex; align-items: center; gap: 12px; }
.logo { font-size: 20px; }
.topbar h1 { font-size: 18px; font-weight: 600; }
.version { color: #666; font-size: 12px; margin-left: auto; }
.error-banner { background: #442222; color: #ff8888; padding: 8px 20px; font-size: 13px; }
.main { display: flex; flex: 1; overflow: hidden; }
.sidebar { width: 240px; border-right: 1px solid #2a2a3c; padding: 12px; overflow-y: auto; flex-shrink: 0; }
.sidebar-header { font-size: 11px; text-transform: uppercase; color: #666; margin-bottom: 8px; padding: 0 12px; }
.sidebar-item { padding: 8px 12px; border-radius: 6px; cursor: pointer; margin-bottom: 2px; color: #ccc; }
.sidebar-item:hover { background: #1e1e2e; }
.sidebar-item.selected { background: #2a2a3c; color: #fff; }
.sidebar-empty { color: #666; font-size: 12px; padding: 8px 12px; }
.content { flex: 1; padding: 20px; overflow-y: auto; }
.welcome { color: #8888a4; font-size: 14px; line-height: 1.6; }
.error-text { color: #ff8888; }
.hint { color: #666; margin-top: 8px; }
.loading { color: #888; font-size: 14px; }
.node-view h2 { font-size: 24px; margin-bottom: 16px; }
.node-view p { color: #888; font-size: 13px; margin-bottom: 8px; }
/* Reset */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* 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-top {
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;
}
.app-name {
font-size: 16px;
font-weight: 600;
color: #e4e4ef;
}
.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-bottom {
padding: 12px 20px;
border-top: 1px solid #2a2a3c;
flex-shrink: 0;
}
.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;
}
</style>
+43
View File
@@ -0,0 +1,43 @@
// Wails v2 API wrapper — single frontend access point to Go backend
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'))
}
// Sections
export const listSections = () => wailsCall('ListSections')
// 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)
// 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)
// Files
export const listFiles = (nodeID) => wailsCall('ListFiles', nodeID)
// Actions
export const listActions = (nodeID) => wailsCall('ListActions', nodeID)
export const runAction = (id) => wailsCall('RunAction', id)
// 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')