Something went wrong. Try again.
Website hub for the atmosphere community, aggregating posts, events, regional, and more
Something went wrong. Try again.
14 kB · 528 lines
Astro
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529---import { getBlueskyAgent } from "@fujocoded/authproto/helpers";import Combobox from "./Combobox.astro";import AtprotoIcon from "./icons/AtprotoIcon.astro";import { getAtmosphereCommunityDid } from "../../lib/community/atmosphere";import { getMembership } from "../../lib/opensocial/membership";
const PROFILE_TTL_MS = 60_000;type CachedProfile = { expiresAt: number; avatar?: string; displayName?: string; // Admin of this site's own (Atmosphere) community. The dropdown is global, so // "admin" here means admin of atmosphere.community, not of any community. isAdmin: boolean;};const profileCache: Map<string, CachedProfile> = (globalThis as any).__authMenuProfileCache ?? ((globalThis as any).__authMenuProfileCache = new Map());
async function loadAdminStatus(did: string): Promise<boolean> { try { const communityDid = await getAtmosphereCommunityDid(); const membership = await getMembership({ communityDid, userDid: did }); return membership.isAdmin; } catch (err) { // A degraded appview shouldn't break the header; just hide the badge. console.warn("[AuthMenu] failed to load admin status", did, err); return false; }}
async function loadProfile(did: string): Promise<CachedProfile | null> { const now = Date.now(); const cached = profileCache.get(did); if (cached && cached.expiresAt > now) return cached; try { const agent = await getBlueskyAgent(); const [profile, isAdmin] = await Promise.all([ agent?.app.bsky.actor.getProfile({ actor: did }), loadAdminStatus(did), ]); const entry: CachedProfile = { expiresAt: now + PROFILE_TTL_MS, avatar: profile?.data.avatar, displayName: profile?.data.displayName, isAdmin, }; profileCache.set(did, entry); return entry; } catch (err) { // Don't poison the cache on transient failures, but surface it server-side // so a degraded appview doesn't disappear silently behind "no avatar". console.warn("[AuthMenu] failed to load profile", did, err); return cached ?? null; }}
const loggedInUser = Astro.locals.loggedInUser;const authError = Astro.locals.authproto?.errorDescription ?? Astro.locals.authproto?.errorCode;
const profile = loggedInUser ? await loadProfile(loggedInUser.did) : null;const displayName = profile?.displayName?.trim() || loggedInUser?.handle || "";const avatarUrl = profile?.avatar;const isAdmin = profile?.isAdmin ?? false;const avatarInitial = (displayName || "@").charAt(0).toUpperCase();---
<details class="auth-menu" open={!loggedInUser && authError ? true : undefined}> { loggedInUser ? ( <> <summary class="auth-summary" aria-label={`Signed in as ${loggedInUser.handle}`} > {avatarUrl ? ( <img class="auth-avatar" src={avatarUrl} alt="" width="32" height="32" loading="lazy" decoding="async" /> ) : ( <span class="auth-avatar auth-avatar--fallback" aria-hidden="true"> {avatarInitial} </span> )} <span class="auth-handle">{displayName}</span> </summary> <div class="auth-popover"> <p class="auth-status"> Signed in as <strong>@{loggedInUser.handle}</strong> {isAdmin && <span class="auth-admin-badge">Admin</span>} </p> <form class="auth-form" action="/oauth/logout" method="post"> <button class="auth-button auth-button--secondary" type="submit"> Log out </button> </form> </div> </> ) : ( <> <summary class="auth-summary auth-summary--login"> <AtprotoIcon class="auth-summary-logo" /> Sign in </summary> <form class="auth-popover auth-form" action="/oauth/login" method="post"> <header class="auth-form-header"> <p class="auth-form-title">Sign in to the Atmosphere</p> <p class="auth-form-hint"> Your{" "} <a class="auth-form-link" href="https://atmosphereaccount.com" target="_blank" rel="noopener noreferrer" > Atmosphere account </a>{" "} lets you access dozens of websites—including this one! </p> </header> <Combobox id="header-atproto-id" name="atproto-id" label="Bluesky handle" placeholder="handle.bsky.social" inputClass="auth-input" autocomplete="username webauthn" inputmode="url" > <AtprotoIcon slot="leading-icon" width={18} height={18} /> </Combobox> {authError && ( <p class="auth-error" role="alert"> {authError} </p> )} <button class="auth-button" type="submit" disabled data-auth-submit> <span class="auth-button__label">Sign in</span> <span class="auth-button__spinner" aria-hidden="true" /> </button> </form> </> ) }</details>
<script> import { wireDetailsPopover } from '../../lib/client/details-popover';
type Actor = { handle: string; displayName?: string; avatar?: string; }; type Suggestion = { value: string; label?: string; sublabel?: string; avatar?: string; };
const HANDLE_SUFFIXES = [ "bsky.social", "atproto.camp", "atprotocol.community", "atprotocol.space", "blacksky.social", ]; const PLACEHOLDER_RESET_DELAY_MS = 30_000; const SEARCH_ACTORS_URL = "https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead";
const searchBskyActors = async (q: string): Promise<Suggestion[]> => { const url = new URL(SEARCH_ACTORS_URL); url.searchParams.set("q", q); url.searchParams.set("limit", "8"); const res = await fetch(url, { headers: { accept: "application/json" } }); if (!res.ok) return []; const data = (await res.json()) as { actors?: Actor[] }; return (data.actors ?? []).map((a) => ({ value: a.handle, label: a.displayName || a.handle, sublabel: `@${a.handle}`, avatar: a.avatar, })); };
const wireSubmitButton = (form: HTMLFormElement, field: HTMLInputElement) => { const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]'); if (!submit) return; const sync = () => { // Don't override the loading state once the form is submitting. if (submit.dataset.loading === "true") return; submit.disabled = field.value.trim().length === 0; }; field.addEventListener("input", sync); field.addEventListener("change", sync); sync();
form.addEventListener("submit", () => { submit.dataset.loading = "true"; submit.disabled = true; const label = submit.querySelector<HTMLElement>(".auth-button__label"); if (label) label.textContent = "Signing in…"; }); };
const wirePlaceholderRotation = ( details: HTMLDetailsElement, field: HTMLInputElement, ) => { let clearTimer: ReturnType<typeof setTimeout> | null = null; details.addEventListener("toggle", () => { if (!details.open) { details.querySelector(".auth-error")?.remove(); clearTimer = setTimeout(() => { field.value = ""; // Event constructor defaults bubbles to false; opt in so the synthetic // event matches the native one. field.dispatchEvent(new Event("input", { bubbles: true })); }, PLACEHOLDER_RESET_DELAY_MS); return; } if (clearTimer) { clearTimeout(clearTimer); clearTimer = null; } const suffix = HANDLE_SUFFIXES[Math.floor(Math.random() * HANDLE_SUFFIXES.length)]; field.placeholder = `handle.${suffix}`; field.focus({ preventScroll: true }); }); };
const input = document.getElementById( "header-atproto-id", ) as HTMLInputElement | null; if (input?.form) wireSubmitButton(input.form, input);
const details = document.querySelector<HTMLDetailsElement>("details.auth-menu"); if (details) { wireDetailsPopover(details, { focusSelector: input ? 'input[name="atproto-id"]' : undefined, surfaceSelector: '.auth-popover', }); }
if (details && input) { wirePlaceholderRotation(details, input); }
const combobox = input?.closest("cmb-combobox"); combobox?.addEventListener("combobox-search", (e) => { const detail = (e as CustomEvent<{ query: string; results: Promise<Suggestion[]> | null }>) .detail; detail.results = searchBskyActors(detail.query); });</script>
<style> .auth-menu { position: relative; flex: 0 0 auto; }
.auth-summary { display: inline-flex; align-items: center; gap: var(--space-sm); min-height: 36px; border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 2px var(--space-md) 2px 2px; color: var(--foreground); font-size: var(--text-sm); font-weight: 600; line-height: 1; list-style: none; cursor: pointer; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; }
.auth-summary::-webkit-details-marker { display: none; }
.auth-summary:hover, .auth-menu[open] .auth-summary { background: var(--primary-muted); border-color: var(--primary-muted); color: var(--primary); }
.auth-summary:focus-visible, .auth-button:focus-visible, :global(.auth-input:focus-visible) { outline: 2px solid var(--primary); outline-offset: 2px; }
.auth-summary--login { padding: var(--space-sm) var(--space-base); background: var(--primary); border-color: var(--primary); color: var(--primary-foreground); }
.auth-summary--login:hover, .auth-menu[open] .auth-summary--login { background: var(--primary-hover); border-color: var(--primary-hover); color: var(--primary-foreground); }
.auth-avatar { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; background: var(--primary-muted); }
.auth-avatar--fallback { display: inline-flex; align-items: center; justify-content: center; color: var(--primary); font-weight: 700; }
.auth-handle { max-width: 13ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auth-popover { position: absolute; top: calc(100% + var(--space-sm)); right: 0; z-index: var(--z-popover); width: min(280px, calc(100vw - 2 * var(--space-lg))); padding: var(--space-base); background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-md); box-shadow: var(--elevation-high); }
.auth-form { display: grid; gap: var(--space-sm); }
.auth-form-header { display: grid; gap: 2px; }
.auth-form-title { margin: 0; color: var(--foreground); font-size: var(--text-sm); font-weight: 700; line-height: var(--leading-tight); }
.auth-summary-logo { flex: 0 0 auto; width: 16px; height: 16px; }
.auth-form-hint { margin: 0; color: var(--foreground-muted); font-size: var(--text-xs); line-height: var(--leading-normal); }
.auth-form-link { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; } .auth-form-link:hover { text-decoration-thickness: 2px; }
/* :global because the input is rendered inside <Combobox>, so Astro's scoped selector for this component wouldn't reach it. */ :global(.auth-input) { width: 100%; min-height: 40px; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--space-sm) var(--space-md); color: var(--foreground); font: inherit; font-size: var(--text-sm); }
.auth-button { display: inline-flex; align-items: center; justify-content: center; gap: var(--space-sm); min-height: 40px; border: 0; border-radius: var(--radius-sm); background: var(--primary); color: var(--primary-foreground); font: inherit; font-size: var(--text-sm); font-weight: 700; cursor: pointer; transition: background 0.15s ease; }
.auth-button__spinner { display: none; width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: auth-button-spin 0.7s linear infinite; } .auth-button[data-loading="true"] .auth-button__spinner { display: inline-block; } .auth-button[data-loading="true"] { cursor: progress; } @keyframes auth-button-spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .auth-button__spinner { animation: none; } }
.auth-button:hover { background: var(--primary-hover); }
.auth-button:disabled { background: var(--border); color: var(--foreground-muted); cursor: not-allowed; } .auth-button:disabled:hover { background: var(--border); }
.auth-button--secondary { width: 100%; background: transparent; border: 1px solid var(--border); color: var(--primary); }
.auth-button--secondary:hover { background: var(--primary-muted); }
.auth-status { margin-bottom: var(--space-sm); color: var(--foreground-secondary); font-size: var(--text-sm); line-height: var(--leading-normal); }
.auth-admin-badge { display: inline-block; margin-left: var(--space-sm); padding: 1px var(--space-sm); border-radius: var(--radius-pill); background: var(--primary-muted); color: var(--primary); font-size: var(--text-xs); font-weight: 700; line-height: 1.4; vertical-align: middle; }
.auth-error { color: var(--error); font-size: var(--text-sm); line-height: var(--leading-normal); }
@media (max-width: 640px) { .auth-menu { margin-left: auto; order: 1; }
.auth-handle { display: none; }
.auth-summary { padding-right: 2px; }
.auth-summary--login { padding-inline: var(--space-md); }
.auth-popover { right: -36px; } }</style>