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:
parent
e30a75c5a0
commit
f92394e3d7
10
AGENTS.md
10
AGENTS.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,13 +1515,16 @@
|
|||
</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>
|
||||
<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>
|
||||
|
||||
<!-- Main -->
|
||||
|
|
@ -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 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>
|
||||
{/if}
|
||||
</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; }
|
||||
|
|
|
|||
|
|
@ -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">⚒</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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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">⚠</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>
|
||||
|
|
@ -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': 'Аккаунт заблокирован',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue