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>