AT Mot — a bilingual (EN/FR) daily word game native to the AT Protocol.
0

Configure Feed

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

at trunk 3.6 kB View raw
1import './ui/theme.css'; 2import { isLang, type Lang, parsePuzzleTarget, pathForLangSwitch } from './config.js'; 3import { 4 isOAuthCallback, 5 completeCallback, 6 resumeSession, 7 signIn, 8 signOut as oauthSignOut, 9 type Session, 10} from './atproto/oauth.js'; 11import { ensureDeclaration, recordCompletedGame } from './atproto/records.js'; 12import { handleForDid } from './atproto/identity.js'; 13import type { Ctx } from './ui/context.js'; 14import { renderPlayView } from './ui/playView.js'; 15import { renderPuzzlePage } from './ui/puzzlePage.js'; 16import { renderAboutPage } from './ui/aboutPage.js'; 17import { initColorBlind } from './ui/colorblind.js'; 18import { toast } from './ui/toast.js'; 19 20const root = document.getElementById('app')!; 21 22// Apply the saved colour-blind preference before the first render. 23initColorBlind(); 24 25const LANG_KEY = 'atmot:lang'; 26function initialLang(): Lang { 27 const saved = localStorage.getItem(LANG_KEY); 28 if (saved && isLang(saved)) return saved; 29 // Default to the browser's preferred language if it's one we support. 30 const nav = navigator.language?.slice(0, 2).toLowerCase(); 31 return nav && isLang(nav) ? nav : 'en'; 32} 33 34const ctx: Ctx = { 35 lang: initialLang(), 36 session: null, 37 handle: null, 38 pendingRecord: null, 39 40 setLang(lang: Lang) { 41 ctx.lang = lang; 42 localStorage.setItem(LANG_KEY, lang); 43 document.documentElement.lang = lang; 44 // On a puzzle page the language lives in the URL, so switch the path too — 45 // otherwise render() re-derives the old language and nothing visibly changes. 46 const dest = pathForLangSwitch(location.pathname, lang); 47 if (dest) { 48 ctx.navigate(dest); 49 return; 50 } 51 render(); 52 }, 53 54 navigate(path: string) { 55 if (path !== location.pathname) history.pushState(null, '', path); 56 render(); 57 }, 58 59 rerender: render, 60 61 async signInWith(identifier: string) { 62 await signIn(identifier); // redirects away 63 }, 64 65 async signOut() { 66 await oauthSignOut(ctx.session); 67 ctx.session = null; 68 ctx.handle = null; 69 render(); 70 }, 71}; 72 73async function adoptSession(session: Session): Promise<void> { 74 ctx.session = session; 75 ctx.handle = null; 76 // Create the declaration record (idempotent) so the account is discoverable. 77 void ensureDeclaration(session).catch(() => {}); 78 // Resolve handle for the UI (best-effort). 79 handleForDid(session.did).then((h) => { 80 ctx.handle = h.startsWith('did:') ? null : h; 81 render(); 82 }); 83 // If a game finished before sign-in, record it now. 84 if (ctx.pendingRecord) { 85 const pending = ctx.pendingRecord; 86 ctx.pendingRecord = null; 87 try { 88 await recordCompletedGame(session, pending); 89 } catch { 90 /* will be retried on next finish; non-fatal */ 91 } 92 } 93} 94 95function render(): void { 96 document.documentElement.lang = ctx.lang; 97 const path = location.pathname; 98 99 const puzzle = parsePuzzleTarget(path); 100 if (puzzle) { 101 renderPuzzlePage(ctx, root, puzzle.lang, puzzle.puzzleNumber); 102 } else if (path === '/about') { 103 renderAboutPage(ctx, root); 104 } else { 105 renderPlayView(ctx, root); 106 } 107 root.removeAttribute('aria-busy'); 108} 109 110// Intercept browser back/forward. 111window.addEventListener('popstate', render); 112 113async function boot(): Promise<void> { 114 try { 115 if (isOAuthCallback()) { 116 const session = await completeCallback(); 117 await adoptSession(session); 118 } else { 119 const session = await resumeSession(); 120 if (session) await adoptSession(session); 121 } 122 } catch { 123 toast('Sign-in failed'); 124 } 125 render(); 126} 127 128void boot();