at main 5.8 kB view raw
1<script lang="ts"> 2 import { API_URL } from '$lib/config'; 3 import SensitiveImage from './SensitiveImage.svelte'; 4 5 interface HandleResult { 6 did: string; 7 handle: string; 8 display_name: string; 9 avatar_url: string | null; 10 } 11 12 interface Props { 13 value: string; 14 onSelect: (_handle: string) => void; 15 placeholder?: string; 16 disabled?: boolean; 17 } 18 19 let { value = $bindable(''), onSelect, placeholder = 'search by handle...', disabled = false }: Props = $props(); 20 21 let results = $state<HandleResult[]>([]); 22 let searching = $state(false); 23 let showResults = $state(false); 24 let searchTimeout: ReturnType<typeof setTimeout> | null = null; 25 26 async function searchHandles() { 27 if (value.length < 2) { 28 results = []; 29 return; 30 } 31 32 searching = true; 33 try { 34 const response = await fetch(`${API_URL}/search/handles?q=${encodeURIComponent(value)}`); 35 if (response.ok) { 36 const data = await response.json(); 37 results = data.results; 38 showResults = results.length > 0; 39 } 40 } catch (e) { 41 console.error('search failed:', e); 42 } finally { 43 searching = false; 44 } 45 } 46 47 function handleInput() { 48 if (searchTimeout) clearTimeout(searchTimeout); 49 searchTimeout = setTimeout(searchHandles, 300); 50 } 51 52 function selectHandle(event: MouseEvent, result: HandleResult) { 53 // stop propagation to prevent click-outside handlers from firing 54 // after we remove the result elements from the DOM 55 event.stopPropagation(); 56 value = result.handle; 57 onSelect(result.handle); 58 results = []; 59 showResults = false; 60 } 61 62 function handleClickOutside(e: MouseEvent) { 63 const target = e.target as HTMLElement; 64 if (!target.closest('.handle-autocomplete')) { 65 showResults = false; 66 } 67 } 68 69 function handleKeydown(e: KeyboardEvent) { 70 if (e.key === 'Escape') { 71 showResults = false; 72 } 73 } 74</script> 75 76<svelte:window onclick={handleClickOutside} /> 77 78<div class="handle-autocomplete"> 79 <div class="input-wrapper"> 80 <input 81 type="text" 82 bind:value 83 oninput={handleInput} 84 onkeydown={handleKeydown} 85 onfocus={() => { if (results.length > 0) showResults = true; }} 86 {placeholder} 87 {disabled} 88 autocomplete="off" 89 autocapitalize="off" 90 spellcheck="false" 91 /> 92 {#if searching} 93 <span class="spinner">...</span> 94 {/if} 95 </div> 96 97 {#if showResults && results.length > 0} 98 <div class="results"> 99 {#each results as result} 100 <button 101 type="button" 102 class="result-item" 103 onclick={(e) => selectHandle(e, result)} 104 > 105 {#if result.avatar_url} 106 <SensitiveImage src={result.avatar_url} compact> 107 <img src={result.avatar_url} alt="" class="avatar" /> 108 </SensitiveImage> 109 {:else} 110 <div class="avatar-placeholder"></div> 111 {/if} 112 <div class="info"> 113 <div class="display-name">{result.display_name}</div> 114 <div class="handle">@{result.handle}</div> 115 </div> 116 </button> 117 {/each} 118 </div> 119 {/if} 120</div> 121 122<style> 123 .handle-autocomplete { 124 position: relative; 125 width: 100%; 126 } 127 128 .input-wrapper { 129 position: relative; 130 } 131 132 .input-wrapper input { 133 width: 100%; 134 padding: 0.75rem; 135 background: var(--bg-primary); 136 border: 1px solid var(--border-default); 137 border-radius: var(--radius-sm); 138 color: var(--text-primary); 139 font-size: var(--text-lg); 140 font-family: inherit; 141 transition: border-color 0.2s; 142 box-sizing: border-box; 143 } 144 145 .input-wrapper input:focus { 146 outline: none; 147 border-color: var(--accent); 148 } 149 150 .input-wrapper input:disabled { 151 opacity: 0.5; 152 cursor: not-allowed; 153 } 154 155 .input-wrapper input::placeholder { 156 color: var(--text-muted); 157 } 158 159 .spinner { 160 position: absolute; 161 right: 0.75rem; 162 top: 50%; 163 transform: translateY(-50%); 164 color: var(--text-muted); 165 font-size: var(--text-sm); 166 } 167 168 .results { 169 position: absolute; 170 z-index: 100; 171 width: 100%; 172 max-height: 240px; 173 overflow-y: auto; 174 background: var(--bg-tertiary); 175 border: 1px solid var(--border-default); 176 border-radius: var(--radius-sm); 177 margin-top: 0.25rem; 178 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 179 scrollbar-width: thin; 180 scrollbar-color: var(--border-default) var(--bg-primary); 181 } 182 183 .results::-webkit-scrollbar { 184 width: 8px; 185 } 186 187 .results::-webkit-scrollbar-track { 188 background: var(--bg-primary); 189 border-radius: var(--radius-sm); 190 } 191 192 .results::-webkit-scrollbar-thumb { 193 background: var(--border-default); 194 border-radius: var(--radius-sm); 195 } 196 197 .results::-webkit-scrollbar-thumb:hover { 198 background: var(--border-emphasis); 199 } 200 201 .result-item { 202 width: 100%; 203 display: flex; 204 align-items: center; 205 gap: 0.75rem; 206 padding: 0.75rem; 207 background: transparent; 208 border: none; 209 border-bottom: 1px solid var(--border-subtle); 210 color: var(--text-primary); 211 text-align: left; 212 font-family: inherit; 213 cursor: pointer; 214 transition: background 0.15s; 215 } 216 217 .result-item:last-child { 218 border-bottom: none; 219 } 220 221 .result-item:hover { 222 background: var(--bg-hover); 223 } 224 225 .avatar { 226 width: 36px; 227 height: 36px; 228 border-radius: var(--radius-full); 229 object-fit: cover; 230 border: 2px solid var(--border-default); 231 flex-shrink: 0; 232 } 233 234 .avatar-placeholder { 235 width: 36px; 236 height: 36px; 237 border-radius: var(--radius-full); 238 background: var(--border-default); 239 flex-shrink: 0; 240 } 241 242 .info { 243 flex: 1; 244 min-width: 0; 245 overflow: hidden; 246 } 247 248 .display-name { 249 font-weight: 500; 250 color: var(--text-primary); 251 margin-bottom: 0.125rem; 252 overflow: hidden; 253 text-overflow: ellipsis; 254 white-space: nowrap; 255 } 256 257 .handle { 258 font-size: var(--text-sm); 259 color: var(--text-tertiary); 260 overflow: hidden; 261 text-overflow: ellipsis; 262 white-space: nowrap; 263 } 264 265 @media (max-width: 768px) { 266 .input-wrapper input { 267 font-size: 16px; /* prevents zoom on iOS */ 268 } 269 270 .results { 271 max-height: 200px; 272 } 273 274 .avatar { 275 width: 32px; 276 height: 32px; 277 } 278 279 .avatar-placeholder { 280 width: 32px; 281 height: 32px; 282 } 283 } 284</style>