draw things doodl.waow.tech
draw atproto
0

Configure Feed

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

refactor: split the main.ts god module into features over a shared app context

main.ts: 1289 → 88 lines — now a composition root that reads as a boot tour.

- src/app.ts: the shell — shared state (session/handle/defaults), the canvas +
chrome refs, the router, and a tiny event bus ("view"/"refresh") so features
react without importing each other.
- src/auth.ts: oauth init, signIn, logout.
- src/ui/: slots (the icon registry), sheets (the shared bottom-sheet).
- src/features/: tools, chrome, gallery, settings, preferences, remix,
icon-mode, save. each exposes init(); save reads remix/icon/prefs via their
getters (the integration seam stays explicit, no behavior change).

behaviour preserved; tsc + build green. plan in docs/refactor-main.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

+1440 -1247
+78
src/app.ts
··· 1 + import { setupCanvas } from "./ui/draw"; 2 + import type { OAuthSession } from "@atproto/oauth-client-browser"; 3 + import 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 + 10 + const $ = <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). 14 + export const DRAFT_KEY = "doodl:draft"; 15 + export const PENDING_SAVE = "doodl:pending-save"; 16 + 17 + // ---- shared DOM refs + the canvas ---- 18 + export const pad = setupCanvas($<HTMLCanvasElement>("pad")); 19 + export const statusEl = $("status"); 20 + export const sessionEl = $("session"); 21 + export const saveBtn = $<HTMLButtonElement>("save"); 22 + export const views = { 23 + draw: $("view-draw"), 24 + explore: $("view-explore"), 25 + settings: $("view-settings"), 26 + }; 27 + export const navBtns = { draw: $("nav-draw"), explore: $("nav-explore") }; 28 + 29 + // ---- shared mutable state (accessor-guarded so reads are always current) ---- 30 + let session: OAuthSession | null = null; 31 + let currentHandle: string | null = null; 32 + let myDefaults: PrefMap = {}; 33 + 34 + export const getSession = () => session; 35 + export const setSession = (s: OAuthSession | null) => (session = s); 36 + export const getHandle = () => currentHandle; 37 + export const setHandle = (h: string | null) => (currentHandle = h); 38 + export const getDefaults = () => myDefaults; 39 + export const setDefaults = (d: PrefMap) => (myDefaults = d); 40 + 41 + // ---- tiny event bus: "view" (a view was shown) and "refresh" (re-render chrome) ---- 42 + type Ev = "view" | "refresh"; 43 + const listeners: Record<Ev, Array<(view?: View) => void>> = { view: [], refresh: [] }; 44 + export function on(ev: Ev, cb: (view?: View) => void) { 45 + listeners[ev].push(cb); 46 + } 47 + function emit(ev: Ev, view?: View) { 48 + for (const f of listeners[ev]) f(view); 49 + } 50 + export function refreshUI() { 51 + emit("refresh"); 52 + } 53 + 54 + // ---- router: views are real URLs (wisp --spa serves index.html for any path) ---- 55 + export type View = "draw" | "explore" | "settings"; 56 + const VIEW_PATH: Record<View, string> = { 57 + draw: "/", 58 + explore: "/explore", 59 + settings: "/settings", 60 + }; 61 + export 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 + } 67 + export 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 + } 78 + window.addEventListener("popstate", () => show(routeView(), { push: false }));
+46
src/auth.ts
··· 1 + import { initOAuth } from "./data/oauth"; 2 + import { 3 + pad, 4 + statusEl, 5 + DRAFT_KEY, 6 + PENDING_SAVE, 7 + getSession, 8 + setSession, 9 + setHandle, 10 + show, 11 + refreshUI, 12 + } from "./app"; 13 + import { iconOverrides } from "./ui/slots"; 14 + 15 + let oauth: Awaited<ReturnType<typeof initOAuth>>; 16 + 17 + // resolve the OAuth client + any returning session. must run before features 18 + // read getSession(); awaited from main during boot. 19 + export async function finishOAuth() { 20 + oauth = await initOAuth(); 21 + const result = await oauth.init(); 22 + if (result?.session) setSession(result.session); 23 + } 24 + 25 + export async function signIn(handle: string) { 26 + statusEl.textContent = "taking you to sign in…"; 27 + // keep the drawing across the redirect 28 + if (!pad.isBlank()) localStorage.setItem(DRAFT_KEY, pad.serialize()); 29 + try { 30 + await oauth.signIn(handle); 31 + } catch (err) { 32 + localStorage.removeItem(DRAFT_KEY); 33 + localStorage.removeItem(PENDING_SAVE); 34 + statusEl.textContent = `sign in failed: ${String(err)}`; 35 + } 36 + } 37 + 38 + export async function logout() { 39 + const s = getSession(); 40 + if (s) await oauth.revoke(s.did); 41 + setSession(null); 42 + setHandle(null); 43 + for (const k of Object.keys(iconOverrides)) delete iconOverrides[k]; 44 + show("draw"); // can't be on the settings page while signed out 45 + refreshUI(); 46 + }
+76
src/features/chrome.ts
··· 1 + import { sessionEl, saveBtn, navBtns, getSession, show, on } from "../app"; 2 + import { slotNode, slotUrl } from "../ui/slots"; 3 + import { el } from "../ui/dom"; 4 + import { mountLogin } from "../ui/login"; 5 + import { signIn } from "../auth"; 6 + import { resyncSize } from "./tools"; 7 + 8 + const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T; 9 + 10 + // the title is a slot (replaceable with a drawing) and takes you home 11 + const titleEl = document.querySelector("h1"); 12 + function renderTitle() { 13 + if (titleEl) titleEl.replaceChildren(slotNode("title")); 14 + } 15 + 16 + // header top-right: sign in until you're signed in, then the settings trigger 17 + function renderTrigger() { 18 + sessionEl.className = "trigger-wrap"; 19 + if (getSession()) { 20 + const btn = el( 21 + "button", 22 + { class: "menu-trigger", id: "menu-btn", "aria-label": "settings", title: "settings" }, 23 + [slotNode("settings")], 24 + ); 25 + btn.addEventListener("click", () => show("settings")); 26 + sessionEl.replaceChildren(btn); 27 + } else { 28 + const btn = el( 29 + "button", 30 + { class: "menu-trigger", id: "signin-btn", "aria-label": "sign in" }, 31 + [slotNode("signin")], 32 + ); 33 + btn.addEventListener("click", () => mountLogin(sessionEl, signIn)); 34 + sessionEl.replaceChildren(btn); 35 + } 36 + saveBtn.disabled = false; 37 + } 38 + 39 + // nav labels are slots too — show the user's drawing if assigned 40 + function renderNav() { 41 + navBtns.draw.replaceChildren(slotNode("nav-draw")); 42 + navBtns.explore.replaceChildren(slotNode("nav-explore")); 43 + } 44 + 45 + // toolbar actions are slots too (the saving/disabled state stays on #status) 46 + function renderToolbar() { 47 + saveBtn.replaceChildren(slotNode("save")); 48 + $("undo").replaceChildren(slotNode("undo")); 49 + $("clear").replaceChildren(slotNode("clear")); 50 + // stroke: the user's drawing if assigned, else a live dot previewing the size 51 + const strokeUrl = slotUrl("stroke"); 52 + $("stroke-btn").replaceChildren( 53 + strokeUrl 54 + ? el("img", { class: "slot-img", src: strokeUrl, alt: "stroke" }) 55 + : el("span", { class: "brush-dot" }), 56 + ); 57 + resyncSize(); // re-sync the freshly-rendered preview dot 58 + } 59 + 60 + function renderChrome() { 61 + renderTitle(); 62 + renderTrigger(); 63 + renderNav(); 64 + renderToolbar(); 65 + } 66 + 67 + export function initChrome() { 68 + if (titleEl) { 69 + (titleEl as HTMLElement).style.cursor = "pointer"; 70 + titleEl.addEventListener("click", () => show("draw")); 71 + } 72 + navBtns.draw.addEventListener("click", () => show("draw")); 73 + navBtns.explore.addEventListener("click", () => show("explore")); 74 + on("refresh", renderChrome); 75 + renderChrome(); 76 + }
+123
src/features/gallery.ts
··· 1 + import { getSession, on } from "../app"; 2 + import { slotNode, slotUrl } from "../ui/slots"; 3 + import { el } from "../ui/dom"; 4 + import { fetchGallery } from "../data/explore"; 5 + import { deleteDrawing } from "../data/pds"; 6 + import { resolveOriginal, REMIX } from "../data/lineage"; 7 + 8 + const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T; 9 + 10 + async function loadGallery() { 11 + const g = $("gallery"); 12 + g.innerHTML = "<p class='hint'>loading…</p>"; 13 + try { 14 + const drawings = await fetchGallery({ hideOptedOut: true }); 15 + g.innerHTML = drawings.length ? "" : "<p class='hint'>no drawings yet — be the first.</p>"; 16 + const session = getSession(); 17 + for (const l of drawings) { 18 + const detailUrl = `${location.origin}/l/${l.did}/${l.rkey}`; 19 + const card = document.createElement("div"); 20 + card.className = "card"; 21 + card.tabIndex = 0; 22 + // the whole card opens the detail page; small actions stop propagation 23 + const openDetail = () => window.open(detailUrl, "_blank", "noreferrer"); 24 + card.addEventListener("click", openDetail); 25 + card.addEventListener("keydown", (e) => { 26 + if (e.key === "Enter") openDetail(); 27 + }); 28 + 29 + const img = document.createElement("img"); 30 + img.className = "card-img"; 31 + img.src = l.imageUrl; 32 + img.alt = `drawing by @${l.handle}`; 33 + img.loading = "lazy"; 34 + 35 + // avatar tucked top-left, out of the way 36 + const ava = document.createElement(l.avatar ? "img" : "div"); 37 + ava.className = "card-ava"; 38 + if (l.avatar) (ava as HTMLImageElement).src = l.avatar; 39 + else ava.textContent = l.handle[0]?.toUpperCase() ?? "?"; 40 + ava.title = `@${l.handle}`; 41 + 42 + const who = document.createElement("div"); 43 + who.className = "who"; 44 + who.textContent = `@${l.handle}`; 45 + who.title = `@${l.handle}`; 46 + 47 + // remix: a quiet accent-tinted card + the viewer's own drawn remix mark 48 + // and the original artist, credited at a glance. lineage resolves lazily 49 + // so the grid never waits on it. 50 + let credit: HTMLAnchorElement | null = null; 51 + if (l.parentUri) { 52 + card.classList.add("remix"); 53 + // the viewer's drawn remix mark if they made one (redraw everything), 54 + // else a compact ↻ — same glyph the share page uses. 55 + const markGlyph = slotUrl("remix") 56 + ? slotNode("remix") 57 + : document.createTextNode("↻"); 58 + const mark = el("span", { class: "remix-mark" }, [markGlyph]); 59 + const origEl = el("span", { class: "remix-orig" }); 60 + credit = el("a", { class: "remix-credit" }, [mark, origEl]); 61 + credit.addEventListener("click", (e) => e.stopPropagation()); 62 + resolveOriginal(l.parentUri) 63 + .then((orig) => { 64 + if (!orig || !credit) return; 65 + credit.href = `${location.origin}/l/${orig.did}/${orig.rkey}`; 66 + credit.title = `${REMIX} of @${orig.handle}`; 67 + origEl.textContent = `@${orig.handle}`; 68 + }) 69 + .catch(() => {}); 70 + } 71 + 72 + // subtle corner actions, top-right, never covering the handle 73 + const actions = document.createElement("div"); 74 + actions.className = "card-actions"; 75 + const copy = document.createElement("button"); 76 + copy.className = "ca-btn"; 77 + copy.title = "copy link"; 78 + copy.textContent = "⧉"; 79 + copy.addEventListener("click", async (e) => { 80 + e.stopPropagation(); 81 + try { 82 + await navigator.clipboard.writeText(detailUrl); 83 + copy.textContent = "✓"; 84 + setTimeout(() => (copy.textContent = "⧉"), 1200); 85 + } catch { 86 + openDetail(); 87 + } 88 + }); 89 + actions.append(copy); 90 + 91 + if (session && l.did === session.did) { 92 + const del = document.createElement("button"); 93 + del.className = "ca-btn danger"; 94 + del.title = "delete"; 95 + del.textContent = "×"; 96 + del.addEventListener("click", async (e) => { 97 + e.stopPropagation(); 98 + if (!confirm("delete this drawing?")) return; 99 + del.disabled = true; 100 + try { 101 + await deleteDrawing(session, l.rkey); 102 + card.remove(); 103 + } catch (err) { 104 + del.disabled = false; 105 + alert(`delete failed: ${String(err)}`); 106 + } 107 + }); 108 + actions.append(del); 109 + } 110 + 111 + card.append(ava, img, who, ...(credit ? [credit] : []), actions); 112 + g.append(card); 113 + } 114 + } catch (err) { 115 + g.replaceChildren(el("p", { class: "hint" }, [`failed to load: ${String(err)}`])); 116 + } 117 + } 118 + 119 + export function initGallery() { 120 + on("view", (v) => { 121 + if (v === "explore") loadGallery(); 122 + }); 123 + }
+37
src/features/icon-mode.ts
··· 1 + import { pad, show } from "../app"; 2 + import { slotLabel } from "../ui/slots"; 3 + import { el } from "../ui/dom"; 4 + import { resetSaveGuard } from "./save"; 5 + 6 + const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T; 7 + 8 + // "draw a new icon for a slot": no special save button — the normal save 9 + // assigns it (see save.ts). a banner just reminds you what you're drawing. 10 + let pendingIconSlot: string | null = null; 11 + export const getPendingIconSlot = () => pendingIconSlot; 12 + export const clearPendingIconSlot = () => (pendingIconSlot = null); 13 + 14 + export function enterIconMode(slot: string) { 15 + pendingIconSlot = slot; 16 + pad.clear(); 17 + resetSaveGuard(); 18 + show("draw"); 19 + const b = $("mode-banner"); 20 + b.hidden = false; 21 + const cancel = el("button", { class: "pill" }, ["cancel"]); 22 + cancel.addEventListener("click", () => { 23 + pendingIconSlot = null; 24 + exitIconMode(); 25 + }); 26 + b.replaceChildren( 27 + "drawing your ", 28 + el("strong", {}, [slotLabel(slot)]), 29 + " icon — just save when ready ", 30 + cancel, 31 + ); 32 + } 33 + 34 + export function exitIconMode() { 35 + const b = document.getElementById("mode-banner"); 36 + if (b) b.hidden = true; 37 + }
+161
src/features/preferences.ts
··· 1 + import { getSession, getDefaults, setDefaults } from "../app"; 2 + import { slotNode } from "../ui/slots"; 3 + import { el } from "../ui/dom"; 4 + import { closeSheet } from "../ui/sheets"; 5 + import { 6 + PREF_QUESTIONS, 7 + HARD_DEFAULTS, 8 + BOT_HANDLE, 9 + effectivePref, 10 + type PrefMap, 11 + type PrefKey, 12 + } from "../data/prefs"; 13 + import { setPreferences } from "../data/pds"; 14 + import { profileLink } from "../data/clients"; 15 + 16 + const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T; 17 + 18 + // render a question title, turning any declared token into a link (resolved here 19 + // so the bot profile honors the preferred-client setting). 20 + function questionTitle(q: (typeof PREF_QUESTIONS)[number]): (Node | string)[] { 21 + let parts: (Node | string)[] = [q.title]; 22 + for (const lk of q.links ?? []) { 23 + const href = 24 + lk.target === "explore-page" ? "/explore" : profileLink(BOT_HANDLE); 25 + parts = parts.flatMap((p): (Node | string)[] => { 26 + if (typeof p !== "string") return [p]; 27 + const i = p.indexOf(lk.token); 28 + if (i < 0) return [p]; 29 + return [ 30 + p.slice(0, i), 31 + el("a", { href, target: "_blank", rel: "noreferrer" }, [lk.token]), 32 + p.slice(i + lk.token.length), 33 + ]; 34 + }); 35 + } 36 + return parts; 37 + } 38 + 39 + // question controls, shared by the walkthrough + the settings editor. when 40 + // onPick returns a promise (settings, which saves live) a "saved ✓" status 41 + // flashes — the change is visibly persisted, no separate save step. 42 + export function prefControls( 43 + current: PrefMap, 44 + onPick: (key: PrefKey, value: string) => void | Promise<void>, 45 + ): Node[] { 46 + const status = el("span", { class: "pref-status", role: "status" }); 47 + let timer: ReturnType<typeof setTimeout> | undefined; 48 + const flash = (text: string, ok: boolean) => { 49 + clearTimeout(timer); 50 + status.textContent = text; 51 + status.classList.toggle("ok", ok); 52 + if (ok) { 53 + timer = setTimeout(() => { 54 + status.textContent = ""; 55 + status.classList.remove("ok"); 56 + }, 1600); 57 + } 58 + }; 59 + 60 + const qs = PREF_QUESTIONS.map((q) => { 61 + const chosen = current[q.key] ?? HARD_DEFAULTS[q.key]; 62 + const opts = el( 63 + "div", 64 + { class: "pref-opts" }, 65 + q.options.map((o) => { 66 + const b = el( 67 + "button", 68 + { class: `pref-opt${o.value === chosen ? " sel" : ""}`, "data-val": o.value }, 69 + [o.label], 70 + ); 71 + b.addEventListener("click", async () => { 72 + opts.querySelectorAll(".pref-opt").forEach((x) => x.classList.remove("sel")); 73 + b.classList.add("sel"); 74 + try { 75 + const r = onPick(q.key, o.value); 76 + if (r instanceof Promise) { 77 + flash("saving…", false); 78 + await r; 79 + flash("saved ✓", true); 80 + } 81 + } catch { 82 + flash("couldn't save — sign out and back in?", false); 83 + } 84 + }); 85 + return b; 86 + }), 87 + ); 88 + return el("div", { class: "pref-q" }, [ 89 + el("h4", {}, questionTitle(q)), 90 + el("p", { class: "hint" }, [q.blurb]), 91 + opts, 92 + ]); 93 + }); 94 + return [...qs, status]; 95 + } 96 + 97 + // the one-time first-run sheet (shown when no preferences record exists). saving 98 + // writes the record, which is what stops it from showing again. 99 + export function openPrefsWalkthrough() { 100 + const session = getSession(); 101 + if (!session) return; 102 + const draft: PrefMap = { ...getDefaults() }; 103 + const sheet = $("sheet"); 104 + sheet.hidden = false; 105 + const save = el("button", { class: "pill primary prefs-save" }, [ 106 + "save preferences", 107 + ]); 108 + sheet.replaceChildren( 109 + el("div", { class: "sheet-card prefs-sheet" }, [ 110 + el("div", { class: "sheet-grab" }), 111 + el("div", { class: "prefs-welcome" }, [ 112 + el("span", { class: "prefs-mark" }, [slotNode("title")]), 113 + el("p", {}, [ 114 + "a couple of one-time choices about what's public. change them anytime in settings.", 115 + ]), 116 + ]), 117 + ...prefControls(draft, (k, v) => { 118 + draft[k] = v; 119 + }), 120 + el("div", { class: "sheet-foot" }, [save]), 121 + ]), 122 + ); 123 + save.addEventListener("click", async () => { 124 + save.disabled = true; 125 + save.textContent = "saving…"; 126 + try { 127 + const full: PrefMap = {}; 128 + for (const q of PREF_QUESTIONS) full[q.key] = draft[q.key] ?? HARD_DEFAULTS[q.key]; 129 + await setPreferences(session, full); 130 + setDefaults(full); 131 + syncExploreToggle(); 132 + save.textContent = "saved ✓"; 133 + save.classList.add("ok"); 134 + setTimeout(closeSheet, 700); 135 + } catch (err) { 136 + save.disabled = false; 137 + save.textContent = "save preferences"; 138 + // a pre-existing session may lack the new preferences scope → re-consent 139 + alert( 140 + `couldn't save — doodl needs a new permission. sign out and back in, then try again.\n\n${String(err)}`, 141 + ); 142 + } 143 + }); 144 + } 145 + 146 + // the per-drawing "show in explore" toggle reflects your global default until 147 + // you flip it for the drawing at hand. reset to the default after each save. 148 + export function syncExploreToggle() { 149 + const cb = document.getElementById("explore-this") as HTMLInputElement | null; 150 + if (cb) cb.checked = effectivePref("explore", undefined, getDefaults()) !== "hide"; 151 + } 152 + 153 + // the per-drawing override to attach on save: only when this drawing's explore 154 + // choice differs from your global default (else inherit, no override written). 155 + export function exploreOverride(): PrefMap | undefined { 156 + const cb = document.getElementById("explore-this") as HTMLInputElement | null; 157 + if (!cb) return undefined; 158 + const choice = cb.checked ? "show" : "hide"; 159 + const def = effectivePref("explore", undefined, getDefaults()); 160 + return choice === def ? undefined : { explore: choice }; 161 + }
+123
src/features/remix.ts
··· 1 + import { pad, statusEl, show } from "../app"; 2 + import { getRemixSource, type RemixSource, type StrongRef } from "../data/pds"; 3 + import { REMIX } from "../data/lineage"; 4 + import { el } from "../ui/dom"; 5 + import { resetSaveGuard } from "./save"; 6 + 7 + const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T; 8 + 9 + // opening someone's doodl as a remix preloads the canvas (their editable strokes 10 + // or their pixels as a locked base) and remembers the lineage so the next save 11 + // writes a child with `parent`. survives the OAuth redirect via REMIX_KEY (the 12 + // strokes ride along in DRAFT_KEY). 13 + const REMIX_KEY = "doodl:remix"; 14 + let pendingParent: StrongRef | null = null; 15 + let remixBaseRef: StrongRef | null = null; 16 + // the canvas state a remix STARTED from — so we refuse to publish a child that's 17 + // identical to its parent (a no-op remix that adds nothing). 18 + let remixInitialSerial: string | null = null; 19 + 20 + export const getPendingParent = () => pendingParent; 21 + export const getRemixBaseRef = () => remixBaseRef; 22 + export const getRemixInitialSerial = () => remixInitialSerial; 23 + 24 + export function enterRemixMode(rs: RemixSource) { 25 + pendingParent = rs.parent; 26 + remixBaseRef = rs.baseRef; 27 + pad.setBaseImage(rs.baseImageUrl); 28 + if (rs.strokes) pad.loadStrokes(rs.strokes); 29 + else pad.clear(); 30 + resetSaveGuard(); 31 + remixInitialSerial = pad.serialize(); 32 + // the original creator: the root of the remixed drawing's chain, if it's 33 + // itself a remix (else the immediate author already is the original). 34 + const original = rs.lineage.length 35 + ? rs.lineage[rs.lineage.length - 1].handle 36 + : null; 37 + localStorage.setItem( 38 + REMIX_KEY, 39 + JSON.stringify({ 40 + parent: rs.parent, 41 + baseRef: rs.baseRef, 42 + baseImageUrl: rs.baseImageUrl, 43 + initialSerial: remixInitialSerial, 44 + parentHandle: rs.authorHandle, 45 + original, 46 + }), 47 + ); 48 + show("draw"); 49 + showRemixBanner(rs.authorHandle, original); 50 + } 51 + 52 + // "remixing @parent's doodl" — and, when @parent's is itself a remix, credit 53 + // the original creator too, so the chain is acknowledged. 54 + function showRemixBanner(parentHandle: string | null, original: string | null) { 55 + const b = $("mode-banner"); 56 + b.hidden = false; 57 + const cancel = el("button", { class: "pill" }, ["cancel"]); 58 + cancel.addEventListener("click", cancelRemix); 59 + const parts: (Node | string)[] = parentHandle 60 + ? [`${REMIX}ing `, el("strong", {}, [`@${parentHandle}`]), "'s doodl"] 61 + : [`${REMIX}ing a doodl`]; 62 + if (original && original !== parentHandle) { 63 + parts.push(" · originally ", el("strong", {}, [`@${original}`]), "'s"); 64 + } 65 + parts.push(" — save to publish yours ", cancel); 66 + b.replaceChildren(...parts); 67 + } 68 + 69 + // drop the lineage but leave the canvas alone (used after a successful save). 70 + export function clearRemixState() { 71 + pendingParent = null; 72 + remixBaseRef = null; 73 + remixInitialSerial = null; 74 + localStorage.removeItem(REMIX_KEY); 75 + const b = document.getElementById("mode-banner"); 76 + if (b) b.hidden = true; 77 + } 78 + 79 + // cancel: abandon the remix entirely — wipe the loaded strokes + locked base. 80 + function cancelRemix() { 81 + clearRemixState(); 82 + pad.setBaseImage(null); 83 + pad.clear(); 84 + statusEl.textContent = ""; 85 + } 86 + 87 + // boot entry: a "remix this" link (/?remix=<did>/<rkey>) or an OAuth-return 88 + // restore. returns true if it handled entry (so main skips the default route). 89 + export function startRemixEntry(): boolean { 90 + const remixParam = new URLSearchParams(location.search).get("remix"); 91 + if (remixParam && remixParam.includes("/")) { 92 + const i = remixParam.indexOf("/"); 93 + const rdid = remixParam.slice(0, i); 94 + const rrkey = remixParam.slice(i + 1); 95 + history.replaceState(null, "", location.pathname); // drop the param 96 + statusEl.textContent = "loading the doodl to remix…"; 97 + getRemixSource(rdid, rrkey) 98 + .then((rs) => { 99 + enterRemixMode(rs); 100 + statusEl.textContent = ""; 101 + }) 102 + .catch((e) => { 103 + statusEl.textContent = `couldn't load that doodl: ${String(e)}`; 104 + }); 105 + return true; 106 + } 107 + if (localStorage.getItem(REMIX_KEY)) { 108 + // returning from an OAuth round-trip mid-remix: strokes restored from 109 + // DRAFT_KEY; re-attach the lineage + locked base. 110 + try { 111 + const r = JSON.parse(localStorage.getItem(REMIX_KEY)!); 112 + pendingParent = r.parent ?? null; 113 + remixBaseRef = r.baseRef ?? null; 114 + remixInitialSerial = r.initialSerial ?? null; 115 + if (r.baseImageUrl) pad.setBaseImage(r.baseImageUrl); 116 + if (pendingParent) showRemixBanner(r.parentHandle ?? null, r.original ?? null); 117 + } catch { 118 + localStorage.removeItem(REMIX_KEY); 119 + } 120 + return true; 121 + } 122 + return false; 123 + }
+133
src/features/save.ts
··· 1 + import { 2 + pad, 3 + statusEl, 4 + saveBtn, 5 + sessionEl, 6 + getSession, 7 + show, 8 + refreshUI, 9 + PENDING_SAVE, 10 + } from "../app"; 11 + import { saveDrawing, assignIcon } from "../data/pds"; 12 + import { iconOverrides } from "../ui/slots"; 13 + import { el } from "../ui/dom"; 14 + import { mountLogin } from "../ui/login"; 15 + import { signIn } from "../auth"; 16 + import { 17 + getPendingParent, 18 + getRemixBaseRef, 19 + getRemixInitialSerial, 20 + clearRemixState, 21 + } from "./remix"; 22 + import { 23 + getPendingIconSlot, 24 + clearPendingIconSlot, 25 + exitIconMode, 26 + } from "./icon-mode"; 27 + import { exploreOverride, syncExploreToggle } from "./preferences"; 28 + 29 + // guard against accidentally saving the same unchanged drawing twice (the 30 + // canvas is intentionally kept after save, so a second tap would dupe it). 31 + let lastSavedSerial: string | null = null; 32 + export const resetSaveGuard = () => (lastSavedSerial = null); 33 + 34 + export async function doSave() { 35 + const session = getSession(); 36 + if (!session || pad.isBlank()) return; 37 + const serial = pad.serialize(); 38 + if (serial === lastSavedSerial) { 39 + statusEl.innerHTML = `<span class="cheer">already saved ✓</span>`; 40 + return; 41 + } 42 + // a remix that adds nothing to its parent isn't a remix — require a change. 43 + if (getPendingParent() && serial === getRemixInitialSerial()) { 44 + statusEl.textContent = "add something to your remix first :)"; 45 + return; 46 + } 47 + saveBtn.disabled = true; 48 + statusEl.textContent = "saving…"; 49 + try { 50 + const png = await pad.toBlob(); 51 + const { ref, blobUrl, pageUrl } = await saveDrawing( 52 + session, 53 + png, 54 + pad.sourceBlob(getRemixBaseRef() ?? undefined), 55 + getPendingParent() ?? undefined, 56 + exploreOverride(), 57 + ); 58 + lastSavedSerial = serial; 59 + syncExploreToggle(); // next drawing starts back at your default 60 + // if we're drawing a new icon for a slot, assign it and head back 61 + const iconSlot = getPendingIconSlot(); 62 + if (iconSlot) { 63 + await assignIcon(session, iconSlot, ref); 64 + iconOverrides[iconSlot] = blobUrl; 65 + clearPendingIconSlot(); 66 + exitIconMode(); 67 + statusEl.textContent = ""; // don't leave "saving…" behind 68 + refreshUI(); 69 + show("settings"); 70 + return; 71 + } 72 + // a remix is now published — drop the lineage (but keep the drawing shown). 73 + clearRemixState(); 74 + // keep the drawing on the canvas — clearing it out is jarring. 75 + showSaved(pageUrl); 76 + } catch (err) { 77 + statusEl.textContent = `save failed: ${String(err)}`; 78 + } finally { 79 + saveBtn.disabled = false; 80 + } 81 + } 82 + 83 + const CHEERS = [ 84 + "nice — it's yours forever ✨", 85 + "saved! a tiny piece of you, on the network 🎉", 86 + "drawing minted 🪄", 87 + "that's a keeper ⭐", 88 + "done — go share it 🚀", 89 + ]; 90 + 91 + function showSaved(pageUrl: string) { 92 + const cheer = CHEERS[Math.floor(Math.random() * CHEERS.length)]; 93 + const openLink = el( 94 + "a", 95 + { id: "open-link", class: "pill", href: pageUrl, target: "_blank", rel: "noreferrer" }, 96 + ["view & share"], 97 + ); 98 + const copyBtn = el("button", { id: "copy-link", class: "pill" }, ["copy link"]); 99 + statusEl.replaceChildren( 100 + el("span", { class: "cheer" }, [cheer]), 101 + el("span", { class: "saved-actions" }, [openLink, copyBtn]), 102 + ); 103 + // retrigger the pop animation 104 + statusEl.classList.remove("pop"); 105 + void statusEl.offsetWidth; 106 + statusEl.classList.add("pop"); 107 + 108 + copyBtn.addEventListener("click", async () => { 109 + try { 110 + await navigator.clipboard.writeText(pageUrl); 111 + copyBtn.textContent = "copied!"; 112 + } catch { 113 + openLink.focus(); 114 + } 115 + }); 116 + } 117 + 118 + export function initSave() { 119 + saveBtn.addEventListener("click", () => { 120 + if (pad.isBlank()) { 121 + statusEl.textContent = "draw something first :)"; 122 + return; 123 + } 124 + if (getSession()) { 125 + doSave(); 126 + } else { 127 + // not signed in: remember the save intent, then ask for a handle 128 + localStorage.setItem(PENDING_SAVE, "1"); 129 + statusEl.textContent = "pick your handle to save →"; 130 + mountLogin(sessionEl, signIn); 131 + } 132 + }); 133 + }
+418
src/features/settings.ts
··· 1 + import { 2 + views, 3 + getSession, 4 + getHandle, 5 + getDefaults, 6 + setDefaults, 7 + show, 8 + refreshUI, 9 + on, 10 + } from "../app"; 11 + import { 12 + SLOTS, 13 + iconOverrides, 14 + slotNode, 15 + slotThumbNode, 16 + slotLabel, 17 + slotUrl, 18 + } from "../ui/slots"; 19 + import { el } from "../ui/dom"; 20 + import { closeSheet } from "../ui/sheets"; 21 + import { mountLogin } from "../ui/login"; 22 + import { getMode, setMode, type ThemeMode } from "../ui/theme"; 23 + import { signIn, logout } from "../auth"; 24 + import { enterIconMode } from "./icon-mode"; 25 + import { prefControls, syncExploreToggle } from "./preferences"; 26 + import { setPreferences } from "../data/pds"; 27 + import { AT_CLIENTS, getClientValue, setClientValue } from "../data/clients"; 28 + import { 29 + resolveIcon, 30 + listIconsets, 31 + resolveIconsetIcons, 32 + applyIconset, 33 + getDrawingRef, 34 + assignIcon, 35 + clearIcon, 36 + type IconAssignment, 37 + } from "../data/pds"; 38 + import { fetchGallery } from "../data/explore"; 39 + 40 + const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T; 41 + 42 + // theme-marketplace try-on: previewing another user's iconset is a preview; your 43 + // own iconset on the PDS is untouched until you "keep". persisted so it survives 44 + // reloads (see main's boot); revert re-reads your own set. 45 + export const TRYON_KEY = "doodl:tryon"; 46 + let tryonAssignments: IconAssignment[] | null = null; 47 + let tryonHandle: string | null = null; 48 + 49 + // resolve your own icon set from your PDS into iconOverrides 50 + export async function loadOwnIcons() { 51 + const session = getSession(); 52 + if (!session) return; 53 + for (const k of Object.keys(iconOverrides)) delete iconOverrides[k]; 54 + await Promise.all( 55 + SLOTS.map(async (s) => { 56 + const url = await resolveIcon(session.did, s.key).catch(() => null); 57 + if (url) iconOverrides[s.key] = url; 58 + }), 59 + ); 60 + refreshUI(); 61 + } 62 + 63 + // full-screen settings view (mobile-first) 64 + function renderSettings() { 65 + const v = $("view-settings"); 66 + const mode = getMode(); 67 + // static shell — every literal here is an app constant (no network data), so 68 + // innerHTML is safe; the dynamic, node-requiring bits are populated below. 69 + v.innerHTML = ` 70 + <div class="settings"> 71 + <div class="settings-bar"> 72 + <h2>settings</h2> 73 + </div> 74 + <section class="set-sec"> 75 + <h3>account</h3> 76 + <div id="set-account"></div> 77 + </section> 78 + <section class="set-sec"> 79 + <h3>theme</h3> 80 + <div class="theme-row" id="theme-row"></div> 81 + </section> 82 + <section class="set-sec"> 83 + <h3>open profiles in</h3> 84 + <p class="hint">which app author profiles open in. records always open in pdsls.</p> 85 + <div class="client-row"> 86 + ${AT_CLIENTS.map( 87 + (c) => 88 + `<button class="client-opt${c.value === getClientValue() ? " sel" : ""}" data-client="${c.value}" title="${c.label}">` + 89 + `<img src="${c.iconUrl}" alt="" loading="lazy"><span>${c.label}</span></button>`, 90 + ).join("")} 91 + </div> 92 + </section> 93 + <section class="set-sec"> 94 + <h3>app icons</h3> 95 + <p class="hint">your drawings are the app's icons. tap a slot to change it.</p> 96 + <div class="slot-grid" id="slot-grid"></div> 97 + </section> 98 + <section class="set-sec"> 99 + <h3>icon sets from others</h3> 100 + <p class="hint">tap a set to preview it across the whole app. nothing changes for real until you save it — back out anytime.</p> 101 + <div id="iconsets" class="iconset-list"><p class="hint">loading…</p></div> 102 + </section> 103 + <section class="set-sec"> 104 + <h3>preferences</h3> 105 + <p class="hint">public declarations stored on your PDS. these are your defaults; you can override any single drawing when you save it.</p> 106 + <div id="pref-controls"></div> 107 + </section> 108 + </div>`; 109 + 110 + // theme options 111 + $("theme-row").replaceChildren( 112 + ...(["light", "dark", "system"] as ThemeMode[]).map((m) => { 113 + const btn = el( 114 + "button", 115 + { class: `theme-opt${m === mode ? " sel" : ""}`, "data-mode": m }, 116 + [slotThumbNode(`theme-${m}`), el("span", {}, [m])], 117 + ); 118 + btn.addEventListener("click", () => { 119 + setMode(m); 120 + refreshUI(); 121 + }); 122 + return btn; 123 + }), 124 + ); 125 + 126 + // app-icon slots — tap a slot to draw/assign its icon 127 + $("slot-grid").replaceChildren( 128 + ...SLOTS.map((s) => { 129 + const btn = el("button", { class: "slot", "data-slot": s.key }, [ 130 + slotThumbNode(s.key), 131 + el("span", { class: "slot-label" }, [s.label]), 132 + ]); 133 + btn.addEventListener("click", () => openIconPicker(s.key)); 134 + return btn; 135 + }), 136 + ); 137 + 138 + const acct = $("set-account"); 139 + const session = getSession(); 140 + if (session) { 141 + const logoutBtn = el("button", { class: "set-btn", id: "logout" }, [ 142 + slotNode("signout"), 143 + ]); 144 + logoutBtn.addEventListener("click", logout); 145 + acct.replaceChildren( 146 + el("div", { class: "acct-handle" }, [`@${getHandle() ?? "signed in"}`]), 147 + logoutBtn, 148 + ); 149 + } else { 150 + const loginBtn = el("button", { class: "set-btn", id: "login" }, [ 151 + slotNode("signin"), 152 + ]); 153 + loginBtn.addEventListener("click", () => mountLogin($("set-account"), signIn)); 154 + acct.replaceChildren(loginBtn); 155 + } 156 + 157 + v.querySelectorAll<HTMLButtonElement>(".client-opt").forEach((b) => 158 + b.addEventListener("click", () => { 159 + setClientValue(b.dataset.client!); 160 + renderSettings(); 161 + }), 162 + ); 163 + 164 + // preference defaults editor — each pick writes the record immediately 165 + const prefHost = $("pref-controls"); 166 + if (session) { 167 + prefHost.replaceChildren( 168 + ...prefControls(getDefaults(), async (k, val) => { 169 + const next = { ...getDefaults(), [k]: val }; 170 + setDefaults(next); 171 + syncExploreToggle(); 172 + // throwing surfaces as the inline "couldn't save" status 173 + await setPreferences(session, next); 174 + }), 175 + ); 176 + } else { 177 + prefHost.replaceChildren( 178 + el("p", { class: "hint" }, ["sign in to set your preferences."]), 179 + ); 180 + } 181 + 182 + renderMarketplace(); 183 + } 184 + 185 + // the icon-set marketplace, in the settings → icon sets section. 186 + const PREVIEW_SLOTS = ["title", "settings", "nav-draw", "nav-explore", "save"]; 187 + function renderMarketplace() { 188 + const host = document.getElementById("iconsets"); 189 + if (!host) return; 190 + host.innerHTML = ""; 191 + const session = getSession(); 192 + 193 + // active try-on control (synchronous, from state) 194 + if (tryonAssignments) { 195 + const revert = el("button", { class: "pill", id: "tryon-revert" }, ["back to mine"]); 196 + const keep = el("button", { class: "pill primary", id: "tryon-keep" }, ["save as mine"]); 197 + revert.addEventListener("click", revertTryon); 198 + keep.addEventListener("click", keepTryon); 199 + host.append( 200 + el("div", { class: "tryon-active" }, [ 201 + el("span", {}, [ 202 + "previewing ", 203 + el("strong", {}, [`@${tryonHandle ?? "a"}`]), 204 + "'s set across the app", 205 + ]), 206 + el("span", { class: "tryon-actions" }, [revert, keep]), 207 + ]), 208 + ); 209 + } 210 + 211 + // the list of other sets 212 + const list = document.createElement("div"); 213 + list.className = "iconset-list"; 214 + list.innerHTML = `<p class="hint">loading…</p>`; 215 + host.append(list); 216 + listIconsets() 217 + .then((sets) => { 218 + // never list your own set — you can't "try on" what you already are 219 + const others = sets.filter((s) => s.did !== session?.did); 220 + if (!others.length) { 221 + list.innerHTML = `<p class="hint">no one else has shared a set yet.</p>`; 222 + return; 223 + } 224 + list.innerHTML = ""; 225 + for (const set of others) { 226 + const prev = el("span", { class: "iconset-prev" }); 227 + const card = el("button", { class: "iconset-card" }, [ 228 + prev, 229 + el("span", { class: "iconset-who" }, [`@${set.handle}`]), 230 + el("span", { class: "iconset-try" }, ["try on"]), 231 + ]); 232 + card.addEventListener("click", () => tryOn(set)); 233 + list.append(card); 234 + resolveIconsetIcons(set.assignments, PREVIEW_SLOTS) 235 + .then((icons) => { 236 + prev.replaceChildren( 237 + ...PREVIEW_SLOTS.filter((s) => icons[s]) 238 + .slice(0, 5) 239 + .map((s) => el("img", { src: icons[s]!, alt: "" })), 240 + ); 241 + }) 242 + .catch(() => {}); 243 + } 244 + }) 245 + .catch(() => { 246 + list.innerHTML = `<p class="hint">couldn't load icon sets.</p>`; 247 + }); 248 + 249 + // reset to defaults — escape hatch back to a clean slate 250 + const reset = document.createElement("button"); 251 + reset.className = "set-link"; 252 + reset.textContent = "reset my icons to defaults"; 253 + reset.addEventListener("click", resetIcons); 254 + host.append(reset); 255 + } 256 + 257 + // user taps a set → persist + apply (a preview; nothing written to your PDS) 258 + function tryOn(set: { handle: string; assignments: IconAssignment[] }) { 259 + localStorage.setItem( 260 + TRYON_KEY, 261 + JSON.stringify({ handle: set.handle, assignments: set.assignments }), 262 + ); 263 + applyTryon(set.handle, set.assignments); 264 + } 265 + 266 + // resolve a set's icons and swap them in app-wide (used by tryOn + on reload) 267 + export async function applyTryon(handle: string, assignments: IconAssignment[]) { 268 + tryonAssignments = assignments; 269 + tryonHandle = handle; 270 + const icons = await resolveIconsetIcons(assignments); 271 + for (const k of Object.keys(iconOverrides)) delete iconOverrides[k]; 272 + Object.assign(iconOverrides, icons); 273 + refreshUI(); 274 + } 275 + 276 + async function revertTryon() { 277 + localStorage.removeItem(TRYON_KEY); 278 + tryonAssignments = null; 279 + tryonHandle = null; 280 + await loadOwnIcons(); // re-read YOUR set from your PDS 281 + } 282 + 283 + async function keepTryon() { 284 + const session = getSession(); 285 + if (!session || !tryonAssignments) return; 286 + if ( 287 + !confirm( 288 + `save @${tryonHandle}'s set as your own? this replaces your current icon set.`, 289 + ) 290 + ) 291 + return; 292 + try { 293 + await applyIconset(session, tryonAssignments); 294 + localStorage.removeItem(TRYON_KEY); 295 + tryonAssignments = null; 296 + tryonHandle = null; 297 + refreshUI(); 298 + } catch (err) { 299 + alert(`couldn't save this set: ${String(err)}`); 300 + } 301 + } 302 + 303 + async function resetIcons() { 304 + const session = getSession(); 305 + if (!session) return; 306 + if (!confirm("reset all your icons back to the defaults?")) return; 307 + try { 308 + localStorage.removeItem(TRYON_KEY); 309 + tryonAssignments = null; 310 + tryonHandle = null; 311 + await applyIconset(session, []); // empty iconset → all defaults 312 + for (const k of Object.keys(iconOverrides)) delete iconOverrides[k]; 313 + refreshUI(); 314 + } catch (err) { 315 + alert(`couldn't reset: ${String(err)}`); 316 + } 317 + } 318 + 319 + // bottom-sheet picker: choose one of your drawings for a slot, or draw new 320 + async function openIconPicker(slot: string) { 321 + const session = getSession(); 322 + if (!session) { 323 + show("settings"); 324 + mountLogin($("set-account"), signIn); 325 + return; 326 + } 327 + const overridden = slot in iconOverrides; 328 + const sheet = $("sheet"); 329 + sheet.hidden = false; 330 + sheet.innerHTML = ` 331 + <div class="sheet-card"> 332 + <div class="sheet-grab"></div> 333 + <div class="sheet-head"> 334 + <strong>your ${slotLabel(slot)} icon</strong> 335 + <div class="sheet-head-actions"> 336 + ${overridden ? '<button class="pill" id="sheet-reset">use default</button>' : ""} 337 + <button class="pill" id="sheet-close"></button> 338 + </div> 339 + </div> 340 + <div class="sheet-grid" id="sheet-grid"><p class="hint">loading…</p></div> 341 + </div>`; 342 + $("sheet-close").replaceChildren(slotNode("sheet-close")); 343 + $("sheet-close").addEventListener("click", closeSheet); 344 + sheet.addEventListener("click", (e) => { 345 + if (e.target === sheet) closeSheet(); 346 + }); 347 + if (overridden) { 348 + $("sheet-reset").addEventListener("click", async () => { 349 + try { 350 + await clearIcon(session, slot); 351 + delete iconOverrides[slot]; 352 + closeSheet(); 353 + refreshUI(); 354 + } catch (err) { 355 + alert(`couldn't reset: ${String(err)}`); 356 + } 357 + }); 358 + } 359 + 360 + const grid = $("sheet-grid"); 361 + grid.innerHTML = ""; 362 + const drawNewUrl = slotUrl("draw-new"); 363 + const neu = el( 364 + "button", 365 + { class: "sheet-new" }, 366 + drawNewUrl 367 + ? [el("img", { class: "slot-img", src: drawNewUrl, alt: "draw new" })] 368 + : [el("span", {}, ["+"]), el("span", { class: "sheet-new-label" }, ["draw new"])], 369 + ); 370 + neu.addEventListener("click", () => { 371 + closeSheet(); 372 + enterIconMode(slot); 373 + }); 374 + grid.append(neu); 375 + 376 + try { 377 + // any drawing on the network can be an icon — yours first, then everyone's 378 + const all = await fetchGallery(); 379 + const mine = session.did; 380 + all.sort((a, b) => (a.did === mine ? -1 : 0) - (b.did === mine ? -1 : 0)); 381 + if (!all.length) { 382 + const p = document.createElement("p"); 383 + p.className = "hint"; 384 + p.textContent = "no drawings yet — draw one to use it."; 385 + grid.append(p); 386 + } 387 + for (const d of all) { 388 + const b = el("button", { class: "sheet-item", title: `@${d.handle}` }, [ 389 + el("img", { src: d.imageUrl, alt: "", loading: "lazy" }), 390 + ]); 391 + b.addEventListener("click", async () => { 392 + b.disabled = true; 393 + try { 394 + const ref = await getDrawingRef(d.did, d.rkey); 395 + await assignIcon(session, slot, ref); 396 + iconOverrides[slot] = d.imageUrl; 397 + closeSheet(); 398 + refreshUI(); 399 + } catch (err) { 400 + b.disabled = false; 401 + alert(`couldn't set icon: ${String(err)}`); 402 + } 403 + }); 404 + grid.append(b); 405 + } 406 + } catch { 407 + grid.innerHTML += `<p class="hint">couldn't load drawings</p>`; 408 + } 409 + } 410 + 411 + export function initSettings() { 412 + on("view", (v) => { 413 + if (v === "settings") renderSettings(); 414 + }); 415 + on("refresh", () => { 416 + if (!views.settings.hidden) renderSettings(); 417 + }); 418 + }
+133
src/features/tools.ts
··· 1 + import { pad, statusEl } from "../app"; 2 + import { slotNode } from "../ui/slots"; 3 + import { closeSheet } from "../ui/sheets"; 4 + 5 + const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T; 6 + 7 + const PALETTE = [ 8 + "#212529", // ink 9 + "#e03131", // red 10 + "#f08c00", // orange 11 + "#f2c200", // yellow 12 + "#2f9e44", // green 13 + "#1c7ed6", // blue 14 + "#7048e8", // violet 15 + "#e64980", // pink 16 + ]; 17 + const DEFAULT_COLOR = "#1c7ed6"; 18 + const DEFAULT_SIZE = 8; 19 + 20 + // color + stroke live in popups; the toolbar buttons just preview the current 21 + // value. setColor/setSize are the single sources of truth — they tint/size the 22 + // triggers and (while a sheet is open) its controls. 23 + let currentColor = DEFAULT_COLOR; 24 + export function setColor(c: string) { 25 + currentColor = c; 26 + pad.setColor(c); 27 + const btn = document.getElementById("color-btn"); 28 + if (btn) btn.style.setProperty("--c", c); 29 + document 30 + .querySelectorAll<HTMLElement>("#sheet .swatch.preset") 31 + .forEach((s) => s.classList.toggle("sel", s.dataset.color === c)); 32 + const custom = document.querySelector<HTMLElement>("#sheet .swatch.custom"); 33 + if (custom) custom.classList.toggle("sel", !PALETTE.includes(c)); 34 + } 35 + 36 + let currentSize = DEFAULT_SIZE; 37 + export function setSize(n: number) { 38 + currentSize = n; 39 + pad.setSize(n); 40 + const d = `${Math.min(n, 26)}px`; 41 + document 42 + .querySelectorAll<HTMLElement>(".brush-dot") 43 + .forEach((el) => el.style.setProperty("--d", d)); 44 + } 45 + 46 + // chrome's renderToolbar re-creates the preview dot, so re-apply the size 47 + export const resyncSize = () => setSize(currentSize); 48 + 49 + // color popup: the palette + a custom picker. a preset picks and closes; the 50 + // custom (native) picker keeps the sheet open so you can nudge the hue. 51 + function openColorSheet() { 52 + const sheet = $("sheet"); 53 + sheet.hidden = false; 54 + const presets = PALETTE.map( 55 + (c) => 56 + `<button class="swatch preset" data-color="${c}" style="background:${c}" title="${c}" aria-label="${c}"></button>`, 57 + ).join(""); 58 + sheet.innerHTML = ` 59 + <div class="sheet-card"> 60 + <div class="sheet-grab"></div> 61 + <div class="sheet-head"> 62 + <strong>color</strong> 63 + <button class="pill" id="sheet-close"></button> 64 + </div> 65 + <div class="color-panel"> 66 + ${presets} 67 + <label class="swatch custom" title="more colors"> 68 + <input type="color" id="color" value="${currentColor}" /> 69 + </label> 70 + </div> 71 + </div>`; 72 + $("sheet-close").replaceChildren(slotNode("sheet-close")); 73 + $("sheet-close").addEventListener("click", closeSheet); 74 + sheet.addEventListener("click", (e) => { 75 + if (e.target === sheet) closeSheet(); 76 + }); 77 + sheet.querySelectorAll<HTMLElement>(".swatch.preset").forEach((s) => { 78 + s.addEventListener("click", () => { 79 + setColor(s.dataset.color!); 80 + closeSheet(); 81 + }); 82 + }); 83 + const colorInput = $<HTMLInputElement>("color"); 84 + colorInput.addEventListener("input", () => setColor(colorInput.value)); 85 + setColor(currentColor); // mark the active swatch 86 + } 87 + 88 + // stroke popup: the width slider today, home for future line styles. 89 + function openStrokeSheet() { 90 + const sheet = $("sheet"); 91 + sheet.hidden = false; 92 + sheet.innerHTML = ` 93 + <div class="sheet-card"> 94 + <div class="sheet-grab"></div> 95 + <div class="sheet-head"> 96 + <strong>stroke</strong> 97 + <button class="pill" id="sheet-close"></button> 98 + </div> 99 + <div class="stroke-panel"> 100 + <span class="brush-dot"></span> 101 + <input type="range" id="stroke-range" min="1" max="64" value="${currentSize}" /> 102 + </div> 103 + </div>`; 104 + $("sheet-close").replaceChildren(slotNode("sheet-close")); 105 + $("sheet-close").addEventListener("click", closeSheet); 106 + sheet.addEventListener("click", (e) => { 107 + if (e.target === sheet) closeSheet(); 108 + }); 109 + const range = $<HTMLInputElement>("stroke-range"); 110 + range.addEventListener("input", () => setSize(Number(range.value))); 111 + setSize(currentSize); // sync the in-sheet preview dot 112 + } 113 + 114 + export function initTools() { 115 + setColor(DEFAULT_COLOR); 116 + setSize(DEFAULT_SIZE); 117 + $("clear").addEventListener("click", () => pad.clear()); 118 + $("undo").addEventListener("click", () => pad.undo()); 119 + $("color-btn").addEventListener("click", openColorSheet); 120 + $("stroke-btn").addEventListener("click", openStrokeSheet); 121 + // any stale status (e.g. a previous "saved") clears as soon as you draw again 122 + $("pad").addEventListener("pointerdown", () => { 123 + statusEl.textContent = ""; 124 + statusEl.classList.remove("pop"); 125 + }); 126 + // platform-independent undo: ctrl+z and cmd+z 127 + window.addEventListener("keydown", (e) => { 128 + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") { 129 + e.preventDefault(); 130 + pad.undo(); 131 + } 132 + }); 133 + }
+46 -1247
src/main.ts
··· 1 - import { initOAuth } from "./data/oauth"; 2 - import { setupCanvas } from "./ui/draw"; 3 - import { el } from "./ui/dom"; 1 + // doodl — composition root. it wires the feature modules together and runs the 2 + // boot sequence; the features themselves live in ./features/* over the shared 3 + // ./app context. read top-to-bottom for the tour. 4 4 import { 5 - saveDrawing, 6 - deleteDrawing, 7 - assignIcon, 8 - clearIcon, 9 - resolveIcon, 10 - getDrawingRef, 11 - getRemixSource, 12 - listIconsets, 13 - resolveIconsetIcons, 14 - applyIconset, 15 - getPreferences, 16 - setPreferences, 17 - type IconAssignment, 18 - type RemixSource, 19 - type StrongRef, 20 - } from "./data/pds"; 21 - import { 22 - PREF_QUESTIONS, 23 - effectivePref, 24 - HARD_DEFAULTS, 25 - BOT_HANDLE, 26 - type PrefMap, 27 - type PrefKey, 28 - } from "./data/prefs"; 29 - import { REMIX, resolveOriginal } from "./data/lineage"; 30 - import { fetchGallery } from "./data/explore"; 31 - import { mountLogin } from "./ui/login"; 32 - import { getMode, setMode, type ThemeMode } from "./ui/theme"; 33 - import { 34 - AT_CLIENTS, 35 - getClientValue, 36 - setClientValue, 37 - profileLink, 38 - } from "./data/clients"; 39 - import type { OAuthSession } from "@atproto/oauth-client-browser"; 5 + pad, 6 + DRAFT_KEY, 7 + PENDING_SAVE, 8 + getSession, 9 + setHandle, 10 + setDefaults, 11 + refreshUI, 12 + show, 13 + routeView, 14 + } from "./app"; 15 + import { finishOAuth } from "./auth"; 16 + import { initSheets } from "./ui/sheets"; 17 + import { initTools } from "./features/tools"; 18 + import { initChrome } from "./features/chrome"; 19 + import { initGallery } from "./features/gallery"; 20 + import { initSettings, loadOwnIcons, applyTryon, TRYON_KEY } from "./features/settings"; 21 + import { initSave, doSave } from "./features/save"; 22 + import { startRemixEntry } from "./features/remix"; 23 + import { openPrefsWalkthrough, syncExploreToggle } from "./features/preferences"; 24 + import { getPreferences } from "./data/pds"; 40 25 41 - const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T; 42 - 43 - const statusEl = $("status"); 44 - const sessionEl = $("session"); 45 - const saveBtn = $<HTMLButtonElement>("save"); 46 - 47 - // in-progress drawing is stashed here so it survives the full-page OAuth 48 - // redirect (sign in → PDS → back). restored on load below. 49 - const DRAFT_KEY = "doodl:draft"; 50 - 51 - let session: OAuthSession | null = null; 52 - let currentHandle: string | null = null; 53 - // the signed-in user's global preference defaults, loaded on session resolve 54 - let myDefaults: PrefMap = {}; 55 - 56 - const pad = setupCanvas($<HTMLCanvasElement>("pad")); 57 - 58 - const saved = localStorage.getItem(DRAFT_KEY); 59 - if (saved) { 60 - pad.restore(saved); 26 + // an in-progress drawing survives the full-page OAuth redirect — restore it. 27 + const draft = localStorage.getItem(DRAFT_KEY); 28 + if (draft) { 29 + pad.restore(draft); 61 30 localStorage.removeItem(DRAFT_KEY); 62 31 } 63 32 64 - // ---- slot registry: any UI affordance whose icon can be a drawing ---- 65 - // to make an element customizable: add it here + render its content with 66 - // slotNode(key). it then shows the user's drawing if assigned (else its 67 - // label text), and auto-appears in settings → app icons. 68 - type Slot = { key: string; label: string; defaultUrl?: string }; 69 - const SLOTS: Slot[] = [ 70 - { key: "title", label: "doodl" }, 71 - { key: "settings", label: "settings" }, 72 - { key: "nav-draw", label: "draw" }, 73 - { key: "nav-explore", label: "explore" }, 74 - { key: "save", label: "save" }, 75 - { key: "undo", label: "undo" }, 76 - { key: "clear", label: "clear" }, 77 - { key: "stroke", label: "stroke" }, 78 - { key: "remix", label: "remix" }, 79 - { key: "theme-light", label: "light" }, 80 - { key: "theme-dark", label: "dark" }, 81 - { key: "theme-system", label: "system" }, 82 - { key: "signin", label: "sign in" }, 83 - { key: "signout", label: "sign out" }, 84 - { key: "sheet-close", label: "close" }, 85 - { key: "draw-new", label: "draw new" }, 86 - ]; 87 - const slot = (k: string) => SLOTS.find((s) => s.key === k); 88 - const slotLabel = (k: string) => slot(k)?.label ?? k; 89 - // user overrides (slot → blob URL), resolved from their iconset on load 90 - const iconOverrides: Record<string, string> = {}; 91 - 92 - // theme marketplace: trying on another user's iconset is a preview — your own 93 - // iconset on the PDS is untouched until you "keep". the in-progress try-on is 94 - // persisted to localStorage so it survives reloads; revert re-reads your own 95 - // set from your PDS (so no snapshot is needed). 96 - const TRYON_KEY = "doodl:tryon"; 97 - let tryonAssignments: IconAssignment[] | null = null; 98 - let tryonHandle: string | null = null; 99 - const slotUrl = (k: string): string | null => 100 - iconOverrides[k] ?? slot(k)?.defaultUrl ?? null; 101 - 102 - // a LIVE affordance's content as a node: the drawing if assigned, else its label 103 - function slotNode(k: string): Node { 104 - const url = slotUrl(k); 105 - return url 106 - ? el("img", { class: "slot-img", src: url, alt: slotLabel(k) }) 107 - : document.createTextNode(slotLabel(k)); 108 - } 109 - // thumbnail for the settings grid: drawing, or a placeholder letter 110 - function slotThumbNode(k: string): Node { 111 - const url = slotUrl(k); 112 - return url 113 - ? el("img", { class: "slot-img", src: url, alt: "" }) 114 - : el("span", { class: "ph" }, [slotLabel(k)[0] ?? ""]); 115 - } 116 - 117 - // ---- palette + size presets ---- 118 - const PALETTE = [ 119 - "#212529", // ink 120 - "#e03131", // red 121 - "#f08c00", // orange 122 - "#f2c200", // yellow 123 - "#2f9e44", // green 124 - "#1c7ed6", // blue 125 - "#7048e8", // violet 126 - "#e64980", // pink 127 - ]; 128 - const DEFAULT_COLOR = "#1c7ed6"; 129 - const DEFAULT_SIZE = 8; 130 - 131 - // color, like stroke, lives in a popup; the toolbar button previews the current 132 - // color as a filled swatch. setColor is the single source of truth — it tints 133 - // the trigger and (if open) re-marks the picker's selected swatch. 134 - let currentColor = DEFAULT_COLOR; 135 - function setColor(c: string) { 136 - currentColor = c; 137 - pad.setColor(c); 138 - const btn = document.getElementById("color-btn"); 139 - if (btn) btn.style.setProperty("--c", c); 140 - document 141 - .querySelectorAll<HTMLElement>("#sheet .swatch.preset") 142 - .forEach((s) => s.classList.toggle("sel", s.dataset.color === c)); 143 - const custom = document.querySelector<HTMLElement>("#sheet .swatch.custom"); 144 - if (custom) custom.classList.toggle("sel", !PALETTE.includes(c)); 145 - } 146 - 147 - // stroke width lives in a popup now; the toolbar button just previews it as a 148 - // dot. setSize is the single source of truth — it syncs every .brush-dot on 149 - // screen (the trigger, and the slider's preview while the sheet is open). 150 - let currentSize = DEFAULT_SIZE; 151 - function setSize(n: number) { 152 - currentSize = n; 153 - pad.setSize(n); 154 - const d = `${Math.min(n, 26)}px`; 155 - document 156 - .querySelectorAll<HTMLElement>(".brush-dot") 157 - .forEach((el) => el.style.setProperty("--d", d)); 158 - } 159 - 160 - setColor(DEFAULT_COLOR); 161 - setSize(DEFAULT_SIZE); 162 - $("clear").addEventListener("click", () => pad.clear()); 163 - $("undo").addEventListener("click", () => pad.undo()); 164 - $("color-btn").addEventListener("click", openColorSheet); 165 - $("stroke-btn").addEventListener("click", openStrokeSheet); 166 - // any stale status (e.g. a previous "saved") clears as soon as you draw again 167 - $("pad").addEventListener("pointerdown", () => { 168 - statusEl.textContent = ""; 169 - statusEl.classList.remove("pop"); 170 - }); 171 - 172 - // platform-independent undo: ctrl+z and cmd+z 173 - window.addEventListener("keydown", (e) => { 174 - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") { 175 - e.preventDefault(); 176 - pad.undo(); 177 - } 178 - // Esc dismisses an open popup (color / stroke / icon picker) 179 - if (e.key === "Escape" && !$("sheet").hidden) closeSheet(); 180 - }); 181 - 182 - // ---- view switching (draw / explore / settings) ---- 183 - const views = { 184 - draw: $("view-draw"), 185 - explore: $("view-explore"), 186 - settings: $("view-settings"), 187 - }; 188 - const navBtns = { draw: $("nav-draw"), explore: $("nav-explore") }; 189 - type View = "draw" | "explore" | "settings"; 190 - 191 - // deep-linkable views: each is a real URL (wisp --spa serves index.html for any 192 - // path, so /explore is shareable without leaving wisp). navigating pushes the 193 - // path; back/forward and direct loads resolve the view from it. 194 - const VIEW_PATH: Record<View, string> = { 195 - draw: "/", 196 - explore: "/explore", 197 - settings: "/settings", 198 - }; 199 - function routeView(): View { 200 - const p = location.pathname.replace(/\/+$/, ""); 201 - if (p.endsWith("/explore")) return "explore"; 202 - if (p.endsWith("/settings")) return "settings"; 203 - return "draw"; 204 - } 205 - function show(view: View, opts: { push?: boolean } = {}) { 206 - (Object.keys(views) as View[]).forEach((k) => (views[k].hidden = k !== view)); 207 - navBtns.draw.classList.toggle("active", view === "draw"); 208 - navBtns.explore.classList.toggle("active", view === "explore"); 209 - // the settings trigger is redundant while already on the settings page 210 - sessionEl.hidden = view === "settings"; 211 - if (view === "explore") loadGallery(); 212 - if (view === "settings") renderSettings(); 213 - if (opts.push !== false && location.pathname !== VIEW_PATH[view]) { 214 - history.pushState({ view }, "", VIEW_PATH[view]); 215 - } 216 - } 217 - window.addEventListener("popstate", () => show(routeView(), { push: false })); 218 - 219 - // the title is a slot (replaceable with a drawing) and takes you home 220 - const titleEl = document.querySelector("h1"); 221 - function renderTitle() { 222 - if (titleEl) titleEl.replaceChildren(slotNode("title")); 223 - } 224 - if (titleEl) { 225 - titleEl.style.cursor = "pointer"; 226 - titleEl.addEventListener("click", () => show("draw")); 227 - } 33 + // resolve the OAuth client + any returning session before the chrome renders. 34 + await finishOAuth(); 228 35 229 - navBtns.draw.addEventListener("click", () => show("draw")); 230 - navBtns.explore.addEventListener("click", () => show("explore")); 36 + // wire each feature: register its listeners, view/refresh hooks, save seam. 37 + initTools(); 38 + initSheets(); 39 + initChrome(); 40 + initGallery(); 41 + initSettings(); 42 + initSave(); 231 43 232 - // ---- "draw a new icon for a slot" mode ---- 233 - // no special save button — the normal save assigns it (see doSave). a small 234 - // banner just reminds you what you're drawing, with a cancel. 235 - let pendingIconSlot: string | null = null; 236 - 237 - function enterIconMode(slot: string) { 238 - pendingIconSlot = slot; 239 - pad.clear(); 240 - lastSavedSerial = null; 241 - show("draw"); 242 - const b = $("mode-banner"); 243 - b.hidden = false; 244 - const cancel = el("button", { class: "pill" }, ["cancel"]); 245 - cancel.addEventListener("click", () => { 246 - pendingIconSlot = null; 247 - exitIconMode(); 248 - }); 249 - b.replaceChildren( 250 - "drawing your ", 251 - el("strong", {}, [slotLabel(slot)]), 252 - " icon — just save when ready ", 253 - cancel, 254 - ); 255 - } 256 - 257 - function exitIconMode() { 258 - const b = document.getElementById("mode-banner"); 259 - if (b) b.hidden = true; 260 - } 261 - 262 - // ---- remix mode ---- 263 - // opening someone's doodl as a remix preloads the canvas (their editable 264 - // strokes, or their pixels as a locked base) and remembers the lineage so the 265 - // next save writes a child record with `parent`. survives the OAuth redirect 266 - // via REMIX_KEY (the strokes themselves ride along in DRAFT_KEY). 267 - let pendingParent: StrongRef | null = null; 268 - let remixBaseRef: StrongRef | null = null; 269 - // the canvas state a remix STARTED from — so we can refuse to publish a child 270 - // that's identical to its parent (a no-op remix that adds nothing). 271 - let remixInitialSerial: string | null = null; 272 - const REMIX_KEY = "doodl:remix"; 273 - 274 - function enterRemixMode(rs: RemixSource) { 275 - pendingParent = rs.parent; 276 - remixBaseRef = rs.baseRef; 277 - pad.setBaseImage(rs.baseImageUrl); 278 - if (rs.strokes) pad.loadStrokes(rs.strokes); 279 - else pad.clear(); 280 - lastSavedSerial = null; 281 - remixInitialSerial = pad.serialize(); 282 - // the original creator: the root of the remixed drawing's chain, if it's 283 - // itself a remix (else the immediate author already is the original). 284 - const original = rs.lineage.length 285 - ? rs.lineage[rs.lineage.length - 1].handle 286 - : null; 287 - localStorage.setItem( 288 - REMIX_KEY, 289 - JSON.stringify({ 290 - parent: rs.parent, 291 - baseRef: rs.baseRef, 292 - baseImageUrl: rs.baseImageUrl, 293 - initialSerial: remixInitialSerial, 294 - parentHandle: rs.authorHandle, 295 - original, 296 - }), 297 - ); 298 - show("draw"); 299 - showRemixBanner(rs.authorHandle, original); 300 - } 301 - 302 - // "remixing @parent's doodl" — and, when @parent's is itself a remix, credit 303 - // the original creator too, so the chain is acknowledged. 304 - function showRemixBanner(parentHandle: string | null, original: string | null) { 305 - const b = $("mode-banner"); 306 - b.hidden = false; 307 - const cancel = el("button", { class: "pill" }, ["cancel"]); 308 - cancel.addEventListener("click", cancelRemix); 309 - const parts: (Node | string)[] = parentHandle 310 - ? [`${REMIX}ing `, el("strong", {}, [`@${parentHandle}`]), "'s doodl"] 311 - : [`${REMIX}ing a doodl`]; 312 - if (original && original !== parentHandle) { 313 - parts.push(" · originally ", el("strong", {}, [`@${original}`]), "'s"); 314 - } 315 - parts.push(" — save to publish yours ", cancel); 316 - b.replaceChildren(...parts); 317 - } 318 - 319 - // drop the lineage but leave the canvas alone (used after a successful save). 320 - function clearRemixState() { 321 - pendingParent = null; 322 - remixBaseRef = null; 323 - remixInitialSerial = null; 324 - localStorage.removeItem(REMIX_KEY); 325 - const b = document.getElementById("mode-banner"); 326 - if (b) b.hidden = true; 327 - } 328 - 329 - // cancel: abandon the remix entirely — wipe the loaded strokes + locked base. 330 - function cancelRemix() { 331 - clearRemixState(); 332 - pad.setBaseImage(null); 333 - pad.clear(); 334 - statusEl.textContent = ""; 335 - } 336 - 337 - // ---- preferences ---- 338 - // render a question title, turning any declared token into a link (resolved 339 - // here so the bot profile honors the preferred-client setting). 340 - function questionTitle(q: (typeof PREF_QUESTIONS)[number]): (Node | string)[] { 341 - let parts: (Node | string)[] = [q.title]; 342 - for (const lk of q.links ?? []) { 343 - const href = 344 - lk.target === "explore-page" ? "/explore" : profileLink(BOT_HANDLE); 345 - parts = parts.flatMap((p): (Node | string)[] => { 346 - if (typeof p !== "string") return [p]; 347 - const i = p.indexOf(lk.token); 348 - if (i < 0) return [p]; 349 - return [ 350 - p.slice(0, i), 351 - el("a", { href, target: "_blank", rel: "noreferrer" }, [lk.token]), 352 - p.slice(i + lk.token.length), 353 - ]; 354 - }); 355 - } 356 - return parts; 357 - } 358 - 359 - // build the question controls (shared by the first-run walkthrough and the 360 - // settings editor). onPick fires with each choice; when it returns a promise 361 - // (settings, which saves live) a "saved ✓" status flashes so the change is 362 - // visibly persisted — no separate save step. 363 - function prefControls( 364 - current: PrefMap, 365 - onPick: (key: PrefKey, value: string) => void | Promise<void>, 366 - ): Node[] { 367 - const status = el("span", { class: "pref-status", role: "status" }); 368 - let timer: ReturnType<typeof setTimeout> | undefined; 369 - const flash = (text: string, ok: boolean) => { 370 - clearTimeout(timer); 371 - status.textContent = text; 372 - status.classList.toggle("ok", ok); 373 - if (ok) { 374 - timer = setTimeout(() => { 375 - status.textContent = ""; 376 - status.classList.remove("ok"); 377 - }, 1600); 378 - } 379 - }; 380 - 381 - const qs = PREF_QUESTIONS.map((q) => { 382 - const chosen = current[q.key] ?? HARD_DEFAULTS[q.key]; 383 - const opts = el( 384 - "div", 385 - { class: "pref-opts" }, 386 - q.options.map((o) => { 387 - const b = el( 388 - "button", 389 - { class: `pref-opt${o.value === chosen ? " sel" : ""}`, "data-val": o.value }, 390 - [o.label], 391 - ); 392 - b.addEventListener("click", async () => { 393 - opts.querySelectorAll(".pref-opt").forEach((x) => x.classList.remove("sel")); 394 - b.classList.add("sel"); 395 - try { 396 - const r = onPick(q.key, o.value); 397 - if (r instanceof Promise) { 398 - flash("saving…", false); 399 - await r; 400 - flash("saved ✓", true); 401 - } 402 - } catch { 403 - flash("couldn't save — sign out and back in?", false); 404 - } 405 - }); 406 - return b; 407 - }), 408 - ); 409 - return el("div", { class: "pref-q" }, [ 410 - el("h4", {}, questionTitle(q)), 411 - el("p", { class: "hint" }, [q.blurb]), 412 - opts, 413 - ]); 414 - }); 415 - return [...qs, status]; 416 - } 417 - 418 - // the one-time first-run sheet (shown when no preferences record exists). saving 419 - // writes the record, which is what stops it from showing again. 420 - function openPrefsWalkthrough() { 421 - if (!session) return; 422 - const draft: PrefMap = { ...myDefaults }; 423 - const sheet = $("sheet"); 424 - sheet.hidden = false; 425 - const save = el("button", { class: "pill primary prefs-save" }, [ 426 - "save preferences", 427 - ]); 428 - sheet.replaceChildren( 429 - el("div", { class: "sheet-card prefs-sheet" }, [ 430 - el("div", { class: "sheet-grab" }), 431 - el("div", { class: "prefs-welcome" }, [ 432 - el("span", { class: "prefs-mark" }, [slotNode("title")]), 433 - el("p", {}, [ 434 - "a couple of one-time choices about what's public. change them anytime in settings.", 435 - ]), 436 - ]), 437 - ...prefControls(draft, (k, v) => { 438 - draft[k] = v; 439 - }), 440 - el("div", { class: "sheet-foot" }, [save]), 441 - ]), 442 - ); 443 - save.addEventListener("click", async () => { 444 - save.disabled = true; 445 - save.textContent = "saving…"; 446 - try { 447 - const full: PrefMap = {}; 448 - for (const q of PREF_QUESTIONS) full[q.key] = draft[q.key] ?? HARD_DEFAULTS[q.key]; 449 - await setPreferences(session!, full); 450 - myDefaults = full; 451 - syncExploreToggle(); 452 - save.textContent = "saved ✓"; 453 - save.classList.add("ok"); 454 - setTimeout(closeSheet, 700); 455 - } catch (err) { 456 - save.disabled = false; 457 - save.textContent = "save preferences"; 458 - // a pre-existing session may lack the new preferences scope → re-consent 459 - alert( 460 - `couldn't save — doodl needs a new permission. sign out and back in, then try again.\n\n${String(err)}`, 461 - ); 462 - } 463 - }); 464 - } 465 - 466 - // the per-drawing "show in explore" toggle reflects your global default until 467 - // you flip it for the drawing at hand. reset to the default after each save. 468 - function syncExploreToggle() { 469 - const cb = document.getElementById("explore-this") as HTMLInputElement | null; 470 - if (cb) cb.checked = effectivePref("explore", undefined, myDefaults) !== "hide"; 471 - } 472 - 473 - // the per-drawing override to attach on save: only when this drawing's explore 474 - // choice differs from your global default (else inherit, no override written). 475 - function exploreOverride(): PrefMap | undefined { 476 - const cb = document.getElementById("explore-this") as HTMLInputElement | null; 477 - if (!cb) return undefined; 478 - const choice = cb.checked ? "show" : "hide"; 479 - const def = effectivePref("explore", undefined, myDefaults); 480 - return choice === def ? undefined : { explore: choice }; 481 - } 482 - 483 - // ---- gallery ---- 484 - async function loadGallery() { 485 - const g = $("gallery"); 486 - g.innerHTML = "<p class='hint'>loading…</p>"; 487 - try { 488 - const drawings = await fetchGallery({ hideOptedOut: true }); 489 - g.innerHTML = drawings.length ? "" : "<p class='hint'>no drawings yet — be the first.</p>"; 490 - for (const l of drawings) { 491 - const detailUrl = `${location.origin}/l/${l.did}/${l.rkey}`; 492 - const card = document.createElement("div"); 493 - card.className = "card"; 494 - card.tabIndex = 0; 495 - // the whole card opens the detail page; small actions stop propagation 496 - const openDetail = () => window.open(detailUrl, "_blank", "noreferrer"); 497 - card.addEventListener("click", openDetail); 498 - card.addEventListener("keydown", (e) => { 499 - if (e.key === "Enter") openDetail(); 500 - }); 501 - 502 - const img = document.createElement("img"); 503 - img.className = "card-img"; 504 - img.src = l.imageUrl; 505 - img.alt = `drawing by @${l.handle}`; 506 - img.loading = "lazy"; 507 - 508 - // avatar tucked top-left, out of the way 509 - const ava = document.createElement(l.avatar ? "img" : "div"); 510 - ava.className = "card-ava"; 511 - if (l.avatar) (ava as HTMLImageElement).src = l.avatar; 512 - else ava.textContent = l.handle[0]?.toUpperCase() ?? "?"; 513 - ava.title = `@${l.handle}`; 514 - 515 - const who = document.createElement("div"); 516 - who.className = "who"; 517 - who.textContent = `@${l.handle}`; 518 - who.title = `@${l.handle}`; 519 - 520 - // remix: a quiet accent-tinted card + the viewer's own drawn remix mark 521 - // and the original artist, credited at a glance. lineage resolves lazily 522 - // so the grid never waits on it. 523 - let credit: HTMLAnchorElement | null = null; 524 - if (l.parentUri) { 525 - card.classList.add("remix"); 526 - // the viewer's drawn remix mark if they made one (redraw everything), 527 - // else a compact ↻ — same glyph the share page uses. 528 - const markGlyph = slotUrl("remix") 529 - ? slotNode("remix") 530 - : document.createTextNode("↻"); 531 - const mark = el("span", { class: "remix-mark" }, [markGlyph]); 532 - const origEl = el("span", { class: "remix-orig" }); 533 - credit = el("a", { class: "remix-credit" }, [mark, origEl]); 534 - credit.addEventListener("click", (e) => e.stopPropagation()); 535 - resolveOriginal(l.parentUri) 536 - .then((orig) => { 537 - if (!orig || !credit) return; 538 - credit.href = `${location.origin}/l/${orig.did}/${orig.rkey}`; 539 - credit.title = `${REMIX} of @${orig.handle}`; 540 - origEl.textContent = `@${orig.handle}`; 541 - }) 542 - .catch(() => {}); 543 - } 544 - 545 - // subtle corner actions, top-right, never covering the handle 546 - const actions = document.createElement("div"); 547 - actions.className = "card-actions"; 548 - const copy = document.createElement("button"); 549 - copy.className = "ca-btn"; 550 - copy.title = "copy link"; 551 - copy.textContent = "⧉"; 552 - copy.addEventListener("click", async (e) => { 553 - e.stopPropagation(); 554 - try { 555 - await navigator.clipboard.writeText(detailUrl); 556 - copy.textContent = "✓"; 557 - setTimeout(() => (copy.textContent = "⧉"), 1200); 558 - } catch { 559 - openDetail(); 560 - } 561 - }); 562 - actions.append(copy); 563 - 564 - if (session && l.did === session.did) { 565 - const del = document.createElement("button"); 566 - del.className = "ca-btn danger"; 567 - del.title = "delete"; 568 - del.textContent = "×"; 569 - del.addEventListener("click", async (e) => { 570 - e.stopPropagation(); 571 - if (!confirm("delete this drawing?")) return; 572 - del.disabled = true; 573 - try { 574 - await deleteDrawing(session!, l.rkey); 575 - card.remove(); 576 - } catch (err) { 577 - del.disabled = false; 578 - alert(`delete failed: ${String(err)}`); 579 - } 580 - }); 581 - actions.append(del); 582 - } 583 - 584 - card.append(ava, img, who, ...(credit ? [credit] : []), actions); 585 - g.append(card); 586 - } 587 - } catch (err) { 588 - g.replaceChildren(el("p", { class: "hint" }, [`failed to load: ${String(err)}`])); 589 - } 590 - } 591 - 592 - // ---- auth ---- 593 - const oauth = await initOAuth(); 594 - const result = await oauth.init(); 595 - if (result?.session) session = result.session; 596 - 597 - async function logout() { 598 - if (session) await oauth.revoke(session.did); 599 - session = null; 600 - currentHandle = null; 601 - for (const k of Object.keys(iconOverrides)) delete iconOverrides[k]; 602 - show("draw"); // can't be on the settings page while signed out 603 - refreshUI(); 604 - } 605 - 606 - // resolve your own icon set from your PDS into iconOverrides 607 - async function loadOwnIcons() { 608 - if (!session) return; 609 - for (const k of Object.keys(iconOverrides)) delete iconOverrides[k]; 610 - await Promise.all( 611 - SLOTS.map(async (s) => { 612 - const url = await resolveIcon(session!.did, s.key).catch(() => null); 613 - if (url) iconOverrides[s.key] = url; 614 - }), 615 - ); 616 - refreshUI(); 617 - } 618 - 619 - // resolve the signed-in handle + icons (best-effort) 44 + // signed in? resolve your handle, preferences (first-run walkthrough if needed), 45 + // and your own icon set (or a persisted try-on). 46 + const session = getSession(); 620 47 if (session) { 621 48 fetch( 622 49 `https://slingshot.microcosm.blue/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${session.did}`, 623 50 ) 624 51 .then((r) => r.json()) 625 52 .then((d) => { 626 - currentHandle = d.handle; 53 + setHandle(d.handle); 627 54 refreshUI(); 628 55 }) 629 56 .catch(() => {}); 630 - // load global preference defaults; first time (no record) → run the walkthrough 631 57 getPreferences(session.did) 632 58 .then(({ configured, defaults }) => { 633 - myDefaults = defaults; 59 + setDefaults(defaults); 634 60 syncExploreToggle(); 635 61 if (!configured) openPrefsWalkthrough(); 636 62 }) 637 63 .catch(() => {}); 638 - // if a try-on was in progress (persisted), restore it; else load your own set 639 64 const savedTryon = localStorage.getItem(TRYON_KEY); 640 65 if (savedTryon) { 641 66 try { ··· 650 75 } 651 76 } 652 77 653 - // when set, an intent to save is pending across the sign-in redirect — on 654 - // return we auto-save the restored draft so "save" feels seamless logged-out. 655 - const PENDING_SAVE = "doodl:pending-save"; 656 - 657 - // header top-right: sign in until you're signed in, then the settings trigger 658 - function renderTrigger() { 659 - sessionEl.className = "trigger-wrap"; 660 - if (session) { 661 - const btn = el( 662 - "button", 663 - { class: "menu-trigger", id: "menu-btn", "aria-label": "settings", title: "settings" }, 664 - [slotNode("settings")], 665 - ); 666 - btn.addEventListener("click", () => show("settings")); 667 - sessionEl.replaceChildren(btn); 668 - } else { 669 - const btn = el( 670 - "button", 671 - { class: "menu-trigger", id: "signin-btn", "aria-label": "sign in" }, 672 - [slotNode("signin")], 673 - ); 674 - btn.addEventListener("click", () => mountLogin(sessionEl, signIn)); 675 - sessionEl.replaceChildren(btn); 676 - } 677 - saveBtn.disabled = false; 678 - } 679 - 680 - // nav labels are slots too — show the user's drawing if assigned 681 - function renderNav() { 682 - navBtns.draw.replaceChildren(slotNode("nav-draw")); 683 - navBtns.explore.replaceChildren(slotNode("nav-explore")); 684 - } 685 - 686 - // toolbar actions are slots too (the saving/disabled state stays on #status) 687 - function renderToolbar() { 688 - saveBtn.replaceChildren(slotNode("save")); 689 - $("undo").replaceChildren(slotNode("undo")); 690 - $("clear").replaceChildren(slotNode("clear")); 691 - // stroke: the user's drawing if assigned, else a live dot previewing the size 692 - const strokeUrl = slotUrl("stroke"); 693 - $("stroke-btn").replaceChildren( 694 - strokeUrl 695 - ? el("img", { class: "slot-img", src: strokeUrl, alt: "stroke" }) 696 - : el("span", { class: "brush-dot" }), 697 - ); 698 - setSize(currentSize); // re-sync the freshly-rendered preview dot 699 - } 700 - 701 - function refreshUI() { 702 - renderTitle(); 703 - renderTrigger(); 704 - renderNav(); 705 - renderToolbar(); 706 - if (!views.settings.hidden) renderSettings(); 707 - } 708 - 709 - // full-screen settings view (mobile-first) 710 - function renderSettings() { 711 - const v = $("view-settings"); 712 - const mode = getMode(); 713 - // static shell — every literal here is an app constant (no network data), so 714 - // innerHTML is safe; the dynamic, node-requiring bits are populated below. 715 - v.innerHTML = ` 716 - <div class="settings"> 717 - <div class="settings-bar"> 718 - <h2>settings</h2> 719 - </div> 720 - <section class="set-sec"> 721 - <h3>account</h3> 722 - <div id="set-account"></div> 723 - </section> 724 - <section class="set-sec"> 725 - <h3>theme</h3> 726 - <div class="theme-row" id="theme-row"></div> 727 - </section> 728 - <section class="set-sec"> 729 - <h3>open profiles in</h3> 730 - <p class="hint">which app author profiles open in. records always open in pdsls.</p> 731 - <div class="client-row"> 732 - ${AT_CLIENTS.map( 733 - (c) => 734 - `<button class="client-opt${c.value === getClientValue() ? " sel" : ""}" data-client="${c.value}" title="${c.label}">` + 735 - `<img src="${c.iconUrl}" alt="" loading="lazy"><span>${c.label}</span></button>`, 736 - ).join("")} 737 - </div> 738 - </section> 739 - <section class="set-sec"> 740 - <h3>app icons</h3> 741 - <p class="hint">your drawings are the app's icons. tap a slot to change it.</p> 742 - <div class="slot-grid" id="slot-grid"></div> 743 - </section> 744 - <section class="set-sec"> 745 - <h3>icon sets from others</h3> 746 - <p class="hint">tap a set to preview it across the whole app. nothing changes for real until you save it — back out anytime.</p> 747 - <div id="iconsets" class="iconset-list"><p class="hint">loading…</p></div> 748 - </section> 749 - <section class="set-sec"> 750 - <h3>preferences</h3> 751 - <p class="hint">public declarations stored on your PDS. these are your defaults; you can override any single drawing when you save it.</p> 752 - <div id="pref-controls"></div> 753 - </section> 754 - </div>`; 755 - 756 - // theme options 757 - $("theme-row").replaceChildren( 758 - ...(["light", "dark", "system"] as ThemeMode[]).map((m) => { 759 - const btn = el( 760 - "button", 761 - { class: `theme-opt${m === mode ? " sel" : ""}`, "data-mode": m }, 762 - [slotThumbNode(`theme-${m}`), el("span", {}, [m])], 763 - ); 764 - btn.addEventListener("click", () => { 765 - setMode(m); 766 - refreshUI(); 767 - }); 768 - return btn; 769 - }), 770 - ); 771 - 772 - // app-icon slots — tap a slot to draw/assign its icon 773 - $("slot-grid").replaceChildren( 774 - ...SLOTS.map((s) => { 775 - const btn = el("button", { class: "slot", "data-slot": s.key }, [ 776 - slotThumbNode(s.key), 777 - el("span", { class: "slot-label" }, [s.label]), 778 - ]); 779 - btn.addEventListener("click", () => openIconPicker(s.key)); 780 - return btn; 781 - }), 782 - ); 783 - 784 - const acct = $("set-account"); 785 - if (session) { 786 - const logoutBtn = el("button", { class: "set-btn", id: "logout" }, [ 787 - slotNode("signout"), 788 - ]); 789 - logoutBtn.addEventListener("click", logout); 790 - acct.replaceChildren( 791 - el("div", { class: "acct-handle" }, [`@${currentHandle ?? "signed in"}`]), 792 - logoutBtn, 793 - ); 794 - } else { 795 - const loginBtn = el("button", { class: "set-btn", id: "login" }, [ 796 - slotNode("signin"), 797 - ]); 798 - loginBtn.addEventListener("click", () => mountLogin($("set-account"), signIn)); 799 - acct.replaceChildren(loginBtn); 800 - } 801 - 802 - v.querySelectorAll<HTMLButtonElement>(".client-opt").forEach((b) => 803 - b.addEventListener("click", () => { 804 - setClientValue(b.dataset.client!); 805 - renderSettings(); 806 - }), 807 - ); 808 - 809 - // preference defaults editor — each pick writes the record immediately 810 - const prefHost = $("pref-controls"); 811 - if (session) { 812 - prefHost.replaceChildren( 813 - ...prefControls(myDefaults, async (k, val) => { 814 - const next = { ...myDefaults, [k]: val }; 815 - myDefaults = next; 816 - syncExploreToggle(); 817 - // throwing surfaces as the inline "couldn't save" status 818 - await setPreferences(session!, next); 819 - }), 820 - ); 821 - } else { 822 - prefHost.replaceChildren( 823 - el("p", { class: "hint" }, ["sign in to set your preferences."]), 824 - ); 825 - } 826 - 827 - renderMarketplace(); 828 - } 829 - 830 - // the icon-set marketplace, contained in the settings → icon sets section. 831 - // the try-on control lives here (not a floating banner), and saving is a 832 - // deliberate, confirmed action. 833 - const PREVIEW_SLOTS = ["title", "settings", "nav-draw", "nav-explore", "save"]; 834 - function renderMarketplace() { 835 - const host = document.getElementById("iconsets"); 836 - if (!host) return; 837 - host.innerHTML = ""; 838 - 839 - // active try-on control (synchronous, from state) 840 - if (tryonAssignments) { 841 - const revert = el("button", { class: "pill", id: "tryon-revert" }, ["back to mine"]); 842 - const keep = el("button", { class: "pill primary", id: "tryon-keep" }, ["save as mine"]); 843 - revert.addEventListener("click", revertTryon); 844 - keep.addEventListener("click", keepTryon); 845 - host.append( 846 - el("div", { class: "tryon-active" }, [ 847 - el("span", {}, [ 848 - "previewing ", 849 - el("strong", {}, [`@${tryonHandle ?? "a"}`]), 850 - "'s set across the app", 851 - ]), 852 - el("span", { class: "tryon-actions" }, [revert, keep]), 853 - ]), 854 - ); 855 - } 856 - 857 - // the list of other sets 858 - const list = document.createElement("div"); 859 - list.className = "iconset-list"; 860 - list.innerHTML = `<p class="hint">loading…</p>`; 861 - host.append(list); 862 - listIconsets() 863 - .then((sets) => { 864 - // never list your own set — you can't "try on" what you already are 865 - const others = sets.filter((s) => s.did !== session?.did); 866 - if (!others.length) { 867 - list.innerHTML = `<p class="hint">no one else has shared a set yet.</p>`; 868 - return; 869 - } 870 - list.innerHTML = ""; 871 - for (const set of others) { 872 - const prev = el("span", { class: "iconset-prev" }); 873 - const card = el("button", { class: "iconset-card" }, [ 874 - prev, 875 - el("span", { class: "iconset-who" }, [`@${set.handle}`]), 876 - el("span", { class: "iconset-try" }, ["try on"]), 877 - ]); 878 - card.addEventListener("click", () => tryOn(set)); 879 - list.append(card); 880 - resolveIconsetIcons(set.assignments, PREVIEW_SLOTS) 881 - .then((icons) => { 882 - prev.replaceChildren( 883 - ...PREVIEW_SLOTS.filter((s) => icons[s]) 884 - .slice(0, 5) 885 - .map((s) => el("img", { src: icons[s]!, alt: "" })), 886 - ); 887 - }) 888 - .catch(() => {}); 889 - } 890 - }) 891 - .catch(() => { 892 - list.innerHTML = `<p class="hint">couldn't load icon sets.</p>`; 893 - }); 894 - 895 - // reset to defaults — escape hatch back to a clean slate 896 - const reset = document.createElement("button"); 897 - reset.className = "set-link"; 898 - reset.textContent = "reset my icons to defaults"; 899 - reset.addEventListener("click", resetIcons); 900 - host.append(reset); 901 - } 902 - 903 - // user taps a set → persist + apply (a preview; nothing written to your PDS) 904 - function tryOn(set: { handle: string; assignments: IconAssignment[] }) { 905 - localStorage.setItem( 906 - TRYON_KEY, 907 - JSON.stringify({ handle: set.handle, assignments: set.assignments }), 908 - ); 909 - applyTryon(set.handle, set.assignments); 910 - } 911 - 912 - // resolve a set's icons and swap them in app-wide (used by tryOn + on reload) 913 - async function applyTryon(handle: string, assignments: IconAssignment[]) { 914 - tryonAssignments = assignments; 915 - tryonHandle = handle; 916 - const icons = await resolveIconsetIcons(assignments); 917 - for (const k of Object.keys(iconOverrides)) delete iconOverrides[k]; 918 - Object.assign(iconOverrides, icons); 919 - refreshUI(); 920 - } 921 - 922 - async function revertTryon() { 923 - localStorage.removeItem(TRYON_KEY); 924 - tryonAssignments = null; 925 - tryonHandle = null; 926 - await loadOwnIcons(); // re-read YOUR set from your PDS 927 - } 928 - 929 - async function keepTryon() { 930 - if (!session || !tryonAssignments) return; 931 - if ( 932 - !confirm( 933 - `save @${tryonHandle}'s set as your own? this replaces your current icon set.`, 934 - ) 935 - ) 936 - return; 937 - try { 938 - await applyIconset(session, tryonAssignments); 939 - localStorage.removeItem(TRYON_KEY); 940 - tryonAssignments = null; 941 - tryonHandle = null; 942 - refreshUI(); 943 - } catch (err) { 944 - alert(`couldn't save this set: ${String(err)}`); 945 - } 946 - } 947 - 948 - async function resetIcons() { 949 - if (!session) return; 950 - if (!confirm("reset all your icons back to the defaults?")) return; 951 - try { 952 - localStorage.removeItem(TRYON_KEY); 953 - tryonAssignments = null; 954 - tryonHandle = null; 955 - await applyIconset(session, []); // empty iconset → all defaults 956 - for (const k of Object.keys(iconOverrides)) delete iconOverrides[k]; 957 - refreshUI(); 958 - } catch (err) { 959 - alert(`couldn't reset: ${String(err)}`); 960 - } 961 - } 962 - 963 - // bottom-sheet picker: choose one of your drawings for a slot, or draw new 964 - async function openIconPicker(slot: string) { 965 - if (!session) { 966 - show("settings"); 967 - mountLogin($("set-account"), signIn); 968 - return; 969 - } 970 - const overridden = slot in iconOverrides; 971 - const sheet = $("sheet"); 972 - sheet.hidden = false; 973 - sheet.innerHTML = ` 974 - <div class="sheet-card"> 975 - <div class="sheet-grab"></div> 976 - <div class="sheet-head"> 977 - <strong>your ${slotLabel(slot)} icon</strong> 978 - <div class="sheet-head-actions"> 979 - ${overridden ? '<button class="pill" id="sheet-reset">use default</button>' : ""} 980 - <button class="pill" id="sheet-close"></button> 981 - </div> 982 - </div> 983 - <div class="sheet-grid" id="sheet-grid"><p class="hint">loading…</p></div> 984 - </div>`; 985 - $("sheet-close").replaceChildren(slotNode("sheet-close")); 986 - $("sheet-close").addEventListener("click", closeSheet); 987 - sheet.addEventListener("click", (e) => { 988 - if (e.target === sheet) closeSheet(); 989 - }); 990 - if (overridden) { 991 - $("sheet-reset").addEventListener("click", async () => { 992 - try { 993 - await clearIcon(session!, slot); 994 - delete iconOverrides[slot]; 995 - closeSheet(); 996 - refreshUI(); 997 - } catch (err) { 998 - alert(`couldn't reset: ${String(err)}`); 999 - } 1000 - }); 1001 - } 1002 - 1003 - const grid = $("sheet-grid"); 1004 - grid.innerHTML = ""; 1005 - const drawNewUrl = slotUrl("draw-new"); 1006 - const neu = el( 1007 - "button", 1008 - { class: "sheet-new" }, 1009 - drawNewUrl 1010 - ? [el("img", { class: "slot-img", src: drawNewUrl, alt: "draw new" })] 1011 - : [el("span", {}, ["+"]), el("span", { class: "sheet-new-label" }, ["draw new"])], 1012 - ); 1013 - neu.addEventListener("click", () => { 1014 - closeSheet(); 1015 - enterIconMode(slot); 1016 - }); 1017 - grid.append(neu); 1018 - 1019 - try { 1020 - // any drawing on the network can be an icon — yours first, then everyone's 1021 - const all = await fetchGallery(); 1022 - const mine = session.did; 1023 - all.sort((a, b) => (a.did === mine ? -1 : 0) - (b.did === mine ? -1 : 0)); 1024 - if (!all.length) { 1025 - const p = document.createElement("p"); 1026 - p.className = "hint"; 1027 - p.textContent = "no drawings yet — draw one to use it."; 1028 - grid.append(p); 1029 - } 1030 - for (const d of all) { 1031 - const b = el("button", { class: "sheet-item", title: `@${d.handle}` }, [ 1032 - el("img", { src: d.imageUrl, alt: "", loading: "lazy" }), 1033 - ]); 1034 - b.addEventListener("click", async () => { 1035 - b.disabled = true; 1036 - try { 1037 - const ref = await getDrawingRef(d.did, d.rkey); 1038 - await assignIcon(session!, slot, ref); 1039 - iconOverrides[slot] = d.imageUrl; 1040 - closeSheet(); 1041 - refreshUI(); 1042 - } catch (err) { 1043 - b.disabled = false; 1044 - alert(`couldn't set icon: ${String(err)}`); 1045 - } 1046 - }); 1047 - grid.append(b); 1048 - } 1049 - } catch { 1050 - grid.innerHTML += `<p class="hint">couldn't load drawings</p>`; 1051 - } 1052 - } 78 + // entry routing: a "remix this" link / OAuth-return restore takes over, else 79 + // open the view named by the path (/explore, /settings, or draw). 80 + if (!startRemixEntry()) show(routeView(), { push: false }); 1053 81 1054 - function closeSheet() { 1055 - const s = document.getElementById("sheet"); 1056 - if (s) s.hidden = true; 1057 - } 1058 - 1059 - // color popup: the palette + a custom picker. tapping a preset is a decisive 1060 - // choice, so it picks and closes; the custom (native) picker keeps the sheet 1061 - // open so you can nudge the hue and see the swatch update. 1062 - function openColorSheet() { 1063 - const sheet = $("sheet"); 1064 - sheet.hidden = false; 1065 - const presets = PALETTE.map( 1066 - (c) => 1067 - `<button class="swatch preset" data-color="${c}" style="background:${c}" title="${c}" aria-label="${c}"></button>`, 1068 - ).join(""); 1069 - sheet.innerHTML = ` 1070 - <div class="sheet-card"> 1071 - <div class="sheet-grab"></div> 1072 - <div class="sheet-head"> 1073 - <strong>color</strong> 1074 - <button class="pill" id="sheet-close"></button> 1075 - </div> 1076 - <div class="color-panel"> 1077 - ${presets} 1078 - <label class="swatch custom" title="more colors"> 1079 - <input type="color" id="color" value="${currentColor}" /> 1080 - </label> 1081 - </div> 1082 - </div>`; 1083 - $("sheet-close").replaceChildren(slotNode("sheet-close")); 1084 - $("sheet-close").addEventListener("click", closeSheet); 1085 - sheet.addEventListener("click", (e) => { 1086 - if (e.target === sheet) closeSheet(); 1087 - }); 1088 - sheet.querySelectorAll<HTMLElement>(".swatch.preset").forEach((s) => { 1089 - s.addEventListener("click", () => { 1090 - setColor(s.dataset.color!); 1091 - closeSheet(); 1092 - }); 1093 - }); 1094 - const colorInput = $<HTMLInputElement>("color"); 1095 - colorInput.addEventListener("input", () => setColor(colorInput.value)); 1096 - setColor(currentColor); // mark the active swatch 1097 - } 1098 - 1099 - // stroke popup: holds the width slider today, and is the home for future line 1100 - // styles (dotted, dashed, texture) — adding one means a new control here, not 1101 - // more toolbar real estate. 1102 - function openStrokeSheet() { 1103 - const sheet = $("sheet"); 1104 - sheet.hidden = false; 1105 - sheet.innerHTML = ` 1106 - <div class="sheet-card"> 1107 - <div class="sheet-grab"></div> 1108 - <div class="sheet-head"> 1109 - <strong>stroke</strong> 1110 - <button class="pill" id="sheet-close"></button> 1111 - </div> 1112 - <div class="stroke-panel"> 1113 - <span class="brush-dot"></span> 1114 - <input type="range" id="stroke-range" min="1" max="64" value="${currentSize}" /> 1115 - </div> 1116 - </div>`; 1117 - $("sheet-close").replaceChildren(slotNode("sheet-close")); 1118 - $("sheet-close").addEventListener("click", closeSheet); 1119 - sheet.addEventListener("click", (e) => { 1120 - if (e.target === sheet) closeSheet(); 1121 - }); 1122 - const range = $<HTMLInputElement>("stroke-range"); 1123 - range.addEventListener("input", () => setSize(Number(range.value))); 1124 - setSize(currentSize); // sync the in-sheet preview dot 1125 - } 1126 - 1127 - async function signIn(handle: string) { 1128 - statusEl.textContent = "taking you to sign in…"; 1129 - // keep the drawing across the redirect 1130 - if (!pad.isBlank()) localStorage.setItem(DRAFT_KEY, pad.serialize()); 1131 - try { 1132 - await oauth.signIn(handle); 1133 - } catch (err) { 1134 - localStorage.removeItem(DRAFT_KEY); 1135 - localStorage.removeItem(PENDING_SAVE); 1136 - statusEl.textContent = `sign in failed: ${String(err)}`; 1137 - } 1138 - } 1139 - 1140 - // guard against accidentally saving the same unchanged drawing twice (the 1141 - // canvas is intentionally kept after save, so a second tap would dupe it). 1142 - let lastSavedSerial: string | null = null; 1143 - 1144 - async function doSave() { 1145 - if (!session || pad.isBlank()) return; 1146 - const serial = pad.serialize(); 1147 - if (serial === lastSavedSerial) { 1148 - statusEl.innerHTML = `<span class="cheer">already saved ✓</span>`; 1149 - return; 1150 - } 1151 - // a remix that adds nothing to its parent isn't a remix — require a change. 1152 - if (pendingParent && serial === remixInitialSerial) { 1153 - statusEl.textContent = "add something to your remix first :)"; 1154 - return; 1155 - } 1156 - saveBtn.disabled = true; 1157 - statusEl.textContent = "saving…"; 1158 - try { 1159 - const png = await pad.toBlob(); 1160 - const { ref, blobUrl, pageUrl } = await saveDrawing( 1161 - session, 1162 - png, 1163 - pad.sourceBlob(remixBaseRef ?? undefined), 1164 - pendingParent ?? undefined, 1165 - exploreOverride(), 1166 - ); 1167 - lastSavedSerial = serial; 1168 - syncExploreToggle(); // next drawing starts back at your default 1169 - // if we're drawing a new icon for a slot, assign it and head back 1170 - if (pendingIconSlot) { 1171 - const slot = pendingIconSlot; 1172 - await assignIcon(session, slot, ref); 1173 - iconOverrides[slot] = blobUrl; 1174 - pendingIconSlot = null; 1175 - exitIconMode(); 1176 - statusEl.textContent = ""; // don't leave "saving…" behind 1177 - refreshUI(); 1178 - show("settings"); 1179 - return; 1180 - } 1181 - // a remix is now published — drop the lineage (but keep the drawing shown). 1182 - clearRemixState(); 1183 - // keep the drawing on the canvas — clearing it out is jarring. 1184 - showSaved(pageUrl); 1185 - } catch (err) { 1186 - statusEl.textContent = `save failed: ${String(err)}`; 1187 - } finally { 1188 - saveBtn.disabled = false; 1189 - } 1190 - } 1191 - 1192 - saveBtn.addEventListener("click", () => { 1193 - if (pad.isBlank()) { 1194 - statusEl.textContent = "draw something first :)"; 1195 - return; 1196 - } 1197 - if (session) { 1198 - doSave(); 1199 - } else { 1200 - // not signed in: remember the save intent, then ask for a handle 1201 - localStorage.setItem(PENDING_SAVE, "1"); 1202 - statusEl.textContent = "pick your handle to save →"; 1203 - mountLogin(sessionEl, signIn); 1204 - } 1205 - }); 1206 - 1207 - const CHEERS = [ 1208 - "nice — it's yours forever ✨", 1209 - "saved! a tiny piece of you, on the network 🎉", 1210 - "drawing minted 🪄", 1211 - "that's a keeper ⭐", 1212 - "done — go share it 🚀", 1213 - ]; 1214 - 1215 - function showSaved(pageUrl: string) { 1216 - const cheer = CHEERS[Math.floor(Math.random() * CHEERS.length)]; 1217 - const openLink = el( 1218 - "a", 1219 - { id: "open-link", class: "pill", href: pageUrl, target: "_blank", rel: "noreferrer" }, 1220 - ["view & share"], 1221 - ); 1222 - const copyBtn = el("button", { id: "copy-link", class: "pill" }, ["copy link"]); 1223 - statusEl.replaceChildren( 1224 - el("span", { class: "cheer" }, [cheer]), 1225 - el("span", { class: "saved-actions" }, [openLink, copyBtn]), 1226 - ); 1227 - // retrigger the pop animation 1228 - statusEl.classList.remove("pop"); 1229 - void statusEl.offsetWidth; 1230 - statusEl.classList.add("pop"); 1231 - 1232 - copyBtn.addEventListener("click", async () => { 1233 - try { 1234 - await navigator.clipboard.writeText(pageUrl); 1235 - copyBtn.textContent = "copied!"; 1236 - } catch { 1237 - openLink.focus(); 1238 - } 1239 - }); 1240 - } 1241 - 1242 - renderTitle(); 1243 - renderTrigger(); 1244 - renderNav(); 1245 - renderToolbar(); 1246 - 1247 - // ---- remix entry ---- 1248 - const remixParam = new URLSearchParams(location.search).get("remix"); 1249 - if (remixParam && remixParam.includes("/")) { 1250 - // arrived via a "remix this" link: /?remix=<did>/<rkey> 1251 - const i = remixParam.indexOf("/"); 1252 - const rdid = remixParam.slice(0, i); 1253 - const rrkey = remixParam.slice(i + 1); 1254 - history.replaceState(null, "", location.pathname); // drop the param 1255 - statusEl.textContent = "loading the doodl to remix…"; 1256 - getRemixSource(rdid, rrkey) 1257 - .then((rs) => { 1258 - enterRemixMode(rs); 1259 - statusEl.textContent = ""; 1260 - }) 1261 - .catch((e) => { 1262 - statusEl.textContent = `couldn't load that doodl: ${String(e)}`; 1263 - }); 1264 - } else if (localStorage.getItem(REMIX_KEY)) { 1265 - // returning from an OAuth round-trip mid-remix: the strokes were restored 1266 - // from DRAFT_KEY above; re-attach the lineage + locked base. 1267 - try { 1268 - const r = JSON.parse(localStorage.getItem(REMIX_KEY)!); 1269 - pendingParent = r.parent ?? null; 1270 - remixBaseRef = r.baseRef ?? null; 1271 - remixInitialSerial = r.initialSerial ?? null; 1272 - if (r.baseImageUrl) pad.setBaseImage(r.baseImageUrl); 1273 - if (pendingParent) showRemixBanner(r.parentHandle ?? null, r.original ?? null); 1274 - } catch { 1275 - localStorage.removeItem(REMIX_KEY); 1276 - } 1277 - } else { 1278 - // direct load / deep link: open the view named by the path (/explore, etc.) 1279 - show(routeView(), { push: false }); 1280 - } 1281 - 1282 - // if we just came back from a sign-in that was triggered by "save", and the 1283 - // draft was restored above, finish the save automatically. 1284 - if (session && localStorage.getItem(PENDING_SAVE)) { 82 + // if we just came back from a sign-in triggered by "save", finish it. 83 + if (getSession() && localStorage.getItem(PENDING_SAVE)) { 1285 84 localStorage.removeItem(PENDING_SAVE); 1286 85 if (!pad.isBlank()) doSave(); 1287 86 } else {
+13
src/ui/sheets.ts
··· 1 + // the single bottom-sheet (#sheet) that color/stroke/icon-picker/walkthrough all 2 + // reuse. closing is shared; Esc dismisses whatever's open. 3 + export function closeSheet() { 4 + const s = document.getElementById("sheet"); 5 + if (s) s.hidden = true; 6 + } 7 + 8 + export function initSheets() { 9 + window.addEventListener("keydown", (e) => { 10 + const sheet = document.getElementById("sheet"); 11 + if (e.key === "Escape" && sheet && !sheet.hidden) closeSheet(); 12 + }); 13 + }
+53
src/ui/slots.ts
··· 1 + import { el } from "./dom"; 2 + 3 + // ---- slot registry: any UI affordance whose icon can be a drawing ---- 4 + // to make an element customizable: add it here + render its content with 5 + // slotNode(key). it then defaults to its label text, can be drawn over, and 6 + // auto-appears in settings → app icons. No special cases (not even `settings`). 7 + type Slot = { key: string; label: string; defaultUrl?: string }; 8 + 9 + export const SLOTS: Slot[] = [ 10 + { key: "title", label: "doodl" }, 11 + { key: "settings", label: "settings" }, 12 + { key: "nav-draw", label: "draw" }, 13 + { key: "nav-explore", label: "explore" }, 14 + { key: "save", label: "save" }, 15 + { key: "undo", label: "undo" }, 16 + { key: "clear", label: "clear" }, 17 + { key: "stroke", label: "stroke" }, 18 + { key: "remix", label: "remix" }, 19 + { key: "theme-light", label: "light" }, 20 + { key: "theme-dark", label: "dark" }, 21 + { key: "theme-system", label: "system" }, 22 + { key: "signin", label: "sign in" }, 23 + { key: "signout", label: "sign out" }, 24 + { key: "sheet-close", label: "close" }, 25 + { key: "draw-new", label: "draw new" }, 26 + ]; 27 + 28 + const slot = (k: string) => SLOTS.find((s) => s.key === k); 29 + export const slotLabel = (k: string) => slot(k)?.label ?? k; 30 + 31 + // user overrides (slot → blob URL), resolved from their iconset. mutated in 32 + // place (loadOwnIcons / try-on / icon picker / logout); a shared reference so 33 + // every reader sees the current set. 34 + export const iconOverrides: Record<string, string> = {}; 35 + 36 + export const slotUrl = (k: string): string | null => 37 + iconOverrides[k] ?? slot(k)?.defaultUrl ?? null; 38 + 39 + // a LIVE affordance's content as a node: the drawing if assigned, else its label 40 + export function slotNode(k: string): Node { 41 + const url = slotUrl(k); 42 + return url 43 + ? el("img", { class: "slot-img", src: url, alt: slotLabel(k) }) 44 + : document.createTextNode(slotLabel(k)); 45 + } 46 + 47 + // thumbnail for the settings grid: drawing, or a placeholder letter 48 + export function slotThumbNode(k: string): Node { 49 + const url = slotUrl(k); 50 + return url 51 + ? el("img", { class: "slot-img", src: url, alt: "" }) 52 + : el("span", { class: "ph" }, [slotLabel(k)[0] ?? ""]); 53 + }