decentralized and customizable links page on top of atproto ligo.at
atproto link-in-bio python uv
9
fork

Configure Feed

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

add actor-typeahead script

+352 -1
+340
src/static/actor-typeahead.js
··· 1 + /* This Source Code Form is subject to the terms of the Mozilla Public 2 + * License, v. 2.0. If a copy of the MPL was not distributed with this 3 + * file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 + * 5 + * Copyright (c) 2026 Jake Lazaroff 6 + * 7 + * Repository: https://tangled.org/jakelazaroff.com/actor-typeahead 8 + */ 9 + 10 + const template = document.createElement("template"); 11 + template.innerHTML = ` 12 + <slot></slot> 13 + 14 + <ul class="menu" part="menu"></ul> 15 + 16 + <style> 17 + :host { 18 + --color-background-inherited: var(--color-background, #ffffff); 19 + --color-border-inherited: var(--color-border, #00000022); 20 + --color-shadow-inherited: var(--color-shadow, #000000); 21 + --color-hover-inherited: var(--color-hover, #00000011); 22 + --color-avatar-fallback-inherited: var(--color-avatar-fallback, #00000022); 23 + --radius-inherited: var(--radius, 8px); 24 + --padding-menu-inherited: var(--padding-menu, 4px); 25 + display: block; 26 + position: relative; 27 + font-family: system-ui; 28 + } 29 + 30 + *, *::before, *::after { 31 + margin: 0; 32 + padding: 0; 33 + box-sizing: border-box; 34 + } 35 + 36 + .menu { 37 + display: flex; 38 + flex-direction: column; 39 + position: absolute; 40 + left: 0; 41 + margin-top: 4px; 42 + width: 100%; 43 + list-style: none; 44 + overflow: hidden; 45 + background-color: var(--color-background-inherited); 46 + background-clip: padding-box; 47 + border: 1px solid var(--color-border-inherited); 48 + border-radius: var(--radius-inherited); 49 + box-shadow: 0 6px 6px -4px rgb(from var(--color-shadow-inherited) r g b / 20%); 50 + padding: var(--padding-menu-inherited); 51 + } 52 + 53 + .menu:empty { 54 + display: none; 55 + } 56 + 57 + .user { 58 + all: unset; 59 + box-sizing: border-box; 60 + display: flex; 61 + align-items: center; 62 + gap: 8px; 63 + padding: 6px 8px; 64 + width: 100%; 65 + height: calc(1.5rem + 6px * 2); 66 + border-radius: calc(var(--radius-inherited) - var(--padding-menu-inherited)); 67 + cursor: default; 68 + } 69 + 70 + .user:hover, 71 + .user[data-active="true"] { 72 + background-color: var(--color-hover-inherited); 73 + } 74 + 75 + .avatar { 76 + width: 1.5rem; 77 + height: 1.5rem; 78 + border-radius: 50%; 79 + background-color: var(--color-avatar-fallback-inherited); 80 + overflow: hidden; 81 + flex-shrink: 0; 82 + } 83 + 84 + .img { 85 + display: block; 86 + width: 100%; 87 + height: 100%; 88 + } 89 + 90 + .handle { 91 + white-space: nowrap; 92 + overflow: hidden; 93 + text-overflow: ellipsis; 94 + } 95 + </style> 96 + `; 97 + 98 + const user = document.createElement("template"); 99 + user.innerHTML = ` 100 + <li> 101 + <button class="user" part="user"> 102 + <div class="avatar" part="avatar"> 103 + <img class="img" part="img"> 104 + </div> 105 + <span class="handle" part="handle"></span> 106 + </button> 107 + </li> 108 + `; 109 + 110 + /** 111 + * @template {HTMLElement} T 112 + * @param {T} tmpl 113 + */ 114 + function clone(tmpl) { 115 + return /** @type {T} */ (tmpl.cloneNode(true)); 116 + } 117 + 118 + /** 119 + * @attribute {string} [host] - The host to which to make the typeahead API call. 120 + * @attribute {number} [rows] - The maximum number of rows to display in the dropdown. 121 + * 122 + * @csspart menu - The dropdown menu. 123 + * @csspart user - The user row. 124 + * @csspart avatar - The user avatar wrapper. 125 + * @csspart img - The user avatar image. 126 + * @csspart handle - The user handle text. 127 + * 128 + * @slot - The <input> tag to progressively enhance. 129 + * 130 + * @cssprop --color-background - Controls the color of the dropdown background. 131 + * @cssprop --color-border - Controls the color of the dropdown border. 132 + * @cssprop --color-shadow - Controls the color of the dropdown shadow. 133 + * @cssprop --color-hover - Controls the background color of each row on hover. 134 + * @cssprop --color-avatar-fallback - Controls the background color of an avatar circle if the image fails to load. 135 + * @cssprop --radius - Controls the corner radius of the dropdown. 136 + * @cssprop --padding-menu - Controls the padding of the dropdown menu. 137 + * 138 + * @summary A small web component that progressively enhances an <input> element into an autocomplete for ATProto handles! 139 + * 140 + * @tag actor-typeahead 141 + */ 142 + export default class ActorTypeahead extends HTMLElement { 143 + static tag = "actor-typeahead"; 144 + 145 + static define(tag = this.tag) { 146 + this.tag = tag; 147 + 148 + const name = customElements.getName(this); 149 + if (name && name !== tag) 150 + return console.warn(`${this.name} already defined as <${name}>!`); 151 + 152 + const ce = customElements.get(tag); 153 + if (ce && ce !== this) 154 + return console.warn(`<${tag}> already defined as ${ce.name}!`); 155 + 156 + customElements.define(tag, this); 157 + } 158 + 159 + static { 160 + const tag = new URL(import.meta.url).searchParams.get("tag") || this.tag; 161 + if (tag !== "none") this.define(tag); 162 + } 163 + 164 + #shadow = this.attachShadow({ mode: "closed" }); 165 + 166 + /** @type {Array<{ handle: string; avatar: string }>} */ 167 + #actors = []; 168 + #index = -1; 169 + #pressed = false; 170 + 171 + constructor() { 172 + super(); 173 + 174 + this.#shadow.append(clone(template).content); 175 + this.#render(); 176 + this.addEventListener("input", this); 177 + this.addEventListener("focusout", this); 178 + this.addEventListener("keydown", this); 179 + this.#shadow.addEventListener("pointerdown", this); 180 + this.#shadow.addEventListener("pointerup", this); 181 + this.#shadow.addEventListener("click", this); 182 + } 183 + 184 + get #rows() { 185 + const rows = Number.parseInt(this.getAttribute("rows") ?? ""); 186 + 187 + if (Number.isNaN(rows)) return 5; 188 + return rows; 189 + } 190 + 191 + /** @param {Event} evt */ 192 + handleEvent(evt) { 193 + switch (evt.type) { 194 + case "input": 195 + this.#oninput( 196 + /** @type {InputEvent & { target: HTMLInputElement }} */ (evt), 197 + ); 198 + break; 199 + 200 + case "keydown": 201 + this.#onkeydown(/** @type {KeyboardEvent} */ (evt)); 202 + break; 203 + 204 + case "focusout": 205 + this.#onfocusout(evt); 206 + break; 207 + 208 + case "pointerdown": 209 + this.#onpointerdown( 210 + /** @type {PointerEvent & { target: HTMLElement }} */ (evt), 211 + ); 212 + break; 213 + 214 + case "pointerup": 215 + this.#onpointerup( 216 + /** @type {PointerEvent & { target: HTMLElement }} */ (evt), 217 + ); 218 + break; 219 + } 220 + } 221 + 222 + /** @param {KeyboardEvent} evt */ 223 + #onkeydown(evt) { 224 + switch (evt.key) { 225 + case "ArrowDown": 226 + evt.preventDefault(); 227 + this.#index = Math.min(this.#index + 1, this.#rows - 1); 228 + this.#render(); 229 + break; 230 + 231 + case "PageDown": 232 + evt.preventDefault(); 233 + this.#index = this.#rows - 1; 234 + this.#render(); 235 + break; 236 + 237 + case "ArrowUp": 238 + evt.preventDefault(); 239 + this.#index = Math.max(this.#index - 1, 0); 240 + this.#render(); 241 + break; 242 + 243 + case "PageUp": 244 + evt.preventDefault(); 245 + this.#index = 0; 246 + this.#render(); 247 + break; 248 + 249 + case "Escape": 250 + evt.preventDefault(); 251 + this.#actors = []; 252 + this.#index = -1; 253 + this.#render(); 254 + break; 255 + 256 + case "Enter": 257 + evt.preventDefault(); 258 + this.#shadow 259 + .querySelectorAll("button") 260 + [ 261 + this.#index 262 + ]?.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })); 263 + break; 264 + } 265 + } 266 + 267 + /** @param {InputEvent & { target: HTMLInputElement }} evt */ 268 + async #oninput(evt) { 269 + const query = evt.target?.value; 270 + if (!query) { 271 + this.#actors = []; 272 + this.#render(); 273 + return; 274 + } 275 + 276 + const host = this.getAttribute("host") ?? "https://public.api.bsky.app"; 277 + const url = new URL("xrpc/app.bsky.actor.searchActorsTypeahead", host); 278 + url.searchParams.set("q", query); 279 + url.searchParams.set("limit", `${this.#rows}`); 280 + 281 + const res = await fetch(url); 282 + const json = await res.json(); 283 + this.#actors = json.actors; 284 + this.#index = -1; 285 + this.#render(); 286 + } 287 + 288 + /** @param {Event} evt */ 289 + async #onfocusout(evt) { 290 + if (this.#pressed) return; 291 + 292 + this.#actors = []; 293 + this.#index = -1; 294 + this.#render(); 295 + } 296 + 297 + #render() { 298 + const fragment = document.createDocumentFragment(); 299 + let i = -1; 300 + for (const actor of this.#actors) { 301 + const li = clone(user).content; 302 + 303 + const button = li.querySelector("button"); 304 + if (button) { 305 + button.dataset.handle = actor.handle; 306 + if (++i === this.#index) button.dataset.active = "true"; 307 + } 308 + 309 + const avatar = li.querySelector("img"); 310 + if (avatar && actor.avatar) avatar.src = actor.avatar; 311 + 312 + const handle = li.querySelector(".handle"); 313 + if (handle) handle.textContent = actor.handle; 314 + 315 + fragment.append(li); 316 + } 317 + 318 + this.#shadow.querySelector(".menu")?.replaceChildren(...fragment.children); 319 + } 320 + 321 + /** @param {PointerEvent} evt */ 322 + #onpointerdown(evt) { 323 + this.#pressed = true; 324 + } 325 + 326 + /** @param {PointerEvent & { target: HTMLElement }} evt */ 327 + #onpointerup(evt) { 328 + this.#pressed = false; 329 + 330 + this.querySelector("input")?.focus(); 331 + 332 + const button = evt.target?.closest("button"); 333 + const input = this.querySelector("input"); 334 + if (!input || !button) return; 335 + 336 + input.value = button.dataset.handle || ""; 337 + this.#actors = []; 338 + this.#render(); 339 + } 340 + }
+12 -1
src/templates/login.html
··· 10 10 <link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='favicon-16.png') }}" /> 11 11 <link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='favicon-32.png') }}" /> 12 12 <link rel="icon" type="image/png" sizes="48x48" href="{{ url_for('static', filename='favicon-48.png') }}" /> 13 + <script async type="module" src="{{ url_for('static', filename='actor-typeahead.js') }}"></script> 13 14 </head> 14 15 <body> 15 16 <div class="wrapper login"> ··· 20 21 <form action="{{ url_for('auth_login') }}" method="post"> 21 22 <label> 22 23 <span>Handle</span> 23 - <input type="text" name="username" placeholder="username.example.com" autocapitalize="off" spellcheck="false" required /> 24 + <actor-typeahead> 25 + <input 26 + type="text" 27 + name="username" 28 + placeholder="username.example.com" 29 + autocapitalize="off" 30 + spellcheck="false" 31 + autocomplete="username" 32 + required 33 + /> 34 + </actor-typeahead> 24 35 </label> 25 36 <span class="faded caption"> 26 37 Use your AT Protocol handle to log in.