/ src / components / resources / AddResourceModal.svelte
AddResourceModal.svelte
  1  <script lang="ts">
  2    import { getLogseqClient } from '../../lib/logseq';
  3    import { createResource, isValidHttpUrl, type WorkspaceResource } from '../../lib/types';
  4  
  5    interface Props {
  6      open: boolean;
  7      logseqConnected: boolean;
  8      onClose: () => void;
  9      onAdd: (resources: WorkspaceResource[]) => void;
 10    }
 11  
 12    let { open = $bindable(), logseqConnected, onClose, onAdd }: Props = $props();
 13  
 14    type TabType = 'logseq' | 'url';
 15    let activeTab = $state<TabType>('url');
 16  
 17    // Logseq search state
 18    let searchQuery = $state('');
 19    let searchResults = $state<Array<{ name: string; displayName: string }>>([]);
 20    let selectedPages = $state<Set<string>>(new Set());
 21    let pageCache = $state<Array<{ name: string; displayName: string }>>([]);
 22    let cacheLoading = $state(false);
 23    let cacheLoaded = $state(false);
 24    let cacheError = $state<string | null>(null);
 25    let includeWorkspaceNotes = $state(false);
 26  
 27    // URL state
 28    let urlInput = $state('');
 29    let urlTitle = $state('');
 30    let urlError = $state<string | null>(null);
 31  
 32    const client = getLogseqClient();
 33  
 34    // Load page cache when switching to Logseq tab and connected
 35    $effect(() => {
 36      if (open && logseqConnected && activeTab === 'logseq' && !cacheLoaded && !cacheLoading) {
 37        loadPageCache();
 38      }
 39    });
 40  
 41    // Track if this is a fresh modal open (to auto-switch only on open)
 42    let wasOpen = $state(false);
 43  
 44    // Auto-switch to Logseq tab only when modal first opens and Logseq is connected
 45    $effect(() => {
 46      if (open && !wasOpen && logseqConnected) {
 47        activeTab = 'logseq';
 48      }
 49      wasOpen = open;
 50    });
 51  
 52    // Reset state when modal opens
 53    $effect(() => {
 54      if (open) {
 55        searchQuery = '';
 56        searchResults = [];
 57        selectedPages = new Set();
 58        urlInput = '';
 59        urlTitle = '';
 60        urlError = null;
 61        if (!logseqConnected) {
 62          activeTab = 'url';
 63        }
 64      }
 65    });
 66  
 67    // Dynamic modal width based on longest result
 68    let longestNameLength = $state(0);
 69  
 70    // Client-side search when query changes
 71    $effect(() => {
 72      if (searchQuery.trim().length >= 2 && cacheLoaded) {
 73        const query = searchQuery.toLowerCase();
 74        const results = pageCache
 75          .filter(page => {
 76            // Exclude Mnemonic/ namespace pages unless checkbox is checked
 77            if (!includeWorkspaceNotes && page.name.startsWith('mnemonic/')) {
 78              return false;
 79            }
 80            return page.name.includes(query) || page.displayName.toLowerCase().includes(query);
 81          })
 82          .slice(0, 50);
 83  
 84        searchResults = results;
 85  
 86        // Calculate longest name for dynamic width
 87        longestNameLength = results.reduce((max, page) =>
 88          Math.max(max, page.displayName.length), 0);
 89      } else {
 90        searchResults = [];
 91        longestNameLength = 0;
 92      }
 93    });
 94  
 95    // Calculate dynamic modal width (approx 8px per character + padding)
 96    const modalWidth = $derived(() => {
 97      const baseWidth = 400; // minimum width in pixels
 98      const charWidth = 8; // approximate pixels per character
 99      const padding = 150; // extra padding for checkbox, buttons, etc.
100      const calculatedWidth = Math.max(baseWidth, longestNameLength * charWidth + padding);
101      const maxWidth = typeof window !== 'undefined' ? window.innerWidth * 0.66 : 900;
102      return Math.min(calculatedWidth, maxWidth);
103    });
104  
105    async function loadPageCache() {
106      if (cacheLoaded || cacheLoading) return;
107  
108      cacheLoading = true;
109      cacheError = null;
110  
111      try {
112        console.log('[AddResourceModal] Loading page cache...');
113        const pages = await client.getAllPageNames();
114  
115        if (pages && pages.length > 0) {
116          pageCache = pages;
117          cacheLoaded = true;
118          console.log(`[AddResourceModal] Cached ${pages.length} pages`);
119        } else {
120          console.warn('[AddResourceModal] No pages returned from getAllPageNames');
121          cacheError = 'No pages found in Logseq graph';
122        }
123      } catch (error) {
124        console.error('[AddResourceModal] Failed to load page cache:', error);
125        cacheError = 'Failed to load pages from Logseq';
126      } finally {
127        cacheLoading = false;
128      }
129    }
130  
131    function togglePageSelection(page: { name: string; displayName: string }) {
132      const newSet = new Set(selectedPages);
133      if (newSet.has(page.name)) {
134        newSet.delete(page.name);
135      } else {
136        newSet.add(page.name);
137      }
138      selectedPages = newSet;
139    }
140  
141    function isPageSelected(pageName: string): boolean {
142      return selectedPages.has(pageName);
143    }
144  
145    function addSelectedPages() {
146      if (selectedPages.size === 0) return;
147  
148      const resources: WorkspaceResource[] = [];
149      for (const pageName of selectedPages) {
150        const page = pageCache.find(p => p.name === pageName);
151        if (page) {
152          resources.push(createResource('logseq', page.displayName, page.name));
153        }
154      }
155  
156      if (resources.length > 0) {
157        onAdd(resources);
158      }
159      handleClose();
160    }
161  
162    function addUrl() {
163      console.log('[AddResourceModal] addUrl called, urlInput:', urlInput);
164      urlError = null;
165  
166      if (!urlInput.trim()) {
167        urlError = 'Please enter a URL';
168        console.log('[AddResourceModal] Error: empty URL');
169        return;
170      }
171  
172      if (!isValidHttpUrl(urlInput)) {
173        urlError = 'Please enter a valid HTTP or HTTPS URL';
174        console.log('[AddResourceModal] Error: invalid URL');
175        return;
176      }
177  
178      const title = urlTitle.trim() || new URL(urlInput).hostname;
179      const resource = createResource('url', title, urlInput);
180      console.log('[AddResourceModal] Adding URL resource:', resource);
181      onAdd([resource]);
182      handleClose();
183    }
184  
185    function handleClose() {
186      open = false;
187      onClose();
188    }
189  
190    function handleUrlKeydown(e: KeyboardEvent) {
191      if (e.key === 'Enter') {
192        addUrl();
193      }
194    }
195  
196    function handleKeydown(e: KeyboardEvent) {
197      if (e.key === 'Escape') {
198        handleClose();
199      }
200    }
201  
202    function handleBackdropClick(e: MouseEvent) {
203      if (e.target === e.currentTarget) {
204        handleClose();
205      }
206    }
207  </script>
208  
209  <svelte:window onkeydown={handleKeydown} />
210  
211  {#if open}
212    <div class="modal-overlay" role="dialog" aria-modal="true">
213      <!-- Backdrop -->
214      <div class="modal-backdrop" onclick={handleBackdropClick} role="presentation"></div>
215  
216      <!-- Modal content -->
217      <div class="modal-content" style="width: {modalWidth()}px;">
218        <!-- Header -->
219        <div class="modal-header">
220          <h2 class="text-lg font-semibold text-phosphor">Add Resource</h2>
221          <button
222            class="text-text-muted hover:text-phosphor transition-colors p-1"
223            onclick={handleClose}
224            aria-label="Close"
225          >
226            <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
227              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
228            </svg>
229          </button>
230        </div>
231  
232        <!-- Tabs -->
233        <div class="tabs">
234          <button
235            class="tab"
236            class:active={activeTab === 'logseq'}
237            class:disabled={!logseqConnected}
238            onclick={() => logseqConnected && (activeTab = 'logseq')}
239            disabled={!logseqConnected}
240            title={!logseqConnected ? 'Logseq is not connected' : ''}
241          >
242            Search Logseq
243          </button>
244          <button
245            class="tab"
246            class:active={activeTab === 'url'}
247            onclick={() => (activeTab = 'url')}
248          >
249            Add URL
250          </button>
251        </div>
252  
253        <!-- Body -->
254        <div class="modal-body">
255          {#if activeTab === 'logseq'}
256            <!-- Logseq Search -->
257            <div class="space-y-4">
258              <div>
259                <label class="block text-sm font-medium text-text mb-1.5" for="logseq-search">Search for pages</label>
260                <input
261                  id="logseq-search"
262                  type="text"
263                  class="input-field"
264                  bind:value={searchQuery}
265                  placeholder="Type to search pages..."
266                />
267                <label class="flex items-center gap-2 mt-2 text-sm text-text-muted cursor-pointer">
268                  <input
269                    type="checkbox"
270                    class="checkbox-input"
271                    bind:checked={includeWorkspaceNotes}
272                  />
273                  <span>Include workspace notes (Mnemonic/...)</span>
274                </label>
275              </div>
276  
277              {#if cacheError}
278                <div class="text-center py-4 text-red-400 text-sm">
279                  {cacheError}
280                  <button class="ml-2 text-phosphor underline" onclick={loadPageCache}>
281                    Retry
282                  </button>
283                </div>
284              {:else if cacheLoading}
285                <div class="text-center py-4 text-text-muted">
286                  <span class="animate-pulse">Loading pages from Logseq...</span>
287                </div>
288              {:else if searchResults.length > 0}
289                <div class="search-results">
290                  {#each searchResults as page (page.name)}
291                    <button
292                      class="search-result"
293                      class:selected={isPageSelected(page.name)}
294                      onclick={() => togglePageSelection(page)}
295                      title={page.displayName}
296                    >
297                      <span class="checkbox" class:checked={isPageSelected(page.name)}>
298                        {#if isPageSelected(page.name)}
299                          <svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
300                            <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
301                          </svg>
302                        {/if}
303                      </span>
304                      <span class="page-name">{page.displayName}</span>
305                    </button>
306                  {/each}
307                </div>
308  
309                {#if selectedPages.size > 0}
310                  <button class="btn-primary w-full" onclick={addSelectedPages}>
311                    Add {selectedPages.size} Page{selectedPages.size > 1 ? 's' : ''}
312                  </button>
313                {/if}
314              {:else if searchQuery.length >= 2}
315                <div class="text-center py-4 text-text-muted text-sm">
316                  No pages found matching "{searchQuery}"
317                </div>
318              {:else if cacheLoaded}
319                <div class="text-center py-4 text-text-muted text-sm">
320                  Enter at least 2 characters to search ({pageCache.length} pages available)
321                </div>
322              {:else}
323                <div class="text-center py-4 text-text-muted text-sm">
324                  Enter at least 2 characters to search
325                </div>
326              {/if}
327            </div>
328          {:else}
329            <!-- URL Input -->
330            <div class="space-y-4">
331              <div>
332                <label class="block text-sm font-medium text-text mb-1.5" for="url-input">URL</label>
333                <input
334                  id="url-input"
335                  type="url"
336                  class="input-field"
337                  bind:value={urlInput}
338                  placeholder="https://example.com"
339                  onkeydown={handleUrlKeydown}
340                />
341                {#if urlError}
342                  <p class="mt-1 text-sm text-red-400">{urlError}</p>
343                {/if}
344              </div>
345  
346              <div>
347                <label class="block text-sm font-medium text-text mb-1.5" for="url-title">Title (optional)</label>
348                <input
349                  id="url-title"
350                  type="text"
351                  class="input-field"
352                  bind:value={urlTitle}
353                  placeholder="Enter a title or leave empty to use hostname"
354                />
355              </div>
356  
357              <button class="btn-primary w-full" onclick={addUrl}>
358                Add URL
359              </button>
360            </div>
361          {/if}
362        </div>
363      </div>
364    </div>
365  {/if}
366  
367  <style>
368    .modal-overlay {
369      position: fixed;
370      inset: 0;
371      z-index: 100;
372      display: flex;
373      align-items: center;
374      justify-content: center;
375      overflow: hidden;
376    }
377  
378    .modal-backdrop {
379      position: fixed;
380      inset: 0;
381      background-color: rgba(13, 61, 61, 0.9);
382      backdrop-filter: blur(4px);
383    }
384  
385    .modal-content {
386      position: relative;
387      background-color: var(--color-surface-raised);
388      border: 1px solid rgba(217, 137, 46, 0.4);
389      border-radius: 0.5rem;
390      box-shadow: 0 0 20px rgba(217, 137, 46, 0.15);
391      min-width: 400px;
392      max-width: 66vw;
393      display: flex;
394      flex-direction: column;
395      animation: modal-in 0.15s ease-out;
396      transition: width 0.15s ease-out;
397    }
398  
399    @keyframes modal-in {
400      from {
401        opacity: 0;
402        transform: scale(0.95);
403      }
404      to {
405        opacity: 1;
406        transform: scale(1);
407      }
408    }
409  
410    .modal-header {
411      display: flex;
412      align-items: center;
413      justify-content: space-between;
414      padding: 1rem 1.5rem;
415      border-bottom: 1px solid rgba(217, 137, 46, 0.3);
416      flex-shrink: 0;
417    }
418  
419    .modal-body {
420      padding: 1rem 1.5rem 1.5rem;
421      overflow-y: auto;
422      max-height: calc(100vh - 200px);
423    }
424  
425    .tabs {
426      display: flex;
427      gap: 0.5rem;
428      padding: 0.75rem 1.5rem;
429      border-bottom: 1px solid rgba(217, 137, 46, 0.3);
430    }
431  
432    .tab {
433      flex: 1;
434      padding: 0.5rem 1rem;
435      text-align: center;
436      color: var(--color-text-muted);
437      border-radius: 0.375rem;
438      transition: all 0.15s;
439      font-size: 0.875rem;
440      background: transparent;
441      border: none;
442      cursor: pointer;
443    }
444  
445    .tab:hover:not(.disabled) {
446      color: var(--color-text);
447      background-color: rgba(217, 137, 46, 0.1);
448    }
449  
450    .tab.active {
451      color: var(--color-phosphor);
452      background-color: rgba(217, 137, 46, 0.15);
453    }
454  
455    .tab.disabled {
456      opacity: 0.5;
457      cursor: not-allowed;
458    }
459  
460    .input-field {
461      width: 100%;
462      padding: 0.5rem 0.75rem;
463      background-color: var(--color-surface);
464      border: 1px solid rgba(217, 137, 46, 0.3);
465      border-radius: 0.375rem;
466      color: var(--color-text);
467      font-size: 0.875rem;
468      transition: border-color 0.15s, box-shadow 0.15s;
469    }
470  
471    .input-field:focus {
472      outline: none;
473      border-color: var(--color-phosphor);
474      box-shadow: 0 0 0 2px rgba(217, 137, 46, 0.2);
475    }
476  
477    .input-field::placeholder {
478      color: var(--color-text-muted);
479    }
480  
481    .btn-primary {
482      padding: 0.5rem 1rem;
483      background-color: rgba(217, 137, 46, 0.15);
484      border: 1px solid rgba(217, 137, 46, 0.4);
485      border-radius: 0.375rem;
486      color: var(--color-phosphor);
487      font-size: 0.875rem;
488      font-weight: 500;
489      cursor: pointer;
490      transition: all 0.15s;
491    }
492  
493    .btn-primary:hover {
494      background-color: rgba(217, 137, 46, 0.25);
495      border-color: var(--color-phosphor);
496    }
497  
498    .search-results {
499      display: flex;
500      flex-direction: column;
501      gap: 0.25rem;
502      max-height: 350px;
503      overflow-y: auto;
504      border: 1px solid rgba(217, 137, 46, 0.2);
505      border-radius: 0.375rem;
506      padding: 0.25rem;
507    }
508  
509    .search-result {
510      display: flex;
511      align-items: center;
512      gap: 0.5rem;
513      padding: 0.5rem 0.75rem;
514      border-radius: 0.25rem;
515      text-align: left;
516      color: var(--color-text);
517      transition: background-color 0.15s;
518      font-size: 0.875rem;
519      background: transparent;
520      border: none;
521      cursor: pointer;
522      width: 100%;
523    }
524  
525    .search-result:hover {
526      background-color: rgba(217, 137, 46, 0.1);
527    }
528  
529    .search-result.selected {
530      background-color: rgba(217, 137, 46, 0.15);
531      color: var(--color-phosphor);
532    }
533  
534    .page-name {
535      flex: 1;
536      overflow: hidden;
537      text-overflow: ellipsis;
538      white-space: nowrap;
539    }
540  
541    .checkbox {
542      width: 1rem;
543      height: 1rem;
544      flex-shrink: 0;
545      border: 1px solid rgba(217, 137, 46, 0.4);
546      border-radius: 0.25rem;
547      display: flex;
548      align-items: center;
549      justify-content: center;
550      transition: all 0.15s;
551    }
552  
553    .checkbox.checked {
554      background-color: var(--color-phosphor);
555      border-color: var(--color-phosphor);
556      color: var(--color-surface);
557    }
558  
559    .checkbox-input {
560      width: 1rem;
561      height: 1rem;
562      accent-color: var(--color-phosphor);
563      cursor: pointer;
564    }
565  </style>