draw things
doodl.waow.tech
draw
atproto
1import { setupCanvas } from "./ui/draw";
2import type { OAuthSession } from "@atproto/oauth-client-browser";
3import type { PrefMap } from "./data/prefs";
4
5// the app shell: the genuinely shared state (session, canvas, the chrome refs),
6// the router, and a tiny event bus so feature modules can react to view changes
7// and ui refreshes without importing each other. each feature `init`s against
8// this; main.ts is just the composition root that wires them together.
9
10const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
11
12// localStorage keys that cross features (the draft + save intent survive the
13// full-page OAuth redirect; restored during boot in main.ts).
14export const DRAFT_KEY = "doodl:draft";
15export const PENDING_SAVE = "doodl:pending-save";
16
17// ---- shared DOM refs + the canvas ----
18export const pad = setupCanvas($<HTMLCanvasElement>("pad"));
19export const statusEl = $("status");
20export const sessionEl = $("session");
21export const saveBtn = $<HTMLButtonElement>("save");
22export const views = {
23 draw: $("view-draw"),
24 explore: $("view-explore"),
25 settings: $("view-settings"),
26};
27export const navBtns = { draw: $("nav-draw"), explore: $("nav-explore") };
28
29// ---- shared mutable state (accessor-guarded so reads are always current) ----
30let session: OAuthSession | null = null;
31let currentHandle: string | null = null;
32let myDefaults: PrefMap = {};
33
34export const getSession = () => session;
35export const setSession = (s: OAuthSession | null) => (session = s);
36export const getHandle = () => currentHandle;
37export const setHandle = (h: string | null) => (currentHandle = h);
38export const getDefaults = () => myDefaults;
39export const setDefaults = (d: PrefMap) => (myDefaults = d);
40
41// ---- tiny event bus: "view" (a view was shown) and "refresh" (re-render chrome) ----
42type Ev = "view" | "refresh";
43const listeners: Record<Ev, Array<(view?: View) => void>> = { view: [], refresh: [] };
44export function on(ev: Ev, cb: (view?: View) => void) {
45 listeners[ev].push(cb);
46}
47function emit(ev: Ev, view?: View) {
48 for (const f of listeners[ev]) f(view);
49}
50export function refreshUI() {
51 emit("refresh");
52}
53
54// ---- router: views are real URLs (wisp --spa serves index.html for any path) ----
55export type View = "draw" | "explore" | "settings";
56const VIEW_PATH: Record<View, string> = {
57 draw: "/",
58 explore: "/explore",
59 settings: "/settings",
60};
61export function routeView(): View {
62 const p = location.pathname.replace(/\/+$/, "");
63 if (p.endsWith("/explore")) return "explore";
64 if (p.endsWith("/settings")) return "settings";
65 return "draw";
66}
67export function show(view: View, opts: { push?: boolean } = {}) {
68 (Object.keys(views) as View[]).forEach((k) => (views[k].hidden = k !== view));
69 navBtns.draw.classList.toggle("active", view === "draw");
70 navBtns.explore.classList.toggle("active", view === "explore");
71 // the settings trigger is redundant while already on the settings page
72 sessionEl.hidden = view === "settings";
73 emit("view", view);
74 if (opts.push !== false && location.pathname !== VIEW_PATH[view]) {
75 history.pushState({ view }, "", VIEW_PATH[view]);
76 }
77}
78window.addEventListener("popstate", () => show(routeView(), { push: false }));