diff --git a/cmd/verstak-gui/app.go b/cmd/verstak-gui/app.go
index d384e53..fc61019 100644
--- a/cmd/verstak-gui/app.go
+++ b/cmd/verstak-gui/app.go
@@ -846,6 +846,7 @@ type SyncStatusDTO struct {
DeviceID string `json:"deviceId"`
UnpushedOps int `json:"unpushedOps"`
LastSyncAt string `json:"lastSyncAt"`
+ SyncInterval int `json:"syncInterval"`
}
func (a *App) SyncStatus() (*SyncStatusDTO, error) {
@@ -863,11 +864,23 @@ func (a *App) SyncStatus() (*SyncStatusDTO, error) {
}
if cfg != nil {
dto.DeviceID = cfg.Sync.DeviceID
+ dto.SyncInterval = cfg.Sync.SyncInterval
}
return dto, nil
}
-func (a *App) SyncConfigure(serverURL, apiKey string) error {
+func (a *App) SyncConfigure(serverURL, username, password string) error {
+ // Register device on server with user credentials.
+ hostname, _ := os.Hostname()
+ if hostname == "" {
+ hostname = "unknown"
+ }
+ client := syncsvc.NewClient(serverURL, "", "", a.vault)
+ deviceID, apiKey, err := client.RegisterDeviceWithAuth(hostname, username, password)
+ if err != nil {
+ return fmt.Errorf("register: %w", err)
+ }
+
if err := a.sync.SetState(serverURL, apiKey); err != nil {
return err
}
@@ -878,6 +891,22 @@ func (a *App) SyncConfigure(serverURL, apiKey string) error {
}
cfg.Sync.ServerURL = serverURL
cfg.Sync.APIKey = apiKey
+ cfg.Sync.DeviceID = deviceID
+ return config.Save(a.vault, cfg)
+}
+
+func (a *App) SyncTestConnection(serverURL, username, password string) error {
+ client := syncsvc.NewClient(serverURL, "", "", a.vault)
+ _, _, err := client.RegisterDeviceWithAuth("test-connection", username, password)
+ return err
+}
+
+func (a *App) SyncSetInterval(minutes int) error {
+ cfg, err := config.Load(a.vault)
+ if err != nil {
+ return err
+ }
+ cfg.Sync.SyncInterval = minutes
return config.Save(a.vault, cfg)
}
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 89ccf37..dd6a040 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -103,7 +103,9 @@
let syncStatus = null
let syncLoading = false
let syncServerUrl = ''
- let syncApiKey = ''
+ let syncUsername = ''
+ let syncPassword = ''
+ let syncInterval = 0
let syncResult = ''
const tabs = [
@@ -897,14 +899,16 @@
try {
syncStatus = await wailsCall('SyncStatus')
} catch (e) {
- syncStatus = { configured: false, serverUrl: '', deviceId: '', unpushedOps: 0, lastSyncAt: '' }
+ syncStatus = { configured: false, serverUrl: '', deviceId: '', unpushedOps: 0, lastSyncAt: '', syncInterval: 0 }
}
}
function openSettings() {
showSettings = true
syncServerUrl = syncStatus?.serverUrl || ''
- syncApiKey = ''
+ syncUsername = ''
+ syncPassword = ''
+ syncInterval = syncStatus?.syncInterval || 0
syncResult = ''
}
@@ -917,7 +921,10 @@
syncLoading = true
syncResult = ''
try {
- await wailsCall('SyncConfigure', syncServerUrl, syncApiKey)
+ await wailsCall('SyncConfigure', syncServerUrl, syncUsername, syncPassword)
+ if (syncInterval > 0) {
+ await wailsCall('SyncSetInterval', syncInterval)
+ }
syncResult = 'ok'
await loadSyncStatus()
} catch (e) {
@@ -926,6 +933,18 @@
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 runSyncNow() {
syncLoading = true
syncResult = ''
@@ -971,10 +990,12 @@
{/if}
@@ -991,6 +1012,16 @@
Выберите раздел или дело
{/if}
+
{#if error}
@@ -1502,17 +1533,26 @@
{/if}
-
+
-
-
+
+
+
+
+
+
+
+
+
+
{#if syncResult}
{syncResult}
{/if}
-
+
+
@@ -1538,12 +1578,18 @@
.nav-item:hover { background: #222233; }
.nav-item.selected { background: #2a2a4a; color: #fff; font-weight: 500; }
.nav-empty { padding: 8px 20px; color: #555; font-size: 12px; }
-.sidebar-footer { padding: 12px 20px; border-top: 1px solid #2a2a3c; flex-shrink: 0; }
-.version { font-size: 11px; color: #555; }
+.sidebar-footer { padding: 8px 12px; border-top: 1px solid #2a2a3c; flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; }
+.version { font-size: 11px; color: #555; text-align: center; }
/* Main */
.main { flex: 1; display: flex; flex-direction: column; height: 100vh; min-width: 0; overflow: hidden; background: #13131f; }
.header { padding: 12px 24px; border-bottom: 1px solid #2a2a3c; display: flex; align-items: center; flex-shrink: 0; min-height: 48px; }
+.header-left { display: flex; align-items: center; gap: 8px; flex: 1; }
+.header-right { display: flex; align-items: center; gap: 8px; }
+.header-sync-btn { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 6px 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; color: #b0b0c0; font-family: inherit; font-size: 13px; position: relative; }
+.header-sync-btn:hover { background: #222233; color: #e4e4ef; border-color: #6366f1; }
+.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; }
.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; }
@@ -1732,8 +1778,11 @@
.activity-feed-time { font-size: 11px; color: #555; }
/* Sync */
-.settings-btn { background: none; border: none; color: #555; cursor: pointer; padding: 4px; display: inline-flex; align-items: center; border-radius: 4px; margin-left: auto; }
-.settings-btn:hover { color: #ccc; background: #222233; }
+.sidebar-sync-btn { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 8px 12px; cursor: pointer; width: 100%; display: flex; align-items: center; gap: 8px; color: #888; font-family: inherit; font-size: 13px; }
+.sidebar-sync-btn:hover { background: #222233; color: #e4e4ef; border-color: #6366f1; }
+.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; }
diff --git a/internal/core/config/config.go b/internal/core/config/config.go
index f4644c8..c4d2fd8 100644
--- a/internal/core/config/config.go
+++ b/internal/core/config/config.go
@@ -27,6 +27,7 @@ type SyncConfig struct {
APIKey string `yaml:"api_key"`
DeviceID string `yaml:"device_id"`
AutoSync bool `yaml:"auto_sync"`
+ SyncInterval int `yaml:"sync_interval"`
}
type BrowserConfig struct {
diff --git a/internal/core/sync/client.go b/internal/core/sync/client.go
index 1a26be9..904c0bb 100644
--- a/internal/core/sync/client.go
+++ b/internal/core/sync/client.go
@@ -45,6 +45,40 @@ func (c *Client) RegisterDevice(name string) (apiKey string, err error) {
return resp.APIKey, nil
}
+// RegisterDeviceWithAuth registers a device with user credentials.
+func (c *Client) RegisterDeviceWithAuth(name, username, password string) (deviceID, apiKey string, err error) {
+ body := map[string]string{"name": name, "username": username, "password": password}
+ var resp struct {
+ DeviceID string `json:"device_id"`
+ APIKey string `json:"api_key"`
+ }
+ // Temporarily clear API key for this request (server expects login/password, not API key).
+ savedKey := c.APIKey
+ c.APIKey = ""
+ err = c.post("/api/v1/device/register", body, &resp)
+ c.APIKey = savedKey
+ if err != nil {
+ return "", "", err
+ }
+ return resp.DeviceID, resp.APIKey, nil
+}
+
+// Login authenticates with user credentials and returns a session token.
+func (c *Client) Login(username, password string) (token string, err error) {
+ body := map[string]string{"username": username, "password": password}
+ var resp struct {
+ Token string `json:"token"`
+ }
+ savedKey := c.APIKey
+ c.APIKey = ""
+ err = c.post("/api/v1/auth/login", body, &resp)
+ c.APIKey = savedKey
+ if err != nil {
+ return "", err
+ }
+ return resp.Token, nil
+}
+
// PushRequest is the payload for POST /sync/push.
type PushRequest struct {
DeviceID string `json:"device_id"`