A build your own ATProto adventure, OAuth already figured out for you. demo.atpoke.xyz
at main 116 lines 2.8 kB view raw
1<script lang="ts"> 2 type ActorSuggestion = { handle: string; displayName?: string }; 3 type TypeaheadResponse = { actors?: ActorSuggestion[] }; 4 5 let { 6 value = $bindable(''), 7 name = 'handle', 8 placeholder = 'Handle or Display name', 9 required = true, 10 id = 'handle-input', 11 showDisplayName = false 12 }: { 13 value?: string; 14 name?: string; 15 placeholder?: string; 16 required?: boolean; 17 id?: string; 18 showDisplayName?: boolean; 19 } = $props(); 20 21 let suggestions = $state<ActorSuggestion[]>([]); 22 let loading = $state(false); 23 let fetchError = $state<string | null>(null); 24 25 let debounceTimer: ReturnType<typeof setTimeout> | null = null; 26 let currentAbort: AbortController | null = null; 27 28 async function fetchSuggestions(q: string) { 29 if (currentAbort) currentAbort.abort(); 30 currentAbort = new AbortController(); 31 loading = true; 32 fetchError = null; 33 34 if(suggestions.length > 0) { 35 const isThereAnExactMatch = suggestions.find(s => s.handle === q); 36 if (isThereAnExactMatch) { 37 loading = false; 38 return; 39 } 40 } 41 42 try { 43 const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(q)}&limit=5`; 44 const res = await fetch(url, { signal: currentAbort.signal }); 45 if (!res.ok) throw new Error(`HTTP ${res.status}`); 46 const data: TypeaheadResponse = await res.json(); 47 suggestions = data.actors ?? []; 48 if(suggestions.length === 1){ 49 value = suggestions[0].handle; 50 } 51 } catch (e) { 52 if (e instanceof DOMException && e.name === 'AbortError') return; 53 fetchError = e instanceof Error ? e.message : 'Failed to load suggestions'; 54 suggestions = []; 55 } finally { 56 loading = false; 57 } 58 } 59 60 function onHandleInput(ev: Event) { 61 const target = ev.target as HTMLInputElement; 62 value = target.value; 63 64 if (debounceTimer) clearTimeout(debounceTimer); 65 66 if (value.trim().length >= 3) { 67 debounceTimer = setTimeout(() => { 68 fetchSuggestions(value.trim()); 69 }, 300); 70 } else { 71 suggestions = []; 72 fetchError = null; 73 if (currentAbort) { 74 currentAbort.abort(); 75 currentAbort = null; 76 } 77 } 78 } 79 80 const datalistId = `${() => id}-suggestions`; 81</script> 82<style> 83 .handle-input { 84 display: flex; 85 flex-direction: column; 86 /*gap: 0.75rem;*/ 87 } 88</style> 89<div class="handle-input"> 90 <input 91 type="text" 92 {name} 93 {id} 94 {placeholder} 95 {required} 96 bind:value 97 oninput={onHandleInput} 98 list={datalistId} 99 autocomplete="off" 100 spellcheck="false" 101 /> 102 <datalist id={datalistId}> 103 {#each suggestions as s, index (s.handle + index)} 104 <option value={s.handle}> 105 {#if s.displayName && showDisplayName}{`(${s.displayName})`}{/if} {s.handle} 106 </option> 107 {/each} 108 </datalist> 109 110 {#if loading} 111 <p>Searching…</p> 112 {/if} 113 {#if fetchError} 114 <p>{fetchError}</p> 115 {/if} 116</div>