WorkspaceNotesPanel.svelte
1 <script lang="ts"> 2 import { onMount, onDestroy } from 'svelte'; 3 import { 4 getLogseqNoteManager, 5 getLogseqClient, 6 formatUtcToLocal, 7 type LogseqConnectionState, 8 } from '../../lib/logseq'; 9 import type { Workspace } from '../../lib/types/workspace'; 10 11 interface Props { 12 workspace: Workspace | null; 13 parentWorkspace?: Workspace | null; 14 } 15 16 let { workspace, parentWorkspace }: Props = $props(); 17 18 const noteManager = getLogseqNoteManager(); 19 const client = getLogseqClient(); 20 21 // State 22 let connectionState = $state<LogseqConnectionState>({ available: false, graphName: null, graphPath: null, lastChecked: '' }); 23 let noteContent = $state(''); 24 let originalContent = $state(''); 25 let modifiedUtc = $state<string | null>(null); 26 let isEditing = $state(false); 27 let loading = $state(false); 28 let saving = $state(false); 29 let error = $state<string | null>(null); 30 let hasUnsavedChanges = $state(false); 31 32 // Debounce timer for auto-save 33 let saveTimer: ReturnType<typeof setTimeout> | null = null; 34 const SAVE_DEBOUNCE_MS = 2000; 35 36 // Connection check interval 37 let connectionCheckInterval: ReturnType<typeof setInterval> | null = null; 38 39 $effect(() => { 40 hasUnsavedChanges = noteContent !== originalContent; 41 }); 42 43 // Load notes when workspace changes 44 $effect(() => { 45 if (workspace && connectionState.available) { 46 loadNotes(); 47 } 48 }); 49 50 onMount(async () => { 51 // Load Logseq settings from storage 52 try { 53 const stored = await browser.storage.local.get('logseqSettings'); 54 if (stored.logseqSettings) { 55 client.setSettings(stored.logseqSettings); 56 } 57 } catch (e) { 58 console.error('[Notes] Failed to load Logseq settings:', e); 59 } 60 61 // Check connection on mount 62 await checkConnection(); 63 64 // Check connection when page becomes visible (on-demand instead of polling) 65 const handleVisibilityChange = () => { 66 if (document.visibilityState === 'visible' && !connectionState.available) { 67 checkConnection(); 68 } 69 }; 70 document.addEventListener('visibilitychange', handleVisibilityChange); 71 72 // Infrequent fallback check (5 minutes) only if not connected 73 connectionCheckInterval = setInterval(() => { 74 if (!connectionState.available) { 75 checkConnection(); 76 } 77 }, 300000); 78 79 return () => { 80 document.removeEventListener('visibilitychange', handleVisibilityChange); 81 if (saveTimer) clearTimeout(saveTimer); 82 if (connectionCheckInterval) clearInterval(connectionCheckInterval); 83 }; 84 }); 85 86 async function checkConnection() { 87 connectionState = await noteManager.checkConnection(); 88 } 89 90 async function loadNotes() { 91 if (!workspace) return; 92 93 loading = true; 94 error = null; 95 96 try { 97 const parentName = parentWorkspace?.name; 98 // Use getWorkspaceNoteWithMetadata to get both content and modified timestamp 99 const result = await noteManager.getWorkspaceNoteWithMetadata(workspace, parentName); 100 noteContent = result.content; 101 originalContent = result.content; 102 modifiedUtc = result.modifiedUtc; 103 } catch (e) { 104 error = e instanceof Error ? e.message : 'Failed to load notes'; 105 } finally { 106 loading = false; 107 } 108 } 109 110 async function saveNotes() { 111 if (!workspace || !hasUnsavedChanges) return; 112 113 saving = true; 114 error = null; 115 116 try { 117 const parentName = parentWorkspace?.name; 118 await noteManager.setWorkspaceNoteContent(workspace, noteContent, parentName); 119 originalContent = noteContent; 120 // Update the modified timestamp to "now" (UTC) 121 modifiedUtc = new Date().toISOString(); 122 } catch (e) { 123 error = e instanceof Error ? e.message : 'Failed to save notes'; 124 } finally { 125 saving = false; 126 } 127 } 128 129 function handleInput() { 130 // Auto-save with debounce 131 if (saveTimer) clearTimeout(saveTimer); 132 133 saveTimer = setTimeout(() => { 134 if (hasUnsavedChanges && connectionState.available) { 135 saveNotes(); 136 } 137 }, SAVE_DEBOUNCE_MS); 138 } 139 140 function handleBlur() { 141 // Save immediately on blur if there are changes 142 if (hasUnsavedChanges && connectionState.available) { 143 if (saveTimer) clearTimeout(saveTimer); 144 saveNotes(); 145 } 146 } 147 148 async function handleConnect() { 149 // Open options to configure Logseq 150 browser.runtime.openOptionsPage(); 151 } 152 153 async function handleRetry() { 154 await checkConnection(); 155 if (connectionState.available && workspace) { 156 await loadNotes(); 157 } 158 } 159 160 function toggleEdit() { 161 isEditing = !isEditing; 162 } 163 164 // Simple markdown to HTML conversion 165 function renderMarkdown(text: string): string { 166 if (!text) return '<p class="empty-note">No notes yet. Click to add some.</p>'; 167 168 return text 169 // Escape HTML 170 .replace(/&/g, '&') 171 .replace(/</g, '<') 172 .replace(/>/g, '>') 173 // Headers 174 .replace(/^### (.*)$/gm, '<h4>$1</h4>') 175 .replace(/^## (.*)$/gm, '<h3>$1</h3>') 176 .replace(/^# (.*)$/gm, '<h2>$1</h2>') 177 // Bold and italic 178 .replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>') 179 .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') 180 .replace(/\*(.+?)\*/g, '<em>$1</em>') 181 .replace(/___(.+?)___/g, '<strong><em>$1</em></strong>') 182 .replace(/__(.+?)__/g, '<strong>$1</strong>') 183 .replace(/_(.+?)_/g, '<em>$1</em>') 184 // Code 185 .replace(/`([^`]+)`/g, '<code>$1</code>') 186 // Links 187 .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>') 188 // Lists 189 .replace(/^- (.*)$/gm, '<li>$1</li>') 190 .replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>') 191 // Line breaks 192 .replace(/\n\n/g, '</p><p>') 193 .replace(/\n/g, '<br>') 194 // Wrap in paragraph 195 .replace(/^/, '<p>') 196 .replace(/$/, '</p>') 197 // Clean up empty paragraphs 198 .replace(/<p><\/p>/g, '') 199 .replace(/<p>(<h[234]>)/g, '$1') 200 .replace(/(<\/h[234]>)<\/p>/g, '$1'); 201 } 202 </script> 203 204 <div class="notes-panel"> 205 <div class="notes-header"> 206 <div class="notes-title"> 207 <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 208 <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path> 209 <polyline points="14 2 14 8 20 8"></polyline> 210 <line x1="16" y1="13" x2="8" y2="13"></line> 211 <line x1="16" y1="17" x2="8" y2="17"></line> 212 <polyline points="10 9 9 9 8 9"></polyline> 213 </svg> 214 <span>Notes</span> 215 {#if connectionState.available} 216 <span class="connection-badge connected" title="Connected to {connectionState.graphName}"> 217 Logseq 218 </span> 219 {/if} 220 </div> 221 222 <div class="notes-actions"> 223 {#if connectionState.available} 224 {#if saving} 225 <span class="save-status saving">Saving...</span> 226 {:else if hasUnsavedChanges} 227 <span class="save-status unsaved">Unsaved</span> 228 {:else if originalContent} 229 <span class="save-status saved">Saved</span> 230 {/if} 231 232 {#if modifiedUtc && !hasUnsavedChanges && !saving} 233 <span class="modified-time" title="Last modified: {formatUtcToLocal(modifiedUtc, { includeDate: true, includeTime: true })}"> 234 {formatUtcToLocal(modifiedUtc, { relative: true })} 235 </span> 236 {/if} 237 238 <button 239 class="toggle-btn" 240 class:active={isEditing} 241 onclick={toggleEdit} 242 title={isEditing ? 'View' : 'Edit'} 243 > 244 {#if isEditing} 245 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 246 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path> 247 <circle cx="12" cy="12" r="3"></circle> 248 </svg> 249 {:else} 250 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 251 <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path> 252 <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path> 253 </svg> 254 {/if} 255 </button> 256 {/if} 257 </div> 258 </div> 259 260 <div class="notes-content"> 261 {#if !connectionState.available} 262 <div class="connection-prompt"> 263 <svg class="prompt-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 264 <circle cx="12" cy="12" r="10"></circle> 265 <line x1="12" y1="8" x2="12" y2="12"></line> 266 <line x1="12" y1="16" x2="12.01" y2="16"></line> 267 </svg> 268 <h4>Logseq Not Connected</h4> 269 <p> 270 {#if connectionState.error} 271 {connectionState.error} 272 {:else} 273 Connect to Logseq to sync your workspace notes. 274 {/if} 275 </p> 276 <div class="prompt-actions"> 277 <button class="btn-primary" onclick={handleConnect}> 278 Configure Logseq 279 </button> 280 <button class="btn-secondary" onclick={handleRetry}> 281 Retry Connection 282 </button> 283 </div> 284 <p class="prompt-hint"> 285 Make sure Logseq is running with HTTP API enabled (port 12315). 286 </p> 287 </div> 288 {:else if loading} 289 <div class="loading"> 290 <div class="spinner"></div> 291 <span>Loading notes...</span> 292 </div> 293 {:else if error} 294 <div class="error"> 295 <p>{error}</p> 296 <button class="btn-secondary" onclick={handleRetry}>Retry</button> 297 </div> 298 {:else if isEditing} 299 <textarea 300 class="note-editor" 301 bind:value={noteContent} 302 oninput={handleInput} 303 onblur={handleBlur} 304 placeholder="Write your notes here... 305 306 Use Markdown: 307 - **bold** or __bold__ 308 - *italic* or _italic_ 309 - `code` 310 - [links](url) 311 - Lists with -" 312 ></textarea> 313 {:else} 314 <div 315 class="note-preview" 316 onclick={() => isEditing = true} 317 role="button" 318 tabindex="0" 319 onkeydown={(e) => e.key === 'Enter' && (isEditing = true)} 320 > 321 {@html renderMarkdown(noteContent)} 322 </div> 323 {/if} 324 </div> 325 </div> 326 327 <style> 328 .notes-panel { 329 display: flex; 330 flex-direction: column; 331 height: 100%; 332 background: rgba(0, 0, 0, 0.2); 333 border-radius: 8px; 334 border: 1px solid rgba(217, 137, 46, 0.15); 335 overflow: hidden; 336 } 337 338 .notes-header { 339 display: flex; 340 align-items: center; 341 justify-content: space-between; 342 padding: 12px 16px; 343 background: rgba(0, 0, 0, 0.2); 344 border-bottom: 1px solid rgba(217, 137, 46, 0.15); 345 } 346 347 .notes-title { 348 display: flex; 349 align-items: center; 350 gap: 8px; 351 font-weight: 600; 352 color: #e8e8e8; 353 } 354 355 .notes-title .icon { 356 width: 18px; 357 height: 18px; 358 color: #d9892e; 359 } 360 361 .connection-badge { 362 font-size: 10px; 363 padding: 2px 6px; 364 border-radius: 4px; 365 font-weight: 500; 366 text-transform: uppercase; 367 } 368 369 .connection-badge.connected { 370 background: rgba(34, 197, 94, 0.2); 371 color: #86efac; 372 } 373 374 .notes-actions { 375 display: flex; 376 align-items: center; 377 gap: 8px; 378 } 379 380 .save-status { 381 font-size: 11px; 382 padding: 2px 8px; 383 border-radius: 4px; 384 } 385 386 .save-status.saving { 387 background: rgba(217, 137, 46, 0.2); 388 color: #d9892e; 389 } 390 391 .save-status.unsaved { 392 background: rgba(239, 68, 68, 0.2); 393 color: #fca5a5; 394 } 395 396 .save-status.saved { 397 background: rgba(34, 197, 94, 0.15); 398 color: #86efac; 399 } 400 401 .modified-time { 402 font-size: 10px; 403 color: #6b7280; 404 cursor: help; 405 } 406 407 .toggle-btn { 408 display: flex; 409 align-items: center; 410 justify-content: center; 411 width: 32px; 412 height: 32px; 413 background: rgba(255, 255, 255, 0.05); 414 border: 1px solid rgba(255, 255, 255, 0.1); 415 border-radius: 6px; 416 color: #9ca3af; 417 cursor: pointer; 418 transition: all 0.15s; 419 } 420 421 .toggle-btn:hover { 422 background: rgba(255, 255, 255, 0.1); 423 color: #e8e8e8; 424 } 425 426 .toggle-btn.active { 427 background: rgba(217, 137, 46, 0.2); 428 border-color: rgba(217, 137, 46, 0.3); 429 color: #d9892e; 430 } 431 432 .toggle-btn svg { 433 width: 16px; 434 height: 16px; 435 } 436 437 .notes-content { 438 flex: 1; 439 overflow: auto; 440 padding: 16px; 441 } 442 443 /* Connection prompt */ 444 .connection-prompt { 445 display: flex; 446 flex-direction: column; 447 align-items: center; 448 justify-content: center; 449 text-align: center; 450 height: 100%; 451 padding: 24px; 452 } 453 454 .prompt-icon { 455 width: 48px; 456 height: 48px; 457 color: #d9892e; 458 margin-bottom: 16px; 459 opacity: 0.7; 460 } 461 462 .connection-prompt h4 { 463 margin: 0 0 8px; 464 color: #e8e8e8; 465 font-size: 16px; 466 } 467 468 .connection-prompt p { 469 margin: 0 0 16px; 470 color: #9ca3af; 471 font-size: 13px; 472 } 473 474 .prompt-actions { 475 display: flex; 476 gap: 8px; 477 } 478 479 .prompt-hint { 480 margin-top: 16px; 481 font-size: 11px; 482 color: #6b7280; 483 } 484 485 /* Buttons */ 486 .btn-primary { 487 padding: 8px 16px; 488 background: #d9892e; 489 color: white; 490 border: none; 491 border-radius: 6px; 492 font-weight: 500; 493 cursor: pointer; 494 transition: background 0.15s; 495 } 496 497 .btn-primary:hover { 498 background: #c77824; 499 } 500 501 .btn-secondary { 502 padding: 8px 16px; 503 background: rgba(255, 255, 255, 0.1); 504 color: #e8e8e8; 505 border: none; 506 border-radius: 6px; 507 font-weight: 500; 508 cursor: pointer; 509 transition: background 0.15s; 510 } 511 512 .btn-secondary:hover { 513 background: rgba(255, 255, 255, 0.15); 514 } 515 516 /* Loading */ 517 .loading { 518 display: flex; 519 flex-direction: column; 520 align-items: center; 521 justify-content: center; 522 height: 100%; 523 gap: 12px; 524 color: #9ca3af; 525 } 526 527 .spinner { 528 width: 24px; 529 height: 24px; 530 border: 2px solid rgba(217, 137, 46, 0.2); 531 border-top-color: #d9892e; 532 border-radius: 50%; 533 animation: spin 0.8s linear infinite; 534 } 535 536 @keyframes spin { 537 to { transform: rotate(360deg); } 538 } 539 540 /* Error */ 541 .error { 542 display: flex; 543 flex-direction: column; 544 align-items: center; 545 gap: 12px; 546 padding: 24px; 547 text-align: center; 548 color: #fca5a5; 549 } 550 551 /* Editor */ 552 .note-editor { 553 width: 100%; 554 height: 100%; 555 min-height: 200px; 556 padding: 12px; 557 background: rgba(0, 0, 0, 0.3); 558 border: 1px solid rgba(255, 255, 255, 0.1); 559 border-radius: 6px; 560 color: #e8e8e8; 561 font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; 562 font-size: 13px; 563 line-height: 1.6; 564 resize: none; 565 } 566 567 .note-editor:focus { 568 outline: none; 569 border-color: rgba(217, 137, 46, 0.5); 570 } 571 572 .note-editor::placeholder { 573 color: #6b7280; 574 } 575 576 /* Preview */ 577 .note-preview { 578 min-height: 200px; 579 cursor: text; 580 } 581 582 .note-preview :global(h2), 583 .note-preview :global(h3), 584 .note-preview :global(h4) { 585 color: #d9892e; 586 margin: 0 0 8px; 587 } 588 589 .note-preview :global(h2) { font-size: 18px; } 590 .note-preview :global(h3) { font-size: 16px; } 591 .note-preview :global(h4) { font-size: 14px; } 592 593 .note-preview :global(p) { 594 margin: 0 0 12px; 595 color: #d1d5db; 596 line-height: 1.6; 597 } 598 599 .note-preview :global(strong) { 600 color: #e8e8e8; 601 font-weight: 600; 602 } 603 604 .note-preview :global(em) { 605 color: #d1d5db; 606 font-style: italic; 607 } 608 609 .note-preview :global(code) { 610 padding: 2px 6px; 611 background: rgba(0, 0, 0, 0.3); 612 border-radius: 4px; 613 font-family: monospace; 614 font-size: 12px; 615 color: #d9892e; 616 } 617 618 .note-preview :global(a) { 619 color: #d9892e; 620 text-decoration: underline; 621 } 622 623 .note-preview :global(a:hover) { 624 color: #e8a54b; 625 } 626 627 .note-preview :global(ul) { 628 margin: 0 0 12px; 629 padding-left: 20px; 630 } 631 632 .note-preview :global(li) { 633 color: #d1d5db; 634 margin-bottom: 4px; 635 } 636 637 .note-preview :global(.empty-note) { 638 color: #6b7280; 639 font-style: italic; 640 } 641 </style>