music on atproto
plyr.fm
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>