feat: unified search with Cmd/Ctrl+K shortcut (#447)

* feat: unified search with Cmd/Ctrl+K shortcut

- add pg_trgm extension and GIN indexes for fuzzy search
- implement /search endpoint with trigram similarity scoring
- search across tracks, artists, albums, and tags
- create SearchModal component with keyboard navigation
- Cmd+K (Mac) / Ctrl+K (Windows/Linux) opens search
- arrow keys navigate, Enter selects, Esc closes
- results grouped by type with relevance scoring

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: improve search modal UX and styling

- use "Cmd+K" text instead of ⌘ symbol for clarity
- add client-side query length validation (max 100 chars)
- show clear error message when query exceeds limit
- apply glassmorphism styling to modal:
- frosted glass background with blur
- subtle border highlights
- refined hover/selected states
- consistent translucent elements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: show artwork/avatars in search results

- display track artwork, artist avatars, album covers when available
- lazy loading with graceful fallback to icon on error
- image hidden on load failure, icon shown instead

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 005f9c4d a54bcf6f

Changed files
+1013 -5
backend
frontend
+63
backend/alembic/versions/2025_12_03_000235_0d634c0a7259_add_pg_trgm_extension_and_search_indexes.py
··· 1 + """add pg_trgm extension and search indexes 2 + 3 + Revision ID: 0d634c0a7259 4 + Revises: f60f46fb6014 5 + Create Date: 2025-12-03 00:02:35.608832 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + from alembic import op 12 + 13 + # revision identifiers, used by Alembic. 14 + revision: str = "0d634c0a7259" 15 + down_revision: str | Sequence[str] | None = "f60f46fb6014" 16 + branch_labels: str | Sequence[str] | None = None 17 + depends_on: str | Sequence[str] | None = None 18 + 19 + 20 + def upgrade() -> None: 21 + """enable pg_trgm and create search indexes.""" 22 + # enable pg_trgm extension for fuzzy/similarity search 23 + op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm") 24 + 25 + # trigram indexes for fuzzy matching 26 + # note: not using CONCURRENTLY since tables are small (<100 rows) 27 + # tracks: search by title 28 + op.execute( 29 + "CREATE INDEX IF NOT EXISTS ix_tracks_title_trgm " 30 + "ON tracks USING GIN (title gin_trgm_ops)" 31 + ) 32 + 33 + # artists: search by handle and display_name 34 + op.execute( 35 + "CREATE INDEX IF NOT EXISTS ix_artists_handle_trgm " 36 + "ON artists USING GIN (handle gin_trgm_ops)" 37 + ) 38 + op.execute( 39 + "CREATE INDEX IF NOT EXISTS ix_artists_display_name_trgm " 40 + "ON artists USING GIN (display_name gin_trgm_ops)" 41 + ) 42 + 43 + # albums: search by title 44 + op.execute( 45 + "CREATE INDEX IF NOT EXISTS ix_albums_title_trgm " 46 + "ON albums USING GIN (title gin_trgm_ops)" 47 + ) 48 + 49 + # tags: search by name 50 + op.execute( 51 + "CREATE INDEX IF NOT EXISTS ix_tags_name_trgm " 52 + "ON tags USING GIN (name gin_trgm_ops)" 53 + ) 54 + 55 + 56 + def downgrade() -> None: 57 + """remove search indexes (keep extension).""" 58 + op.execute("DROP INDEX IF EXISTS ix_tags_name_trgm") 59 + op.execute("DROP INDEX IF EXISTS ix_albums_title_trgm") 60 + op.execute("DROP INDEX IF EXISTS ix_artists_display_name_trgm") 61 + op.execute("DROP INDEX IF EXISTS ix_artists_handle_trgm") 62 + op.execute("DROP INDEX IF EXISTS ix_tracks_title_trgm") 63 + # note: not dropping pg_trgm extension as other things may depend on it
+252 -1
backend/src/backend/api/search.py
··· 1 1 """search endpoints for relay.""" 2 2 3 - from fastapi import APIRouter, Query 3 + from typing import Annotated, Literal 4 + 5 + from fastapi import APIRouter, Depends, Query 6 + from pydantic import BaseModel 7 + from sqlalchemy import func, select 8 + from sqlalchemy.ext.asyncio import AsyncSession 4 9 5 10 from backend._internal.atproto.handles import search_handles 11 + from backend.models import Album, Artist, Tag, Track, TrackTag, get_db 6 12 7 13 router = APIRouter(prefix="/search", tags=["search"]) 8 14 9 15 16 + # response models 17 + class TrackSearchResult(BaseModel): 18 + """track search result.""" 19 + 20 + type: Literal["track"] = "track" 21 + id: int 22 + title: str 23 + artist_handle: str 24 + artist_display_name: str 25 + image_url: str | None 26 + relevance: float 27 + 28 + 29 + class ArtistSearchResult(BaseModel): 30 + """artist search result.""" 31 + 32 + type: Literal["artist"] = "artist" 33 + did: str 34 + handle: str 35 + display_name: str 36 + avatar_url: str | None 37 + relevance: float 38 + 39 + 40 + class AlbumSearchResult(BaseModel): 41 + """album search result.""" 42 + 43 + type: Literal["album"] = "album" 44 + id: str 45 + title: str 46 + slug: str 47 + artist_handle: str 48 + artist_display_name: str 49 + image_url: str | None 50 + relevance: float 51 + 52 + 53 + class TagSearchResult(BaseModel): 54 + """tag search result.""" 55 + 56 + type: Literal["tag"] = "tag" 57 + id: int 58 + name: str 59 + track_count: int 60 + relevance: float 61 + 62 + 63 + SearchResult = ( 64 + TrackSearchResult | ArtistSearchResult | AlbumSearchResult | TagSearchResult 65 + ) 66 + 67 + 68 + class SearchResponse(BaseModel): 69 + """unified search response.""" 70 + 71 + results: list[SearchResult] 72 + counts: dict[str, int] 73 + 74 + 10 75 @router.get("/handles") 11 76 async def search_atproto_handles( 12 77 q: str = Query(..., min_length=2, description="search query (handle prefix)"), ··· 18 83 """ 19 84 results = await search_handles(q, limit=limit) 20 85 return {"results": results} 86 + 87 + 88 + @router.get("/") 89 + async def unified_search( 90 + db: Annotated[AsyncSession, Depends(get_db)], 91 + q: str = Query(..., min_length=2, max_length=100, description="search query"), 92 + type: str | None = Query( 93 + None, 94 + description="filter by type: tracks, artists, albums, tags (comma-separated for multiple)", 95 + ), 96 + limit: int = Query(20, ge=1, le=50, description="max results per type"), 97 + ) -> SearchResponse: 98 + """unified search across tracks, artists, albums, and tags. 99 + 100 + uses pg_trgm for fuzzy matching with similarity scoring. 101 + results are sorted by relevance within each type. 102 + """ 103 + # parse types filter 104 + if type: 105 + types = {t.strip().lower() for t in type.split(",")} 106 + else: 107 + types = {"tracks", "artists", "albums", "tags"} 108 + 109 + results: list[SearchResult] = [] 110 + counts: dict[str, int] = {"tracks": 0, "artists": 0, "albums": 0, "tags": 0} 111 + 112 + # search tracks 113 + if "tracks" in types: 114 + track_results = await _search_tracks(db, q, limit) 115 + results.extend(track_results) 116 + counts["tracks"] = len(track_results) 117 + 118 + # search artists 119 + if "artists" in types: 120 + artist_results = await _search_artists(db, q, limit) 121 + results.extend(artist_results) 122 + counts["artists"] = len(artist_results) 123 + 124 + # search albums 125 + if "albums" in types: 126 + album_results = await _search_albums(db, q, limit) 127 + results.extend(album_results) 128 + counts["albums"] = len(album_results) 129 + 130 + # search tags 131 + if "tags" in types: 132 + tag_results = await _search_tags(db, q, limit) 133 + results.extend(tag_results) 134 + counts["tags"] = len(tag_results) 135 + 136 + # sort all results by relevance (highest first) 137 + results.sort(key=lambda x: x.relevance, reverse=True) 138 + 139 + return SearchResponse(results=results, counts=counts) 140 + 141 + 142 + async def _search_tracks( 143 + db: AsyncSession, query: str, limit: int 144 + ) -> list[TrackSearchResult]: 145 + """search tracks by title using trigram similarity.""" 146 + # use pg_trgm similarity function for fuzzy matching 147 + similarity = func.similarity(Track.title, query) 148 + 149 + stmt = ( 150 + select(Track, Artist, similarity.label("relevance")) 151 + .join(Artist, Track.artist_did == Artist.did) 152 + .where(similarity > 0.1) # minimum similarity threshold 153 + .order_by(similarity.desc()) 154 + .limit(limit) 155 + ) 156 + 157 + result = await db.execute(stmt) 158 + rows = result.all() 159 + 160 + return [ 161 + TrackSearchResult( 162 + id=track.id, 163 + title=track.title, 164 + artist_handle=artist.handle, 165 + artist_display_name=artist.display_name, 166 + image_url=track.image_url, 167 + relevance=round(relevance, 3), 168 + ) 169 + for track, artist, relevance in rows 170 + ] 171 + 172 + 173 + async def _search_artists( 174 + db: AsyncSession, query: str, limit: int 175 + ) -> list[ArtistSearchResult]: 176 + """search artists by handle and display_name using trigram similarity.""" 177 + # combine similarity scores from handle and display_name (take max) 178 + handle_sim = func.similarity(Artist.handle, query) 179 + name_sim = func.similarity(Artist.display_name, query) 180 + combined_sim = func.greatest(handle_sim, name_sim) 181 + 182 + stmt = ( 183 + select(Artist, combined_sim.label("relevance")) 184 + .where(combined_sim > 0.1) 185 + .order_by(combined_sim.desc()) 186 + .limit(limit) 187 + ) 188 + 189 + result = await db.execute(stmt) 190 + rows = result.all() 191 + 192 + return [ 193 + ArtistSearchResult( 194 + did=artist.did, 195 + handle=artist.handle, 196 + display_name=artist.display_name, 197 + avatar_url=artist.avatar_url, 198 + relevance=round(relevance, 3), 199 + ) 200 + for artist, relevance in rows 201 + ] 202 + 203 + 204 + async def _search_albums( 205 + db: AsyncSession, query: str, limit: int 206 + ) -> list[AlbumSearchResult]: 207 + """search albums by title using trigram similarity.""" 208 + similarity = func.similarity(Album.title, query) 209 + 210 + stmt = ( 211 + select(Album, Artist, similarity.label("relevance")) 212 + .join(Artist, Album.artist_did == Artist.did) 213 + .where(similarity > 0.1) 214 + .order_by(similarity.desc()) 215 + .limit(limit) 216 + ) 217 + 218 + result = await db.execute(stmt) 219 + rows = result.all() 220 + 221 + return [ 222 + AlbumSearchResult( 223 + id=album.id, 224 + title=album.title, 225 + slug=album.slug, 226 + artist_handle=artist.handle, 227 + artist_display_name=artist.display_name, 228 + image_url=album.image_url, 229 + relevance=round(relevance, 3), 230 + ) 231 + for album, artist, relevance in rows 232 + ] 233 + 234 + 235 + async def _search_tags( 236 + db: AsyncSession, query: str, limit: int 237 + ) -> list[TagSearchResult]: 238 + """search tags by name using trigram similarity.""" 239 + similarity = func.similarity(Tag.name, query) 240 + 241 + # count tracks per tag 242 + track_count_subq = ( 243 + select(TrackTag.tag_id, func.count(TrackTag.track_id).label("track_count")) 244 + .group_by(TrackTag.tag_id) 245 + .subquery() 246 + ) 247 + 248 + stmt = ( 249 + select( 250 + Tag, 251 + func.coalesce(track_count_subq.c.track_count, 0).label("track_count"), 252 + similarity.label("relevance"), 253 + ) 254 + .outerjoin(track_count_subq, Tag.id == track_count_subq.c.tag_id) 255 + .where(similarity > 0.1) 256 + .order_by(similarity.desc()) 257 + .limit(limit) 258 + ) 259 + 260 + result = await db.execute(stmt) 261 + rows = result.all() 262 + 263 + return [ 264 + TagSearchResult( 265 + id=tag.id, 266 + name=tag.name, 267 + track_count=track_count, 268 + relevance=round(relevance, 3), 269 + ) 270 + for tag, track_count, relevance in rows 271 + ]
+498
frontend/src/lib/components/SearchModal.svelte
··· 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 { fade } from 'svelte/transition'; 6 + import { onMount, onDestroy } from 'svelte'; 7 + 8 + let inputRef: HTMLInputElement | null = $state(null); 9 + 10 + // focus input when modal opens 11 + $effect(() => { 12 + if (search.isOpen && inputRef && browser) { 13 + // small delay to ensure modal is rendered 14 + window.requestAnimationFrame(() => { 15 + inputRef?.focus(); 16 + }); 17 + } 18 + }); 19 + 20 + function handleKeydown(event: KeyboardEvent) { 21 + if (!search.isOpen) return; 22 + 23 + switch (event.key) { 24 + case 'Escape': 25 + event.preventDefault(); 26 + search.close(); 27 + break; 28 + case 'ArrowDown': 29 + event.preventDefault(); 30 + search.selectNext(); 31 + break; 32 + case 'ArrowUp': 33 + event.preventDefault(); 34 + search.selectPrevious(); 35 + break; 36 + case 'Enter': { 37 + event.preventDefault(); 38 + const selected = search.getSelectedResult(); 39 + if (selected) { 40 + navigateToResult(selected); 41 + } 42 + break; 43 + } 44 + } 45 + } 46 + 47 + function navigateToResult(result: SearchResult) { 48 + const href = search.getResultHref(result); 49 + search.close(); 50 + goto(href); 51 + } 52 + 53 + function handleBackdropClick(event: MouseEvent) { 54 + if (event.target === event.currentTarget) { 55 + search.close(); 56 + } 57 + } 58 + 59 + function getResultIcon(type: SearchResult['type']): string { 60 + switch (type) { 61 + case 'track': 62 + return '♪'; 63 + case 'artist': 64 + return '◉'; 65 + case 'album': 66 + return '◫'; 67 + case 'tag': 68 + return '#'; 69 + } 70 + } 71 + 72 + function getResultImage(result: SearchResult): string | null { 73 + switch (result.type) { 74 + case 'track': 75 + return result.image_url; 76 + case 'artist': 77 + return result.avatar_url; 78 + case 'album': 79 + return result.image_url; 80 + case 'tag': 81 + return null; 82 + } 83 + } 84 + 85 + function getResultTitle(result: SearchResult): string { 86 + switch (result.type) { 87 + case 'track': 88 + return result.title; 89 + case 'artist': 90 + return result.display_name; 91 + case 'album': 92 + return result.title; 93 + case 'tag': 94 + return result.name; 95 + } 96 + } 97 + 98 + function getResultSubtitle(result: SearchResult): string { 99 + switch (result.type) { 100 + case 'track': 101 + return `by ${result.artist_display_name}`; 102 + case 'artist': 103 + return `@${result.handle}`; 104 + case 'album': 105 + return `by ${result.artist_display_name}`; 106 + case 'tag': 107 + return `${result.track_count} track${result.track_count === 1 ? '' : 's'}`; 108 + } 109 + } 110 + 111 + function getShortcutHint(): string { 112 + // detect platform - use text instead of symbols for clarity 113 + if (browser && navigator.platform.toLowerCase().includes('mac')) { 114 + return 'Cmd+K'; 115 + } 116 + return 'Ctrl+K'; 117 + } 118 + 119 + onMount(() => { 120 + window.addEventListener('keydown', handleKeydown); 121 + }); 122 + 123 + onDestroy(() => { 124 + if (browser) { 125 + window.removeEventListener('keydown', handleKeydown); 126 + } 127 + }); 128 + </script> 129 + 130 + {#if search.isOpen} 131 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 132 + <div 133 + class="search-backdrop" 134 + onclick={handleBackdropClick} 135 + transition:fade={{ duration: 150 }} 136 + > 137 + <div class="search-modal" role="dialog" aria-modal="true" aria-label="search"> 138 + <div class="search-input-wrapper"> 139 + <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"> 140 + <circle cx="11" cy="11" r="8"></circle> 141 + <line x1="21" y1="21" x2="16.65" y2="16.65"></line> 142 + </svg> 143 + <input 144 + bind:this={inputRef} 145 + type="text" 146 + class="search-input" 147 + placeholder="search tracks, artists, albums, tags..." 148 + value={search.query} 149 + oninput={(e) => search.setQuery(e.currentTarget.value)} 150 + autocomplete="off" 151 + autocorrect="off" 152 + autocapitalize="off" 153 + spellcheck="false" 154 + /> 155 + {#if search.loading} 156 + <div class="search-spinner"></div> 157 + {:else} 158 + <kbd class="search-shortcut">{getShortcutHint()}</kbd> 159 + {/if} 160 + </div> 161 + 162 + {#if search.results.length > 0} 163 + <div class="search-results"> 164 + {#each search.results as result, index (result.type + '-' + (result.type === 'track' ? result.id : result.type === 'artist' ? result.did : result.type === 'album' ? result.id : result.id))} 165 + {@const imageUrl = getResultImage(result)} 166 + <button 167 + class="search-result" 168 + class:selected={index === search.selectedIndex} 169 + onclick={() => navigateToResult(result)} 170 + onmouseenter={() => (search.selectedIndex = index)} 171 + > 172 + <span class="result-icon" data-type={result.type}> 173 + {#if imageUrl} 174 + <img 175 + src={imageUrl} 176 + alt="" 177 + class="result-image" 178 + loading="lazy" 179 + onerror={(e) => ((e.currentTarget as HTMLImageElement).style.display = 'none')} 180 + /> 181 + <span class="result-icon-fallback">{getResultIcon(result.type)}</span> 182 + {:else} 183 + {getResultIcon(result.type)} 184 + {/if} 185 + </span> 186 + <div class="result-content"> 187 + <span class="result-title">{getResultTitle(result)}</span> 188 + <span class="result-subtitle">{getResultSubtitle(result)}</span> 189 + </div> 190 + <span class="result-type">{result.type}</span> 191 + </button> 192 + {/each} 193 + </div> 194 + {:else if search.query.length >= 2 && !search.loading} 195 + <div class="search-empty"> 196 + no results for "{search.query}" 197 + </div> 198 + {:else if search.query.length === 0} 199 + <div class="search-hints"> 200 + <p>start typing to search across all content</p> 201 + <div class="hint-shortcuts"> 202 + <span><kbd>↑</kbd><kbd>↓</kbd> navigate</span> 203 + <span><kbd>↵</kbd> select</span> 204 + <span><kbd>esc</kbd> close</span> 205 + </div> 206 + </div> 207 + {/if} 208 + 209 + {#if search.error} 210 + <div class="search-error">{search.error}</div> 211 + {/if} 212 + </div> 213 + </div> 214 + {/if} 215 + 216 + <style> 217 + .search-backdrop { 218 + position: fixed; 219 + inset: 0; 220 + background: rgba(0, 0, 0, 0.6); 221 + backdrop-filter: blur(4px); 222 + -webkit-backdrop-filter: blur(4px); 223 + z-index: 9999; 224 + display: flex; 225 + align-items: flex-start; 226 + justify-content: center; 227 + padding-top: 15vh; 228 + } 229 + 230 + .search-modal { 231 + width: 100%; 232 + max-width: 560px; 233 + background: rgba(18, 18, 20, 0.85); 234 + backdrop-filter: blur(20px) saturate(180%); 235 + -webkit-backdrop-filter: blur(20px) saturate(180%); 236 + border: 1px solid rgba(255, 255, 255, 0.08); 237 + border-radius: 16px; 238 + box-shadow: 239 + 0 24px 80px rgba(0, 0, 0, 0.5), 240 + 0 0 1px rgba(255, 255, 255, 0.1) inset; 241 + overflow: hidden; 242 + margin: 0 1rem; 243 + } 244 + 245 + .search-input-wrapper { 246 + display: flex; 247 + align-items: center; 248 + gap: 0.75rem; 249 + padding: 1rem 1.25rem; 250 + border-bottom: 1px solid rgba(255, 255, 255, 0.06); 251 + background: rgba(255, 255, 255, 0.02); 252 + } 253 + 254 + .search-icon { 255 + color: var(--text-tertiary); 256 + flex-shrink: 0; 257 + } 258 + 259 + .search-input { 260 + flex: 1; 261 + background: transparent; 262 + border: none; 263 + outline: none; 264 + font-size: 1rem; 265 + font-family: inherit; 266 + color: var(--text-primary); 267 + } 268 + 269 + .search-input::placeholder { 270 + color: var(--text-muted); 271 + } 272 + 273 + .search-shortcut { 274 + font-size: 0.7rem; 275 + padding: 0.25rem 0.5rem; 276 + background: rgba(255, 255, 255, 0.06); 277 + border: 1px solid rgba(255, 255, 255, 0.08); 278 + border-radius: 5px; 279 + color: var(--text-muted); 280 + font-family: inherit; 281 + } 282 + 283 + .search-spinner { 284 + width: 16px; 285 + height: 16px; 286 + border: 2px solid var(--border-default); 287 + border-top-color: var(--accent); 288 + border-radius: 50%; 289 + animation: spin 0.6s linear infinite; 290 + } 291 + 292 + @keyframes spin { 293 + to { 294 + transform: rotate(360deg); 295 + } 296 + } 297 + 298 + .search-results { 299 + max-height: 400px; 300 + overflow-y: auto; 301 + padding: 0.5rem; 302 + } 303 + 304 + .search-result { 305 + display: flex; 306 + align-items: center; 307 + gap: 0.75rem; 308 + width: 100%; 309 + padding: 0.75rem; 310 + background: transparent; 311 + border: none; 312 + border-radius: 8px; 313 + cursor: pointer; 314 + text-align: left; 315 + font-family: inherit; 316 + color: var(--text-primary); 317 + transition: background 0.1s; 318 + } 319 + 320 + .search-result:hover, 321 + .search-result.selected { 322 + background: rgba(255, 255, 255, 0.06); 323 + } 324 + 325 + .search-result.selected { 326 + background: rgba(255, 255, 255, 0.08); 327 + box-shadow: 0 0 0 1px rgba(var(--accent-rgb, 255, 107, 107), 0.3) inset; 328 + } 329 + 330 + .result-icon { 331 + width: 32px; 332 + height: 32px; 333 + display: flex; 334 + align-items: center; 335 + justify-content: center; 336 + background: rgba(255, 255, 255, 0.05); 337 + border-radius: 8px; 338 + font-size: 0.9rem; 339 + flex-shrink: 0; 340 + position: relative; 341 + overflow: hidden; 342 + } 343 + 344 + .result-image { 345 + position: absolute; 346 + inset: 0; 347 + width: 100%; 348 + height: 100%; 349 + object-fit: cover; 350 + border-radius: 8px; 351 + } 352 + 353 + .result-icon-fallback { 354 + /* shown behind image, visible if image fails */ 355 + position: relative; 356 + z-index: 0; 357 + } 358 + 359 + .result-image + .result-icon-fallback { 360 + /* hide fallback when image is present and loaded */ 361 + opacity: 0; 362 + } 363 + 364 + .result-icon[data-type='track'] { 365 + color: var(--accent); 366 + } 367 + 368 + .result-icon[data-type='artist'] { 369 + color: #a78bfa; 370 + } 371 + 372 + .result-icon[data-type='album'] { 373 + color: #34d399; 374 + } 375 + 376 + .result-icon[data-type='tag'] { 377 + color: #fbbf24; 378 + } 379 + 380 + .result-content { 381 + flex: 1; 382 + min-width: 0; 383 + display: flex; 384 + flex-direction: column; 385 + gap: 0.15rem; 386 + } 387 + 388 + .result-title { 389 + font-size: 0.9rem; 390 + font-weight: 500; 391 + white-space: nowrap; 392 + overflow: hidden; 393 + text-overflow: ellipsis; 394 + } 395 + 396 + .result-subtitle { 397 + font-size: 0.75rem; 398 + color: var(--text-secondary); 399 + white-space: nowrap; 400 + overflow: hidden; 401 + text-overflow: ellipsis; 402 + } 403 + 404 + .result-type { 405 + font-size: 0.6rem; 406 + text-transform: uppercase; 407 + letter-spacing: 0.03em; 408 + color: var(--text-muted); 409 + padding: 0.2rem 0.45rem; 410 + background: rgba(255, 255, 255, 0.04); 411 + border-radius: 4px; 412 + flex-shrink: 0; 413 + } 414 + 415 + .search-empty { 416 + padding: 2rem; 417 + text-align: center; 418 + color: var(--text-secondary); 419 + font-size: 0.9rem; 420 + } 421 + 422 + .search-hints { 423 + padding: 1.5rem 2rem; 424 + text-align: center; 425 + } 426 + 427 + .search-hints p { 428 + margin: 0 0 1rem 0; 429 + color: var(--text-secondary); 430 + font-size: 0.85rem; 431 + } 432 + 433 + .hint-shortcuts { 434 + display: flex; 435 + justify-content: center; 436 + gap: 1.5rem; 437 + color: var(--text-muted); 438 + font-size: 0.75rem; 439 + } 440 + 441 + .hint-shortcuts span { 442 + display: flex; 443 + align-items: center; 444 + gap: 0.25rem; 445 + } 446 + 447 + .hint-shortcuts kbd { 448 + font-size: 0.65rem; 449 + padding: 0.15rem 0.35rem; 450 + background: rgba(255, 255, 255, 0.05); 451 + border: 1px solid rgba(255, 255, 255, 0.08); 452 + border-radius: 4px; 453 + font-family: inherit; 454 + } 455 + 456 + .search-error { 457 + padding: 1rem; 458 + text-align: center; 459 + color: var(--error); 460 + font-size: 0.85rem; 461 + } 462 + 463 + /* mobile optimizations */ 464 + @media (max-width: 768px) { 465 + .search-backdrop { 466 + padding-top: 10vh; 467 + } 468 + 469 + .search-modal { 470 + margin: 0 0.75rem; 471 + max-height: 80vh; 472 + } 473 + 474 + .search-input-wrapper { 475 + padding: 0.875rem 1rem; 476 + } 477 + 478 + .search-input { 479 + font-size: 16px; /* prevents iOS zoom */ 480 + } 481 + 482 + .search-results { 483 + max-height: 60vh; 484 + } 485 + 486 + .hint-shortcuts { 487 + flex-wrap: wrap; 488 + gap: 1rem; 489 + } 490 + } 491 + 492 + /* respect reduced motion */ 493 + @media (prefers-reduced-motion: reduce) { 494 + .search-spinner { 495 + animation: none; 496 + } 497 + } 498 + </style>
+186
frontend/src/lib/search.svelte.ts
··· 1 + // global search state using Svelte 5 runes 2 + import { API_URL } from '$lib/config'; 3 + 4 + export type SearchResultType = 'track' | 'artist' | 'album' | 'tag'; 5 + 6 + export interface TrackSearchResult { 7 + type: 'track'; 8 + id: number; 9 + title: string; 10 + artist_handle: string; 11 + artist_display_name: string; 12 + image_url: string | null; 13 + relevance: number; 14 + } 15 + 16 + export interface ArtistSearchResult { 17 + type: 'artist'; 18 + did: string; 19 + handle: string; 20 + display_name: string; 21 + avatar_url: string | null; 22 + relevance: number; 23 + } 24 + 25 + export interface AlbumSearchResult { 26 + type: 'album'; 27 + id: string; 28 + title: string; 29 + slug: string; 30 + artist_handle: string; 31 + artist_display_name: string; 32 + image_url: string | null; 33 + relevance: number; 34 + } 35 + 36 + export interface TagSearchResult { 37 + type: 'tag'; 38 + id: number; 39 + name: string; 40 + track_count: number; 41 + relevance: number; 42 + } 43 + 44 + export type SearchResult = TrackSearchResult | ArtistSearchResult | AlbumSearchResult | TagSearchResult; 45 + 46 + export interface SearchResponse { 47 + results: SearchResult[]; 48 + counts: { 49 + tracks: number; 50 + artists: number; 51 + albums: number; 52 + tags: number; 53 + }; 54 + } 55 + 56 + const MAX_QUERY_LENGTH = 100; 57 + 58 + class SearchState { 59 + isOpen = $state(false); 60 + query = $state(''); 61 + results = $state<SearchResult[]>([]); 62 + counts = $state<SearchResponse['counts']>({ tracks: 0, artists: 0, albums: 0, tags: 0 }); 63 + loading = $state(false); 64 + error = $state<string | null>(null); 65 + selectedIndex = $state(0); 66 + 67 + // debounce timer 68 + private searchTimeout: ReturnType<typeof setTimeout> | null = null; 69 + 70 + open() { 71 + this.isOpen = true; 72 + this.query = ''; 73 + this.results = []; 74 + this.counts = { tracks: 0, artists: 0, albums: 0, tags: 0 }; 75 + this.error = null; 76 + this.selectedIndex = 0; 77 + } 78 + 79 + close() { 80 + this.isOpen = false; 81 + this.query = ''; 82 + this.results = []; 83 + this.error = null; 84 + if (this.searchTimeout) { 85 + clearTimeout(this.searchTimeout); 86 + this.searchTimeout = null; 87 + } 88 + } 89 + 90 + toggle() { 91 + if (this.isOpen) { 92 + this.close(); 93 + } else { 94 + this.open(); 95 + } 96 + } 97 + 98 + setQuery(value: string) { 99 + this.query = value; 100 + this.selectedIndex = 0; 101 + 102 + // clear previous timeout 103 + if (this.searchTimeout) { 104 + clearTimeout(this.searchTimeout); 105 + } 106 + 107 + // validate length 108 + if (value.length > MAX_QUERY_LENGTH) { 109 + this.error = `query too long (max ${MAX_QUERY_LENGTH} characters)`; 110 + this.results = []; 111 + this.counts = { tracks: 0, artists: 0, albums: 0, tags: 0 }; 112 + return; 113 + } 114 + 115 + this.error = null; 116 + 117 + // debounce search 118 + if (value.length >= 2) { 119 + this.searchTimeout = setTimeout(() => { 120 + void this.search(value); 121 + }, 150); 122 + } else { 123 + this.results = []; 124 + this.counts = { tracks: 0, artists: 0, albums: 0, tags: 0 }; 125 + } 126 + } 127 + 128 + async search(query: string): Promise<void> { 129 + if (query.length < 2) return; 130 + 131 + this.loading = true; 132 + this.error = null; 133 + 134 + try { 135 + const response = await fetch( 136 + `${API_URL}/search/?q=${encodeURIComponent(query)}&limit=10` 137 + ); 138 + 139 + if (!response.ok) { 140 + throw new Error(`search failed: ${response.statusText}`); 141 + } 142 + 143 + const data: SearchResponse = await response.json(); 144 + this.results = data.results; 145 + this.counts = data.counts; 146 + this.selectedIndex = 0; 147 + } catch (e) { 148 + console.error('search error:', e); 149 + this.error = e instanceof Error ? e.message : 'search failed'; 150 + this.results = []; 151 + } finally { 152 + this.loading = false; 153 + } 154 + } 155 + 156 + selectNext() { 157 + if (this.results.length > 0) { 158 + this.selectedIndex = (this.selectedIndex + 1) % this.results.length; 159 + } 160 + } 161 + 162 + selectPrevious() { 163 + if (this.results.length > 0) { 164 + this.selectedIndex = (this.selectedIndex - 1 + this.results.length) % this.results.length; 165 + } 166 + } 167 + 168 + getSelectedResult(): SearchResult | null { 169 + return this.results[this.selectedIndex] ?? null; 170 + } 171 + 172 + getResultHref(result: SearchResult): string { 173 + switch (result.type) { 174 + case 'track': 175 + return `/track/${result.id}`; 176 + case 'artist': 177 + return `/u/${result.handle}`; 178 + case 'album': 179 + return `/u/${result.artist_handle}/album/${result.slug}`; 180 + case 'tag': 181 + return `/tag/${result.name}`; 182 + } 183 + } 184 + } 185 + 186 + export const search = new SearchState();
+14 -4
frontend/src/routes/+layout.svelte
··· 8 8 import Player from '$lib/components/Player.svelte'; 9 9 import Toast from '$lib/components/Toast.svelte'; 10 10 import Queue from '$lib/components/Queue.svelte'; 11 + import SearchModal from '$lib/components/SearchModal.svelte'; 11 12 import { onMount, onDestroy } from 'svelte'; 12 13 import { page } from '$app/stores'; 13 14 import { afterNavigate } from '$app/navigation'; 14 15 import { auth } from '$lib/auth.svelte'; 15 16 import { preferences } from '$lib/preferences.svelte'; 16 17 import { player } from '$lib/player.svelte'; 18 + import { search } from '$lib/search.svelte'; 17 19 import { browser } from '$app/environment'; 18 20 import type { LayoutData } from './$types'; 19 21 ··· 78 80 document.documentElement.style.setProperty('--queue-width', queueWidth); 79 81 }); 80 82 81 - function handleQueueShortcut(event: KeyboardEvent) { 82 - // ignore modifier keys 83 + function handleKeyboardShortcuts(event: KeyboardEvent) { 84 + // Cmd/Ctrl+K: toggle search 85 + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { 86 + event.preventDefault(); 87 + search.toggle(); 88 + return; 89 + } 90 + 91 + // ignore other modifier keys for remaining shortcuts 83 92 if (event.metaKey || event.ctrlKey || event.altKey) { 84 93 return; 85 94 } ··· 125 134 } 126 135 127 136 // add keyboard listener for shortcuts 128 - window.addEventListener('keydown', handleQueueShortcut); 137 + window.addEventListener('keydown', handleKeyboardShortcuts); 129 138 130 139 // listen for system theme changes 131 140 const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); ··· 145 154 onDestroy(() => { 146 155 // cleanup keyboard listener 147 156 if (browser) { 148 - window.removeEventListener('keydown', handleQueueShortcut); 157 + window.removeEventListener('keydown', handleKeyboardShortcuts); 149 158 } 150 159 }); 151 160 ··· 257 266 <Player /> 258 267 {/if} 259 268 <Toast /> 269 + <SearchModal /> 260 270 261 271 <style> 262 272 :global(*),