update the login page

This commit is contained in:
Evan Jarrett
2026-04-11 21:01:31 -05:00
parent 564019d1c3
commit 25628dad2c
10 changed files with 630 additions and 376 deletions

7
package-lock.json generated
View File

@@ -8,7 +8,6 @@
"name": "atcr-styles",
"version": "1.0.0",
"dependencies": {
"actor-typeahead": "^0.1.2",
"htmx-ext-json-enc": "^2.0.3",
"htmx.org": "^2.0.8",
"lucide": "^0.577.0"
@@ -1112,12 +1111,6 @@
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/actor-typeahead": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/actor-typeahead/-/actor-typeahead-0.1.2.tgz",
"integrity": "sha512-I97YqqNl7Kar0J/bIJvgY/KmHpssHcDElhfwVTLP7wRFlkxso2ZLBqiS2zol5A8UVUJbQK2JXYaqNpZXz8Uk2A==",
"license": "MPL-2.0"
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",

View File

@@ -24,7 +24,6 @@
"tailwindcss": "^4.2"
},
"dependencies": {
"actor-typeahead": "^0.1.2",
"htmx-ext-json-enc": "^2.0.3",
"htmx.org": "^2.0.8",
"lucide": "^0.577.0"

View File

@@ -601,29 +601,29 @@ func (h *RepositoryTagsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
}
data := struct {
Owner *db.User
Repository *db.Repository
Entries []db.ManifestEntry
IsOwner bool
ScanBatchParams []template.HTML
RegistryURL string
OciClient string
HasMore bool
NextOffset int
IsFirstPage bool
ViewerDefaultHold string
Owner *db.User
Repository *db.Repository
Entries []db.ManifestEntry
IsOwner bool
ScanBatchParams []template.HTML
RegistryURL string
OciClient string
HasMore bool
NextOffset int
IsFirstPage bool
ViewerDefaultHold string
}{
Owner: owner,
Repository: &db.Repository{Name: repository},
Entries: entries,
IsOwner: isOwner,
ScanBatchParams: scanBatchParams,
RegistryURL: h.RegistryURL,
OciClient: ociClient,
HasMore: hasMore,
NextOffset: offset + pageSize,
IsFirstPage: isFirstPage,
ViewerDefaultHold: viewerDefaultHold,
Owner: owner,
Repository: &db.Repository{Name: repository},
Entries: entries,
IsOwner: isOwner,
ScanBatchParams: scanBatchParams,
RegistryURL: h.RegistryURL,
OciClient: ociClient,
HasMore: hasMore,
NextOffset: offset + pageSize,
IsFirstPage: isFirstPage,
ViewerDefaultHold: viewerDefaultHold,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")

File diff suppressed because one or more lines are too long

View File

@@ -303,56 +303,107 @@
}
/* ----------------------------------------
ACTOR-TYPEAHEAD COMPONENT STYLING
SAILOR TYPEAHEAD
---------------------------------------- */
actor-typeahead {
/* Use DaisyUI CSS variables - they auto-switch with theme */
--color-background: var(--color-base-100);
--color-border: var(--color-base-300);
--color-shadow: var(--color-base-content);
--color-hover: var(--color-base-200);
--color-avatar-fallback: var(--color-base-300);
--radius: 0.5rem;
--padding-menu: 0.25rem;
z-index: 50;
.sailor-typeahead {
position: relative;
}
actor-typeahead::part(handle) {
@apply text-base-content;
}
actor-typeahead::part(menu) {
@apply shadow-lg;
margin-top: 0.25rem;
}
/* ----------------------------------------
RECENT ACCOUNTS DROPDOWN
---------------------------------------- */
.recent-accounts-dropdown {
.sailor-typeahead-dropdown {
@apply absolute top-full left-0 right-0;
@apply bg-base-100 border border-base-300;
@apply rounded-lg shadow-lg;
@apply max-h-60 overflow-y-auto z-50;
@apply max-h-80 overflow-y-auto z-50;
margin-top: 0.25rem;
}
.recent-accounts-header {
.sailor-typeahead-header {
@apply px-3 py-2 text-xs font-semibold uppercase;
@apply text-base-content/60 border-b border-base-300;
}
.recent-accounts-item {
@apply px-3 py-2.5;
.sailor-typeahead-item {
@apply flex items-center gap-3 px-3 py-2.5;
@apply cursor-pointer transition-colors duration-150;
@apply text-base-content;
}
.recent-accounts-item:hover,
.recent-accounts-item.focused {
.sailor-typeahead-item:hover,
.sailor-typeahead-item.focused {
@apply bg-base-200;
}
.sailor-typeahead-item-compact {
@apply py-2;
}
.sailor-typeahead-avatar {
@apply flex-shrink-0 w-9 h-9 rounded-full overflow-hidden;
@apply bg-base-300;
}
.sailor-typeahead-avatar img {
@apply w-full h-full object-cover;
}
.sailor-typeahead-text {
@apply flex-1 min-w-0;
}
.sailor-typeahead-name {
@apply text-sm font-medium truncate;
}
.sailor-typeahead-handle {
@apply text-xs text-base-content/60 truncate;
}
.sailor-typeahead-selected {
@apply flex items-center gap-3 w-full;
@apply border border-base-300 rounded-lg bg-base-100;
@apply px-4;
height: 3rem; /* matches input-lg */
cursor: default;
}
.sailor-typeahead-selected .sailor-typeahead-avatar {
@apply w-9 h-9;
}
.sailor-typeahead-selected .sailor-typeahead-clear {
@apply flex-shrink-0 w-8 h-8 rounded-full;
@apply flex items-center justify-center;
@apply text-xl leading-none text-base-content/60;
@apply hover:bg-base-200 hover:text-base-content;
@apply transition-colors cursor-pointer;
}
/* ----------------------------------------
SAILOR INFO DISCLOSURE
---------------------------------------- */
.sailor-info summary {
@apply cursor-pointer text-sm text-base-content/70;
@apply py-2 select-none;
list-style: none;
}
.sailor-info summary::-webkit-details-marker {
display: none;
}
.sailor-info summary::before {
content: '\25B8';
@apply inline-block mr-2 transition-transform;
}
.sailor-info[open] summary::before {
transform: rotate(90deg);
}
.sailor-info-body {
@apply text-sm text-base-content/70 space-y-2 pl-5 pt-1 pb-2;
}
/* ----------------------------------------
MENU FORM ITEM (styled like menu <a>)
For forms inside DaisyUI menu dropdowns

View File

@@ -429,192 +429,6 @@ async function openVulnDetails(digest, holdEndpoint) {
}
}
// Login page recent accounts helper (works alongside actor-typeahead web component)
class RecentAccountsHelper {
constructor(inputElement) {
this.input = inputElement;
this.typeahead = inputElement.closest('actor-typeahead');
this.dropdown = null;
this.currentFocus = -1;
this.typeaheadClosed = false; // Track when typeahead closes after selection
this.init();
}
init() {
this.createDropdown();
// Show recent accounts on focus when input is empty
this.input.addEventListener('focus', () => this.handleFocus());
// Hide recent accounts when user starts typing (actor-typeahead takes over)
this.input.addEventListener('input', () => this.handleInput());
// Keyboard navigation for recent accounts dropdown
this.input.addEventListener('keydown', (e) => this.handleKeydown(e));
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!this.input.contains(e.target) && !this.dropdown.contains(e.target)) {
this.hideDropdown();
}
});
}
createDropdown() {
this.dropdown = document.createElement('div');
this.dropdown.className = 'recent-accounts-dropdown';
this.dropdown.style.display = 'none';
// Insert after the actor-typeahead element
if (this.typeahead) {
this.typeahead.insertAdjacentElement('afterend', this.dropdown);
} else {
this.input.insertAdjacentElement('afterend', this.dropdown);
}
}
handleFocus() {
const value = this.input.value.trim();
if (value.length < 1) {
this.showRecentAccounts();
}
}
handleInput() {
const value = this.input.value.trim();
// Hide recent accounts once user starts typing (actor-typeahead shows its menu at 2+ chars)
if (value.length >= 1) {
this.hideDropdown();
}
// Reset typeahead closed flag when user types (re-enable Tab navigation)
this.typeaheadClosed = false;
}
showRecentAccounts() {
const recent = this.getRecentAccounts();
if (recent.length === 0) {
this.hideDropdown();
return;
}
this.dropdown.innerHTML = '';
this.currentFocus = -1;
const header = document.createElement('div');
header.className = 'recent-accounts-header';
header.textContent = 'Recent accounts';
this.dropdown.appendChild(header);
recent.forEach((handle, index) => {
const item = document.createElement('div');
item.className = 'recent-accounts-item';
item.dataset.index = index;
item.dataset.handle = handle;
item.textContent = handle;
item.addEventListener('click', () => this.selectItem(handle));
this.dropdown.appendChild(item);
});
this.dropdown.style.display = 'block';
}
selectItem(handle) {
this.input.value = handle;
this.hideDropdown();
this.input.focus();
}
hideDropdown() {
this.dropdown.style.display = 'none';
this.currentFocus = -1;
}
handleKeydown(e) {
// Track Enter key to detect typeahead selection (menu closes after)
if (e.key === 'Enter') {
const value = this.input.value.trim();
if (value.length >= 2) {
this.typeaheadClosed = true;
}
}
// Handle Tab for actor-typeahead navigation
// When input has 2+ chars and typeahead hasn't been closed by selection
if (e.key === 'Tab') {
const value = this.input.value.trim();
if (value.length >= 2 && !this.typeaheadClosed) {
e.preventDefault();
// Dispatch synthetic arrow key event to navigate typeahead
const arrowKey = e.shiftKey ? 'ArrowUp' : 'ArrowDown';
const syntheticEvent = new KeyboardEvent('keydown', {
key: arrowKey,
bubbles: true,
cancelable: true
});
this.typeahead.dispatchEvent(syntheticEvent);
return;
}
}
if (this.dropdown.style.display === 'none') return;
const items = this.dropdown.querySelectorAll('.recent-accounts-item');
if (e.key === 'ArrowDown') {
e.preventDefault();
this.currentFocus++;
if (this.currentFocus >= items.length) this.currentFocus = 0;
this.updateFocus(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.currentFocus--;
if (this.currentFocus < 0) this.currentFocus = items.length - 1;
this.updateFocus(items);
} else if (e.key === 'Enter' && this.currentFocus > -1 && items[this.currentFocus]) {
e.preventDefault();
this.selectItem(items[this.currentFocus].dataset.handle);
} else if (e.key === 'Escape') {
this.hideDropdown();
}
}
updateFocus(items) {
items.forEach((item, index) => {
item.classList.toggle('focused', index === this.currentFocus);
});
}
getRecentAccounts() {
try {
const recent = localStorage.getItem('atcr_recent_handles');
return recent ? JSON.parse(recent) : [];
} catch (_) {
return [];
}
}
saveRecentAccount(handle) {
if (!handle) return;
try {
let recent = this.getRecentAccounts();
recent = recent.filter(h => h !== handle);
recent.unshift(handle);
recent = recent.slice(0, 5);
localStorage.setItem('atcr_recent_handles', JSON.stringify(recent));
} catch (err) {
console.error('Failed to save recent account:', err);
}
}
}
// Initialize recent accounts helper on login page
document.addEventListener('DOMContentLoaded', () => {
const loginForm = document.getElementById('login-form');
const handleInput = document.getElementById('handle');
if (loginForm && handleInput) {
new RecentAccountsHelper(handleInput);
}
});
// Save successful login handle from cookie (set by server after OAuth success)
document.addEventListener('DOMContentLoaded', () => {
const cookie = document.cookie.split('; ').find(c => c.startsWith('atcr_login_handle='));

View File

@@ -8,8 +8,8 @@ window.htmx = htmx;
// only GET using URL params so DELETE bodies get JSON-encoded properly.
htmx.config.methodsThatUseUrlParams = ['get'];
// Actor Typeahead (web component, auto-registers on import)
import 'actor-typeahead';
// Sailor Typeahead (custom in-repo handle typeahead with waow.tech primary + bsky fallback)
import './sailor-typeahead.js';
// Import app functionality
import './app.js';

View File

@@ -0,0 +1,488 @@
// Sailor Typeahead
//
// Attaches to a plain <input> and renders a dropdown of matching ATProto
// actors. Replaces the third-party actor-typeahead package so we can:
// - primary-fetch from typeahead.waow.tech with a bsky.app fallback
// - require 4 characters before searching
// - fire a lightweight prefetch probe at 2-3 characters
// - integrate recent accounts (localStorage) into the same dropdown
const PRIMARY_HOST = 'https://typeahead.waow.tech';
const FALLBACK_HOST = 'https://public.api.bsky.app';
const SEARCH_PATH = '/xrpc/app.bsky.actor.searchActorsTypeahead';
const PROFILES_PATH = '/xrpc/app.bsky.actor.getProfiles';
const MIN_SEARCH = 4;
const MIN_PREFETCH = 2;
const DEBOUNCE_MS = 150;
const SEARCH_TIMEOUT_MS = 1500;
const PREFETCH_TIMEOUT_MS = 400;
const PROFILES_TIMEOUT_MS = 3000;
const UNHEALTHY_WINDOW_MS = 60_000;
const PREFETCH_DEDUPE_MS = 10_000;
const PROFILE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const RESULT_LIMIT = 8;
const RECENT_KEY = 'atcr_recent_handles';
const PROFILE_CACHE_KEY = 'atcr_recent_profile_cache';
const RECENT_MAX = 5;
class SailorTypeahead {
constructor(input) {
this.input = input;
this.container = input.closest('.sailor-typeahead') || input.parentElement;
this.dropdown = null;
this.selectedCard = null;
this.actors = [];
this.currentItems = []; // profiles in dropdown display order, for keyboard Enter
this.mode = 'hidden'; // 'hidden' | 'recent' | 'results'
this.focusIndex = -1;
this.debounceTimer = null;
this.requestSeq = 0;
this.primaryUnhealthyUntil = 0;
this.lastPrefetchPrefix = '';
this.lastPrefetchAt = 0;
this.createDropdown();
this.bindEvents();
// Show recents immediately on page load (don't wait for focus)
if (this.input.value.trim().length === 0) {
this.showRecent();
}
}
createDropdown() {
this.dropdown = document.createElement('div');
this.dropdown.className = 'sailor-typeahead-dropdown';
this.dropdown.setAttribute('role', 'listbox');
this.dropdown.style.display = 'none';
this.input.insertAdjacentElement('afterend', this.dropdown);
}
bindEvents() {
this.input.addEventListener('focus', () => this.handleFocus());
this.input.addEventListener('input', () => this.handleInput());
this.input.addEventListener('keydown', (e) => this.handleKeydown(e));
document.addEventListener('click', (e) => {
if (!this.input.contains(e.target) && !this.dropdown.contains(e.target)) {
this.hide();
}
});
// Escape from anywhere clears the selected account card (input is
// display:none in that state, so its own keydown handler can't fire)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.selectedCard) {
this.clearSelection();
}
});
}
handleFocus() {
if (this.input.value.trim().length === 0) {
this.showRecent();
}
}
handleInput() {
const q = this.input.value.trim();
if (q.length === 0) {
this.showRecent();
return;
}
if (q.length >= MIN_PREFETCH && q.length < MIN_SEARCH) {
this.hide();
this.schedulePrefetch(q);
return;
}
if (q.length >= MIN_SEARCH) {
this.scheduleSearch(q);
return;
}
this.hide();
}
schedulePrefetch(q) {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => this.runPrefetch(q), DEBOUNCE_MS);
}
scheduleSearch(q) {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => this.runSearch(q), DEBOUNCE_MS);
}
async runPrefetch(q) {
const now = Date.now();
if (q === this.lastPrefetchPrefix && now - this.lastPrefetchAt < PREFETCH_DEDUPE_MS) {
return;
}
if (now < this.primaryUnhealthyUntil) {
return;
}
this.lastPrefetchPrefix = q;
this.lastPrefetchAt = now;
try {
await fetchTypeahead(PRIMARY_HOST, q, PREFETCH_TIMEOUT_MS);
} catch (_) {
this.primaryUnhealthyUntil = Date.now() + UNHEALTHY_WINDOW_MS;
}
}
async runSearch(q) {
const seq = ++this.requestSeq;
let actors = null;
const canUsePrimary = Date.now() >= this.primaryUnhealthyUntil;
if (canUsePrimary) {
try {
actors = await fetchTypeahead(PRIMARY_HOST, q, SEARCH_TIMEOUT_MS);
} catch (_) {
this.primaryUnhealthyUntil = Date.now() + UNHEALTHY_WINDOW_MS;
}
}
if (actors === null) {
try {
actors = await fetchTypeahead(FALLBACK_HOST, q, SEARCH_TIMEOUT_MS);
} catch (_) {
actors = [];
}
}
if (seq !== this.requestSeq) return; // a newer request superseded this
this.actors = actors || [];
this.focusIndex = -1;
this.renderResults();
}
renderResults() {
this.mode = 'results';
this.dropdown.innerHTML = '';
this.currentItems = [];
if (this.actors.length === 0) {
this.hide();
return;
}
this.actors.forEach((actor, i) => {
this.currentItems.push(actor);
this.dropdown.appendChild(this.buildActorRow(actor, i));
});
this.dropdown.style.display = 'block';
}
buildActorRow(actor, index) {
const row = document.createElement('div');
row.className = 'sailor-typeahead-item';
row.setAttribute('role', 'option');
row.dataset.index = String(index);
row.dataset.handle = actor.handle;
const avatar = document.createElement('div');
avatar.className = 'sailor-typeahead-avatar';
if (actor.avatar) {
const img = document.createElement('img');
img.src = actor.avatar;
img.alt = '';
img.loading = 'lazy';
avatar.appendChild(img);
}
const text = document.createElement('div');
text.className = 'sailor-typeahead-text';
const hasDisplayName = actor.displayName && actor.displayName !== actor.handle;
if (hasDisplayName) {
const name = document.createElement('div');
name.className = 'sailor-typeahead-name';
name.textContent = actor.displayName;
text.appendChild(name);
}
const handle = document.createElement('div');
handle.className = hasDisplayName ? 'sailor-typeahead-handle' : 'sailor-typeahead-name';
handle.textContent = '@' + actor.handle;
text.appendChild(handle);
row.append(avatar, text);
row.addEventListener('mousedown', (e) => {
e.preventDefault(); // keep focus on input
this.select(actor);
});
return row;
}
showRecent() {
const recent = getRecentAccounts();
if (recent.length === 0) {
this.hide();
return;
}
this.mode = 'recent';
this.focusIndex = -1;
this.renderRecent(recent);
// Enrich any handles missing from cache (or stale), then re-render
this.enrichRecent(recent);
}
renderRecent(handles) {
const cache = getProfileCache();
this.dropdown.innerHTML = '';
this.currentItems = [];
const header = document.createElement('div');
header.className = 'sailor-typeahead-header';
header.textContent = 'Recent accounts';
this.dropdown.appendChild(header);
handles.forEach((handle, i) => {
const profile = cache[handle]?.profile || { handle };
this.currentItems.push(profile);
this.dropdown.appendChild(this.buildActorRow(profile, i));
});
this.dropdown.style.display = 'block';
}
async enrichRecent(handles) {
const cache = getProfileCache();
const now = Date.now();
const stale = handles.filter((h) => {
const entry = cache[h];
return !entry || now - entry.ts > PROFILE_CACHE_TTL_MS;
});
if (stale.length === 0) return;
const profiles = await fetchProfiles(stale);
if (profiles.length === 0) return;
const updated = getProfileCache();
profiles.forEach((p) => {
updated[p.handle] = {
ts: now,
profile: {
handle: p.handle,
displayName: p.displayName,
avatar: p.avatar,
},
};
});
setProfileCache(updated);
// Re-render if still in recent mode
if (this.mode === 'recent') {
this.renderRecent(handles);
}
}
hide() {
this.mode = 'hidden';
this.focusIndex = -1;
this.dropdown.style.display = 'none';
}
select(profile) {
// Accept either a profile object or a bare handle string
if (typeof profile === 'string') profile = { handle: profile };
this.input.value = profile.handle;
this.hide();
this.showSelectedCard(profile);
// Cache this profile so re-render (and future page loads) show rich data
if (profile.handle) {
const cache = getProfileCache();
cache[profile.handle] = {
ts: Date.now(),
profile: {
handle: profile.handle,
displayName: profile.displayName,
avatar: profile.avatar,
},
};
setProfileCache(cache);
}
}
showSelectedCard(profile) {
this.clearSelectedCard();
const card = document.createElement('div');
card.className = 'sailor-typeahead-selected';
const avatar = document.createElement('div');
avatar.className = 'sailor-typeahead-avatar';
if (profile.avatar) {
const img = document.createElement('img');
img.src = profile.avatar;
img.alt = '';
avatar.appendChild(img);
}
const text = document.createElement('div');
text.className = 'sailor-typeahead-text';
const hasDisplayName = profile.displayName && profile.displayName !== profile.handle;
if (hasDisplayName) {
const name = document.createElement('div');
name.className = 'sailor-typeahead-name';
name.textContent = profile.displayName;
text.appendChild(name);
}
const handleEl = document.createElement('div');
handleEl.className = hasDisplayName ? 'sailor-typeahead-handle' : 'sailor-typeahead-name';
handleEl.textContent = '@' + profile.handle;
text.appendChild(handleEl);
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'sailor-typeahead-clear';
clearBtn.tabIndex = -1; // keep out of tab order; Navigate button should come next
clearBtn.setAttribute('aria-label', 'Change account');
clearBtn.innerHTML = '&times;';
clearBtn.addEventListener('click', () => this.clearSelection());
card.append(avatar, text, clearBtn);
this.input.style.display = 'none';
this.input.insertAdjacentElement('beforebegin', card);
this.selectedCard = card;
}
clearSelectedCard() {
if (this.selectedCard) {
this.selectedCard.remove();
this.selectedCard = null;
}
}
clearSelection() {
this.clearSelectedCard();
this.input.style.display = '';
this.input.value = '';
this.input.focus();
this.showRecent();
}
handleKeydown(e) {
if (this.mode === 'hidden') return;
const items = this.dropdown.querySelectorAll('.sailor-typeahead-item');
if (items.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
this.focusIndex = (this.focusIndex + 1) % items.length;
this.updateFocus(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.focusIndex = this.focusIndex <= 0 ? items.length - 1 : this.focusIndex - 1;
this.updateFocus(items);
} else if (e.key === 'Enter') {
if (this.focusIndex >= 0 && this.currentItems[this.focusIndex]) {
e.preventDefault();
this.select(this.currentItems[this.focusIndex]);
}
} else if (e.key === 'Escape') {
this.hide();
} else if (e.key === 'Tab' && this.focusIndex === -1 && items.length > 0) {
// First Tab moves into dropdown instead of leaving the field
e.preventDefault();
this.focusIndex = 0;
this.updateFocus(items);
}
}
updateFocus(items) {
items.forEach((item, i) => {
item.classList.toggle('focused', i === this.focusIndex);
if (i === this.focusIndex) {
item.scrollIntoView({ block: 'nearest' });
}
});
}
}
async function fetchTypeahead(host, q, timeoutMs) {
const url = new URL(SEARCH_PATH, host);
url.searchParams.set('q', q);
url.searchParams.set('limit', String(RESULT_LIMIT));
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error('HTTP ' + res.status);
const json = await res.json();
return Array.isArray(json.actors) ? json.actors : [];
} finally {
clearTimeout(timer);
}
}
async function fetchProfiles(handles) {
if (handles.length === 0) return [];
const url = new URL(PROFILES_PATH, FALLBACK_HOST);
handles.forEach((h) => url.searchParams.append('actors', h));
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PROFILES_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) return [];
const json = await res.json();
return Array.isArray(json.profiles) ? json.profiles : [];
} catch (_) {
return [];
} finally {
clearTimeout(timer);
}
}
function getProfileCache() {
try {
return JSON.parse(localStorage.getItem(PROFILE_CACHE_KEY) || '{}');
} catch (_) {
return {};
}
}
function setProfileCache(cache) {
try {
localStorage.setItem(PROFILE_CACHE_KEY, JSON.stringify(cache));
} catch (_) { /* quota exceeded — ignore */ }
}
function getRecentAccounts() {
try {
const raw = localStorage.getItem(RECENT_KEY);
return raw ? JSON.parse(raw) : [];
} catch (_) {
return [];
}
}
export function saveRecentAccount(handle) {
if (!handle) return;
try {
let recent = getRecentAccounts().filter((h) => h !== handle);
recent.unshift(handle);
recent = recent.slice(0, RECENT_MAX);
localStorage.setItem(RECENT_KEY, JSON.stringify(recent));
} catch (err) {
console.error('Failed to save recent account:', err);
}
}
document.addEventListener('DOMContentLoaded', () => {
const input = document.getElementById('handle');
if (input) {
new SailorTypeahead(input);
}
});

View File

@@ -8,17 +8,16 @@
<body>
{{ template "nav-simple" . }}
<main class="min-h-[calc(100vh-4rem)] flex items-center justify-center px-4">
<div class="w-full max-w-md">
<h1 class="text-2xl font-semibold text-center mb-2">Sign in to {{ .ClientName }}</h1>
<p class="text-center text-base-content/60 mb-6">Use your ATProto handle to sign in</p>
<main class="min-h-[calc(100vh-4rem)] flex items-start justify-center px-4 pt-16 sm:pt-24">
<div class="w-full max-w-2xl">
<h1 class="text-3xl sm:text-4xl font-semibold text-center mb-8">Sign in to {{ .ClientName }}</h1>
{{ if .Error }}
<div class="alert alert-error mb-6">
{{ icon "circle-x" "size-5" }}
<span>
{{ if eq .Error "handle_required" }}
Please enter your handle
Please enter your Atmosphere Account
{{ else if eq .Error "auth_failed" }}
Authentication failed. Please try again.
{{ else }}
@@ -28,38 +27,43 @@
</div>
{{ end }}
<form action="/auth/oauth/login" method="POST" id="login-form" class="card bg-base-100 p-6">
<form action="/auth/oauth/login" method="POST" id="login-form" class="max-w-md mx-auto flex flex-col">
<input type="hidden" name="return_to" value="{{ .ReturnTo }}" />
<fieldset class="fieldset relative">
<legend class="sr-only">Login credentials</legend>
<label class="label" for="handle">
<span class="label-text">Your ATProto Handle</span>
</label>
<actor-typeahead rows="5" class="block">
<input type="text"
id="handle"
name="handle"
class="input input-bordered w-full"
placeholder="alice.bsky.social"
autocomplete="off"
required
autofocus />
</actor-typeahead>
<p class="label">
<span class="label-text-alt text-base-content/60">Enter your Bluesky or ATProto handle</span>
</p>
</fieldset>
<div class="sailor-typeahead relative order-1">
<input type="text"
id="handle"
name="handle"
class="input input-bordered input-lg w-full"
placeholder="alice.bsky.social"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
required
autofocus />
</div>
<button type="submit" class="btn btn-primary w-full mt-4">
Continue with ATProto
<button type="submit" class="btn btn-primary btn-lg w-full mt-6 order-3">
Navigate
</button>
</form>
<p class="text-center text-base-content/60 mt-6">
Don't have an account? Create one at
<a href="https://bsky.app" target="_blank" rel="noopener noreferrer" class="link link-primary" aria-label="Create an account on bsky.app (opens in new tab)">bsky.app</a>
</p>
<details class="sailor-info mt-3 order-2">
<summary>Not sure if you have an account?</summary>
<div class="sailor-info-body">
<p>
An <strong>Atmosphere Account</strong> is a portable identity on the AT Protocol —
the same network that powers Bluesky, Tangled, and other apps. One account works
across every application built on the protocol.
</p>
<p>
The easiest way to create one is at
<a href="https://bsky.app" target="_blank" rel="noopener noreferrer" class="link link-primary">bsky.app</a>.
Already have a Bluesky handle? You're all set — use it here.
</p>
</div>
</details>
</form>
</div>
</main>

View File

@@ -308,4 +308,3 @@ func parseBasicAuthDID(username, password string) (string, string) {
return username, password
}