/ src / components / sync / SyncSettingsPanel.svelte
SyncSettingsPanel.svelte
  1  <script lang="ts">
  2    import type { SyncState, SyncBackendType } from '../../lib/sync/types';
  3    import { isFileSystemAccessSupported } from '../../lib/sync/filesystem-bridge';
  4    import { DEFAULT_BRIDGE_URL, BRIDGE_URL_STORAGE_KEY } from '../../lib/sync/rest-bridge';
  5    import SyncStatusIndicator from './SyncStatusIndicator.svelte';
  6  
  7    interface Props {
  8      backendType: SyncBackendType;
  9      state: SyncState;
 10      onBackendChange: (type: SyncBackendType) => void;
 11      onTestConnection: () => Promise<void>;
 12      onSyncNow: () => Promise<void>;
 13      onRestore: () => Promise<void>;
 14      onSelectDirectory?: () => Promise<void>;
 15      onBridgeUrlChange?: (url: string) => Promise<void>;
 16    }
 17  
 18    let {
 19      backendType,
 20      state,
 21      onBackendChange,
 22      onTestConnection,
 23      onSyncNow,
 24      onRestore,
 25      onSelectDirectory,
 26      onBridgeUrlChange,
 27    }: Props = $props();
 28  
 29    let testing = $state(false);
 30    let syncing = $state(false);
 31    let restoring = $state(false);
 32    let selectingDirectory = $state(false);
 33    let testResult = $state<{ success: boolean; message: string } | null>(null);
 34    let bridgeUrl = $state(DEFAULT_BRIDGE_URL);
 35    let editingUrl = $state(false);
 36    let urlInput = $state(DEFAULT_BRIDGE_URL);
 37  
 38    const filesystemSupported = isFileSystemAccessSupported();
 39  
 40    // Load saved bridge URL on mount
 41    $effect(() => {
 42      browser.storage.local.get(BRIDGE_URL_STORAGE_KEY).then((result) => {
 43        if (result[BRIDGE_URL_STORAGE_KEY]) {
 44          bridgeUrl = result[BRIDGE_URL_STORAGE_KEY];
 45          urlInput = bridgeUrl;
 46        }
 47      });
 48    });
 49  
 50    async function handleTest() {
 51      testing = true;
 52      testResult = null;
 53      try {
 54        await onTestConnection();
 55        testResult = { success: true, message: 'Connection successful!' };
 56      } catch (e) {
 57        testResult = {
 58          success: false,
 59          message: e instanceof Error ? e.message : 'Connection failed',
 60        };
 61      } finally {
 62        testing = false;
 63      }
 64    }
 65  
 66    async function handleSync() {
 67      syncing = true;
 68      try {
 69        await onSyncNow();
 70      } finally {
 71        syncing = false;
 72      }
 73    }
 74  
 75    async function handleRestore() {
 76      restoring = true;
 77      try {
 78        await onRestore();
 79      } finally {
 80        restoring = false;
 81      }
 82    }
 83  
 84    async function handleSelectDirectory() {
 85      if (!onSelectDirectory) return;
 86      selectingDirectory = true;
 87      testResult = null;
 88      try {
 89        await onSelectDirectory();
 90        testResult = { success: true, message: 'Directory selected successfully!' };
 91      } catch (e) {
 92        testResult = {
 93          success: false,
 94          message: e instanceof Error ? e.message : 'Failed to select directory',
 95        };
 96      } finally {
 97        selectingDirectory = false;
 98      }
 99    }
