mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-05-12 19:11:28 +00:00
update the login page
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
96
pkg/appview/public/js/bundle.min.js
vendored
96
pkg/appview/public/js/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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='));
|
||||
|
||||
@@ -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';
|
||||
|
||||
488
pkg/appview/src/js/sailor-typeahead.js
Normal file
488
pkg/appview/src/js/sailor-typeahead.js
Normal 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 = '×';
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -308,4 +308,3 @@ func parseBasicAuthDID(username, password string) (string, string) {
|
||||
|
||||
return username, password
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user