AT Mot — a bilingual (EN/FR) daily word game native to the AT Protocol.
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();