100  
101    async function handleSaveUrl() {
102      if (onBridgeUrlChange) {
103        await onBridgeUrlChange(urlInput);
104      }
105      await browser.storage.local.set({ [BRIDGE_URL_STORAGE_KEY]: urlInput });
106      bridgeUrl = urlInput;
107      editingUrl = false;
108      testResult = null;
109    }
110  
111    function handleCancelEditUrl() {
112      urlInput = bridgeUrl;
113      editingUrl = false;
114    }
115  </script>
116  
117  <div class="sync-settings-panel">
118    <h3 class="section-title">Sync Backend</h3>
119  
120    <div class="backend-selector">
121      <label class="radio-option">
122        <input
123          type="radio"
124          name="sync-backend"
125          value="none"
126          checked={backendType === 'none'}
127          onchange={() => onBackendChange('none')}
128        />
129        <span class="radio-label">
130          <span class="option-title">None</span>
131          <span class="option-description">
132            Data stored only in browser storage (local only)
133          </span>
134        </span>
135      </label>
136  
137      <label class="radio-option">
138        <input
139          type="radio"
140          name="sync-backend"
141          value="filesystem"
142          checked={backendType === 'filesystem'}
143          onchange={() => onBackendChange('filesystem')}
144          disabled={!filesystemSupported}
145        />
146        <span class="radio-label">
147          <span class="option-title">
148            File System
149            {#if !filesystemSupported}
150              <span class="unsupported-badge">Not Supported</span>
151            {/if}
152          </span>
153          <span class="option-description">
154            Sync to a folder on your computer (Dropbox, Google Drive, OneDrive, etc.)
155          </span>
156        </span>
157      </label>
158  
159      <label class="radio-option">
160        <input
161          type="radio"
162          name="sync-backend"
163          value="native"
164          checked={backendType === 'native'}
165          onchange={() => onBackendChange('native')}
166        />
167        <span class="radio-label">
168          <span class="option-title">Workspaces Bridge (REST Service)</span>
169          <span class="option-description">
170            Sync via local REST service - works with both Chrome and Firefox
171          </span>
172        </span>
173      </label>
174    </div>
175  
176    {#if backendType === 'filesystem'}
177      <div class="filesystem-config">
178        <h4 class="subsection-title">Sync Folder</h4>
179  
180        <SyncStatusIndicator {state} />
181  
182        {#if state.configDir}
183          <div class="current-folder">
184            <span class="folder-label">Current folder:</span>
185            <span class="folder-path">{state.configDir}</span>
186          </div>
187        {/if}
188  
189        <div class="action-buttons">
190          <button
191            type="button"
192            class="btn btn-primary"
193            onclick={handleSelectDirectory}
194            disabled={selectingDirectory}
195          >
196            {selectingDirectory ? 'Selecting...' : state.configDir ? 'Change Directory' : 'Select Directory'}
197          </button>
198  
199          {#if state.status === 'connected'}
200            <button
201              type="button"
202              class="btn btn-secondary"
203              onclick={handleTest}
204              disabled={testing}
205            >
206              {testing ? 'Testing...' : 'Test Connection'}
207            </button>
208  
209            <button
210              type="button"
211              class="btn btn-secondary"
212              onclick={handleSync}
213              disabled={syncing}
214            >
215              {syncing ? 'Syncing...' : 'Sync Now'}
216            </button>
217  
218            <button
219              type="button"
220              class="btn btn-danger"
221              onclick={handleRestore}
222              disabled={restoring}
223              title="Restore from backup file"
224            >
225              {restoring ? 'Restoring...' : 'Restore Backup'}
226            </button>
227          {/if}
228        </div>
229  
230        {#if testResult}
231          <div
232            class="test-result"
233            class:success={testResult.success}
234            class:error={!testResult.success}
235          >
236            {testResult.message}
237          </div>
238        {/if}
239  
240        <div class="setup-instructions">
241          <h4 class="subsection-title">How it Works</h4>
242          <ol class="instruction-list">
243            <li>Click <strong>Select Directory</strong> to choose a sync folder</li>
244            <li>Your workspaces will be saved as <code>mnemonic-workspaces.json</code></li>
245            <li>
246              Use a cloud-synced folder for cross-device sync:
247              <ul class="platform-list">
248                <li><strong>Dropbox:</strong> <code>~/Dropbox/Mnemonic</code></li>
249                <li><strong>Google Drive:</strong> <code>Google Drive/Mnemonic</code></li>
250                <li><strong>OneDrive:</strong> <code>OneDrive/Mnemonic</code></li>
251              </ul>
252            </li>
253            <li>Changes sync automatically every 30 seconds</li>
254          </ol>
255          <p class="note">
256            <strong>Note:</strong> Permission is usually remembered. Re-granting may be needed if you clear browser data.
257          </p>
258        </div>
259      </div>
260    {/if}
261  
262    {#if backendType === 'native'}
263      <div class="native-config">
264        <h4 class="subsection-title">Bridge Configuration</h4>
265  
266        <div class="bridge-url-config">
267          <span class="config-label">Bridge URL:</span>
268          {#if editingUrl}
269            <div class="url-edit-row">
270              <input
271                type="text"
272                class="url-input"
273                bind:value={urlInput}
274                placeholder="http://127.0.0.1:8765"
275              />
276              <button type="button" class="btn btn-small btn-primary" onclick={handleSaveUrl}>
277                Save
278              </button>
279              <button type="button" class="btn btn-small btn-secondary" onclick={handleCancelEditUrl}>
280                Cancel
281              </button>
282            </div>
283          {:else}
284            <div class="url-display-row">
285              <code class="url-value">{bridgeUrl}</code>
286              <button type="button" class="btn btn-small btn-secondary" onclick={() => { editingUrl = true; }}>
287                Edit
288              </button>
289            </div>
290          {/if}
291        </div>
292  
293        <h4 class="subsection-title">Connection Status</h4>
294  
295        <SyncStatusIndicator {state} />
296  
297        <div class="action-buttons">
298          <button
299            type="button"
300            class="btn btn-secondary"
301            onclick={handleTest}
302            disabled={testing}
303          >
304            {testing ? 'Testing...' : 'Test Connection'}
305          </button>
306  
307          <button
308            type="button"
309            class="btn btn-primary"
310            onclick={handleSync}
311            disabled={syncing || state.status !== 'connected'}
312          >
313            {syncing ? 'Syncing...' : 'Sync Now'}
314          </button>
315  
316          <button
317            type="button"
318            class="btn btn-danger"
319            onclick={handleRestore}
320            disabled={restoring || state.status !== 'connected'}
321            title="Restore from backup file"
322          >
323            {restoring ? 'Restoring...' : 'Restore Backup'}
324          </button>
325        </div>
326  
327        {#if testResult}
328          <div
329            class="test-result"
330            class:success={testResult.success}
331            class:error={!testResult.success}
332          >
333            {testResult.message}
334          </div>
335        {/if}
336  
337        <div class="setup-instructions">
338          <h4 class="subsection-title">Setup Instructions</h4>
339          <ol class="instruction-list">
340            <li>
341              Install Python 3.8+ and the bridge dependencies:
342              <code class="code-block">pip install fastapi uvicorn pydantic</code>
343            </li>
344            <li>
345              Start the bridge service from the <code>native-host</code> folder:
346              <code class="code-block">python workspaces-bridge.py</code>
347              <span class="hint">Custom options: <code>--host 0.0.0.0 --port 9000 --config-dir /path/to/folder</code></span>
348            </li>
349            <li>
350              <strong>Optional:</strong> Run as a background service:
351              <ul class="platform-list">
352                <li><strong>Windows:</strong> Task Scheduler, NSSM, or <code>pythonw workspaces-bridge.py</code></li>
353                <li><strong>macOS:</strong> launchd plist in <code>~/Library/LaunchAgents/</code></li>
354                <li><strong>Linux:</strong> systemd user service in <code>~/.config/systemd/user/</code></li>
355              </ul>
356            </li>
357            <li>
358              Configure <strong>Syncthing</strong> to sync the workspace folder
359              <span class="hint">Default: <code>~/Syncthing/MnemonicWorkspaces/</code></span>
360            </li>
361          </ol>
362  
363          <h4 class="subsection-title">How Sync Works</h4>
364          <ul class="instruction-list">
365            <li><strong>Auto-push:</strong> Workspace changes sync to the bridge automatically (debounced 5s)</li>
366            <li><strong>Auto-pull:</strong> External changes are detected every 30 seconds</li>
367            <li><strong>Cross-browser:</strong> Each browser stores its workspaces under a separate key (chrome/firefox)</li>
368            <li><strong>Conflict resolution:</strong> Last-write-wins based on timestamps</li>
369          </ul>
370          <p class="note">
371            <strong>Tip:</strong> Use the Cross-Browser Sharing panel in the Dashboard to pull workspaces from other browsers.
372          </p>
373        </div>
374      </div>
375    {/if}
376  </div>
377  
378  <style>
379    .sync-settings-panel {
380      padding: 16px;
381      background: rgba(0, 0, 0, 0.2);
382      border-radius: 8px;
383    }
384  
385    .section-title {
386      margin: 0 0 16px;
387      font-size: 16px;
388      font-weight: 600;
389      color: #e8e8e8;
390    }
391  
392    .subsection-title {
393      margin: 16px 0 8px;
394      font-size: 14px;
395      font-weight: 500;
396      color: #d1d5db;
397    }
398  
399    .backend-selector {
400      display: flex;
401      flex-direction: column;
402      gap: 12px;
403    }
404  
405    .radio-option {
406      display: flex;
407      align-items: flex-start;
408      gap: 12px;
409      padding: 12px;
410      background: rgba(255, 255, 255, 0.05);
411      border-radius: 6px;
412      cursor: pointer;
413      transition: background-color 0.15s;
414    }
415  
416    .radio-option:hover {
417      background: rgba(255, 255, 255, 0.1);
418    }
419  
420    .radio-option input {
421      margin-top: 4px;
422    }
423  
424    .radio-label {
425      display: flex;
426      flex-direction: column;
427      gap: 4px;
428    }
429  
430    .option-title {
431      font-weight: 500;
432      color: #e8e8e8;
433    }
434  
435    .option-description {
436      font-size: 12px;
437      color: #9ca3af;
438    }
439  
440    .native-config,
441    .filesystem-config {
442      margin-top: 20px;
443      padding-top: 16px;
444      border-top: 1px solid rgba(255, 255, 255, 0.1);
445    }
446  
447    .unsupported-badge {
448      margin-left: 8px;
449      padding: 2px 6px;
450      background: rgba(239, 68, 68, 0.2);
451      border-radius: 4px;
452      font-size: 10px;
453      color: #fca5a5;
454    }
455  
456    .current-folder {
457      margin: 12px 0;
458      padding: 8px 12px;
459      background: rgba(0, 0, 0, 0.2);
460      border-radius: 6px;
461      font-size: 13px;
462    }
463  
464    .folder-label {
465      color: #9ca3af;
466      margin-right: 8px;
467    }
468  
469    .folder-path {
470      color: #d9892e;
471      font-family: monospace;
472    }
473  
474    .bridge-url-config {
475      margin: 12px 0;
476      padding: 12px;
477      background: rgba(0, 0, 0, 0.2);
478      border-radius: 6px;
479    }
480  
481    .config-label {
482      display: block;
483      color: #9ca3af;
484      font-size: 12px;
485      margin-bottom: 8px;
486    }
487  
488    .url-display-row,
489    .url-edit-row {
490      display: flex;
491      align-items: center;
492      gap: 8px;
493    }
494  
495    .url-value {
496      flex: 1;
497      padding: 6px 10px;
498      background: rgba(0, 0, 0, 0.3);
499      border-radius: 4px;
500      font-family: monospace;
501      font-size: 13px;
502      color: #d9892e;
503    }
504  
505    .url-input {
506      flex: 1;
507      padding: 6px 10px;
508      background: rgba(0, 0, 0, 0.3);
509      border: 1px solid rgba(255, 255, 255, 0.2);
510      border-radius: 4px;
511      font-family: monospace;
512      font-size: 13px;
513      color: #e8e8e8;
514    }
515  
516    .url-input:focus {
517      outline: none;
518      border-color: #d9892e;
519    }
520  
521    .note {
522      margin-top: 12px;
523      padding: 8px 12px;
524      background: rgba(217, 137, 46, 0.1);
525      border-radius: 6px;
526      font-size: 12px;
527      color: #d9892e;
528    }
529  
530    .hint {
531      display: block;
532      margin-top: 4px;
533      font-size: 11px;
534      color: #6b7280;
535    }
536  
537    .action-buttons {
538      display: flex;
539      gap: 8px;
540      margin-top: 12px;
541    }
542  
543    .btn {
544      padding: 8px 16px;
545      border: none;
546      border-radius: 6px;
547      font-size: 14px;
548      font-weight: 500;
549      cursor: pointer;
550      transition: all 0.15s;
551    }
552  
553    .btn-small {
554      padding: 4px 10px;
555      font-size: 12px;
556    }
557  
558    .btn:disabled {
559      opacity: 0.5;
560      cursor: not-allowed;
561    }
562  
563    .btn-primary {
564      background: #d9892e;
565      color: white;
566    }
567  
568    .btn-primary:hover:not(:disabled) {
569      background: #c77824;
570    }
571  
572    .btn-secondary {
573      background: rgba(255, 255, 255, 0.1);
574      color: #e8e8e8;
575    }
576  
577    .btn-secondary:hover:not(:disabled) {
578      background: rgba(255, 255, 255, 0.2);
579    }
580  
581    .btn-danger {
582      background: rgba(239, 68, 68, 0.2);
583      color: #fca5a5;
584    }
585  
586    .btn-danger:hover:not(:disabled) {
587      background: rgba(239, 68, 68, 0.3);
588    }
589  
590    .test-result {
591      margin-top: 12px;
592      padding: 8px 12px;
593      border-radius: 6px;
594      font-size: 13px;
595    }
596  
597    .test-result.success {
598      background: rgba(34, 197, 94, 0.2);
599      color: #86efac;
600    }
601  
602    .test-result.error {
603      background: rgba(239, 68, 68, 0.2);
604      color: #fca5a5;
605    }
606  
607    .setup-instructions {
608      margin-top: 20px;
609      padding-top: 16px;
610      border-top: 1px solid rgba(255, 255, 255, 0.1);
611    }
612  
613    .instruction-list {
614      margin: 0;
615      padding-left: 20px;
616      color: #9ca3af;
617      font-size: 13px;
618      line-height: 1.8;
619    }
620  
621    .instruction-list li {
622      margin-bottom: 8px;
623    }
624  
625    .platform-list {
626      margin: 8px 0 0;
627      padding-left: 20px;
628    }
629  
630    .platform-list li {
631      margin-bottom: 4px;
632    }
633  
634    code {
635      padding: 2px 6px;
636      background: rgba(0, 0, 0, 0.3);
637      border-radius: 4px;
638      font-family: monospace;
639      font-size: 12px;
640      color: #d9892e;
641    }
642  
643    .code-block {
644      display: block;
645      margin: 8px 0;
646      padding: 8px 12px;
647      background: rgba(0, 0, 0, 0.4);
648      border-radius: 4px;
649      font-family: monospace;
650      font-size: 12px;
651      color: #a5b4fc;
652      white-space: pre-wrap;
653      word-break: break-all;
654    }
655  </style>