/ src / components / notes / WorkspaceNotesPanel.svelte
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, '&amp;')
171        .replace(/</g, '&lt;')
172        .replace(/>/g, '&gt;')
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>