top poasts
highlights.waow.tech
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>