Something went wrong. Try again.
A Takuzu game native to the AT Protocol. atkuzu.tomplanche.com
binairo atproto game takuzu
Something went wrong. Try again.
9.4 kB · 369 lines
Svelte
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370<script lang="ts"> // Handle typeahead against the public Bluesky appview. // Debounces the search, shows a rich dropdown (avatar + displayName + handle), // remembers recently picked handles in localStorage, and supports keyboard navigation.
type ActorSuggestion = { did: string; handle: string; displayName?: string; avatar?: string }; type TypeaheadResponse = { actors?: ActorSuggestion[] };
const SEARCH_HOST = "https://public.api.bsky.app"; const SEARCH_PATH = "/xrpc/app.bsky.actor.searchActorsTypeahead"; const MIN_SEARCH = 2; const DEBOUNCE_MS = 200; const SEARCH_TIMEOUT_MS = 4000; const RESULT_LIMIT = 8; const RECENT_KEY = "atkuzu_recent_handles"; const RECENT_MAX = 5;
let { value = $bindable(""), name = "handle", placeholder = "Handle or Display name", required = true, id = "handle-input", showDisplayName = false }: { value?: string; name?: string; placeholder?: string; required?: boolean; id?: string; showDisplayName?: boolean; } = $props();
const loadRecent = (): ActorSuggestion[] => { try { const raw = localStorage.getItem(RECENT_KEY); return raw ? JSON.parse(raw) : []; } catch { return []; } };
let suggestions = $state<ActorSuggestion[]>([]); let recent = $state<ActorSuggestion[]>(loadRecent()); let open = $state(false); let loading = $state(false); let fetchError = $state<string | null>(null); let focusIndex = $state(-1);
let container: HTMLDivElement; let debounceTimer: ReturnType<typeof setTimeout> | null = null; let currentAbort: AbortController | null = null;
const showingRecent = $derived(value.trim().length === 0); const items = $derived(showingRecent ? recent : suggestions);
const saveRecent = (actor: ActorSuggestion) => { try { const next = [actor, ...recent.filter((a) => a.handle !== actor.handle)].slice(0, RECENT_MAX); localStorage.setItem(RECENT_KEY, JSON.stringify(next)); recent = next; } catch { // localStorage unavailable (private mode, quota), not fatal } };
const search = async (q: string) => { if (currentAbort) { currentAbort.abort(); } currentAbort = new AbortController(); const controller = currentAbort;
loading = true; fetchError = null;
const timer = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS); try { const url = new URL(SEARCH_PATH, SEARCH_HOST); url.searchParams.set("q", q); url.searchParams.set("limit", String(RESULT_LIMIT));
const res = await fetch(url, { signal: controller.signal }); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } const data: TypeaheadResponse = await res.json();
if (controller.signal.aborted) { return; } suggestions = data.actors ?? []; focusIndex = -1; open = true; } catch (e) { if (e instanceof DOMException && e.name === "AbortError") { return; } fetchError = e instanceof Error ? e.message : "Failed to load suggestions"; suggestions = []; } finally { clearTimeout(timer); if (controller === currentAbort) { loading = false; } } };
const onInput = (ev: Event) => { value = (ev.target as HTMLInputElement).value; fetchError = null;
if (debounceTimer) { clearTimeout(debounceTimer); } if (currentAbort) { currentAbort.abort(); currentAbort = null; }
const q = value.trim(); if (q.length === 0) { suggestions = []; open = recent.length > 0; return; }
if (q.length < MIN_SEARCH) { open = false; return; }
debounceTimer = setTimeout(() => search(q), DEBOUNCE_MS); };
const onFocus = () => { if (showingRecent && recent.length > 0) { open = true; } };
const select = (actor: ActorSuggestion) => { value = actor.handle; open = false; focusIndex = -1; saveRecent(actor); };
const onKeydown = (ev: KeyboardEvent) => { if (!open || items.length === 0) { return; }
if (ev.key === "ArrowDown") { ev.preventDefault(); focusIndex = (focusIndex + 1) % items.length; } else if (ev.key === "ArrowUp") { ev.preventDefault(); focusIndex = focusIndex <= 0 ? items.length - 1 : focusIndex - 1; } else if (ev.key === "Enter") { if (focusIndex >= 0 && items[focusIndex]) { ev.preventDefault(); select(items[focusIndex]); } } else if (ev.key === "Escape") { open = false; focusIndex = -1; } };
const onWindowClick = (ev: MouseEvent) => { if (container && !container.contains(ev.target as Node)) { open = false; focusIndex = -1; } };
// Positioned in the viewport (not relative to the input) so it never gets folded into a // scrollable ancestor's overflow region: inside the login modal, a `position: absolute` // dropdown was measured as part of the dialog's own `overflow-y: auto` content instead of // floating over it, so "Recent" only showed up if you scrolled the (visually static) modal. let dropdownPos = $state<{ top: number; left: number; width: number } | null>(null);
const updateDropdownPos = () => { if (!container) { return; }
const rect = container.getBoundingClientRect(); dropdownPos = { top: rect.bottom, left: rect.left, width: rect.width }; };
$effect(() => { if (!open) { return; }
updateDropdownPos();
window.addEventListener("resize", updateDropdownPos); window.addEventListener("scroll", updateDropdownPos, true);
return () => { window.removeEventListener("resize", updateDropdownPos); window.removeEventListener("scroll", updateDropdownPos, true); }; });</script>
<svelte:window onclick={onWindowClick} />
<div class="handle-input" bind:this={container}> <input type="text" {name} {id} {placeholder} {required} {value} oninput={onInput} onfocus={onFocus} onkeydown={onKeydown} autocomplete="off" spellcheck="false" role="combobox" aria-expanded={open} aria-controls={`${id}-listbox`} aria-autocomplete="list" />
{#if open && items.length > 0 && dropdownPos} <ul class="dropdown" id={`${id}-listbox`} role="listbox" style:top="{dropdownPos.top}px" style:left="{dropdownPos.left}px" style:width="{dropdownPos.width}px" > {#if showingRecent} <li class="dropdown-header">Recent</li> {/if} {#each items as item, i (item.did ?? item.handle)} <li class="item" class:focused={i === focusIndex} role="option" aria-selected={i === focusIndex} onmousedown={(e) => { e.preventDefault(); select(item); }} > <span class="avatar"> {#if item.avatar} <img src={item.avatar} alt="" loading="lazy" /> {/if} </span> <span class="text"> {#if showDisplayName && item.displayName && item.displayName !== item.handle} <span class="name">{item.displayName}</span> {/if} <span class="at-handle">@{item.handle}</span> </span> </li> {/each} </ul> {/if}
{#if loading} <p class="status">Searching…</p> {/if} {#if fetchError} <p class="status error">{fetchError}</p> {/if}</div>
<style lang="scss"> .handle-input { position: relative; display: flex; flex-direction: column; width: 100%; }
// Fixed, not absolute: its top/left/width come from the input's own getBoundingClientRect // (see updateDropdownPos), so it floats over the page instead of expanding a scrollable // ancestor's content box (that was the bug in the login modal: Modal.svelte's dialog has // overflow-y: auto, which folded this in as hidden, scroll-to-reveal content). .dropdown { position: fixed; z-index: 1000; margin: 2px 0 0; padding: 4px; list-style: none; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); max-height: 260px; overflow-y: auto;
&-header { padding: 4px 8px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; } }
.item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: var(--radius-sm); cursor: pointer;
&.focused, &:hover { background: color-mix(in oklab, var(--surface) 70%, var(--fg)); } }
.avatar { width: 28px; height: 28px; border-radius: 50%; overflow: hidden; flex-shrink: 0; background: var(--border);
img { width: 100%; height: 100%; object-fit: cover; } }
.text { display: flex; flex-direction: column; line-height: 1.2; overflow: hidden; }
.name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.at-handle { color: var(--muted); font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.status { margin: 2px 0 0; font-size: 0.85rem; }
.error { color: var(--danger); }</style>