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>