this repo has no description
10
fork

Configure Feed

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

feat(explore): sign-in handle search + explore static sky

- Add /api/identity/preview using Bluesky searchActors with ranking and
full-handle / DID fallbacks; return multiple matches for typeahead
- SignInForm: glass dropdown list, 150ms debounce, i18n for loading/empty
- Explore routes: force sky-static and hide effects toggle bar
- Vite: port 5173 with strictPort to avoid stale multi-dev-server confusion

Made-with: Cursor

+553 -29
+123
assets/styles.css
··· 2404 2404 .dark-phase .signin-form-hint { 2405 2405 color: rgba(255, 255, 255, 0.55); 2406 2406 } 2407 + 2408 + /* Hide the sky-effects toggle anywhere we explicitly disable effects (e.g. /explore). */ 2409 + body.explore-no-effects #nav-effects-bar { 2410 + display: none !important; 2411 + } 2412 + 2413 + /* ---- Sign-in handle preview dropdown ---- */ 2414 + .signin-form-preview-wrap { 2415 + position: relative; 2416 + overflow: visible; 2417 + z-index: 1; 2418 + } 2419 + /* Uses shared .glass for frosted fill + border; only layout/typography here */ 2420 + .signin-form-preview { 2421 + position: absolute; 2422 + z-index: 200; 2423 + top: calc(100% + 0.45rem); 2424 + left: 0; 2425 + right: 0; 2426 + padding: 0.45rem 0.55rem; 2427 + display: flex; 2428 + flex-direction: column; 2429 + gap: 0.2rem; 2430 + font-size: 0.92rem; 2431 + color: #0e1428; 2432 + } 2433 + .dark-phase .signin-form-preview { 2434 + color: #f3f5fb; 2435 + } 2436 + .signin-form-preview-list { 2437 + list-style: none; 2438 + margin: 0; 2439 + padding: 0; 2440 + max-height: min(52vh, 320px); 2441 + overflow-y: auto; 2442 + -webkit-overflow-scrolling: touch; 2443 + } 2444 + .signin-form-preview-li { 2445 + margin: 0; 2446 + padding: 0; 2447 + } 2448 + .signin-form-preview-row { 2449 + display: flex; 2450 + align-items: center; 2451 + gap: 0.7rem; 2452 + padding: 0.55rem 0.6rem; 2453 + border-radius: 12px; 2454 + text-align: left; 2455 + background: transparent; 2456 + border: 0; 2457 + cursor: pointer; 2458 + font: inherit; 2459 + color: inherit; 2460 + width: 100%; 2461 + } 2462 + .signin-form-preview-row:hover, 2463 + .signin-form-preview-row:focus-visible { 2464 + background: rgba(42, 90, 168, 0.08); 2465 + outline: none; 2466 + } 2467 + .dark-phase .signin-form-preview-row:hover, 2468 + .dark-phase .signin-form-preview-row:focus-visible { 2469 + background: rgba(255, 255, 255, 0.08); 2470 + } 2471 + .signin-form-preview-avatar { 2472 + width: 36px; 2473 + height: 36px; 2474 + border-radius: 50%; 2475 + background: linear-gradient(135deg, #c9d8f5 0%, #a8c4f0 100%); 2476 + flex-shrink: 0; 2477 + object-fit: cover; 2478 + border: 1px solid rgba(255, 255, 255, 0.6); 2479 + } 2480 + .signin-form-preview-meta { 2481 + display: flex; 2482 + flex-direction: column; 2483 + gap: 0.1rem; 2484 + min-width: 0; 2485 + } 2486 + .signin-form-preview-name { 2487 + font-weight: 600; 2488 + white-space: nowrap; 2489 + overflow: hidden; 2490 + text-overflow: ellipsis; 2491 + } 2492 + .signin-form-preview-handle { 2493 + font-size: 0.8rem; 2494 + color: rgba(18, 26, 47, 0.6); 2495 + white-space: nowrap; 2496 + overflow: hidden; 2497 + text-overflow: ellipsis; 2498 + } 2499 + .dark-phase .signin-form-preview-handle { 2500 + color: rgba(255, 255, 255, 0.55); 2501 + } 2502 + .signin-form-preview-status { 2503 + padding: 0.55rem 0.7rem; 2504 + font-size: 0.85rem; 2505 + color: rgba(18, 26, 47, 0.65); 2506 + display: flex; 2507 + align-items: center; 2508 + gap: 0.55rem; 2509 + } 2510 + .dark-phase .signin-form-preview-status { 2511 + color: rgba(255, 255, 255, 0.6); 2512 + } 2513 + .signin-form-preview-spinner { 2514 + width: 14px; 2515 + height: 14px; 2516 + border-radius: 50%; 2517 + border: 2px solid rgba(18, 26, 47, 0.25); 2518 + border-top-color: rgba(18, 26, 47, 0.65); 2519 + animation: signin-spin 0.7s linear infinite; 2520 + } 2521 + .dark-phase .signin-form-preview-spinner { 2522 + border-color: rgba(255, 255, 255, 0.25); 2523 + border-top-color: rgba(255, 255, 255, 0.7); 2524 + } 2525 + @keyframes signin-spin { 2526 + to { 2527 + transform: rotate(360deg); 2528 + } 2529 + }
+1
deno.lock
··· 9 9 "jsr:@fresh/core@2": "2.2.2", 10 10 "jsr:@fresh/core@^2.2.0": "2.2.2", 11 11 "jsr:@fresh/core@^2.2.2": "2.2.2", 12 + "jsr:@fresh/plugin-vite@*": "1.0.8", 12 13 "jsr:@fresh/plugin-vite@^1.0.8": "1.0.8", 13 14 "jsr:@std/bytes@^1.0.6": "1.0.6", 14 15 "jsr:@std/dotenv@~0.225.5": "0.225.6",
+4
i18n/messages/en.tsx
··· 354 354 "OAuth isn't configured on this deployment yet. Try again shortly.", 355 355 whyHandle: 356 356 "We resolve your handle to your atproto DID, then redirect you to your account's authorization server.", 357 + previewLoading: "Looking up account…", 358 + previewNotFound: "No account found for that handle.", 359 + previewNeedFullHandle: 360 + "Keep typing a full handle (for example name.bsky.social).", 357 361 }, 358 362 manage: { 359 363 headline: "Your registry profile",
+215 -26
islands/SignInForm.tsx
··· 1 1 import { useSignal } from "@preact/signals"; 2 + import { useEffect, useRef } from "preact/hooks"; 2 3 import { useT } from "../i18n/mod.ts"; 3 4 4 5 interface Props { ··· 6 7 returnTo?: string; 7 8 } 8 9 10 + interface PreviewMatch { 11 + did: string; 12 + handle: string; 13 + displayName?: string; 14 + avatarUrl?: string; 15 + } 16 + 17 + interface PreviewSuccess { 18 + found: true; 19 + matches: PreviewMatch[]; 20 + } 21 + 22 + interface PreviewMiss { 23 + found: false; 24 + reason: "invalid_handle" | "not_found"; 25 + } 26 + 27 + type PreviewResponse = PreviewSuccess | PreviewMiss; 28 + 9 29 export default function SignInForm({ returnTo: _returnTo }: Props) { 10 30 const t = useT(); 11 31 const handle = useSignal(""); 12 32 const submitting = useSignal(false); 13 33 const error = useSignal<string | null>(null); 34 + const matches = useSignal<PreviewMatch[]>([]); 35 + const previewLoading = useSignal(false); 36 + const missReason = useSignal<PreviewMiss["reason"] | null>(null); 37 + const showPreview = useSignal(false); 38 + 39 + const debounceRef = useRef<number | null>(null); 40 + const requestSeq = useRef(0); 41 + const wrapRef = useRef<HTMLDivElement | null>(null); 42 + 43 + useEffect(() => { 44 + function onDocPointerDown(e: PointerEvent) { 45 + if (!wrapRef.current) return; 46 + const node = e.target; 47 + if (node instanceof Node && !wrapRef.current.contains(node)) { 48 + showPreview.value = false; 49 + } 50 + } 51 + document.addEventListener("pointerdown", onDocPointerDown); 52 + return () => document.removeEventListener("pointerdown", onDocPointerDown); 53 + }, []); 54 + 55 + function schedulePreview(value: string) { 56 + if (debounceRef.current !== null) { 57 + clearTimeout(debounceRef.current); 58 + debounceRef.current = null; 59 + } 60 + const trimmed = value.trim().replace(/^@/, "").toLowerCase(); 61 + if (!trimmed) { 62 + matches.value = []; 63 + missReason.value = null; 64 + previewLoading.value = false; 65 + showPreview.value = false; 66 + return; 67 + } 68 + matches.value = []; 69 + missReason.value = null; 70 + previewLoading.value = true; 71 + showPreview.value = true; 72 + const mySeq = ++requestSeq.current; 73 + debounceRef.current = setTimeout(() => { 74 + fetch(`/api/identity/preview?handle=${encodeURIComponent(trimmed)}`) 75 + .then((r) => r.json() as Promise<PreviewResponse>) 76 + .then((data) => { 77 + if (mySeq !== requestSeq.current) return; 78 + previewLoading.value = false; 79 + if (data.found) { 80 + matches.value = data.matches; 81 + missReason.value = null; 82 + } else { 83 + matches.value = []; 84 + missReason.value = data.reason; 85 + } 86 + }) 87 + .catch(() => { 88 + if (mySeq !== requestSeq.current) return; 89 + previewLoading.value = false; 90 + matches.value = []; 91 + missReason.value = "not_found"; 92 + }); 93 + }, 150); 94 + } 14 95 15 96 const onSubmit = (event: Event) => { 16 97 event.preventDefault(); ··· 21 102 form.submit(); 22 103 }; 23 104 105 + const onSelectMatch = (m: PreviewMatch) => { 106 + handle.value = m.handle; 107 + showPreview.value = false; 108 + }; 109 + 110 + const missMessage = () => { 111 + const r = missReason.value; 112 + if (r === "invalid_handle") return t.explore.create.previewNeedFullHandle; 113 + return t.explore.create.previewNotFound; 114 + }; 115 + 24 116 return ( 25 117 <form 26 118 method="POST" ··· 28 120 onSubmit={onSubmit} 29 121 class="signin-form" 30 122 > 31 - <label class="signin-form-label" for="signin-handle"> 32 - {t.explore.create.handlePlaceholder} 33 - </label> 34 - <div class="signin-form-row"> 35 - <input 36 - id="signin-handle" 37 - name="handle" 38 - type="text" 39 - inputMode="email" 40 - autoCapitalize="none" 41 - autoCorrect="off" 42 - spellcheck={false} 43 - required 44 - placeholder={t.explore.create.handlePlaceholder} 45 - value={handle.value} 46 - onInput={(e) => 47 - handle.value = (e.currentTarget as HTMLInputElement).value} 48 - class="signin-form-input" 49 - /> 50 - <button 51 - type="submit" 52 - class="signin-form-submit" 53 - disabled={submitting.value} 54 - > 55 - {submitting.value ? "…" : t.explore.create.signIn} 56 - </button> 123 + <div class="signin-form-preview-wrap" ref={wrapRef}> 124 + <label class="signin-form-label" for="signin-handle"> 125 + {t.explore.create.handlePlaceholder} 126 + </label> 127 + <div class="signin-form-row"> 128 + <div style={{ flex: 1, minWidth: 0, position: "relative" }}> 129 + <input 130 + id="signin-handle" 131 + name="handle" 132 + type="text" 133 + inputMode="email" 134 + autoCapitalize="none" 135 + autoCorrect="off" 136 + spellcheck={false} 137 + autoComplete="off" 138 + required 139 + placeholder={t.explore.create.handlePlaceholder} 140 + value={handle.value} 141 + onInput={(e) => { 142 + const v = (e.currentTarget as HTMLInputElement).value; 143 + handle.value = v; 144 + schedulePreview(v); 145 + }} 146 + onFocus={() => { 147 + const v = handle.value; 148 + if (v.trim()) schedulePreview(v); 149 + }} 150 + class="signin-form-input" 151 + style={{ width: "100%" }} 152 + aria-autocomplete="list" 153 + aria-expanded={showPreview.value} 154 + aria-controls="signin-handle-preview" 155 + /> 156 + {showPreview.value && ( 157 + <div 158 + id="signin-handle-preview" 159 + class="signin-form-preview glass" 160 + role="listbox" 161 + > 162 + {previewLoading.value && ( 163 + <div class="signin-form-preview-status"> 164 + <span 165 + class="signin-form-preview-spinner" 166 + aria-hidden="true" 167 + /> 168 + <span>{t.explore.create.previewLoading}</span> 169 + </div> 170 + )} 171 + {!previewLoading.value && missReason.value !== null && ( 172 + <div class="signin-form-preview-status"> 173 + <span>{missMessage()}</span> 174 + </div> 175 + )} 176 + {!previewLoading.value && 177 + missReason.value === null && 178 + matches.value.length > 0 && ( 179 + <ul class="signin-form-preview-list"> 180 + {matches.value.map((m) => ( 181 + <li key={m.did} class="signin-form-preview-li"> 182 + <button 183 + type="button" 184 + class="signin-form-preview-row" 185 + role="option" 186 + onClick={() => 187 + onSelectMatch(m)} 188 + > 189 + {m.avatarUrl 190 + ? ( 191 + <img 192 + class="signin-form-preview-avatar" 193 + src={m.avatarUrl} 194 + alt="" 195 + loading="lazy" 196 + decoding="async" 197 + /> 198 + ) 199 + : ( 200 + <span 201 + class="signin-form-preview-avatar" 202 + aria-hidden="true" 203 + /> 204 + )} 205 + <span class="signin-form-preview-meta"> 206 + {m.displayName 207 + ? ( 208 + <> 209 + <span class="signin-form-preview-name"> 210 + {m.displayName} 211 + </span> 212 + <span class="signin-form-preview-handle"> 213 + @{m.handle} 214 + </span> 215 + </> 216 + ) 217 + : ( 218 + <span class="signin-form-preview-name"> 219 + @{m.handle} 220 + </span> 221 + )} 222 + </span> 223 + </button> 224 + </li> 225 + ))} 226 + </ul> 227 + )} 228 + {!previewLoading.value && 229 + missReason.value === null && 230 + matches.value.length === 0 && ( 231 + <div class="signin-form-preview-status"> 232 + <span>{t.explore.create.previewNotFound}</span> 233 + </div> 234 + )} 235 + </div> 236 + )} 237 + </div> 238 + <button 239 + type="submit" 240 + class="signin-form-submit" 241 + disabled={submitting.value} 242 + > 243 + {submitting.value ? "…" : t.explore.create.signIn} 244 + </button> 245 + </div> 57 246 </div> 58 247 {error.value && <p class="signin-form-error">{error.value}</p>} 59 248 <p class="signin-form-hint">{t.explore.create.whyHandle}</p>
+21 -3
routes/_app.tsx
··· 210 210 function isMobileViewport(){ 211 211 return typeof window.matchMedia==='function'&&window.matchMedia('(max-width: 768px)').matches; 212 212 } 213 + function effectsForciblyOff(){ 214 + return document.body && document.body.classList.contains('explore-no-effects'); 215 + } 213 216 function applySkyForViewport(){ 217 + if(effectsForciblyOff()){ 218 + document.documentElement.classList.add('sky-static'); 219 + syncSkyToggle(); 220 + update(); 221 + return; 222 + } 214 223 if(isMobileViewport()){ 215 224 document.documentElement.classList.add('sky-static'); 216 225 }else{ ··· 239 248 `; 240 249 241 250 export default define.page(function App(ctx) { 242 - const { Component, state } = ctx; 251 + const { Component, state, url } = ctx; 243 252 const locale = state.locale; 244 253 const t = getMessages(locale); 254 + /** 255 + * The dynamic sky / sun / cloud parallax is intentionally disabled on the 256 + * explore section — it competes with content density there. We force-apply 257 + * `sky-static` server-side and hide the user-facing toggle. 258 + */ 259 + const effectsOff = url.pathname === "/explore" || 260 + url.pathname.startsWith("/explore/"); 261 + const htmlClass = effectsOff ? "sky-static" : undefined; 262 + const bodyClass = effectsOff ? "sky-bg explore-no-effects" : "sky-bg"; 245 263 return ( 246 - <html lang={locale}> 264 + <html lang={locale} class={htmlClass}> 247 265 <head> 248 266 <meta charset="utf-8" /> 249 267 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> ··· 274 292 defer 275 293 /> 276 294 </head> 277 - <body class="sky-bg"> 295 + <body class={bodyClass}> 278 296 <I18nProvider locale={locale}> 279 297 <Component /> 280 298 </I18nProvider>
+184
routes/api/identity/preview.ts
··· 1 + /** 2 + * /api/identity/preview?handle=<prefix or full handle> 3 + * 4 + * Typeahead for sign-in: uses Bluesky AppView `app.bsky.actor.searchActors` so 5 + * we can match from the first typed character (prefix / fuzzy), not only after 6 + * a complete atproto handle. Results are ranked so closer handle-prefix matches 7 + * float to the top as the query approaches a full handle. 8 + * 9 + * Optional: paste a full `did:…` — we resolve and return a single row. 10 + */ 11 + 12 + import { define } from "../../../utils.ts"; 13 + import { isDid, isHandle, resolveIdentity } from "../../../lib/identity.ts"; 14 + import { getBskyProfile } from "../../../lib/pds.ts"; 15 + 16 + const BSKY_SEARCH = 17 + "https://public.api.bsky.app/xrpc/app.bsky.actor.searchActors"; 18 + 19 + export interface PreviewMatch { 20 + did: string; 21 + handle: string; 22 + displayName?: string; 23 + avatarUrl?: string; 24 + } 25 + 26 + interface PreviewSuccess { 27 + found: true; 28 + matches: PreviewMatch[]; 29 + } 30 + 31 + interface PreviewMiss { 32 + found: false; 33 + reason: "invalid_handle" | "not_found"; 34 + } 35 + 36 + type PreviewResponse = PreviewSuccess | PreviewMiss; 37 + 38 + function json( 39 + body: PreviewResponse, 40 + init: ResponseInit = {}, 41 + ): Response { 42 + return new Response(JSON.stringify(body), { 43 + ...init, 44 + headers: { 45 + "content-type": "application/json; charset=utf-8", 46 + "cache-control": "public, max-age=15, s-maxage=30", 47 + ...(init.headers ?? {}), 48 + }, 49 + }); 50 + } 51 + 52 + function parseActor(a: unknown): PreviewMatch | null { 53 + if (!a || typeof a !== "object") return null; 54 + const o = a as Record<string, unknown>; 55 + const did = typeof o.did === "string" ? o.did : ""; 56 + const handle = typeof o.handle === "string" ? o.handle : ""; 57 + if (!did || !handle) return null; 58 + const displayName = typeof o.displayName === "string" 59 + ? o.displayName 60 + : undefined; 61 + const avatarUrl = typeof o.avatar === "string" ? o.avatar : undefined; 62 + return { did, handle, displayName, avatarUrl }; 63 + } 64 + 65 + /** Prefer exact handle, then handle prefix, then substring / display name. */ 66 + function rankMatches(query: string, matches: PreviewMatch[]): PreviewMatch[] { 67 + const q = query.toLowerCase(); 68 + const score = (m: PreviewMatch): number => { 69 + const h = m.handle.toLowerCase(); 70 + const dn = (m.displayName ?? "").toLowerCase(); 71 + if (h === q) return 0; 72 + if (h.startsWith(q)) return 1; 73 + if (dn.startsWith(q)) return 2; 74 + if (h.includes(q)) return 3; 75 + if (dn.includes(q)) return 4; 76 + return 5; 77 + }; 78 + return [...matches].sort((a, b) => { 79 + const diff = score(a) - score(b); 80 + if (diff !== 0) return diff; 81 + return a.handle.localeCompare(b.handle); 82 + }); 83 + } 84 + 85 + async function searchActors(query: string): Promise<PreviewMatch[]> { 86 + const url = new URL(BSKY_SEARCH); 87 + url.searchParams.set("q", query); 88 + url.searchParams.set("limit", "12"); 89 + const res = await fetch(url.toString(), { 90 + signal: AbortSignal.timeout(10_000), 91 + headers: { accept: "application/json" }, 92 + }); 93 + if (!res.ok) { 94 + throw new Error(`searchActors HTTP ${res.status}`); 95 + } 96 + const json = await res.json() as { actors?: unknown[] }; 97 + const out: PreviewMatch[] = []; 98 + for (const a of json.actors ?? []) { 99 + const m = parseActor(a); 100 + if (m) out.push(m); 101 + } 102 + return out; 103 + } 104 + 105 + export const handler = define.handlers({ 106 + async GET(ctx) { 107 + const raw = (ctx.url.searchParams.get("handle") ?? 108 + ctx.url.searchParams.get("q") ?? "").trim() 109 + .replace(/^@/, ""); 110 + if (!raw) { 111 + return json({ found: false, reason: "invalid_handle" }); 112 + } 113 + 114 + // Pasted DID — single resolved row (not in search index the same way). 115 + if (isDid(raw)) { 116 + try { 117 + const identity = await resolveIdentity(raw); 118 + const profile = await getBskyProfile(identity.pdsUrl, identity.did); 119 + let avatarUrl: string | undefined; 120 + const cid = profile?.avatar?.ref?.$link; 121 + if (cid) { 122 + const u = new URL( 123 + `${ 124 + identity.pdsUrl.replace(/\/$/, "") 125 + }/xrpc/com.atproto.sync.getBlob`, 126 + ); 127 + u.searchParams.set("did", identity.did); 128 + u.searchParams.set("cid", cid); 129 + avatarUrl = u.toString(); 130 + } 131 + return json({ 132 + found: true, 133 + matches: [ 134 + { 135 + did: identity.did, 136 + handle: identity.handle, 137 + displayName: profile?.displayName, 138 + avatarUrl, 139 + }, 140 + ], 141 + }); 142 + } catch { 143 + return json({ found: true, matches: [] }); 144 + } 145 + } 146 + 147 + try { 148 + let matches = rankMatches(raw, await searchActors(raw)); 149 + const exact = matches.some((m) => m.handle.toLowerCase() === raw); 150 + if (!exact && isHandle(raw)) { 151 + try { 152 + const identity = await resolveIdentity(raw); 153 + const profile = await getBskyProfile(identity.pdsUrl, identity.did); 154 + let avatarUrl: string | undefined; 155 + const cid = profile?.avatar?.ref?.$link; 156 + if (cid) { 157 + const u = new URL( 158 + `${ 159 + identity.pdsUrl.replace(/\/$/, "") 160 + }/xrpc/com.atproto.sync.getBlob`, 161 + ); 162 + u.searchParams.set("did", identity.did); 163 + u.searchParams.set("cid", cid); 164 + avatarUrl = u.toString(); 165 + } 166 + matches = rankMatches(raw, [ 167 + { 168 + did: identity.did, 169 + handle: identity.handle, 170 + displayName: profile?.displayName, 171 + avatarUrl, 172 + }, 173 + ...matches.filter((m) => m.did !== identity.did), 174 + ]); 175 + } catch { 176 + /* keep search-only results */ 177 + } 178 + } 179 + return json({ found: true, matches }); 180 + } catch { 181 + return json({ found: false, reason: "not_found" }); 182 + } 183 + }, 184 + });
+5
vite.config.ts
··· 3 3 4 4 export default defineConfig({ 5 5 plugins: [fresh()], 6 + server: { 7 + /** Match local OAuth / site URL defaults; fail fast if another dev server is still bound. */ 8 + port: 5173, 9 + strictPort: true, 10 + }, 6 11 });