at main 14 kB view raw
1<script lang="ts"> 2 import { goto } from '$app/navigation'; 3 import { browser } from '$app/environment'; 4 import { search, type SearchResult } from '$lib/search.svelte'; 5 import { onMount, onDestroy } from 'svelte'; 6 import SensitiveImage from './SensitiveImage.svelte'; 7 8 let inputRef: HTMLInputElement | null = $state(null); 9 let isMobile = $state(false); 10 11 // detect mobile on mount 12 $effect(() => { 13 if (browser) { 14 const checkMobile = () => window.matchMedia('(max-width: 768px)').matches; 15 isMobile = checkMobile(); 16 const mediaQuery = window.matchMedia('(max-width: 768px)'); 17 const handler = (e: MediaQueryListEvent) => (isMobile = e.matches); 18 mediaQuery.addEventListener('change', handler); 19 return () => mediaQuery.removeEventListener('change', handler); 20 } 21 }); 22 23 // register input ref with search state for direct focus (mobile keyboard fix) 24 $effect(() => { 25 if (inputRef) { 26 search.setInputRef(inputRef); 27 } 28 }); 29 30 function handleKeydown(event: KeyboardEvent) { 31 if (!search.isOpen) return; 32 33 switch (event.key) { 34 case 'Escape': 35 event.preventDefault(); 36 search.close(); 37 break; 38 case 'ArrowDown': 39 event.preventDefault(); 40 search.selectNext(); 41 break; 42 case 'ArrowUp': 43 event.preventDefault(); 44 search.selectPrevious(); 45 break; 46 case 'Enter': { 47 event.preventDefault(); 48 const selected = search.getSelectedResult(); 49 if (selected) { 50 navigateToResult(selected); 51 } 52 break; 53 } 54 } 55 } 56 57 function navigateToResult(result: SearchResult) { 58 const href = search.getResultHref(result); 59 search.close(); 60 goto(href); 61 } 62 63 function handleBackdropClick(event: MouseEvent) { 64 if (event.target === event.currentTarget) { 65 search.close(); 66 } 67 } 68 69 function getResultImage(result: SearchResult): string | null { 70 switch (result.type) { 71 case 'track': 72 return result.image_url; 73 case 'artist': 74 return result.avatar_url; 75 case 'album': 76 return result.image_url; 77 case 'tag': 78 return null; 79 case 'playlist': 80 return result.image_url; 81 } 82 } 83 84 function getResultTitle(result: SearchResult): string { 85 switch (result.type) { 86 case 'track': 87 return result.title; 88 case 'artist': 89 return result.display_name; 90 case 'album': 91 return result.title; 92 case 'tag': 93 return result.name; 94 case 'playlist': 95 return result.name; 96 } 97 } 98 99 function getResultSubtitle(result: SearchResult): string { 100 switch (result.type) { 101 case 'track': 102 return `by ${result.artist_display_name}`; 103 case 'artist': 104 return `@${result.handle}`; 105 case 'album': 106 return `by ${result.artist_display_name}`; 107 case 'tag': 108 return `${result.track_count} track${result.track_count === 1 ? '' : 's'}`; 109 case 'playlist': 110 return `by ${result.owner_display_name} · ${result.track_count} track${result.track_count === 1 ? '' : 's'}`; 111 } 112 } 113 114 function getShortcutHint(): string { 115 // detect platform - use text instead of symbols for clarity 116 if (browser && navigator.platform.toLowerCase().includes('mac')) { 117 return 'Cmd+K'; 118 } 119 return 'Ctrl+K'; 120 } 121 122 onMount(() => { 123 window.addEventListener('keydown', handleKeydown); 124 }); 125 126 onDestroy(() => { 127 if (browser) { 128 window.removeEventListener('keydown', handleKeydown); 129 } 130 }); 131</script> 132 133<!-- always render for mobile keyboard focus, use CSS to show/hide --> 134<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 135<div 136 class="search-backdrop" 137 class:open={search.isOpen} 138 role="presentation" 139 onclick={handleBackdropClick} 140> 141 <div class="search-modal" role="dialog" aria-modal="true" aria-label="search"> 142 <div class="search-input-wrapper"> 143 <svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 144 <circle cx="11" cy="11" r="8"></circle> 145 <line x1="21" y1="21" x2="16.65" y2="16.65"></line> 146 </svg> 147 <input 148 bind:this={inputRef} 149 type="text" 150 class="search-input" 151 placeholder="search tracks, artists, albums, playlists..." 152 value={search.query} 153 oninput={(e) => search.setQuery(e.currentTarget.value)} 154 autocomplete="off" 155 autocorrect="off" 156 autocapitalize="off" 157 spellcheck="false" 158 /> 159 {#if search.loading} 160 <div class="search-spinner"></div> 161 {:else if !isMobile} 162 <kbd class="search-shortcut">{getShortcutHint()}</kbd> 163 {/if} 164 </div> 165 166 {#if search.results.length > 0} 167 <div class="search-results"> 168 {#each search.results as result, index (result.type + '-' + (result.type === 'track' ? result.id : result.type === 'artist' ? result.did : result.type === 'album' ? result.id : result.type === 'playlist' ? result.id : result.id))} 169 {@const imageUrl = getResultImage(result)} 170 <button 171 class="search-result" 172 class:selected={index === search.selectedIndex} 173 onclick={() => navigateToResult(result)} 174 onmouseenter={() => (search.selectedIndex = index)} 175 > 176 <span class="result-icon" data-type={result.type}> 177 {#if imageUrl} 178 <SensitiveImage src={imageUrl} compact> 179 <img 180 src={imageUrl} 181 alt="" 182 class="result-image" 183 loading="lazy" 184 /> 185 </SensitiveImage> 186 {:else if result.type === 'track'} 187 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 188 <path d="M9 18V5l12-2v13"></path> 189 <circle cx="6" cy="18" r="3"></circle> 190 <circle cx="18" cy="16" r="3"></circle> 191 </svg> 192 {:else if result.type === 'artist'} 193 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"> 194 <circle cx="8" cy="5" r="3" fill="none" /> 195 <path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" stroke-linecap="round" /> 196 </svg> 197 {:else if result.type === 'album'} 198 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"> 199 <rect x="2" y="2" width="12" height="12" fill="none" /> 200 <circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none" /> 201 </svg> 202 {:else if result.type === 'tag'} 203 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 204 <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path> 205 <line x1="7" y1="7" x2="7.01" y2="7"></line> 206 </svg> 207 {:else if result.type === 'playlist'} 208 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 209 <line x1="8" y1="6" x2="21" y2="6"></line> 210 <line x1="8" y1="12" x2="21" y2="12"></line> 211 <line x1="8" y1="18" x2="21" y2="18"></line> 212 <line x1="3" y1="6" x2="3.01" y2="6"></line> 213 <line x1="3" y1="12" x2="3.01" y2="12"></line> 214 <line x1="3" y1="18" x2="3.01" y2="18"></line> 215 </svg> 216 {/if} 217 </span> 218 <div class="result-content"> 219 <span class="result-title">{getResultTitle(result)}</span> 220 <span class="result-subtitle">{getResultSubtitle(result)}</span> 221 </div> 222 <span class="result-type">{result.type}</span> 223 </button> 224 {/each} 225 </div> 226 {:else if search.query.length >= 2 && !search.loading} 227 <div class="search-empty"> 228 no results for "{search.query}" 229 </div> 230 {:else if search.query.length === 0} 231 <div class="search-hints"> 232 <p>start typing to search across all content</p> 233 {#if !isMobile} 234 <div class="hint-shortcuts"> 235 <span><kbd></kbd><kbd></kbd> navigate</span> 236 <span><kbd></kbd> select</span> 237 <span><kbd>esc</kbd> close</span> 238 </div> 239 {/if} 240 </div> 241 {/if} 242 243 {#if search.error} 244 <div class="search-error">{search.error}</div> 245 {/if} 246 </div> 247 </div> 248 249<style> 250 .search-backdrop { 251 position: fixed; 252 inset: 0; 253 background: color-mix(in srgb, var(--bg-primary) 60%, transparent); 254 backdrop-filter: blur(4px); 255 -webkit-backdrop-filter: blur(4px); 256 z-index: 9999; 257 display: flex; 258 align-items: flex-start; 259 justify-content: center; 260 padding-top: 15vh; 261 /* hidden by default - use opacity only (not visibility) so input remains focusable for mobile keyboard */ 262 opacity: 0; 263 pointer-events: none; 264 transition: opacity 0.15s; 265 } 266 267 .search-backdrop.open { 268 opacity: 1; 269 pointer-events: auto; 270 } 271 272 .search-modal { 273 width: 100%; 274 max-width: 560px; 275 background: color-mix(in srgb, var(--bg-secondary) 95%, transparent); 276 backdrop-filter: blur(20px) saturate(180%); 277 -webkit-backdrop-filter: blur(20px) saturate(180%); 278 border: 1px solid var(--border-subtle); 279 border-radius: var(--radius-xl); 280 box-shadow: 281 0 24px 80px color-mix(in srgb, var(--bg-primary) 50%, transparent), 282 0 0 1px var(--border-subtle) inset; 283 overflow: hidden; 284 margin: 0 1rem; 285 } 286 287 .search-input-wrapper { 288 display: flex; 289 align-items: center; 290 gap: 0.75rem; 291 padding: 1rem 1.25rem; 292 border-bottom: 1px solid var(--border-subtle); 293 background: color-mix(in srgb, var(--bg-tertiary) 50%, transparent); 294 } 295 296 .search-icon { 297 color: var(--text-tertiary); 298 flex-shrink: 0; 299 } 300 301 .search-input { 302 flex: 1; 303 background: transparent; 304 border: none; 305 outline: none; 306 font-size: var(--text-lg); 307 font-family: inherit; 308 color: var(--text-primary); 309 } 310 311 .search-input::placeholder { 312 color: var(--text-muted); 313 } 314 315 .search-shortcut { 316 font-size: var(--text-xs); 317 padding: 0.25rem 0.5rem; 318 background: var(--bg-tertiary); 319 border: 1px solid var(--border-default); 320 border-radius: var(--radius-sm); 321 color: var(--text-muted); 322 font-family: inherit; 323 } 324 325 .search-spinner { 326 width: 16px; 327 height: 16px; 328 border: 2px solid var(--border-default); 329 border-top-color: var(--accent); 330 border-radius: var(--radius-full); 331 animation: spin 0.6s linear infinite; 332 } 333 334 @keyframes spin { 335 to { 336 transform: rotate(360deg); 337 } 338 } 339 340 .search-results { 341 max-height: 400px; 342 overflow-y: auto; 343 padding: 0.5rem; 344 scrollbar-width: thin; 345 scrollbar-color: var(--border-default) transparent; 346 } 347 348 .search-results::-webkit-scrollbar { 349 width: 8px; 350 } 351 352 .search-results::-webkit-scrollbar-track { 353 background: transparent; 354 border-radius: var(--radius-sm); 355 } 356 357 .search-results::-webkit-scrollbar-thumb { 358 background: var(--border-default); 359 border-radius: var(--radius-sm); 360 } 361 362 .search-results::-webkit-scrollbar-thumb:hover { 363 background: var(--border-emphasis); 364 } 365 366 .search-result { 367 display: flex; 368 align-items: center; 369 gap: 0.75rem; 370 width: 100%; 371 padding: 0.75rem; 372 background: transparent; 373 border: none; 374 border-radius: var(--radius-md); 375 cursor: pointer; 376 text-align: left; 377 font-family: inherit; 378 color: var(--text-primary); 379 transition: background 0.1s; 380 } 381 382 .search-result:hover, 383 .search-result.selected { 384 background: var(--bg-hover); 385 } 386 387 .search-result.selected { 388 background: var(--bg-hover); 389 box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent) inset; 390 } 391 392 .result-icon { 393 width: 32px; 394 height: 32px; 395 display: flex; 396 align-items: center; 397 justify-content: center; 398 background: var(--bg-tertiary); 399 border-radius: var(--radius-md); 400 font-size: var(--text-base); 401 flex-shrink: 0; 402 position: relative; 403 overflow: hidden; 404 } 405 406 .result-image { 407 position: absolute; 408 inset: 0; 409 width: 100%; 410 height: 100%; 411 object-fit: cover; 412 border-radius: var(--radius-md); 413 } 414 415 .result-icon[data-type='track'] { 416 color: var(--accent); 417 } 418 419 .result-icon[data-type='artist'] { 420 color: #a78bfa; 421 } 422 423 .result-icon[data-type='album'] { 424 color: #34d399; 425 } 426 427 .result-icon[data-type='tag'] { 428 color: #fbbf24; 429 } 430 431 .result-icon[data-type='playlist'] { 432 color: #f472b6; 433 } 434 435 .result-content { 436 flex: 1; 437 min-width: 0; 438 display: flex; 439 flex-direction: column; 440 gap: 0.15rem; 441 } 442 443 .result-title { 444 font-size: var(--text-base); 445 font-weight: 500; 446 white-space: nowrap; 447 overflow: hidden; 448 text-overflow: ellipsis; 449 } 450 451 .result-subtitle { 452 font-size: var(--text-xs); 453 color: var(--text-secondary); 454 white-space: nowrap; 455 overflow: hidden; 456 text-overflow: ellipsis; 457 } 458 459 .result-type { 460 font-size: 0.6rem; 461 text-transform: uppercase; 462 letter-spacing: 0.03em; 463 color: var(--text-muted); 464 padding: 0.2rem 0.45rem; 465 background: var(--bg-tertiary); 466 border-radius: var(--radius-sm); 467 flex-shrink: 0; 468 } 469 470 .search-empty { 471 padding: 2rem; 472 text-align: center; 473 color: var(--text-secondary); 474 font-size: var(--text-base); 475 } 476 477 .search-hints { 478 padding: 1.5rem 2rem; 479 text-align: center; 480 } 481 482 .search-hints p { 483 margin: 0 0 1rem 0; 484 color: var(--text-secondary); 485 font-size: var(--text-sm); 486 } 487 488 .hint-shortcuts { 489 display: flex; 490 justify-content: center; 491 gap: 1.5rem; 492 color: var(--text-muted); 493 font-size: var(--text-xs); 494 } 495 496 .hint-shortcuts span { 497 display: flex; 498 align-items: center; 499 gap: 0.25rem; 500 } 501 502 .hint-shortcuts kbd { 503 font-size: 0.65rem; 504 padding: 0.15rem 0.35rem; 505 background: var(--bg-tertiary); 506 border: 1px solid var(--border-default); 507 border-radius: var(--radius-sm); 508 font-family: inherit; 509 } 510 511 .search-error { 512 padding: 1rem; 513 text-align: center; 514 color: var(--error); 515 font-size: var(--text-sm); 516 } 517 518 /* mobile optimizations */ 519 @media (max-width: 768px) { 520 .search-backdrop { 521 padding-top: 10vh; 522 } 523 524 .search-modal { 525 margin: 0 0.75rem; 526 max-height: 80vh; 527 } 528 529 .search-input-wrapper { 530 padding: 0.875rem 1rem; 531 } 532 533 .search-input { 534 font-size: 16px; /* prevents iOS zoom */ 535 } 536 537 .search-input::placeholder { 538 font-size: var(--text-sm); 539 } 540 541 .search-results { 542 max-height: 60vh; 543 } 544 545 .hint-shortcuts { 546 flex-wrap: wrap; 547 gap: 1rem; 548 } 549 } 550 551 /* respect reduced motion */ 552 @media (prefers-reduced-motion: reduce) { 553 .search-spinner { 554 animation: none; 555 } 556 } 557</style>