0
fork

Configure Feed

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

at main 250 lines 5.3 kB view raw
1<script lang="ts"> 2 interface Props { 3 value: string; 4 onsubmit: (handle: string) => void; 5 disabled?: boolean; 6 } 7 let { value = $bindable(), onsubmit, disabled = false }: Props = $props(); 8 9 let results = $state<Array<{ did: string; handle: string; displayName?: string; avatar?: string }>>([]); 10 let showResults = $state(false); 11 let searching = $state(false); 12 let debounceTimer: ReturnType<typeof setTimeout> | null = $state(null); 13 let containerEl: HTMLDivElement | undefined = $state(); 14 15 function handleInput() { 16 if (debounceTimer) clearTimeout(debounceTimer); 17 18 const query = value.trim(); 19 if (query.length < 2) { 20 results = []; 21 showResults = false; 22 return; 23 } 24 25 searching = true; 26 debounceTimer = setTimeout(async () => { 27 try { 28 const res = await fetch( 29 `https://typeahead.waow.tech/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=8`, 30 { headers: { 'X-Client': 'bsky-highlight-reel' } } 31 ); 32 if (res.ok) { 33 const data = await res.json(); 34 results = data.actors ?? []; 35 showResults = results.length > 0; 36 } 37 } catch { 38 results = []; 39 showResults = false; 40 } finally { 41 searching = false; 42 } 43 }, 250); 44 } 45 46 function selectActor(actor: { did: string; handle: string; displayName?: string; avatar?: string }) { 47 value = actor.handle; 48 showResults = false; 49 onsubmit(actor.handle); 50 } 51 52 function handleClickOutside(e: MouseEvent) { 53 if (containerEl && !containerEl.contains(e.target as Node)) { 54 showResults = false; 55 } 56 } 57</script> 58 59<svelte:window onclick={handleClickOutside} /> 60 61<div class="handle-input" role="combobox" aria-controls="handle-results" aria-expanded={showResults && results.length > 0} bind:this={containerEl}> 62 <form onsubmit={(e) => { e.preventDefault(); showResults = false; onsubmit(value); }}> 63 <div class="input-row"> 64 <span class="at">@</span> 65 <input 66 type="text" 67 bind:value 68 oninput={handleInput} 69 onfocus={() => { if (results.length > 0) showResults = true; }} 70 onkeydown={(e) => { if (e.key === 'Escape') showResults = false; }} 71 placeholder="who are you?" 72 autocomplete="off" 73 {disabled} 74 /> 75 <button type="submit" disabled={disabled || !value.trim()}> 76 {#if disabled} 77 <span class="spinner"></span> 78 {:else} 79 80 {/if} 81 </button> 82 </div> 83 </form> 84 85 {#if showResults && results.length > 0} 86 <ul class="results" id="handle-results"> 87 {#each results as actor (actor.did)} 88 <li> 89 <button onclick={(e) => { e.stopPropagation(); selectActor(actor); }}> 90 {#if actor.avatar} 91 <img src={actor.avatar} alt="" class="avatar" /> 92 {:else} 93 <div class="avatar placeholder"></div> 94 {/if} 95 <div class="info"> 96 {#if actor.displayName} 97 <span class="name">{actor.displayName}</span> 98 {/if} 99 <span class="handle">@{actor.handle}</span> 100 </div> 101 </button> 102 </li> 103 {/each} 104 </ul> 105 {/if} 106</div> 107 108<style> 109 .handle-input { 110 position: relative; 111 width: 100%; 112 max-width: 400px; 113 } 114 115 .input-row { 116 display: flex; 117 align-items: center; 118 background: transparent; 119 border-bottom: 2px solid #333; 120 transition: border-color 0.2s; 121 } 122 .input-row:focus-within { 123 border-color: #f90; 124 } 125 126 .at { 127 color: #555; 128 font-size: 1.1rem; 129 font-weight: 300; 130 padding-right: 0.25rem; 131 user-select: none; 132 } 133 134 input { 135 flex: 1; 136 background: transparent; 137 border: none; 138 outline: none; 139 color: #e0e0e0; 140 font-size: 1.1rem; 141 font-weight: 300; 142 padding: 0.6rem 0; 143 font-family: inherit; 144 } 145 input::placeholder { 146 color: #444; 147 font-style: italic; 148 } 149 150 .input-row button { 151 background: none; 152 border: none; 153 color: #555; 154 font-size: 1.2rem; 155 cursor: pointer; 156 padding: 0.4rem; 157 transition: color 0.2s; 158 } 159 .input-row button:hover:not(:disabled) { 160 color: #f90; 161 } 162 .input-row button:disabled { 163 cursor: default; 164 } 165 166 .spinner { 167 display: inline-block; 168 width: 14px; 169 height: 14px; 170 border: 2px solid #333; 171 border-top-color: #f90; 172 border-radius: 50%; 173 animation: spin 0.6s linear infinite; 174 } 175 @keyframes spin { 176 to { transform: rotate(360deg); } 177 } 178 179 .results { 180 position: absolute; 181 top: 100%; 182 left: 0; 183 right: 0; 184 margin-top: 0.5rem; 185 background: #111; 186 border: 1px solid #222; 187 border-radius: 8px; 188 list-style: none; 189 padding: 0; 190 max-height: 280px; 191 overflow-y: auto; 192 z-index: 100; 193 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); 194 } 195 196 .results li button { 197 display: flex; 198 align-items: center; 199 gap: 0.75rem; 200 width: 100%; 201 padding: 0.6rem 0.75rem; 202 background: none; 203 border: none; 204 color: inherit; 205 cursor: pointer; 206 text-align: left; 207 font-family: inherit; 208 } 209 .results li button:hover { 210 background: #1a1a1a; 211 } 212 .results li:first-child button { 213 border-radius: 8px 8px 0 0; 214 } 215 .results li:last-child button { 216 border-radius: 0 0 8px 8px; 217 } 218 219 .avatar { 220 width: 32px; 221 height: 32px; 222 border-radius: 50%; 223 object-fit: cover; 224 flex-shrink: 0; 225 } 226 .avatar.placeholder { 227 background: #222; 228 } 229 230 .info { 231 display: flex; 232 flex-direction: column; 233 gap: 0.1rem; 234 min-width: 0; 235 } 236 .name { 237 font-size: 0.85rem; 238 color: #ccc; 239 white-space: nowrap; 240 overflow: hidden; 241 text-overflow: ellipsis; 242 } 243 .handle { 244 font-size: 0.75rem; 245 color: #666; 246 white-space: nowrap; 247 overflow: hidden; 248 text-overflow: ellipsis; 249 } 250</style>