feat: settings window polish, sync widget fix, dark form controls

- Fix: settings overlay uses on:click|self so sidebar clicks don't close it
- Fix: openSettings(section) supports opening at specific tab
- Fix: 'Настроить' opens Settings → Синхронизация instead of Общие
- Style: dark theme select with custom arrow, global :global() CSS
- Style: settings cards, section descriptions, button/layout polish
- Style: settings gear buttons (icon-button pattern, 32px, soft hover)
- Style: settings sidebar with disabled stubs, consistent icons
- i18n: add generalDesc, workspaceDesc, appearance, localization keys
This commit is contained in:
mirivlad 2026-06-03 23:09:40 +08:00
parent e30a75c5a0
commit f92394e3d7
26 changed files with 2747 additions and 327 deletions

View File

@ -30,6 +30,12 @@
27. **WriteDebugLog binding**`bindings_debug.go` writes frontend logs to `<vault>/.verstak/debug.log` for production GUI debugging.
28. **Journal regression tests**`TestJournalFullRegression`, `TestSuggestionOnRepeatedActivity`, `TestManualWorklogEntry`.
29. **resolveActivityTarget helper** — pure function returning `{ nodeId, tab, noteId/fileId/targetPath }`, used by `openActivityTarget`.
30. **First-run flow** — no auto-vault creation. New `GetStartupStatus` binding returns `first_run`/`recovery`/`ready`. Frontend shows FirstRun.svelte or VaultRecovery.svelte accordingly.
31. **Global config.json** — moved vault path, sync settings, templates, theme, language from implicit CLI args to `~/.config/verstak/config.json` (`AppConfig` struct).
32. **Sync settings in Settings** — extracted sync modal into Settings → Sync section. Removed all inline sync form fields from `App.svelte`. Added `SyncStatus.svelte` widget replacing navbar sync button.
33. **Settings window** — modal with sidebar (8 sections: General, Workspace, Templates, Plugins, Files, Activity, Sync, Backup). ESC to close. Lazy-loaded content panels.
34. **Template enable/disable**`AllTemplates` + `SetTemplateEnabled` bindings propagate to `appCfg.EnabledTemplates`. `initVault` applies filter to registry.
35. **Vault recovery screen** — when vault path exists but vault is missing, shows VaultRecovery.svelte with choose/create/quit options.
## Key patterns
- Always use explicit toggle icons (▸/▾) on expandable rows.
@ -40,6 +46,10 @@
- **NEVER pass `[]string` through Wails v2 bindings** — always JSON-serialize to `string` first. Wails v2.12.0 silently drops slice arguments.
- **Always wrap create-entry + link-events in a transaction** with pre-validation and post-commit verification to prevent orphan entries.
- Frontend debug logs in production: use `wailsCall('WriteDebugLog', msg)` → writes to `<vault>/.verstak/debug.log`.
- `AppConfig` stores all global settings in `~/.config/verstak/config.json`. Vault-specific config stays in `.verstak/config.yml`.
- Use `GetStartupStatus` to determine first-run vs recovery vs normal startup.
- Settings window uses a sidebar with 8 sections; each section is a separate Svelte component imported lazily.
- Template enable/disable state is stored in `appCfg.EnabledTemplates` and applied to the registry during `initVault`.
# Build instructions

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"log"
"path/filepath"
"sync"
"time"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
@ -26,6 +27,9 @@ import (
// App is the Wails v2 application adapter. It wraps core services.
type App struct {
ctx context.Context
mu sync.RWMutex
vaultOpen bool
db *storage.DB
nodes *nodes.Repository
templates *templates.Registry
@ -48,33 +52,45 @@ func (a *App) startup(ctx context.Context) {
wailsruntime.EventsEmit(ctx, "files-dropped", paths)
}
})
go a.autoSyncLoop()
}
func (a *App) autoSyncLoop() {
// Wait for vault to be ready
time.Sleep(5 * time.Second)
if !a.IsReady() {
return
}
const checkInterval = 60 * time.Second
ticker := time.NewTicker(checkInterval)
defer ticker.Stop()
log.Printf("[autosync] started, vault=%s", a.vault)
log.Printf("[autosync] started")
var lastSync time.Time
for {
select {
case <-ticker.C:
serverURL := ""
cfg, err := config.Load(a.vault)
if err == nil {
serverURL = cfg.Sync.ServerURL
if !a.IsReady() {
return
}
a.mu.RLock()
vaultPath := a.vault
a.mu.RUnlock()
serverURL, _, _, _, _ := a.sync.GetState()
if serverURL == "" {
sURL, _, _, _, _ := a.sync.GetState()
serverURL = sURL
appCfg, _ := config.LoadAppConfig()
if appCfg != nil && appCfg.Vault.Sync.ServerURL != "" {
serverURL = appCfg.Vault.Sync.ServerURL
}
}
if serverURL == "" {
continue
}
interval := 0
if cfg != nil {
interval = cfg.Sync.SyncInterval
appCfg, _ := config.LoadAppConfig()
if appCfg != nil {
interval = appCfg.Vault.Sync.SyncInterval
}
if interval <= 0 {
continue
@ -82,7 +98,8 @@ func (a *App) autoSyncLoop() {
if !lastSync.IsZero() && time.Since(lastSync) < time.Duration(interval)*time.Minute {
continue
}
deviceToken := config.LoadDeviceToken(a.vault)
deviceToken := config.LoadDeviceToken(vaultPath)
if deviceToken == "" {
continue
}

View File

@ -0,0 +1,393 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/config"
"verstak/internal/core/files"
"verstak/internal/core/nodes"
"verstak/internal/core/notes"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/templates"
"verstak/internal/core/vault"
"verstak/internal/core/worklog"
)
// StartupStatus describes the application startup state.
type StartupStatus struct {
Status string `json:"status"` // "first_run", "recovery", "ready"
VaultPath string `json:"vaultPath"` // configured or default vault path
VaultExists bool `json:"vaultExists"` // whether vault.db exists at the path
DefaultPath string `json:"defaultPath"` // default vault path suggestion
Error string `json:"error,omitempty"`
AppConfig *config.AppConfig `json:"appConfig,omitempty"`
}
// GetStartupStatus checks the global config and vault state.
func (a *App) GetStartupStatus() (*StartupStatus, error) {
defaultPath, _ := config.DefaultVaultPath()
appCfg, err := config.LoadAppConfig()
if err != nil {
return &StartupStatus{
Status: "first_run",
DefaultPath: defaultPath,
Error: fmt.Sprintf("config load error: %v", err),
}, nil
}
// No config at all → first run
if appCfg == nil {
return &StartupStatus{
Status: "first_run",
DefaultPath: defaultPath,
}, nil
}
// Config says not completed → first run
if !appCfg.FirstRunCompleted {
return &StartupStatus{
Status: "first_run",
VaultPath: appCfg.VaultPath,
DefaultPath: defaultPath,
}, nil
}
// Config has no vault path → first run
if appCfg.VaultPath == "" {
appCfg.VaultPath = defaultPath
_ = config.SaveAppConfig(appCfg)
}
// Check if vault exists
vaultExists := vaultExistsAt(appCfg.VaultPath)
if !vaultExists {
return &StartupStatus{
Status: "recovery",
VaultPath: appCfg.VaultPath,
DefaultPath: defaultPath,
AppConfig: appCfg,
}, nil
}
return &StartupStatus{
Status: "ready",
VaultPath: appCfg.VaultPath,
VaultExists: true,
AppConfig: appCfg,
}, nil
}
func vaultExistsAt(vaultPath string) bool {
dbPath := filepath.Join(vaultPath, ".verstak", "index.db")
if _, err := os.Stat(dbPath); err != nil {
return false
}
return true
}
// CreateVault creates a new vault at the given path and initializes all services.
func (a *App) CreateVault(vaultPath string) (*StartupStatus, error) {
if vaultPath == "" {
return nil, fmt.Errorf("vault path is empty")
}
// Create vault directories and database
if err := vault.Init(vaultPath); err != nil {
return nil, fmt.Errorf("create vault: %w", err)
}
// Initialize services for this vault
if err := a.initVault(vaultPath); err != nil {
return nil, fmt.Errorf("init vault services: %w", err)
}
// Save global config
appCfg, err := config.LoadAppConfig()
if err != nil || appCfg == nil {
appCfg = config.DefaultAppConfig()
}
appCfg.VaultPath = vaultPath
appCfg.FirstRunCompleted = true
if err := config.SaveAppConfig(appCfg); err != nil {
return nil, fmt.Errorf("save config: %w", err)
}
log.Printf("[startup] vault created at %s", vaultPath)
return &StartupStatus{
Status: "ready",
VaultPath: vaultPath,
VaultExists: true,
AppConfig: appCfg,
}, nil
}
// OpenVault opens an existing vault and initializes services.
func (a *App) OpenVault(vaultPath string) (*StartupStatus, error) {
if vaultPath == "" {
return nil, fmt.Errorf("vault path is empty")
}
if !vaultExistsAt(vaultPath) {
return nil, fmt.Errorf("vault not found at %s", vaultPath)
}
if err := a.initVault(vaultPath); err != nil {
return nil, fmt.Errorf("init vault: %w", err)
}
// Update config
appCfg, err := config.LoadAppConfig()
if err != nil || appCfg == nil {
appCfg = config.DefaultAppConfig()
}
appCfg.VaultPath = vaultPath
appCfg.FirstRunCompleted = true
if err := config.SaveAppConfig(appCfg); err != nil {
return nil, fmt.Errorf("save config: %w", err)
}
log.Printf("[startup] vault opened at %s", vaultPath)
return &StartupStatus{
Status: "ready",
VaultPath: vaultPath,
VaultExists: true,
AppConfig: appCfg,
}, nil
}
// initVault opens the vault DB and initializes all core services.
func (a *App) initVault(vaultPath string) error {
// Close previous vault if any
a.closeVault()
abs, err := filepath.Abs(vaultPath)
if err != nil {
return err
}
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
nodeRepo := nodes.NewRepository(db)
fileSvc := files.NewService(db, abs, nodeRepo)
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
actionSvc := actions.NewService(db)
activitySvc := activity.NewService(db)
worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db)
pm := plugins.NewManager(abs)
pm.Discover()
templatesReg := templates.NewRegistry()
if err := templatesReg.LoadSystem(); err != nil {
log.Printf("warning: failed to load system templates: %v", err)
}
// Apply enabled templates from config
appCfg, _ := config.LoadAppConfig()
if appCfg != nil && len(appCfg.EnabledTemplates) > 0 {
enabledSet := make(map[string]bool)
for _, id := range appCfg.EnabledTemplates {
enabledSet[id] = true
}
for _, t := range templatesReg.All() {
if !enabledSet[t.ID] {
_ = templatesReg.Disable(t.ID)
}
}
}
// Sync service
deviceID := ""
_ = appCfg // will store sync settings
if appCfg != nil && appCfg.Vault.Sync.DeviceID != "" {
deviceID = appCfg.Vault.Sync.DeviceID
}
if deviceID == "" {
deviceID = "gui-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
a.mu.Lock()
a.db = db
a.nodes = nodeRepo
a.files = fileSvc
a.notes = noteSvc
a.activity = activitySvc
a.actions = actionSvc
a.worklog = worklogSvc
a.search = searchSvc
a.plugins = pm
a.templates = templatesReg
a.sync = syncSvc
a.vault = abs
a.vaultOpen = true
a.mu.Unlock()
// Start auto-sync loop
go a.autoSyncLoop()
return nil
}
// closeVault shuts down current vault services if any.
func (a *App) closeVault() {
a.mu.Lock()
defer a.mu.Unlock()
if !a.vaultOpen {
return
}
if a.db != nil {
a.db.Close()
}
a.db = nil
a.nodes = nil
a.files = nil
a.notes = nil
a.activity = nil
a.actions = nil
a.worklog = nil
a.search = nil
a.plugins = nil
a.templates = nil
a.sync = nil
a.vault = ""
a.vaultOpen = false
}
// IsReady returns true if a vault is open and services are initialized.
func (a *App) IsReady() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.vaultOpen
}
// GetAppConfig returns the current global app config.
func (a *App) GetAppConfig() (*config.AppConfig, error) {
cfg, err := config.LoadAppConfig()
if err != nil {
return config.DefaultAppConfig(), nil
}
if cfg == nil {
return config.DefaultAppConfig(), nil
}
return cfg, nil
}
// SaveAppConfig saves the global app config.
func (a *App) SaveAppConfig(cfg *config.AppConfig) error {
return config.SaveAppConfig(cfg)
}
// GetDefaultVaultPath returns the default vault path.
func (a *App) GetDefaultVaultPath() (string, error) {
return config.DefaultVaultPath()
}
// CheckVaultPath checks whether a given path is usable as a vault.
type CheckVaultPathResult struct {
Exists bool `json:"exists"`
HasVault bool `json:"hasVault"`
Writable bool `json:"writable"`
Description string `json:"description"`
}
func (a *App) CheckVaultPath(vaultPath string) (*CheckVaultPathResult, error) {
if vaultPath == "" {
return nil, fmt.Errorf("path is empty")
}
info, err := os.Stat(vaultPath)
exists := err == nil
hasVault := false
writable := false
if exists {
if info.IsDir() {
writable = checkDirWritable(vaultPath)
hasVault = vaultExistsAt(vaultPath)
}
} else {
// Path doesn't exist - check if parent is writable
parent := filepath.Dir(vaultPath)
parentInfo, pErr := os.Stat(parent)
if pErr == nil && parentInfo.IsDir() {
writable = checkDirWritable(parent)
}
}
desc := describeVaultPath(vaultPath, exists, hasVault)
return &CheckVaultPathResult{
Exists: exists,
HasVault: hasVault,
Writable: writable,
Description: desc,
}, nil
}
func checkDirWritable(dir string) bool {
testFile := filepath.Join(dir, ".verstak-write-test")
if err := os.WriteFile(testFile, []byte{}, 0o640); err != nil {
return false
}
os.Remove(testFile)
return true
}
func describeVaultPath(path string, exists, hasVault bool) string {
if !exists {
return "Путь не существует. Будет создан новый vault."
}
if hasVault {
return "Найден существующий vault. Можно подключиться."
}
return "Папка существует, но vault не найден. Можно создать новый vault."
}
// VaultInfo returns information about the currently open vault.
type VaultInfo struct {
Path string `json:"path"`
DBPath string `json:"dbPath"`
FilesPath string `json:"filesPath"`
TrashPath string `json:"trashPath"`
Healthy bool `json:"healthy"`
NodeCount int `json:"nodeCount"`
FileCount int `json:"fileCount"`
}
func (a *App) GetVaultInfo() (*VaultInfo, error) {
if !a.IsReady() {
return nil, fmt.Errorf("vault not open")
}
a.mu.RLock()
vp := a.vault
nodesCount := 0
if a.nodes != nil {
roots, _ := a.nodes.ListRoots(true)
nodesCount = len(roots)
}
fileCount := 0
_ = a.db.QueryRow("SELECT COUNT(*) FROM files").Scan(&fileCount)
a.mu.RUnlock()
return &VaultInfo{
Path: vp,
DBPath: filepath.Join(vp, ".verstak", "index.db"),
FilesPath: filepath.Join(vp, "spaces"),
TrashPath: filepath.Join(vp, ".verstak", "trash"),
Healthy: true,
NodeCount: nodesCount,
FileCount: fileCount,
}, nil
}

View File

@ -881,6 +881,9 @@ func (a *App) moveNoteFileNode(nodeID string, node *nodes.Node, parent *nodes.No
}
func (a *App) ListEnabledTemplates() ([]TemplateDTO, error) {
if !a.IsReady() || a.templates == nil {
return []TemplateDTO{}, nil
}
list := a.templates.Enabled()
result := make([]TemplateDTO, len(list))
for i, t := range list {

View File

@ -1,10 +1,12 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"verstak/internal/core/config"
"verstak/internal/core/nodes"
"verstak/internal/core/plugins"
"verstak/internal/i18n"
@ -12,6 +14,86 @@ import (
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// ===== Template management =====
// AllTemplates returns all registered templates with their enabled status.
type TemplateWithStatus struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Icon string `json:"icon,omitempty"`
Enabled bool `json:"enabled"`
}
func (a *App) AllTemplates() ([]TemplateWithStatus, error) {
if !a.IsReady() || a.templates == nil {
return nil, fmt.Errorf("vault not ready")
}
appCfg, _ := config.LoadAppConfig()
enabledSet := make(map[string]bool)
if appCfg != nil {
for _, id := range appCfg.EnabledTemplates {
enabledSet[id] = true
}
}
all := a.templates.All()
result := make([]TemplateWithStatus, len(all))
for i, t := range all {
// If config has explicit list, use it; otherwise default to true
enabled := true
if appCfg != nil && len(appCfg.EnabledTemplates) > 0 {
enabled = enabledSet[t.ID]
}
result[i] = TemplateWithStatus{
ID: t.ID,
Title: t.Title,
Type: t.Type,
Icon: t.Icon,
Enabled: enabled,
}
}
return result, nil
}
func (a *App) SetTemplateEnabled(templateID string, enabled bool) error {
if !a.IsReady() || a.templates == nil {
return fmt.Errorf("vault not ready")
}
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
}
// Update enabled templates list
existing := make(map[string]bool)
for _, id := range appCfg.EnabledTemplates {
existing[id] = true
}
if enabled {
existing[templateID] = true
} else {
delete(existing, templateID)
}
appCfg.EnabledTemplates = make([]string, 0, len(existing))
for id := range existing {
appCfg.EnabledTemplates = append(appCfg.EnabledTemplates, id)
}
if err := config.SaveAppConfig(appCfg); err != nil {
return err
}
// Update in-memory registry
if enabled {
_ = a.templates.Enable(templateID)
} else {
_ = a.templates.Disable(templateID)
}
return nil
}
func (a *App) ListTemplates() []TemplateDTO {
templates := a.plugins.Templates()
out := make([]TemplateDTO, 0, len(templates))
@ -63,25 +145,7 @@ func (a *App) FromTemplate(parentID, nodeType, title, section, template string)
return &dto, nil
}
func (a *App) Search(query string) ([]SearchResultDTO, error) {
if query == "" {
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
}
// ===== File picking =====
func (a *App) PickFile() (string, error) {
return wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{
@ -141,6 +205,34 @@ func (a *App) OpenFolder(nodeID string) error {
return cmd.Run()
}
func (a *App) OpenVaultFolder() error {
if !a.IsReady() {
return fmt.Errorf("vault not open")
}
cmd := exec.Command("xdg-open", a.vault)
return cmd.Run()
}
func (a *App) Search(query string) ([]SearchResultDTO, error) {
if query == "" {
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
}
func (a *App) VerstakVersion() string {
return "verstak-gui/v2"
}

View File

@ -21,15 +21,22 @@ type SyncStatusDTO struct {
UnpushedOps int `json:"unpushedOps"`
LastSyncAt string `json:"lastSyncAt"`
SyncInterval int `json:"syncInterval"`
LastError string `json:"lastError"`
StatusLabel string `json:"statusLabel"` // human-readable status
}
func (a *App) SyncStatus() (*SyncStatusDTO, error) {
if !a.IsReady() {
return &SyncStatusDTO{}, nil
}
serverURL, apiKey, _, lastSyncAt, err := a.sync.GetState()
if err != nil {
return &SyncStatusDTO{}, nil
}
cfg, _ := config.Load(a.vault)
appCfg, _ := config.LoadAppConfig()
deviceToken := config.LoadDeviceToken(a.vault)
dto := &SyncStatusDTO{
Configured: serverURL != "" && (apiKey != "" || deviceToken != ""),
ServerURL: serverURL,
@ -37,18 +44,20 @@ func (a *App) SyncStatus() (*SyncStatusDTO, error) {
UnpushedOps: 0,
TokenStored: deviceToken != "",
}
if cfg != nil {
dto.DeviceID = cfg.Sync.DeviceID
dto.SyncInterval = cfg.Sync.SyncInterval
if appCfg != nil {
dto.DeviceID = appCfg.Vault.Sync.DeviceID
dto.SyncInterval = appCfg.Vault.Sync.SyncInterval
dto.LastError = appCfg.Vault.Sync.LastError
}
unpushed, _ := a.sync.GetUnpushedOps()
dto.UnpushedOps = len(unpushed)
if deviceToken != "" {
client := syncsvc.NewClient(serverURL, "", "", a.vault)
client.DeviceToken = deviceToken
if cfg != nil {
client.DeviceID = cfg.Sync.DeviceID
if appCfg != nil {
client.DeviceID = appCfg.Vault.Sync.DeviceID
}
if info, err := client.GetMe(); err == nil {
dto.DeviceName = info.DeviceName
@ -60,9 +69,69 @@ func (a *App) SyncStatus() (*SyncStatusDTO, error) {
}
}
}
// Build status label
switch {
case dto.Revoked:
dto.StatusLabel = "revoked"
case dto.Connected:
dto.StatusLabel = "connected"
case dto.Configured:
dto.StatusLabel = "disconnected"
default:
dto.StatusLabel = "disabled"
}
// Update config with latest status
if appCfg != nil {
changed := false
if dto.LastSyncAt != "" && appCfg.Vault.Sync.LastSyncAt != dto.LastSyncAt {
appCfg.Vault.Sync.LastSyncAt = dto.LastSyncAt
changed = true
}
if appCfg.Vault.Sync.LastStatus != dto.StatusLabel {
appCfg.Vault.Sync.LastStatus = dto.StatusLabel
changed = true
}
if changed {
_ = config.SaveAppConfig(appCfg)
}
}
return dto, nil
}
type SyncSettingsDTO struct {
Enabled bool `json:"enabled"`
ServerURL string `json:"serverUrl"`
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
SyncInterval int `json:"syncInterval"`
LastStatus string `json:"lastStatus"`
LastSyncAt string `json:"lastSyncAt"`
LastError string `json:"lastError"`
TokenStored bool `json:"tokenStored"`
}
func (a *App) GetSyncSettings() (*SyncSettingsDTO, error) {
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
}
deviceToken := config.LoadDeviceToken(a.vault)
return &SyncSettingsDTO{
Enabled: appCfg.Vault.Sync.Enabled,
ServerURL: appCfg.Vault.Sync.ServerURL,
DeviceID: appCfg.Vault.Sync.DeviceID,
DeviceName: appCfg.Vault.Sync.DeviceName,
SyncInterval: appCfg.Vault.Sync.SyncInterval,
LastStatus: appCfg.Vault.Sync.LastStatus,
LastSyncAt: appCfg.Vault.Sync.LastSyncAt,
LastError: appCfg.Vault.Sync.LastError,
TokenStored: deviceToken != "",
}, nil
}
func (a *App) SyncConfigure(serverURL, username, password string) error {
hostname, _ := os.Hostname()
if hostname == "" {
@ -79,32 +148,42 @@ func (a *App) SyncConfigure(serverURL, username, password string) error {
if err := a.sync.SetState(serverURL, ""); err != nil {
return err
}
cfg, err := config.Load(a.vault)
if err != nil {
cfg = &config.Config{}
// Update global config
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
}
cfg.Sync.ServerURL = serverURL
cfg.Sync.DeviceID = deviceID
cfg.Sync.APIKey = ""
return config.Save(a.vault, cfg)
appCfg.Vault.Sync.Enabled = true
appCfg.Vault.Sync.ServerURL = serverURL
appCfg.Vault.Sync.DeviceID = deviceID
appCfg.Vault.Sync.DeviceName = hostname
appCfg.Vault.Sync.LastStatus = "connected"
_ = config.SaveAppConfig(appCfg)
return nil
}
func (a *App) SyncDisconnect() error {
deviceToken := config.LoadDeviceToken(a.vault)
cfg, err := config.Load(a.vault)
if err != nil {
cfg = &config.Config{}
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
}
if deviceToken != "" {
client := syncsvc.NewClient(cfg.Sync.ServerURL, "", "", a.vault)
client := syncsvc.NewClient(appCfg.Vault.Sync.ServerURL, "", "", a.vault)
client.DeviceToken = deviceToken
_ = client.RevokeCurrent()
}
config.RemoveDeviceToken(a.vault)
cfg.Sync.ServerURL = ""
cfg.Sync.DeviceID = ""
cfg.Sync.APIKey = ""
if err := config.Save(a.vault, cfg); err != nil {
appCfg.Vault.Sync.Enabled = false
appCfg.Vault.Sync.ServerURL = ""
appCfg.Vault.Sync.DeviceID = ""
appCfg.Vault.Sync.DeviceName = ""
appCfg.Vault.Sync.LastStatus = "disabled"
appCfg.Vault.Sync.LastError = ""
if err := config.SaveAppConfig(appCfg); err != nil {
return err
}
return a.sync.SetState("", "")
@ -116,21 +195,15 @@ func (a *App) SyncTestConnection(serverURL, username, password string) error {
}
func (a *App) SyncSetInterval(minutes int) error {
cfg, err := config.Load(a.vault)
if err != nil {
cfg = &config.Config{}
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
}
if cfg.Sync.ServerURL == "" {
sURL, _, _, _, _ := a.sync.GetState()
if sURL != "" {
cfg.Sync.ServerURL = sURL
}
appCfg.Vault.Sync.SyncInterval = minutes
if appCfg.Vault.Sync.DeviceID == "" && a.sync != nil {
appCfg.Vault.Sync.DeviceID = a.sync.GetDeviceID()
}
if cfg.Sync.DeviceID == "" {
cfg.Sync.DeviceID = a.sync.GetDeviceID()
}
cfg.Sync.SyncInterval = minutes
return config.Save(a.vault, cfg)
return config.SaveAppConfig(appCfg)
}
func (a *App) SyncNow() (map[string]interface{}, error) {
@ -141,8 +214,9 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
}
deviceID := ""
if cfg, err := config.Load(a.vault); err == nil {
deviceID = cfg.Sync.DeviceID
appCfg, _ := config.LoadAppConfig()
if appCfg != nil {
deviceID = appCfg.Vault.Sync.DeviceID
}
client := syncsvc.NewClient(serverURL, apiKey, deviceID, a.vault)
@ -159,6 +233,7 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
if len(unpushed) > 0 {
pushResult, err = client.Push(unpushed)
if err != nil {
_ = a.updateSyncError(fmt.Sprintf("push: %v", err))
return nil, fmt.Errorf("push: %w", err)
}
if err := a.sync.MarkPushed(pushResult.Accepted); err != nil {
@ -168,6 +243,7 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
pullResult, err := client.Pull(lastPullSeq)
if err != nil {
_ = a.updateSyncError(fmt.Sprintf("pull: %v", err))
return nil, fmt.Errorf("pull: %w", err)
}
@ -199,6 +275,10 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
}
_ = a.sync.SetLastSyncAt(time.Now().UTC().Format(time.RFC3339))
// Update config with success
now := time.Now().UTC().Format(time.RFC3339)
a.updateSyncSuccess(now)
result := map[string]interface{}{
"pushed": len(pushResult.Accepted),
"pulled": len(pullResult.Ops),
@ -212,3 +292,58 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
}
return result, nil
}
func (a *App) updateSyncError(errMsg string) error {
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
return nil
}
appCfg.Vault.Sync.LastError = errMsg
appCfg.Vault.Sync.LastStatus = "error"
return config.SaveAppConfig(appCfg)
}
func (a *App) updateSyncSuccess(lastSyncAt string) error {
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
return nil
}
appCfg.Vault.Sync.LastError = ""
appCfg.Vault.Sync.LastStatus = "connected"
appCfg.Vault.Sync.LastSyncAt = lastSyncAt
return config.SaveAppConfig(appCfg)
}
// CheckSyncConnection tests the current sync connection.
func (a *App) CheckSyncConnection() (bool, string) {
appCfg, _ := config.LoadAppConfig()
if appCfg == nil || !appCfg.Vault.Sync.Enabled {
return false, "sync not configured"
}
deviceToken := config.LoadDeviceToken(a.vault)
if deviceToken == "" {
return false, "no device token"
}
client := syncsvc.NewClient(appCfg.Vault.Sync.ServerURL, "", appCfg.Vault.Sync.DeviceID, a.vault)
client.DeviceToken = deviceToken
info, err := client.GetMe()
if err != nil {
return false, err.Error()
}
if info.RevokedAt != "" {
return false, "device revoked"
}
return true, ""
}
// ResetSyncKey clears the device token and resets sync state.
func (a *App) ResetSyncKey() error {
config.RemoveDeviceToken(a.vault)
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
return nil
}
appCfg.Vault.Sync.LastStatus = "disabled"
appCfg.Vault.Sync.LastError = ""
return config.SaveAppConfig(appCfg)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -16,8 +16,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-D_8wOpYY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BafVhx43.css">
<script type="module" crossorigin src="/assets/main-DS67FqQ2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-oJnEtKWF.css">
</head>
<body>
<div id="app"></div>

View File

@ -3,21 +3,8 @@ package main
import (
"embed"
"log"
"os"
"path/filepath"
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/config"
"verstak/internal/core/files"
"verstak/internal/core/nodes"
"verstak/internal/core/notes"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/templates"
"verstak/internal/core/worklog"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
@ -28,65 +15,9 @@ import (
var assets embed.FS
func main() {
vaultPath := "."
if len(os.Args) > 1 {
vaultPath = os.Args[1]
}
app := &App{}
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, nodeRepo)
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
actionSvc := actions.NewService(db)
activitySvc := activity.NewService(db)
worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db)
pm := plugins.NewManager(abs)
pm.Discover()
templatesReg := templates.NewRegistry()
if err := templatesReg.LoadSystem(); err != nil {
log.Printf("warning: failed to load system templates: %v", err)
}
// Sync service — use configured device ID or vault ID as fallback.
deviceID := ""
if cfg, err := config.Load(abs); err == nil {
deviceID = cfg.Sync.DeviceID
}
if deviceID == "" {
deviceID = "gui-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
app := &App{
db: db,
nodes: nodeRepo,
templates: templatesReg,
files: fileSvc,
notes: noteSvc,
activity: activitySvc,
actions: actionSvc,
worklog: worklogSvc,
search: searchSvc,
plugins: pm,
sync: syncSvc,
vault: abs,
}
err = wails.Run(&options.App{
err := wails.Run(&options.App{
Title: "Верстак",
Width: 1280,
Height: 800,
@ -106,4 +37,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
// Ensure config dir exists for logging/cli usage
config.EnsureConfigDir()
}

View File

@ -5,13 +5,15 @@
import ConfirmModal from './lib/ConfirmModal.svelte'
import TreeNode from './TreeNode.svelte'
import TemplateIcon from './lib/TemplateIcon.svelte'
import FirstRun from './lib/FirstRun.svelte'
import VaultRecovery from './lib/VaultRecovery.svelte'
import SettingsWindow from './lib/SettingsWindow.svelte'
import SyncStatus from './lib/SyncStatus.svelte'
import { onMount, onDestroy } from 'svelte'
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js'
import { t } from './lib/i18n'
// ===== 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']) {
@ -26,6 +28,13 @@
return Promise.reject(new Error('Wails not connected: ' + method))
}
// ===== Startup state =====
let startupStatus = null
let startupChecked = false
let showFirstRun = false
let showRecovery = false
let showSettings = false
// ===== State =====
let systemViews = []
let workspaceTree = []
@ -131,14 +140,8 @@
let renameError = ''
// ===== Sync state =====
let showSettings = false
let syncStatus = null
let syncLoading = false
let syncServerUrl = ''
let syncUsername = ''
let syncPassword = ''
let syncInterval = 0
let syncResult = ''
const tabs = [
{ id: 'overview', label: t('tab.overview') },
@ -153,6 +156,25 @@
// ===== Lifecycle =====
onMount(async () => {
// Check startup status
try {
startupStatus = await wailsCall('GetStartupStatus')
startupChecked = true
if (startupStatus.status === 'first_run') {
showFirstRun = true
loading = false
return
}
if (startupStatus.status === 'recovery') {
showRecovery = true
loading = false
return
}
} catch (e) {
error = String(e)
}
// Vault is ready — load main UI
try {
version = await wailsCall('VerstakVersion') || 'verstak-gui/v2'
systemViews = await wailsCall('ListSystemViews') || []
@ -1392,89 +1414,44 @@
}
}
function openSettings() {
let settingsInitialSection = 'general'
function openSettings(section) {
settingsInitialSection = section || 'general'
showSettings = true
syncServerUrl = syncStatus?.serverUrl || ''
syncUsername = ''
syncPassword = ''
syncInterval = syncStatus?.syncInterval || 0
syncResult = ''
}
function closeSettings() {
showSettings = false
syncResult = ''
}
async function saveSyncConfig() {
syncLoading = true
syncResult = ''
try {
await wailsCall('SyncConfigure', syncServerUrl, syncUsername, syncPassword)
if (syncInterval > 0) {
await wailsCall('SyncSetInterval', syncInterval)
}
syncPassword = ''
syncUsername = ''
await loadSyncStatus()
showSettings = false
} catch (e) {
syncResult = 'err: ' + String(e)
}
syncLoading = false
}
async function saveSyncInterval() {
syncLoading = true
syncResult = ''
try {
await wailsCall('SyncSetInterval', syncInterval)
syncResult = t('sync.settingsSaved')
await loadSyncStatus()
} catch (e) {
syncResult = 'err: ' + String(e)
}
syncLoading = false
}
async function testConnection() {
syncLoading = true
syncResult = ''
try {
await wailsCall('SyncTestConnection', syncServerUrl, syncUsername, syncPassword)
syncResult = 'connection ok'
} catch (e) {
syncResult = 'connection failed: ' + String(e)
}
syncLoading = false
}
async function disconnectSync() {
syncLoading = true
syncResult = ''
try {
await wailsCall('SyncDisconnect')
syncResult = 'disconnected'
await loadSyncStatus()
} catch (e) {
syncResult = 'err: ' + String(e)
}
syncLoading = false
}
async function runSyncNow() {
syncLoading = true
syncResult = ''
try {
const r = await wailsCall('SyncNow')
syncResult = 'pushed ' + r.pushed + ', pulled ' + r.pulled + ' (seq ' + r.serverSequence + ')'
await wailsCall('SyncNow')
await loadSyncStatus()
} catch (e) {
syncResult = 'err: ' + String(e)
console.error('sync error:', e)
}
syncLoading = false
}
// First run / recovery handlers
function onFirstRunComplete(status) {
showFirstRun = false
startupStatus = status
loading = false
// Reload main UI
window.location.reload()
}
function onRecoveryComplete(status) {
showRecovery = false
startupStatus = status
loading = false
window.location.reload()
}
function onKeyActivate(fn) {
return (e) => {
if (e.key === 'Enter' || e.key === ' ') {
@ -1485,6 +1462,11 @@
}
</script>
{#if showFirstRun}
<FirstRun onComplete={onFirstRunComplete} />
{:else if showRecovery}
<VaultRecovery vaultPath={startupStatus?.vaultPath || ''} onComplete={onRecoveryComplete} />
{:else}
<div class="app">
<!-- Sidebar -->
<aside class="sidebar">
@ -1533,12 +1515,15 @@
</div>
</nav>
<div class="sidebar-footer">
<button class="sidebar-sync-btn" on:click={openSettings} title={t('nav.syncSettings')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
<span class="sync-dot" class:active={syncStatus?.configured}></span>
<span class="sidebar-sync-label">{t('nav.sync')}</span>
</button>
<span class="version">{version}</span>
<SyncStatus {syncStatus} {syncLoading} onSync={runSyncNow} onOpenSettings={() => openSettings('sync')} />
<div class="sidebar-footer-row">
<button class="sidebar-settings-btn" on:click={() => openSettings()} title={t('common.settings')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="2.8"/><path d="M12 1.5v1.9a1.8 1.8 0 0 0 1.6 1.77 1.8 1.8 0 0 0 1.74-.67l1.23-1.45a9 9 0 0 1 3.54 2.04l-.96 1.6a1.8 1.8 0 0 0 .2 2.08 1.8 1.8 0 0 0 1.98.49l1.75-.58a9 9 0 0 1 .68 4.09l-1.86.6a1.8 1.8 0 0 0-1.16 1.66 1.8 1.8 0 0 0 .93 1.6l.93.57a9 9 0 0 1-2.26 3.42l-1.32-1a1.8 1.8 0 0 0-2.1-.15 1.8 1.8 0 0 0-.87 1.55V22.5a9 9 0 0 1-4.1.01v-1.93a1.8 1.8 0 0 0-.93-1.56 1.8 1.8 0 0 0-2.1.16l-1.3.98a9 9 0 0 1-3.48-2.09l.92-1.54a1.8 1.8 0 0 0-.96-2.6 1.8 1.8 0 0 0-2.08.5l-.98 1.2a9 9 0 0 1-2.5-3.22l1.7-.67a1.8 1.8 0 0 0-1.7-2.51 1.8 1.8 0 0 0-.4.05L1.4 9.56a9 9 0 0 1 .22-4.12l1.72.68a1.8 1.8 0 0 0 2.1-.42 1.8 1.8 0 0 0 .22-2.03L4.6 2.34A9 9 0 0 1 8.84.38l.98 1.6a1.8 1.8 0 0 0 1.74.94A1.8 1.8 0 0 0 13 1.47V1.5z"/>
</svg>
</button>
<span class="version">{version}</span>
</div>
</div>
</aside>
@ -1556,14 +1541,11 @@
{/if}
</div>
<div class="header-right">
{#if syncStatus?.configured}
<button class="header-sync-btn" on:click={runSyncNow} disabled={syncLoading} title={t('nav.syncNow')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
{#if syncStatus.unpushedOps > 0}
<span class="sync-badge">{syncStatus.unpushedOps}</span>
{/if}
</button>
{/if}
<button class="header-settings-btn" on:click={() => openSettings()} title={t('common.settings')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="2.8"/><path d="M12 1.5v1.9a1.8 1.8 0 0 0 1.6 1.77 1.8 1.8 0 0 0 1.74-.67l1.23-1.45a9 9 0 0 1 3.54 2.04l-.96 1.6a1.8 1.8 0 0 0 .2 2.08 1.8 1.8 0 0 0 1.98.49l1.75-.58a9 9 0 0 1 .68 4.09l-1.86.6a1.8 1.8 0 0 0-1.16 1.66 1.8 1.8 0 0 0 .93 1.6l.93.57a9 9 0 0 1-2.26 3.42l-1.32-1a1.8 1.8 0 0 0-2.1-.15 1.8 1.8 0 0 0-.87 1.55V22.5a9 9 0 0 1-4.1.01v-1.93a1.8 1.8 0 0 0-.93-1.56 1.8 1.8 0 0 0-2.1.16l-1.3.98a9 9 0 0 1-3.48-2.09l.92-1.54a1.8 1.8 0 0 0-.96-2.6 1.8 1.8 0 0 0-2.08.5l-.98 1.2a9 9 0 0 1-2.5-3.22l1.7-.67a1.8 1.8 0 0 0-1.7-2.51 1.8 1.8 0 0 0-.4.05L1.4 9.56a9 9 0 0 1 .22-4.12l1.72.68a1.8 1.8 0 0 0 2.1-.42 1.8 1.8 0 0 0 .22-2.03L4.6 2.34A9 9 0 0 1 8.84.38l.98 1.6a1.8 1.8 0 0 0 1.74.94A1.8 1.8 0 0 0 13 1.47V1.5z"/>
</svg>
</button>
</div>
</header>
@ -2478,89 +2460,11 @@
{/if}
{#if showSettings}
<div class="modal-overlay" role="button" tabindex="0" on:click|self={closeSettings} on:keydown={onKeyActivate(closeSettings)}>
<div class="modal modal-sync">
<h3>{t('sync.settings')}</h3>
{#if syncStatus}
<div class="sync-status">
<div class="sync-row">
<span class="sync-label">{t('sync.status')}</span>
<span class="sync-value">
{#if syncStatus.revoked}
<span style="color:#ff6b6b">{t('sync.revoked')}</span>
{:else if syncStatus.connected}
<span style="color:#34d399">{t('sync.connected')}</span>
{:else if syncStatus.configured}
<span style="color:#f59e0b">{t('sync.notConnected')}</span>
{:else}
<span style="color:#666">{t('sync.disabled')}</span>
{/if}
</span>
</div>
{#if syncStatus.serverUrl}
<div class="sync-row"><span class="sync-label">{t('sync.server')}</span><span class="sync-value mono">{syncStatus.serverUrl}</span></div>
{/if}
{#if syncStatus.deviceName}
<div class="sync-row"><span class="sync-label">{t('sync.device')}</span><span class="sync-value">{syncStatus.deviceName}</span></div>
{/if}
{#if syncStatus.deviceId && !syncStatus.deviceName}
<div class="sync-row"><span class="sync-label">{t('sync.deviceId')}</span><span class="sync-value mono">{syncStatus.deviceId}</span></div>
{/if}
<div class="sync-row"><span class="sync-label">{t('sync.unpushed')}</span><span class="sync-value">{syncStatus.unpushedOps}</span></div>
{#if syncStatus.lastSyncAt}
<div class="sync-row"><span class="sync-label">{t('sync.lastSync')}</span><span class="sync-value">{syncStatus.lastSyncAt}</span></div>
{/if}
</div>
{/if}
{#if syncStatus?.configured}
<div class="sync-connected-actions">
<button class="btn" on:click={runSyncNow} disabled={syncLoading}>{t('sync.syncNow')}</button>
<button class="btn btn-danger" on:click={disconnectSync} disabled={syncLoading}>{t('sync.disconnect')}</button>
</div>
{:else}
<div class="form-group">
<label><span class="label-text">{t('sync.serverUrl')}</span>
<input type="text" placeholder={t('sync.serverUrlPlaceholder')} bind:value={syncServerUrl} />
</label>
</div>
<div class="form-group">
<label><span class="label-text">{t('sync.username')}</span>
<input type="text" placeholder={t('sync.usernamePlaceholder')} bind:value={syncUsername} />
</label>
</div>
<div class="form-group">
<label><span class="label-text">{t('sync.password')}</span>
<input type="password" placeholder={t('sync.passwordPlaceholder')} bind:value={syncPassword} />
</label>
</div>
<div class="modal-actions" style="margin-top:12px">
<button class="btn" on:click={testConnection} disabled={syncLoading || !syncServerUrl}>{t('sync.test')}</button>
<button class="btn btn-primary" on:click={saveSyncConfig} disabled={syncLoading}>{t('sync.connect')}</button>
</div>
{/if}
<div style="margin-top:16px;padding-top:16px;border-top:1px solid #2a2a3c">
<div class="form-group">
<label><span class="label-text">{t('sync.autoSync')}</span>
<input type="number" placeholder="0" bind:value={syncInterval} min="0" />
</label>
</div>
<button class="btn" on:click={saveSyncInterval} disabled={syncLoading}>{t('sync.saveInterval')}</button>
</div>
{#if syncResult}
<div class="sync-result" style="margin-top:8px">{syncResult}</div>
{/if}
<div class="modal-actions" style="margin-top:12px">
<button class="btn" on:click={closeSettings}>{t('common.close')}</button>
</div>
</div>
</div>
<SettingsWindow onClose={closeSettings} onSyncRefresh={loadSyncStatus} initialSection={settingsInitialSection} />
{/if}
</main>
</div>
{/if}
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@ -2597,6 +2501,7 @@
.create-context { font-size: 12px; color: #888; margin-bottom: 12px; }
.sidebar-footer { padding: 8px 12px; border-top: 1px solid #2a2a3c; flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; }
.sidebar-footer-row { display: flex; align-items: center; justify-content: space-between; }
.version { font-size: 11px; color: #555; text-align: center; }
/* Main */
@ -2604,10 +2509,12 @@
.header { padding: 12px 24px; border-bottom: 1px solid #2a2a3c; display: flex; align-items: center; flex-shrink: 0; min-height: 48px; }
.header-left { display: flex; align-items: center; gap: 8px; flex: 1; }
.header-right { display: flex; align-items: center; gap: 8px; }
.header-sync-btn { background: #1e1e38; border: 1px solid #6366f1; border-radius: 8px; padding: 6px 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; color: #c0c0f0; font-family: inherit; font-size: 13px; position: relative; }
.header-sync-btn:hover { background: #2a2a50; color: #e4e4ef; border-color: #818cf8; }
.header-sync-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.sync-badge { background: #6366f1; color: #fff; font-size: 10px; border-radius: 50%; width: 16px; height: 16px; display: inline-flex; align-items: center; justify-content: center; position: absolute; top: -6px; right: -6px; }
.sidebar-settings-btn { background: transparent; border: none; border-radius: 6px; padding: 6px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: #666; font-family: inherit; width: 32px; height: 32px; }
.sidebar-settings-btn:hover { background: #1e1e38; color: #a5b4fc; }
.sidebar-settings-btn:active { background: #252545; color: #818cf8; }
.header-settings-btn { background: transparent; border: none; border-radius: 6px; padding: 6px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: #666; font-family: inherit; width: 32px; height: 32px; }
.header-settings-btn:hover { background: #1e1e38; color: #a5b4fc; }
.header-settings-btn:active { background: #252545; color: #818cf8; }
.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; }
@ -2797,6 +2704,53 @@
.btn-danger { color: #ff6b6b; border-color: #4a2222; }
.btn-danger:hover { background: #3a2222; }
/* Dark form controls for settings */
:global(.settings-section select),
:global(.settings-section input[type="text"]),
:global(.settings-section input[type="url"]),
:global(.settings-section input[type="number"]),
:global(.settings-section input[type="password"]) {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
width: 100%;
padding: 0.5rem 0.75rem;
background: #13131f;
border: 1px solid #2a2a3e;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.9rem;
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
:global(.settings-section select) {
padding-right: 2rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.6rem center;
background-size: 0.85rem;
}
:global(.settings-section select:hover),
:global(.settings-section input:hover) {
border-color: #3a3a5e;
}
:global(.settings-section select:focus),
:global(.settings-section input:focus) {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
:global(.settings-section select:disabled),
:global(.settings-section input:disabled) {
opacity: 0.45;
cursor: not-allowed;
border-color: #2a2a3e;
}
:global(.settings-section select option) {
background: #1e1e2e;
color: #e0e0e0;
}
/* Files tab */
.files-tab { padding: 20px; }
.files-tab .tab-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; }
@ -2879,20 +2833,8 @@
.activity-feed-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; }
.activity-feed-time { font-size: 11px; color: #555; }
/* Sync */
.sidebar-sync-btn { background: #1e1e38; border: 1px solid #6366f1; border-radius: 8px; padding: 8px 12px; cursor: pointer; width: 100%; display: flex; align-items: center; gap: 8px; color: #c0c0f0; font-family: inherit; font-size: 13px; }
.sidebar-sync-btn:hover { background: #2a2a50; color: #e4e4ef; border-color: #818cf8; }
.sidebar-sync-label { flex: 1; text-align: left; }
.sync-dot { width: 8px; height: 8px; border-radius: 50%; background: #4a4a4a; flex-shrink: 0; }
.sync-dot.active { background: #4ade80; box-shadow: 0 0 6px rgba(74,222,128,0.5); }
.modal-sync { width: 460px; }
.sync-status { background: #13131f; border-radius: 8px; padding: 12px; margin-bottom: 16px; }
.sync-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
.sync-label { color: #666; }
.sync-value { color: #e4e4ef; }
.sync-value.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
.sync-result { font-size: 12px; color: #6366f1; padding: 4px 0; }
.sync-connected-actions { display: flex; gap: 8px; margin-bottom: 16px; }
/* Journal filter/export sections */
.journal-filter-section { margin-bottom: 12px; padding: 12px 16px; background: #1a1a2e; border: 1px solid #2a2a3c; border-radius: 8px; }

View File

@ -0,0 +1,209 @@
<script>
import { t } from './i18n'
export let onComplete = null
export let onQuit = null
let vaultPath = ''
let defaultPath = ''
let loading = false
let errorMsg = ''
let pathInfo = ''
let pathCheck = null
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 error:', method, e) }
return Promise.reject(new Error('Wails not connected: ' + method))
}
async function init() {
try {
const defaultVaultPath = await wailsCall('GetDefaultVaultPath')
defaultPath = defaultVaultPath || ''
vaultPath = defaultPath || ''
if (vaultPath) await checkPath()
} catch (e) { defaultPath = '' }
}
init()
async function browseFolder() {
try {
const chosen = await wailsCall('PickDirectory')
if (chosen) {
vaultPath = chosen
await checkPath()
}
} catch (e) { errorMsg = String(e) }
}
async function checkPath() {
if (!vaultPath || !vaultPath.trim()) {
pathInfo = ''
pathCheck = null
return
}
try {
pathCheck = await wailsCall('CheckVaultPath', vaultPath.trim())
if (pathCheck) {
pathInfo = pathCheck.description
}
} catch (e) { pathInfo = ''; pathCheck = null }
}
async function createWorkspace() {
if (!vaultPath || !vaultPath.trim()) return
loading = true
errorMsg = ''
if (pathCheck && !pathCheck.writable) {
errorMsg = t('firstrun.errorNoWrite')
loading = false
return
}
try {
const status = await wailsCall('CreateVault', vaultPath.trim())
if (status && status.status === 'ready' && onComplete) {
onComplete(status)
}
} catch (e) {
errorMsg = String(e)
}
loading = false
}
function handleQuit() {
if (onQuit) onQuit()
}
function handleKeydown(e) {
if (e.key === 'Enter' && !loading && vaultPath.trim()) createWorkspace()
}
</script>
<div class="first-run-screen">
<div class="first-run-card">
<div class="first-run-logo">&#9874;</div>
<h1>{t('firstrun.title')}</h1>
<p class="first-run-desc">{t('firstrun.desc')}</p>
<div class="form-group">
<label class="form-label" for="vault-path">{t('firstrun.pathLabel')}</label>
<div class="input-row">
<input
id="vault-path"
type="text"
bind:value={vaultPath}
on:input={checkPath}
on:keydown={handleKeydown}
placeholder={defaultPath || t('firstrun.defaultPath')}
disabled={loading}
/>
<button class="btn" on:click={browseFolder} disabled={loading}>{t('firstrun.browse')}</button>
</div>
</div>
{#if pathInfo}
<div class="path-info">{pathInfo}</div>
{/if}
{#if errorMsg}
<div class="error-msg">{errorMsg}</div>
{/if}
<div class="first-run-actions">
<button class="btn btn-primary btn-lg" on:click={createWorkspace}
disabled={!vaultPath.trim() || loading}>
{loading ? t('firstrun.creating') : t('firstrun.create')}
</button>
<button class="btn btn-lg" on:click={handleQuit}>{t('firstrun.quit')}</button>
</div>
</div>
</div>
<style>
.first-run-screen {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
background: var(--bg, #13131f);
}
.first-run-card {
max-width: 520px;
width: 100%;
padding: 2.5rem;
background: var(--surface, #1e1e2e);
border-radius: 12px;
border: 1px solid var(--border, #2a2a3e);
text-align: center;
}
.first-run-logo {
font-size: 3rem;
margin-bottom: 0.5rem;
}
.first-run-card h1 {
margin: 0 0 0.75rem 0;
font-size: 1.5rem;
color: var(--text, #e0e0e0);
}
.first-run-desc {
color: var(--text-dim, #888);
margin-bottom: 1.5rem;
font-size: 0.9rem;
line-height: 1.5;
}
.input-row {
display: flex;
gap: 0.5rem;
}
.input-row input {
flex: 1;
}
.path-info {
text-align: left;
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
margin-top: 0.5rem;
background: var(--surface-alt, #252538);
border-radius: 6px;
color: var(--text-dim, #888);
}
.error-msg {
text-align: left;
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
margin-top: 0.5rem;
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
border-radius: 6px;
color: #ff6b6b;
}
.first-run-actions {
display: flex;
gap: 0.75rem;
justify-content: center;
margin-top: 1.5rem;
}
.btn-lg {
padding: 0.65rem 1.5rem;
font-size: 1rem;
}
.form-group {
margin-bottom: 1rem;
text-align: left;
}
.form-label {
display: block;
font-size: 0.85rem;
color: var(--text-dim, #888);
margin-bottom: 0.4rem;
}
</style>

View File

@ -0,0 +1,14 @@
<script>
import { t } from './i18n'
</script>
<div class="settings-section">
<h2>{t('settings.activity')}</h2>
<p class="section-desc">{t('settings.activityStub')}</p>
</div>
<style>
.settings-section { padding: 1.5rem; max-width: 500px; }
.settings-section h2 { margin: 0 0 0.5rem 0; font-size: 1.2rem; color: var(--text, #e0e0e0); }
.section-desc { color: var(--text-dim, #888); font-size: 0.85rem; }
</style>

View File

@ -0,0 +1,14 @@
<script>
import { t } from './i18n'
</script>
<div class="settings-section">
<h2>{t('settings.backup')}</h2>
<p class="section-desc">{t('settings.backupStub')}</p>
</div>
<style>
.settings-section { padding: 1.5rem; max-width: 500px; }
.settings-section h2 { margin: 0 0 0.5rem 0; font-size: 1.2rem; color: var(--text, #e0e0e0); }
.section-desc { color: var(--text-dim, #888); font-size: 0.85rem; }
</style>

View File

@ -0,0 +1,14 @@
<script>
import { t } from './i18n'
</script>
<div class="settings-section">
<h2>{t('settings.files')}</h2>
<p class="section-desc">{t('settings.filesStub')}</p>
</div>
<style>
.settings-section { padding: 1.5rem; max-width: 500px; }
.settings-section h2 { margin: 0 0 0.5rem 0; font-size: 1.2rem; color: var(--text, #e0e0e0); }
.section-desc { color: var(--text-dim, #888); font-size: 0.85rem; }
</style>

View File

@ -0,0 +1,111 @@
<script>
import { t } from './i18n'
export let appConfig = null
export let onSave = null
let theme = 'system'
let saved = false
function init() {
if (appConfig) {
theme = appConfig.theme || 'system'
}
}
$: if (appConfig) init()
async function save() {
if (!appConfig) return
appConfig.theme = theme
if (onSave) await onSave(appConfig)
saved = true
setTimeout(() => saved = false, 2000)
}
</script>
<div class="settings-section">
<h2>{t('settings.general')}</h2>
<p class="section-desc">{t('settings.generalDesc')}</p>
<div class="settings-card">
<h3 class="card-title">{t('settings.appearance')}</h3>
<div class="form-group">
<label>
<span class="label-text">{t('settings.theme')}</span>
<select bind:value={theme}>
<option value="system">{t('settings.themeSystem')}</option>
<option value="light" disabled>{t('settings.themeLight')} ({t('settings.langSoon')})</option>
<option value="dark" disabled>{t('settings.themeDark')} ({t('settings.langSoon')})</option>
</select>
</label>
</div>
</div>
<div class="settings-card">
<h3 class="card-title">{t('settings.localization')}</h3>
<div class="form-group">
<label>
<span class="label-text">{t('settings.language')}</span>
<select disabled>
<option value="ru">{t('settings.langRu')}</option>
<option value="en">{t('settings.langEn')} ({t('settings.langSoon')})</option>
</select>
</label>
</div>
</div>
<div class="settings-actions">
{#if saved}
<div class="save-notice">{t('settings.saved')}</div>
{/if}
<button class="btn btn-primary" on:click={save}>{t('settings.save')}</button>
</div>
</div>
<style>
.settings-section {
padding: 1.5rem;
max-width: 560px;
}
.settings-section h2 {
margin: 0 0 0.25rem 0;
font-size: 1.2rem;
color: var(--text, #e0e0e0);
}
.section-desc {
color: var(--text-dim, #888);
font-size: 0.85rem;
margin-bottom: 1.25rem;
line-height: 1.4;
}
.settings-card {
background: var(--surface-alt, #1e1e30);
border: 1px solid var(--border, #2a2a3e);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
.card-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--text, #e0e0e0);
margin: 0 0 0.75rem 0;
}
.form-group { margin-bottom: 0; }
.label-text { display: block; font-size: 0.85rem; color: var(--text-dim, #888); margin-bottom: 0.3rem; }
.settings-actions {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 1rem;
}
.save-notice {
padding: 0.4rem 0.75rem;
background: rgba(52, 211, 153, 0.1);
border: 1px solid rgba(52, 211, 153, 0.3);
border-radius: 6px;
color: #34d399;
font-size: 0.85rem;
}
</style>

View File

@ -0,0 +1,29 @@
<script>
import { t } from './i18n'
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 error:', method, e) }
return Promise.reject(new Error('Wails not connected: ' + method))
}
async function openPluginsDir() {
try { await wailsCall('OpenFolder', '') } catch(e) {}
}
</script>
<div class="settings-section">
<h2>{t('settings.plugins')}</h2>
<p class="section-desc">{t('settings.noPlugins')}</p>
<button class="btn" on:click={openPluginsDir}>{t('settings.openPluginsDir')}</button>
</div>
<style>
.settings-section { padding: 1.5rem; max-width: 500px; }
.settings-section h2 { margin: 0 0 0.5rem 0; font-size: 1.2rem; color: var(--text, #e0e0e0); }
.section-desc { color: var(--text-dim, #888); font-size: 0.85rem; margin-bottom: 1rem; }
</style>

View File

@ -0,0 +1,85 @@
<script>
import { t } from './i18n'
export let activeSection = 'general'
export let onSelect = null
const sections = [
{ id: 'general', label: t('settings.general'), icon: '⚙️' },
{ id: 'workspace', label: t('settings.workspace'), icon: '🏠' },
{ id: 'templates', label: t('settings.templates'), icon: '📋' },
{ id: 'plugins', label: t('settings.plugins'), icon: '🔌' },
{ id: 'files', label: t('settings.files'), icon: '📁' },
{ id: 'activity', label: t('settings.activity'), icon: '📊' },
{ id: 'sync', label: t('settings.sync'), icon: '🔄' },
{ id: 'backup', label: t('settings.backup'), icon: '💾' },
]
function select(id) {
if (onSelect) onSelect(id)
}
</script>
<nav class="settings-sidebar">
{#each sections as sec}
<button
class="settings-nav-item"
class:active={activeSection === sec.id}
class:disabled={sec.id === 'plugins' || sec.id === 'files' || sec.id === 'activity' || sec.id === 'backup'}
on:click={() => select(sec.id)}
>
<span class="settings-nav-icon">{sec.icon}</span>
<span class="settings-nav-label">{sec.label}</span>
</button>
{/each}
</nav>
<style>
.settings-sidebar {
width: 200px;
min-width: 200px;
border-right: 1px solid var(--border, #2a2a3e);
padding: 0.75rem 0;
overflow-y: auto;
background: var(--surface, #1e1e2e);
}
.settings-nav-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.55rem 1rem;
border: none;
background: transparent;
color: var(--text-dim, #888);
font-size: 0.9rem;
cursor: pointer;
text-align: left;
transition: background 0.15s, color 0.15s;
font-family: inherit;
}
.settings-nav-item:hover:not(.disabled) {
background: var(--surface-alt, #252538);
color: var(--text, #e0e0e0);
}
.settings-nav-item.active {
background: var(--accent-bg, rgba(99, 102, 241, 0.15));
color: var(--accent, #818cf8);
font-weight: 600;
}
.settings-nav-item.disabled {
opacity: 0.4;
cursor: default;
}
.settings-nav-icon {
font-size: 1rem;
width: 1.4rem;
text-align: center;
flex-shrink: 0;
}
.settings-nav-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@ -0,0 +1,405 @@
<script>
import { t } from './i18n'
export let onRefresh = null
let settings = null
let loading = false
let errorMsg = ''
let resultMsg = ''
let serverUrl = ''
let username = ''
let password = ''
let syncInterval = 0
let showDisconnectConfirm = false
let showResetKeyConfirm = false
let connectionOk = null
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 error:', method, e) }
return Promise.reject(new Error('Wails not connected: ' + method))
}
async function load() {
try {
settings = await wailsCall('GetSyncSettings')
if (settings) {
serverUrl = settings.serverUrl || ''
syncInterval = settings.syncInterval || 0
}
} catch (e) { settings = null }
}
load()
async function testConnection() {
loading = true
errorMsg = ''
connectionOk = null
try {
await wailsCall('SyncTestConnection', serverUrl, username, password)
connectionOk = true
resultMsg = 'connection ok'
} catch (e) {
connectionOk = false
resultMsg = 'connection failed: ' + String(e)
}
loading = false
}
async function configureSync() {
loading = true
errorMsg = ''
try {
await wailsCall('SyncConfigure', serverUrl, username, password)
resultMsg = 'configured'
username = ''
password = ''
await load()
if (onRefresh) onRefresh()
} catch (e) {
errorMsg = String(e)
}
loading = false
}
async function runSyncNow() {
loading = true
errorMsg = ''
try {
const r = await wailsCall('SyncNow')
resultMsg = 'pushed ' + r.pushed + ', pulled ' + r.pulled
await load()
if (onRefresh) onRefresh()
} catch (e) {
errorMsg = String(e)
}
loading = false
}
async function setInterval() {
try {
await wailsCall('SyncSetInterval', syncInterval)
resultMsg = t('sync.settingsSaved')
} catch (e) { errorMsg = String(e) }
}
function confirmDisconnect() {
showDisconnectConfirm = true
}
async function doDisconnect() {
showDisconnectConfirm = false
loading = true
try {
await wailsCall('SyncDisconnect')
resultMsg = 'disconnected'
await load()
if (onRefresh) onRefresh()
} catch (e) { errorMsg = String(e) }
loading = false
}
function confirmResetKey() {
showResetKeyConfirm = true
}
async function doResetKey() {
showResetKeyConfirm = false
loading = true
try {
await wailsCall('ResetSyncKey')
resultMsg = 'key reset'
await load()
if (onRefresh) onRefresh()
} catch (e) { errorMsg = String(e) }
loading = false
}
function statusLabel(s) {
if (!s) return t('settings.notConfigured')
const labels = {
'connected': t('settings.connected'),
'disconnected': t('settings.disconnected'),
'disabled': t('settings.notConfigured'),
'error': t('settings.error'),
'revoked': t('settings.revoked'),
}
return labels[s] || s
}
</script>
<div class="settings-section">
<h2>{t('settings.sync')}</h2>
<p class="section-desc">{t('settings.syncDesc')}</p>
{#if errorMsg}
<div class="error-msg">{errorMsg}</div>
{/if}
{#if resultMsg && !errorMsg}
<div class="result-msg">{resultMsg}</div>
{/if}
{#if settings && settings.enabled}
<div class="settings-card">
<div class="sync-info">
<div class="info-row">
<span class="info-label">{t('settings.status')}</span>
<span class="info-value" class:status-ok={settings.lastStatus === 'connected'} class:status-err={settings.lastStatus === 'error' || settings.lastStatus === 'revoked'}>
{statusLabel(settings.lastStatus)}
</span>
</div>
{#if settings.serverUrl}
<div class="info-row">
<span class="info-label">{t('settings.serverUrl')}</span>
<span class="info-value mono">{settings.serverUrl}</span>
</div>
{/if}
{#if settings.deviceName}
<div class="info-row">
<span class="info-label">{t('settings.deviceName')}</span>
<span class="info-value">{settings.deviceName}</span>
</div>
{/if}
{#if settings.deviceId}
<div class="info-row">
<span class="info-label">{t('settings.deviceId')}</span>
<span class="info-value mono">{settings.deviceId}</span>
</div>
{/if}
{#if settings.lastSyncAt}
<div class="info-row">
<span class="info-label">{t('settings.lastSync')}</span>
<span class="info-value">{settings.lastSyncAt}</span>
</div>
{/if}
{#if settings.lastError}
<div class="info-row">
<span class="info-label">{t('settings.lastError')}</span>
<span class="info-value error">{settings.lastError}</span>
</div>
{/if}
</div>
</div>
<div class="sync-actions">
<button class="btn btn-primary" on:click={runSyncNow} disabled={loading}>
{t('settings.syncNow')}
</button>
<button class="btn" on:click={confirmDisconnect} disabled={loading}>
{t('settings.disconnect')}
</button>
<button class="btn" on:click={confirmResetKey} disabled={loading}>
{t('settings.resetKey')}
</button>
</div>
<div class="sync-interval">
<label>
<span class="label-text">{t('sync.interval')}</span>
<div class="interval-row">
<input type="number" bind:value={syncInterval} min="0" placeholder="0" />
<button class="btn btn-sm" on:click={setInterval}>{t('common.save')}</button>
</div>
</label>
</div>
{:else}
<div class="settings-card">
<div class="sync-setup">
<div class="form-group">
<label>
<span class="label-text">{t('settings.serverUrl')}</span>
<input type="text" placeholder="https://example.com" bind:value={serverUrl} />
</label>
</div>
<div class="form-group">
<label>
<span class="label-text">{t('sync.username')}</span>
<input type="text" bind:value={username} />
</label>
</div>
<div class="form-group">
<label>
<span class="label-text">{t('sync.password')}</span>
<input type="password" bind:value={password} />
</label>
</div>
<div class="sync-setup-actions">
<button class="btn" on:click={testConnection} disabled={loading || !serverUrl}>
{t('settings.checkConnection')}
</button>
<button class="btn btn-primary" on:click={configureSync}
disabled={loading || !serverUrl || !username || !password}>
{t('common.connect')}
</button>
</div>
{#if connectionOk !== null}
<div class="connection-result" class:ok={connectionOk} class:fail={!connectionOk}>
{connectionOk ? t('common.test') + ' OK' : t('error.generic')}
</div>
{/if}
</div>
</div>
{/if}
</div>
{#if showDisconnectConfirm}
<button class="modal-overlay" on:click={() => showDisconnectConfirm = false}>
<div class="modal">
<h3>{t('settings.confirmDisconnect')}</h3>
<p class="modal-desc">{t('settings.confirmDisconnectDesc')}</p>
<div class="modal-actions">
<button class="btn btn-danger" on:click={doDisconnect}>{t('common.disconnect')}</button>
<button class="btn" on:click={() => showDisconnectConfirm = false}>{t('common.cancel')}</button>
</div>
</div>
</button>
{/if}
{#if showResetKeyConfirm}
<button class="modal-overlay" on:click={() => showResetKeyConfirm = false}>
<div class="modal">
<h3>{t('settings.confirmResetKey')}</h3>
<p class="modal-desc">{t('settings.confirmResetKeyDesc')}</p>
<div class="modal-actions">
<button class="btn btn-danger" on:click={doResetKey}>{t('settings.resetKey')}</button>
<button class="btn" on:click={() => showResetKeyConfirm = false}>{t('common.cancel')}</button>
</div>
</div>
</button>
{/if}
<style>
.settings-section {
padding: 1.5rem;
max-width: 600px;
}
.settings-section h2 {
margin: 0 0 0.25rem 0;
font-size: 1.2rem;
color: var(--text, #e0e0e0);
}
.section-desc {
color: var(--text-dim, #888);
font-size: 0.85rem;
margin-bottom: 1.25rem;
line-height: 1.4;
}
.settings-card {
background: var(--surface-alt, #1e1e30);
border: 1px solid var(--border, #2a2a3e);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
.sync-info {}
.info-row {
display: flex;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border, #2a2a3e);
font-size: 0.9rem;
}
.info-row:last-child { border-bottom: none; }
.info-label {
width: 180px;
min-width: 180px;
color: var(--text-dim, #888);
}
.info-value { color: var(--text, #e0e0e0); word-break: break-all; }
.info-value.mono { font-family: monospace; font-size: 0.85rem; }
.info-value.error { color: #ff6b6b; }
.status-ok { color: #34d399; font-weight: 600; }
.status-err { color: #ff6b6b; }
.sync-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.sync-interval {
margin-bottom: 0;
}
.interval-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.interval-row input {
width: 100px;
}
.sync-setup {}
.sync-setup .form-group { margin-bottom: 1rem; }
.sync-setup .form-group:last-of-type { margin-bottom: 0; }
.sync-setup-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.connection-result {
margin-top: 0.75rem;
padding: 0.4rem 0.75rem;
border-radius: 6px;
font-size: 0.85rem;
}
.connection-result.ok {
background: rgba(52, 211, 153, 0.1);
border: 1px solid rgba(52, 211, 153, 0.3);
color: #34d399;
}
.connection-result.fail {
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
color: #ff6b6b;
}
.error-msg {
padding: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
border-radius: 6px;
color: #ff6b6b;
font-size: 0.85rem;
}
.result-msg {
padding: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
background: rgba(52, 211, 153, 0.1);
border: 1px solid rgba(52, 211, 153, 0.3);
border-radius: 6px;
color: #34d399;
font-size: 0.85rem;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
border: none;
padding: 0;
cursor: pointer;
width: 100%;
color: inherit;
font: inherit;
}
.modal {
background: var(--surface, #1e1e2e);
border: 1px solid var(--border, #2a2a3e);
border-radius: 10px;
padding: 1.5rem;
max-width: 420px;
width: 90%;
cursor: default;
}
.modal h3 { margin: 0 0 0.75rem 0; }
.modal-desc { color: var(--text-dim, #888); font-size: 0.9rem; line-height: 1.5; margin-bottom: 1rem; }
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
</style>

View File

@ -0,0 +1,126 @@
<script>
import { t } from './i18n'
export let onRefresh = null
let templates = []
let loading = true
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 error:', method, e) }
return Promise.reject(new Error('Wails not connected: ' + method))
}
async function load() {
loading = true
try {
templates = await wailsCall('AllTemplates') || []
} catch (e) { templates = [] }
loading = false
}
load()
async function toggle(tpl) {
const newEnabled = !tpl.enabled
try {
await wailsCall('SetTemplateEnabled', tpl.id, newEnabled)
tpl.enabled = newEnabled
templates = templates
if (onRefresh) onRefresh()
} catch (e) { console.error(e) }
}
</script>
<div class="settings-section">
<h2>{t('settings.templates')}</h2>
<p class="section-desc">{t('settings.templatesDesc')}</p>
{#if loading}
<p class="loading">{t('common.loading')}</p>
{:else if templates.length === 0}
<p class="no-data">{t('common.empty')}</p>
{:else}
<div class="template-list">
{#each templates as tpl}
<div class="template-item">
<div class="template-info">
<span class="template-name">{t('template.' + tpl.id) || tpl.title}</span>
<span class="template-type">({tpl.type})</span>
</div>
<label class="toggle-label">
<input type="checkbox" checked={tpl.enabled} on:change={() => toggle(tpl)} />
<span class="toggle-text">{tpl.enabled ? t('settings.templateEnabled') : t('settings.templateDisabled')}</span>
</label>
</div>
{/each}
</div>
{/if}
</div>
<style>
.settings-section {
padding: 1.5rem;
max-width: 560px;
}
.settings-section h2 {
margin: 0 0 0.25rem 0;
font-size: 1.2rem;
color: var(--text, #e0e0e0);
}
.section-desc {
color: var(--text-dim, #888);
font-size: 0.85rem;
margin-bottom: 1.25rem;
line-height: 1.4;
}
.loading, .no-data {
color: var(--text-dim, #888);
font-style: italic;
}
.template-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.template-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.75rem;
background: var(--surface-alt, #252538);
border-radius: 6px;
border: 1px solid var(--border, #2a2a3e);
}
.template-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.template-name {
color: var(--text, #e0e0e0);
font-size: 0.9rem;
}
.template-type {
color: var(--text-dim, #888);
font-size: 0.8rem;
}
.toggle-label {
display: flex;
align-items: center;
gap: 0.4rem;
cursor: pointer;
}
.toggle-text {
font-size: 0.8rem;
color: var(--text-dim, #888);
}
input[type="checkbox"] {
accent-color: var(--accent, #818cf8);
}
</style>

View File

@ -0,0 +1,165 @@
<script>
import { t } from './i18n'
import SettingsSidebar from './SettingsSidebar.svelte'
import SettingsGeneral from './SettingsGeneral.svelte'
import SettingsWorkspace from './SettingsWorkspace.svelte'
import SettingsTemplates from './SettingsTemplates.svelte'
import SettingsPlugins from './SettingsPlugins.svelte'
import SettingsFiles from './SettingsFiles.svelte'
import SettingsActivity from './SettingsActivity.svelte'
import SettingsSync from './SettingsSync.svelte'
import SettingsBackup from './SettingsBackup.svelte'
export let onClose = null
export let onSyncRefresh = null
export let initialSection = 'general'
let activeSection = initialSection
let appConfig = null
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 error:', method, e) }
return Promise.reject(new Error('Wails not connected: ' + method))
}
async function loadConfig() {
try {
appConfig = await wailsCall('GetAppConfig')
} catch (e) { appConfig = null }
}
loadConfig()
function selectSection(id) {
activeSection = id
}
function handleClose() {
if (onClose) onClose()
}
async function saveConfig(cfg) {
try {
await wailsCall('SaveAppConfig', cfg)
appConfig = cfg
} catch (e) { console.error(e) }
}
function handleSyncRefresh() {
if (onSyncRefresh) onSyncRefresh()
}
function onKeydown(e) {
if (e.key === 'Escape') handleClose()
}
</script>
<svelte:window on:keydown={onKeydown} />
<button class="settings-overlay" on:click|self={handleClose}>
<div class="settings-window">
<div class="settings-header">
<h2>{t('settings.title')}</h2>
<button class="close-btn" on:click={handleClose} title={t('settings.close')}>
<svg width="18" height="18" 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>
<div class="settings-body">
<SettingsSidebar {activeSection} onSelect={selectSection} />
<div class="settings-content">
{#if activeSection === 'general'}
<SettingsGeneral {appConfig} onSave={saveConfig} />
{:else if activeSection === 'workspace'}
<SettingsWorkspace onRefresh={loadConfig} />
{:else if activeSection === 'templates'}
<SettingsTemplates onRefresh={loadConfig} />
{:else if activeSection === 'plugins'}
<SettingsPlugins />
{:else if activeSection === 'files'}
<SettingsFiles />
{:else if activeSection === 'activity'}
<SettingsActivity />
{:else if activeSection === 'sync'}
<SettingsSync onRefresh={handleSyncRefresh} />
{:else if activeSection === 'backup'}
<SettingsBackup />
{/if}
</div>
</div>
</div>
</button>
<style>
.settings-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 900;
border: none;
padding: 0;
cursor: pointer;
width: 100%;
color: inherit;
font: inherit;
}
.settings-window {
display: flex;
flex-direction: column;
width: 800px;
max-width: 90vw;
height: 600px;
max-height: 85vh;
background: var(--surface, #1e1e2e);
border: 1px solid var(--border, #2a2a3e);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border, #2a2a3e);
flex-shrink: 0;
}
.settings-header h2 {
margin: 0;
font-size: 1.1rem;
color: var(--text, #e0e0e0);
}
.close-btn {
background: transparent;
border: none;
color: var(--text-dim, #888);
cursor: pointer;
padding: 0.25rem;
border-radius: 4px;
display: flex;
align-items: center;
}
.close-btn:hover {
color: var(--text, #e0e0e0);
background: var(--surface-alt, #252538);
}
.settings-body {
display: flex;
flex: 1;
overflow: hidden;
}
.settings-content {
flex: 1;
overflow-y: auto;
background: var(--bg, #13131f);
}
</style>

View File

@ -0,0 +1,165 @@
<script>
import { t } from './i18n'
export let onRefresh = null
let info = null
let errorMsg = ''
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 error:', method, e) }
return Promise.reject(new Error('Wails not connected: ' + method))
}
async function load() {
try {
info = await wailsCall('GetVaultInfo')
} catch (e) { info = null }
}
load()
async function openVaultFolder() {
try { await wailsCall('OpenVaultFolder') } catch (e) { errorMsg = String(e) }
}
async function checkIntegrity() {
try {
const result = await wailsCall('VaultCheck')
if (result.healthy) {
errorMsg = 'Vault healthy'
} else {
errorMsg = 'Issues found: ' + (result.errors || []).join('; ')
}
} catch (e) { errorMsg = String(e) }
}
async function chooseVault() {
try {
const chosen = await wailsCall('PickDirectory')
if (!chosen) return
const status = await wailsCall('OpenVault', chosen)
if (status && status.status === 'ready') {
info = await wailsCall('GetVaultInfo')
if (onRefresh) onRefresh()
}
} catch (e) { errorMsg = String(e) }
}
function formatSize(bytes) {
if (!bytes) return '—'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
</script>
<div class="settings-section">
<h2>{t('settings.workspace')}</h2>
<p class="section-desc">{t('settings.workspaceDesc')}</p>
{#if errorMsg}
<div class="notice">{errorMsg}</div>
{/if}
<div class="settings-card">
{#if info}
<div class="vault-info">
<div class="info-row">
<span class="info-label">{t('settings.vaultPath')}</span>
<span class="info-value mono">{info.path}</span>
</div>
<div class="info-row">
<span class="info-label">DB</span>
<span class="info-value mono small">{info.dbPath}</span>
</div>
{#if info.nodeCount !== undefined}
<div class="info-row">
<span class="info-label">Nodes</span>
<span class="info-value">{info.nodeCount}</span>
</div>
{/if}
{#if info.fileCount !== undefined}
<div class="info-row">
<span class="info-label">Files</span>
<span class="info-value">{info.fileCount}</span>
</div>
{/if}
</div>
{:else}
<p class="no-data">{t('settings.noVaultInfo')}</p>
{/if}
</div>
<div class="workspace-actions">
<button class="btn" on:click={openVaultFolder}>{t('settings.openVault')}</button>
<button class="btn" on:click={chooseVault}>{t('settings.changeVault')}</button>
<button class="btn" on:click={checkIntegrity}>{t('settings.checkIntegrity')}</button>
</div>
</div>
<style>
.settings-section {
padding: 1.5rem;
max-width: 600px;
}
.settings-section h2 {
margin: 0 0 0.25rem 0;
font-size: 1.2rem;
color: var(--text, #e0e0e0);
}
.section-desc {
color: var(--text-dim, #888);
font-size: 0.85rem;
margin-bottom: 1.25rem;
line-height: 1.4;
}
.settings-card {
background: var(--surface-alt, #1e1e30);
border: 1px solid var(--border, #2a2a3e);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
.notice {
padding: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
border-radius: 6px;
color: #ff6b6b;
font-size: 0.85rem;
}
.vault-info {}
.info-row {
display: flex;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border, #2a2a3e);
font-size: 0.9rem;
}
.info-row:last-child { border-bottom: none; }
.info-label {
width: 120px;
min-width: 120px;
color: var(--text-dim, #888);
}
.info-value { color: var(--text, #e0e0e0); word-break: break-all; }
.info-value.mono { font-family: monospace; font-size: 0.85rem; }
.info-value.small { font-size: 0.8rem; }
.workspace-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.no-data {
color: var(--text-dim, #888);
font-style: italic;
margin: 0;
}
</style>

View File

@ -0,0 +1,119 @@
<script>
import { t } from './i18n'
export let syncStatus = null
export let syncLoading = false
export let onSync = null
export let onOpenSettings = null
function getDotClass(status) {
if (!status || !status.configured) return 'dot-disabled'
if (syncLoading) return 'dot-syncing'
if (status.revoked) return 'dot-disabled'
if (status.connected) return 'dot-connected'
if (status.configured) return 'dot-warning'
return 'dot-disabled'
}
function getLabel(status) {
if (!status || !status.configured) return t('sync.status.disabled')
if (syncLoading) return t('sync.status.syncing')
if (status.revoked) return t('sync.status.error')
if (status.connected) return t('sync.status.connected')
return t('sync.status.disconnected')
}
</script>
<div class="sync-status-widget">
{#if syncStatus?.configured}
<div class="sync-status-row">
<span class="sync-dot {getDotClass(syncStatus)}"></span>
<span class="sync-label">{getLabel(syncStatus)}</span>
{#if syncStatus.unpushedOps > 0}
<span class="sync-count">{syncStatus.unpushedOps} {t('sync.changes')}</span>
{/if}
<div class="sync-actions-row">
<button class="btn btn-xs" on:click={onSync} disabled={syncLoading}>
{syncLoading ? t('sync.running') : t('sync.run')}
</button>
<button class="btn btn-xs btn-link" on:click={onOpenSettings}>
{t('sync.details')}
</button>
</div>
</div>
{:else}
<div class="sync-status-row">
<span class="sync-dot dot-disabled"></span>
<span class="sync-label">{t('sync.status.disabled')}</span>
<button class="btn btn-xs" on:click={onOpenSettings}>{t('sync.configure')}</button>
</div>
{/if}
</div>
<style>
.sync-status-widget {
font-size: 0.8rem;
padding: 0.4rem 0.5rem;
}
.sync-status-row {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.sync-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-connected { background: #34d399; box-shadow: 0 0 4px rgba(52, 211, 153, 0.4); }
.dot-warning { background: #f59e0b; }
.dot-disabled { background: #555; }
.dot-syncing { background: #818cf8; animation: pulse 1s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.sync-label {
color: var(--text-dim, #888);
white-space: nowrap;
}
.sync-count {
color: var(--accent, #818cf8);
font-weight: 600;
}
.sync-actions-row {
display: flex;
gap: 0.3rem;
margin-left: auto;
}
.btn-xs {
padding: 0.2rem 0.5rem;
font-size: 0.75rem;
border: 1px solid var(--border, #2a2a3e);
background: var(--surface-alt, #252538);
color: var(--text-dim, #888);
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.btn-xs:hover {
background: var(--border, #2a2a3e);
color: var(--text, #e0e0e0);
}
.btn-xs:disabled {
opacity: 0.5;
cursor: default;
}
.btn-link {
border: none;
background: transparent;
color: var(--accent, #818cf8);
text-decoration: underline;
}
.btn-link:hover {
color: #a5b4fc;
background: transparent;
}
</style>

View File

@ -0,0 +1,197 @@
<script>
import { t } from './i18n'
export let vaultPath = ''
export let onComplete = null
export let onQuit = null
let loading = false
let errorMsg = ''
let showCreateConfirm = false
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 error:', method, e) }
return Promise.reject(new Error('Wails not connected: ' + method))
}
async function chooseVault() {
try {
const chosen = await wailsCall('PickDirectory')
if (!chosen) return
loading = true
errorMsg = ''
const status = await wailsCall('OpenVault', chosen)
if (status && status.status === 'ready' && onComplete) {
onComplete(status)
}
} catch (e) {
errorMsg = String(e)
}
loading = false
}
function promptCreateVault() {
showCreateConfirm = true
}
async function confirmCreateVault() {
showCreateConfirm = false
loading = true
errorMsg = ''
try {
const status = await wailsCall('CreateVault', vaultPath)
if (status && status.status === 'ready' && onComplete) {
onComplete(status)
}
} catch (e) {
errorMsg = String(e)
}
loading = false
}
function cancelCreateVault() {
showCreateConfirm = false
}
function handleQuit() {
if (onQuit) onQuit()
}
</script>
<div class="recovery-screen">
<div class="recovery-card">
<div class="recovery-icon">&#9888;</div>
<h1>{t('recovery.title')}</h1>
<p class="recovery-desc">{t('recovery.expected')}:</p>
<code class="recovery-path">{vaultPath || '—'}</code>
{#if errorMsg}
<div class="error-msg">{errorMsg}</div>
{/if}
<div class="recovery-actions">
<button class="btn btn-primary btn-lg" on:click={chooseVault} disabled={loading}>
{t('recovery.choose')}
</button>
<button class="btn btn-lg" on:click={promptCreateVault} disabled={loading}>
{t('recovery.create')}
</button>
<button class="btn btn-lg" on:click={handleQuit}>{t('recovery.quit')}</button>
</div>
</div>
</div>
{#if showCreateConfirm}
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelCreateVault} on:keydown={(e) => e.key === 'Enter' && cancelCreateVault()}>
<div class="modal">
<h3>{t('recovery.createConfirmTitle')}</h3>
<p class="modal-desc">{t('recovery.createConfirm')}</p>
<div class="modal-actions">
<button class="btn btn-primary" on:click={confirmCreateVault} disabled={loading}>
{t('common.yes')}
</button>
<button class="btn" on:click={cancelCreateVault}>{t('common.cancel')}</button>
</div>
</div>
</div>
{/if}
<style>
.recovery-screen {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
background: var(--bg, #13131f);
}
.recovery-card {
max-width: 480px;
width: 100%;
padding: 2.5rem;
background: var(--surface, #1e1e2e);
border-radius: 12px;
border: 1px solid var(--border, #2a2a3e);
text-align: center;
}
.recovery-icon {
font-size: 2.5rem;
margin-bottom: 0.5rem;
color: #f59e0b;
}
.recovery-card h1 {
margin: 0 0 0.75rem 0;
font-size: 1.4rem;
color: var(--text, #e0e0e0);
}
.recovery-desc {
color: var(--text-dim, #888);
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.recovery-path {
display: block;
padding: 0.5rem 0.75rem;
background: var(--surface-alt, #252538);
border-radius: 6px;
font-size: 0.85rem;
color: var(--text-dim, #aaa);
word-break: break-all;
margin-bottom: 1.5rem;
}
.error-msg {
padding: 0.5rem 0.75rem;
margin-bottom: 1rem;
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
border-radius: 6px;
color: #ff6b6b;
font-size: 0.85rem;
}
.recovery-actions {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.btn-lg {
padding: 0.65rem 1.25rem;
font-size: 0.95rem;
width: 100%;
}
.modal-desc {
color: var(--text-dim, #888);
font-size: 0.9rem;
line-height: 1.5;
margin-bottom: 1rem;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--surface, #1e1e2e);
border: 1px solid var(--border, #2a2a3e);
border-radius: 10px;
padding: 1.5rem;
max-width: 420px;
width: 90%;
}
.modal h3 {
margin: 0 0 0.75rem 0;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
</style>

View File

@ -186,7 +186,7 @@ export default {
'sync.usernamePlaceholder': 'username',
'sync.password': 'Пароль',
'sync.passwordPlaceholder': 'password',
'sync.autoSync': 'Автосинхронизация (мин, 0 = отключено)',
'sync.interval': 'Автосинхронизация (мин)',
'sync.saveInterval': 'Сохранить интервал',
'sync.syncNow': 'Синхронизировать',
'sync.disconnect': 'Отключиться',
@ -333,6 +333,104 @@ export default {
'case.new': 'Новое дело',
'case.namePlaceholder': 'Название дела',
'settings.title': 'Настройки',
'settings.general': 'Общие',
'settings.generalDesc': 'Основные настройки интерфейса.',
'settings.workspace': 'Рабочее пространство',
'settings.workspaceDesc': 'Управление хранилищем данных.',
'settings.appearance': 'Внешний вид',
'settings.localization': 'Локализация',
'settings.templates': 'Шаблоны',
'settings.plugins': 'Плагины',
'settings.files': 'Файлы',
'settings.activity': 'Активность',
'settings.sync': 'Синхронизация',
'settings.backup': 'Резервные копии',
'settings.close': 'Закрыть настройки',
'settings.noPlugins': 'Плагины пока не установлены.',
'settings.openPluginsDir': 'Открыть папку plugins',
'settings.activityStub': 'Здесь будут настройки отслеживания активности.',
'settings.backupStub': 'Здесь будут настройки резервного копирования.',
'settings.theme': 'Тема интерфейса',
'settings.themeSystem': 'Системная',
'settings.themeLight': 'Светлая',
'settings.themeDark': 'Тёмная',
'settings.language': 'Язык интерфейса',
'settings.langRu': 'Русский',
'settings.langEn': 'Английский',
'settings.langSoon': 'будет доступно позже',
'settings.save': 'Сохранить настройки',
'settings.saved': 'Настройки сохранены',
'settings.vaultPath': 'Путь к рабочему пространству',
'settings.noVaultInfo': 'Нет информации о хранилище.',
'settings.vaultInfo': 'Информация о рабочем пространстве',
'settings.openVault': 'Открыть папку vault',
'settings.changeVault': 'Изменить путь',
'settings.checkIntegrity': 'Проверить целостность',
'settings.templatesDesc': 'Включите или выключите шаблоны создания элементов.',
'settings.templateEnabled': 'Включён',
'settings.templateDisabled': 'Выключен',
'settings.filesStub': 'Здесь будут настройки файлового хранилища.',
'settings.syncEnabled': 'Синхронизация включена',
'settings.syncDisabled': 'Синхронизация отключена',
'settings.syncDesc': 'Настройте подключение к серверу синхронизации.',
'settings.serverUrl': 'URL сервера',
'settings.deviceName': 'Имя устройства',
'settings.deviceId': 'ID устройства',
'settings.lastSync': 'Последняя синхронизация',
'settings.lastError': 'Последняя ошибка',
'settings.pendingChanges': 'Ожидающие изменения',
'settings.checkConnection': 'Проверить подключение',
'settings.syncNow': 'Синхронизировать сейчас',
'settings.reconnect': 'Переподключить',
'settings.disconnect': 'Отключить устройство',
'settings.resetKey': 'Сбросить ключ',
'settings.confirmDisconnect': 'Вы уверены, что хотите отключить устройство?',
'settings.confirmDisconnectDesc': 'Синхронизация будет остановлена. Для повторного подключения потребуется логин и пароль.',
'settings.confirmResetKey': 'Сбросить ключ синхронизации?',
'settings.confirmResetKeyDesc': 'Текущий ключ устройства будет удалён. Потребуется переподключение.',
'settings.connected': 'Подключено',
'settings.disconnected': 'Не подключено',
'settings.notConfigured': 'Не настроено',
'settings.error': 'Ошибка',
'settings.revoked': 'Отозвано',
'settings.status': 'Статус',
'firstrun.title': 'Добро пожаловать в Верстак',
'firstrun.desc': 'Верстак хранит данные в локальном рабочем пространстве — vault.',
'firstrun.pathLabel': 'Путь к vault',
'firstrun.defaultPath': 'Путь по умолчанию',
'firstrun.browse': 'Выбрать папку',
'firstrun.create': 'Создать рабочее пространство',
'firstrun.quit': 'Выйти',
'firstrun.pathExists': 'Папка существует. Будет создан новый vault.',
'firstrun.pathHasVault': 'Найден существующий vault. Будет подключён.',
'firstrun.pathNew': 'Путь не существует. Будет создан новый vault.',
'firstrun.errorNoWrite': 'Нет прав на запись в выбранную папку.',
'firstrun.creating': 'Создание рабочего пространства...',
'firstrun.created': 'Рабочее пространство создано!',
'recovery.title': 'Рабочее пространство не найдено',
'recovery.expected': 'Ожидался путь',
'recovery.choose': 'Выбрать другой vault',
'recovery.create': 'Создать новый vault',
'recovery.settings': 'Открыть настройки',
'recovery.quit': 'Выйти',
'recovery.createConfirm': 'Будет создано новое пустое рабочее пространство. Старый путь в настройках будет заменён.',
'recovery.createConfirmTitle': 'Создать новое рабочее пространство?',
'sync.status.connected': 'Синхронизация доступна',
'sync.status.disconnected': 'Сервер недоступен',
'sync.status.disabled': 'Синхронизация не настроена',
'sync.status.syncing': 'Синхронизация...',
'sync.status.error': 'Ошибка синхронизации',
'sync.changes': 'изменений',
'sync.details': 'Подробнее',
'sync.configure': 'Настроить',
'sync.retry': 'Повторить',
'sync.run': 'Синхронизировать',
'sync.running': 'Синхронизация...',
'error.generic': 'Произошла ошибка',
'error.invalidCredentials': 'Неверный логин или пароль',
'error.accountBlocked': 'Аккаунт заблокирован',

View File

@ -0,0 +1,139 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
const (
AppConfigVersion = 1
ConfigDirName = "verstak"
AppConfigFileName = "config.json"
)
// AppConfig is the global application config stored at ~/.config/verstak/config.json.
type AppConfig struct {
Version int `json:"version"`
VaultPath string `json:"vault_path"`
Theme string `json:"theme"`
Language string `json:"language"`
EnabledTemplates []string `json:"enabled_templates"`
EnabledPlugins []string `json:"enabled_plugins"`
FirstRunCompleted bool `json:"first_run_completed"`
Window WindowConfig `json:"window,omitempty"`
Vault VaultAppConfig `json:"vault,omitempty"`
}
type WindowConfig struct {
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
}
// VaultAppConfig holds per-vault settings in the global config.
type VaultAppConfig struct {
VaultID string `json:"vault_id,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
Sync SyncSettings `json:"sync,omitempty"`
}
// SyncSettings holds sync configuration for the current vault.
type SyncSettings struct {
Enabled bool `json:"enabled"`
ServerURL string `json:"server_url"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
SyncInterval int `json:"sync_interval"`
LastStatus string `json:"last_status"`
LastSyncAt string `json:"last_sync_at"`
LastError string `json:"last_error,omitempty"`
}
func DefaultAppConfig() *AppConfig {
return &AppConfig{
Version: AppConfigVersion,
Theme: "system",
Language: "ru",
EnabledTemplates: []string{"folder", "project", "client", "document", "recipe"},
EnabledPlugins: []string{},
}
}
// ConfigDir returns ~/.config/verstak (or platform equivalent).
func ConfigDir() (string, error) {
cfgDir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(cfgDir, ConfigDirName), nil
}
// AppConfigPath returns the path to config.json.
func AppConfigPath() (string, error) {
dir, err := ConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, AppConfigFileName), nil
}
// EnsureConfigDir creates the config directory if it doesn't exist.
func EnsureConfigDir() (string, error) {
dir, err := ConfigDir()
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return "", err
}
return dir, nil
}
// LoadAppConfig reads the global config from disk. Returns nil if the file doesn't exist.
func LoadAppConfig() (*AppConfig, error) {
path, err := AppConfigPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read config: %w", err)
}
if len(data) == 0 {
return nil, nil
}
var cfg AppConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
return &cfg, nil
}
// SaveAppConfig writes the global config to disk.
func SaveAppConfig(cfg *AppConfig) error {
path, err := AppConfigPath()
if err != nil {
return err
}
if _, err := EnsureConfigDir(); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o640)
}
// DefaultVaultPath returns the default vault location.
func DefaultVaultPath() (string, error) {
dir, err := ConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "vault"), nil
}