grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
57
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add typeahead actor search to sidebar search input

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

+219 -4
+219 -4
app/lib/components/organisms/SidebarRight.svelte
··· 1 1 <script lang="ts"> 2 - import { Search, Plus } from 'lucide-svelte' 2 + import { Search, Plus, X } from 'lucide-svelte' 3 3 import { page } from '$app/state' 4 + import { goto } from '$app/navigation' 4 5 import { pinnedFeeds, feedIcon } from '$lib/preferences' 5 6 import { isAuthenticated } from '$lib/stores' 6 7 import { createQuery } from '@tanstack/svelte-query' 7 8 import { camerasQuery, locationsQuery } from '$lib/queries' 9 + import { callXrpc } from '$hatk/client' 10 + import Avatar from '../atoms/Avatar.svelte' 8 11 9 12 const camerasQ = createQuery(() => camerasQuery()) 10 13 const locationsQ = createQuery(() => locationsQuery()) 14 + 15 + let searchValue = $state('') 16 + let suggestions = $state<any[]>([]) 17 + let activeIndex = $state(-1) 18 + let debounceTimer: ReturnType<typeof setTimeout> | null = null 19 + let showSuggestions = $state(false) 20 + let hasSearched = $state(false) 21 + 22 + function onInput() { 23 + const q = searchValue.trim() 24 + if (debounceTimer) clearTimeout(debounceTimer) 25 + if (!q || q.length < 2) { 26 + suggestions = [] 27 + showSuggestions = false 28 + hasSearched = false 29 + return 30 + } 31 + showSuggestions = true 32 + debounceTimer = setTimeout(() => searchActors(q), 200) 33 + } 34 + 35 + async function searchActors(q: string) { 36 + try { 37 + const result = await callXrpc('social.grain.unspecced.searchActorsTypeahead', { q, limit: 8 }) 38 + suggestions = result.actors || [] 39 + activeIndex = -1 40 + hasSearched = true 41 + } catch { 42 + hasSearched = true 43 + } 44 + } 45 + 46 + function submitSearch() { 47 + const q = searchValue.trim() 48 + if (!q) return 49 + suggestions = [] 50 + showSuggestions = false 51 + goto(`/search?q=${encodeURIComponent(q)}`) 52 + } 53 + 54 + function selectActor(actor: any) { 55 + suggestions = [] 56 + showSuggestions = false 57 + searchValue = '' 58 + goto(`/profile/${actor.did}`) 59 + } 60 + 61 + function clearSearch() { 62 + searchValue = '' 63 + suggestions = [] 64 + showSuggestions = false 65 + } 66 + 67 + function onKeydown(e: KeyboardEvent) { 68 + const totalItems = suggestions.length + 1 // +1 for "search for" row 69 + if (!showSuggestions || totalItems <= 1) { 70 + if (e.key === 'Enter') { e.preventDefault(); submitSearch() } 71 + return 72 + } 73 + switch (e.key) { 74 + case 'ArrowDown': 75 + e.preventDefault() 76 + activeIndex = Math.min(activeIndex + 1, totalItems - 1) 77 + break 78 + case 'ArrowUp': 79 + e.preventDefault() 80 + activeIndex = Math.max(activeIndex - 1, -1) 81 + break 82 + case 'Enter': 83 + e.preventDefault() 84 + if (activeIndex === 0) { 85 + submitSearch() 86 + } else if (activeIndex > 0) { 87 + selectActor(suggestions[activeIndex - 1]) 88 + } else { 89 + submitSearch() 90 + } 91 + break 92 + case 'Escape': 93 + e.preventDefault() 94 + showSuggestions = false 95 + break 96 + } 97 + } 98 + 99 + function onFocusout() { 100 + setTimeout(() => { showSuggestions = false }, 150) 101 + } 102 + 103 + function onFocusin() { 104 + if (searchValue.trim().length >= 2 && suggestions.length > 0) { 105 + showSuggestions = true 106 + } 107 + } 11 108 </script> 12 109 13 110 <aside class="sidebar-right"> 14 - <form action="/search" class="search-wrapper"> 111 + <div class="search-wrapper"> 15 112 <span class="search-icon"><Search size={16} /></span> 16 113 <input 17 114 type="text" 18 - name="q" 19 115 class="search-input" 20 116 placeholder="Search..." 117 + bind:value={searchValue} 118 + oninput={onInput} 119 + onkeydown={onKeydown} 120 + onfocusout={onFocusout} 121 + onfocusin={onFocusin} 122 + autocomplete="off" 21 123 /> 22 - </form> 124 + {#if searchValue} 125 + <button class="search-clear" type="button" onclick={clearSearch}><X size={14} /></button> 126 + {/if} 127 + {#if showSuggestions && searchValue.trim() && hasSearched} 128 + <div class="suggestions"> 129 + <button 130 + class="suggestion-item search-row" 131 + class:active={activeIndex === 0} 132 + type="button" 133 + onmousedown={(e) => { e.preventDefault(); submitSearch() }} 134 + > 135 + <span class="suggestion-search-icon"><Search size={22} /></span> 136 + <span>{searchValue.trim()}</span> 137 + </button> 138 + {#each suggestions as actor, i} 139 + <button 140 + class="suggestion-item" 141 + class:active={activeIndex === i + 1} 142 + type="button" 143 + onmousedown={(e) => { e.preventDefault(); selectActor(actor) }} 144 + > 145 + <div class="suggestion-avatar"> 146 + {#if actor.avatar} 147 + <img src={actor.avatar} alt="" /> 148 + {:else} 149 + <Avatar did={actor.did} src={null} size={32} /> 150 + {/if} 151 + </div> 152 + <div class="suggestion-info"> 153 + <div class="suggestion-name">{actor.displayName || actor.handle || actor.did?.slice(0, 18)}</div> 154 + {#if actor.handle} 155 + <div class="suggestion-handle">@{actor.handle}</div> 156 + {/if} 157 + </div> 158 + </button> 159 + {/each} 160 + </div> 161 + {/if} 162 + </div> 23 163 24 164 <div class="sidebar-card"> 25 165 <div class="sidebar-card-header">Feeds</div> ··· 116 256 } 117 257 .search-input::placeholder { color: var(--text-faint); } 118 258 .search-input:focus { border-color: var(--grain); background: var(--bg-root); } 259 + .search-clear { 260 + position: absolute; 261 + right: 10px; 262 + top: 50%; 263 + transform: translateY(-50%); 264 + background: none; 265 + border: none; 266 + color: var(--text-muted); 267 + cursor: pointer; 268 + padding: 4px; 269 + display: flex; 270 + align-items: center; 271 + } 272 + .search-clear:hover { color: var(--text-primary); } 273 + .suggestions { 274 + position: absolute; 275 + top: calc(100% + 4px); 276 + left: 0; 277 + right: 0; 278 + background: var(--bg-surface); 279 + border: 1px solid var(--border); 280 + border-radius: 12px; 281 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); 282 + overflow: hidden; 283 + z-index: 100; 284 + } 285 + .suggestion-item { 286 + display: flex; 287 + align-items: center; 288 + gap: 10px; 289 + padding: 8px 12px; 290 + width: 100%; 291 + border: none; 292 + background: none; 293 + cursor: pointer; 294 + transition: background 0.1s; 295 + text-align: left; 296 + color: var(--text-primary); 297 + font-family: var(--font-body); 298 + font-size: 14px; 299 + } 300 + .suggestion-item:hover, .suggestion-item.active { background: var(--bg-hover); } 301 + .search-row { font-weight: 500; } 302 + .suggestion-search-icon { 303 + width: 32px; 304 + height: 32px; 305 + display: flex; 306 + align-items: center; 307 + justify-content: center; 308 + color: var(--text-muted); 309 + } 310 + .suggestion-avatar { 311 + width: 32px; 312 + height: 32px; 313 + border-radius: 50%; 314 + overflow: hidden; 315 + flex-shrink: 0; 316 + background: var(--bg-elevated); 317 + } 318 + .suggestion-avatar img { width: 100%; height: 100%; object-fit: cover; } 319 + .suggestion-info { min-width: 0; flex: 1; } 320 + .suggestion-name { 321 + font-size: 14px; 322 + font-weight: 500; 323 + overflow: hidden; 324 + text-overflow: ellipsis; 325 + white-space: nowrap; 326 + } 327 + .suggestion-handle { 328 + font-size: 12px; 329 + color: var(--text-muted); 330 + overflow: hidden; 331 + text-overflow: ellipsis; 332 + white-space: nowrap; 333 + } 119 334 120 335 .sidebar-card { 121 336 background: var(--bg-surface);