Lexicon-driven ATProto AppView in Gleam — port of HappyView
1/**
2 * GleamView web console — vanilla ES module SPA.
3 * Keys live in localStorage only; XRPC needs X-Client-Key, admin needs Bearer.
4 */
5
6const STORAGE = {
7 clientKey: "gleamview.clientKey",
8 adminKey: "gleamview.adminKey",
9 did: "gleamview.did",
10};
11
12const $ = (sel, root = document) => root.querySelector(sel);
13const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
14
15const state = {
16 config: null,
17 tab: "feed",
18};
19
20function loadSettings() {
21 return {
22 clientKey: localStorage.getItem(STORAGE.clientKey) || "",
23 adminKey: localStorage.getItem(STORAGE.adminKey) || "",
24 did: localStorage.getItem(STORAGE.did) || "",
25 };
26}
27
28function saveSettings(partial) {
29 const cur = loadSettings();
30 const next = { ...cur, ...partial };
31 if (next.clientKey) localStorage.setItem(STORAGE.clientKey, next.clientKey);
32 else localStorage.removeItem(STORAGE.clientKey);
33 if (next.adminKey) localStorage.setItem(STORAGE.adminKey, next.adminKey);
34 else localStorage.removeItem(STORAGE.adminKey);
35 if (next.did) localStorage.setItem(STORAGE.did, next.did);
36 else localStorage.removeItem(STORAGE.did);
37 return next;
38}
39
40function toast(msg, kind = "") {
41 const el = $("#toast");
42 el.hidden = false;
43 el.textContent = msg;
44 el.className = "toast" + (kind ? ` ${kind}` : "");
45 clearTimeout(toast._t);
46 toast._t = setTimeout(() => {
47 el.hidden = true;
48 }, 3800);
49}
50
51async function api(path, { method = "GET", headers = {}, body, json } = {}) {
52 const opts = { method, headers: { ...headers } };
53 if (json !== undefined) {
54 opts.headers["Content-Type"] = "application/json";
55 opts.body = JSON.stringify(json);
56 } else if (body !== undefined) {
57 opts.body = body;
58 }
59 const res = await fetch(path, opts);
60 const text = await res.text();
61 let data = null;
62 try {
63 data = text ? JSON.parse(text) : null;
64 } catch {
65 data = text;
66 }
67 if (!res.ok) {
68 const msg =
69 (data && (data.message || data.error)) ||
70 text ||
71 `${res.status} ${res.statusText}`;
72 const err = new Error(msg);
73 err.status = res.status;
74 err.data = data;
75 throw err;
76 }
77 return data;
78}
79
80function withClient(headers = {}) {
81 const { clientKey } = loadSettings();
82 if (clientKey) headers["X-Client-Key"] = clientKey;
83 return headers;
84}
85
86function withAdmin(headers = {}) {
87 const { adminKey } = loadSettings();
88 if (adminKey) headers["Authorization"] = `Bearer ${adminKey}`;
89 return headers;
90}
91
92function setTab(name) {
93 state.tab = name;
94 $$(".tab").forEach((btn) => {
95 const on = btn.dataset.tab === name;
96 btn.classList.toggle("active", on);
97 btn.setAttribute("aria-selected", on ? "true" : "false");
98 });
99 $$(".panel").forEach((panel) => {
100 const on = panel.id === `panel-${name}`;
101 panel.hidden = !on;
102 panel.classList.toggle("active", on);
103 });
104 if (name === "feed") loadFeed().catch(() => {});
105 if (name === "lexicons") loadLexicons().catch(() => {});
106 if (name === "admin") loadAdmin().catch(() => {});
107 if (name === "oauth") {
108 const h = $("#oauth-handle").value || loadSettings().did;
109 if (h && !h.startsWith("did:")) checkOAuth(h).catch(() => {});
110 }
111 if (name === "settings") fillSettingsForm();
112 if (name === "compose") {
113 const s = loadSettings();
114 if (s.did && !$("#compose-did").value) $("#compose-did").value = s.did;
115 }
116}
117
118function fillSettingsForm() {
119 const s = loadSettings();
120 $("#set-client-key").value = s.clientKey;
121 $("#set-admin-key").value = s.adminKey;
122 $("#set-did").value = s.did;
123}
124
125function esc(s) {
126 return String(s ?? "")
127 .replaceAll("&", "&")
128 .replaceAll("<", "<")
129 .replaceAll(">", ">")
130 .replaceAll('"', """);
131}
132
133function shortDid(did) {
134 if (!did) return "unknown";
135 if (did.length <= 22) return did;
136 return did.slice(0, 12) + "…" + did.slice(-6);
137}
138
139function fmtTime(iso) {
140 if (!iso) return "";
141 try {
142 return new Date(iso).toLocaleString();
143 } catch {
144 return iso;
145 }
146}
147
148// —— Status / config ——
149
150async function refreshStatus() {
151 const pill = $("#status-pill");
152 try {
153 const [health, config] = await Promise.all([
154 api("/health"),
155 api("/config"),
156 ]);
157 state.config = config;
158 const mode = config.allowLocalWrites ? "local writes" : "PDS proxy";
159 pill.textContent = `${health.service || "ok"} · ${mode}`;
160 pill.className = "pill ok";
161 $("#write-mode").textContent = mode;
162 $("#write-mode").className =
163 "pill sm " + (config.allowLocalWrites ? "warn" : "ok");
164 $("#config-json").textContent = JSON.stringify(config, null, 2);
165 } catch (e) {
166 pill.textContent = "offline";
167 pill.className = "pill err";
168 $("#config-json").textContent = String(e.message || e);
169 }
170}
171
172// —— Feed ——
173
174async function loadFeed() {
175 const empty = $("#feed-empty");
176 const list = $("#feed-list");
177 empty.hidden = false;
178 empty.textContent = "Loading…";
179 list.hidden = true;
180 try {
181 const data = await api("/xrpc/com.example.getPosts?limit=50", {
182 headers: withClient(),
183 });
184 const records = data.records || data || [];
185 if (!records.length) {
186 empty.hidden = false;
187 empty.textContent = "No posts indexed yet. Compose one!";
188 list.hidden = true;
189 return;
190 }
191 empty.hidden = true;
192 list.hidden = false;
193 list.innerHTML = records
194 .map((r) => {
195 const uri = r.uri || "";
196 const did = uri.startsWith("at://")
197 ? uri.slice(5).split("/")[0]
198 : r.did || "";
199 const text = r.text ?? r.record?.text ?? JSON.stringify(r);
200 const created = r.createdAt || r.indexed_at || r.indexedAt || "";
201 return `<article class="card post">
202 <div class="meta">
203 <span title="${esc(did)}">${esc(shortDid(did))}</span>
204 <span>${esc(fmtTime(created))}</span>
205 </div>
206 <div class="body">${esc(text)}</div>
207 <div class="sub" style="margin-top:0.55rem">${esc(uri)}</div>
208 </article>`;
209 })
210 .join("");
211 } catch (e) {
212 empty.hidden = false;
213 list.hidden = true;
214 empty.textContent = `Failed to load feed: ${e.message}`;
215 }
216}
217
218// —— Compose ——
219
220async function submitCompose(ev) {
221 ev.preventDefault();
222 const text = $("#compose-text").value.trim();
223 const did = ($("#compose-did").value || loadSettings().did).trim();
224 const status = $("#compose-status");
225 const result = $("#compose-result");
226 if (!text) return;
227 if (!did) {
228 toast("Author DID / handle is required", "err");
229 return;
230 }
231 status.textContent = "Publishing…";
232 result.hidden = true;
233 try {
234 const body = {
235 text,
236 createdAt: new Date().toISOString(),
237 };
238 const data = await api("/xrpc/com.example.createPost", {
239 method: "POST",
240 headers: withClient({ "X-Gleamview-Did": did }),
241 json: body,
242 });
243 status.textContent = "Published";
244 result.hidden = false;
245 result.textContent = JSON.stringify(data, null, 2);
246 $("#compose-text").value = "";
247 toast("Post created", "ok");
248 saveSettings({ did });
249 } catch (e) {
250 status.textContent = "";
251 result.hidden = false;
252 result.textContent = e.message;
253 toast(e.message, "err");
254 }
255}
256
257// —— OAuth ——
258
259async function startOAuth(ev) {
260 ev.preventDefault();
261 const handle = $("#oauth-handle").value.trim().replace(/^@/, "");
262 if (!handle) return;
263 // Full navigation so the authorization server redirect works cleanly
264 window.location.href = `/oauth/login?handle=${encodeURIComponent(handle)}`;
265}
266
267async function checkOAuth(handle) {
268 const h = (handle || $("#oauth-handle").value || "").trim().replace(/^@/, "");
269 const box = $("#oauth-session");
270 if (!h) {
271 toast("Enter a handle", "err");
272 return;
273 }
274 try {
275 const data = await api(
276 `/oauth/session?identity=${encodeURIComponent(h)}`,
277 );
278 box.hidden = false;
279 if (data.authenticated) {
280 box.innerHTML = `
281 <div class="title">Signed in</div>
282 <div class="sub">handle: ${esc(data.handle)}</div>
283 <div class="sub">did: ${esc(data.did)}</div>
284 <div class="sub">pds: ${esc(data.pds)}</div>
285 <div class="sub">expires: ${esc(fmtTime(data.expiresAt * 1000))}</div>`;
286 saveSettings({ did: data.did });
287 $("#compose-did").value = data.did;
288 toast("Session active", "ok");
289 } else {
290 box.innerHTML = `<div class="title">Not signed in</div>
291 <div class="sub">No session for ${esc(h)}</div>`;
292 }
293 } catch (e) {
294 box.hidden = false;
295 box.innerHTML = `<div class="title">Error</div><div class="sub">${esc(e.message)}</div>`;
296 }
297}
298
299async function logoutOAuth() {
300 const handle = $("#oauth-handle").value.trim().replace(/^@/, "");
301 if (!handle) {
302 toast("Enter handle to sign out", "err");
303 return;
304 }
305 try {
306 await api(`/oauth/session?handle=${encodeURIComponent(handle)}`, {
307 method: "DELETE",
308 });
309 $("#oauth-session").hidden = true;
310 toast("Signed out", "ok");
311 } catch (e) {
312 toast(e.message, "err");
313 }
314}
315
316// —— Lexicons ——
317
318async function loadLexicons() {
319 const list = $("#lexicon-list");
320 if (!loadSettings().adminKey) {
321 list.innerHTML =
322 '<div class="empty">Add an admin key in Settings to list lexicons.</div>';
323 return;
324 }
325 list.innerHTML = '<div class="empty">Loading…</div>';
326 try {
327 const data = await api("/admin/lexicons", { headers: withAdmin() });
328 const items = Array.isArray(data) ? data : data.lexicons || data;
329 if (!items?.length) {
330 list.innerHTML = '<div class="empty">No lexicons registered.</div>';
331 return;
332 }
333 list.innerHTML = items
334 .map((lex) => {
335 const id = lex.id || lex.nsid || "?";
336 const type = lex.type || lex.lexiconType || lex.kind || "";
337 const rev = lex.revision ?? lex.rev ?? "";
338 const target = lex.targetCollection || lex.target_collection || "";
339 return `<div class="card">
340 <div class="title">${esc(id)}</div>
341 <div class="sub">type: ${esc(type)}${rev !== "" ? ` · rev ${esc(rev)}` : ""}${
342 target ? ` · → ${esc(target)}` : ""
343 }</div>
344 </div>`;
345 })
346 .join("");
347 } catch (e) {
348 list.innerHTML = `<div class="empty">${esc(e.message)}</div>`;
349 }
350}
351
352// —— Admin ——
353
354async function loadAdmin() {
355 if (!loadSettings().adminKey) {
356 $("#client-list").innerHTML =
357 '<div class="empty">Add an admin key in Settings.</div>';
358 $("#record-list").innerHTML = "";
359 return;
360 }
361 try {
362 const stats = await api("/admin/stats", { headers: withAdmin() });
363 $("#stat-records").textContent = stats.records ?? "—";
364 $("#stat-lexicons").textContent = stats.lexicons ?? "—";
365 } catch (e) {
366 $("#stat-records").textContent = "—";
367 $("#stat-lexicons").textContent = "—";
368 toast(e.message, "err");
369 }
370
371 try {
372 const clients = await api("/admin/api-clients", { headers: withAdmin() });
373 const items = Array.isArray(clients) ? clients : clients.clients || [];
374 $("#client-list").innerHTML = items.length
375 ? items
376 .map(
377 (c) => `<div class="card">
378 <div class="title">${esc(c.name || "client")}</div>
379 <div class="sub">${esc(c.clientKey || c.client_key || c.id || "")}</div>
380 </div>`,
381 )
382 .join("")
383 : '<div class="empty">No API clients yet.</div>';
384 } catch (e) {
385 $("#client-list").innerHTML = `<div class="empty">${esc(e.message)}</div>`;
386 }
387
388 try {
389 const recs = await api("/admin/records?collection=com.example.post&limit=20", { headers: withAdmin() });
390 const items = Array.isArray(recs) ? recs : recs.records || [];
391 $("#record-list").innerHTML = items.length
392 ? items
393 .map((r) => {
394 const uri = r.uri || "";
395 const col = r.collection || "";
396 const snippet =
397 typeof r.record === "string"
398 ? r.record.slice(0, 120)
399 : JSON.stringify(r.record || r).slice(0, 120);
400 return `<div class="card">
401 <div class="title">${esc(col || "record")}</div>
402 <div class="sub">${esc(uri)}</div>
403 <div class="sub" style="margin-top:0.35rem">${esc(snippet)}</div>
404 </div>`;
405 })
406 .join("")
407 : '<div class="empty">No records.</div>';
408 } catch (e) {
409 $("#record-list").innerHTML = `<div class="empty">${esc(e.message)}</div>`;
410 }
411}
412
413async function createClient(ev) {
414 ev.preventDefault();
415 const name = $("#client-name").value.trim() || "web";
416 if (!loadSettings().adminKey) {
417 toast("Admin key required", "err");
418 setTab("settings");
419 return;
420 }
421 try {
422 const data = await api("/admin/api-clients", {
423 method: "POST",
424 headers: withAdmin(),
425 json: { name },
426 });
427 const key = data.clientKey || data.client_key;
428 const secret = data.clientSecret || data.client_secret;
429 const box = $("#client-created");
430 box.hidden = false;
431 box.innerHTML = `
432 <strong>Client created — copy now</strong><br/>
433 key: <code>${esc(key)}</code><br/>
434 secret: <code>${esc(secret || "(not shown)")}</code>
435 <div class="row" style="margin-top:0.55rem">
436 <button type="button" class="btn primary" id="btn-use-client">Use this client key</button>
437 </div>`;
438 $("#btn-use-client")?.addEventListener("click", () => {
439 saveSettings({ clientKey: key });
440 fillSettingsForm();
441 toast("Client key saved to Settings", "ok");
442 });
443 if (key) {
444 // offer auto-save
445 }
446 $("#client-name").value = "";
447 await loadAdmin();
448 } catch (e) {
449 toast(e.message, "err");
450 }
451}
452
453// —— Settings ——
454
455function bindSettings() {
456 $("#form-settings").addEventListener("submit", (ev) => {
457 ev.preventDefault();
458 saveSettings({
459 clientKey: $("#set-client-key").value.trim(),
460 adminKey: $("#set-admin-key").value.trim(),
461 did: $("#set-did").value.trim(),
462 });
463 $("#settings-status").textContent = "Saved";
464 toast("Settings saved", "ok");
465 setTimeout(() => {
466 $("#settings-status").textContent = "";
467 }, 1500);
468 });
469 $("#set-show-client")?.addEventListener("change", (ev) => {
470 const input = $("#set-client-key");
471 if (input) input.type = ev.target.checked ? "text" : "password";
472 });
473 $("#set-show-admin")?.addEventListener("change", (ev) => {
474 const input = $("#set-admin-key");
475 if (input) input.type = ev.target.checked ? "text" : "password";
476 });
477 $("#btn-clear-settings").addEventListener("click", () => {
478 localStorage.removeItem(STORAGE.clientKey);
479 localStorage.removeItem(STORAGE.adminKey);
480 localStorage.removeItem(STORAGE.did);
481 fillSettingsForm();
482 toast("Cleared local settings", "ok");
483 });
484}
485
486// —— Boot ——
487
488function bindTabs() {
489 $$(".tab").forEach((btn) => {
490 btn.addEventListener("click", () => setTab(btn.dataset.tab));
491 });
492}
493
494function bindActions() {
495 $("#btn-refresh").addEventListener("click", () => {
496 refreshStatus();
497 if (state.tab === "feed") loadFeed();
498 if (state.tab === "admin") loadAdmin();
499 if (state.tab === "lexicons") loadLexicons();
500 });
501 $("#btn-load-feed").addEventListener("click", () => loadFeed());
502 $("#form-compose").addEventListener("submit", submitCompose);
503 $("#form-oauth").addEventListener("submit", startOAuth);
504 $("#btn-oauth-check").addEventListener("click", () => checkOAuth());
505 $("#btn-oauth-logout").addEventListener("click", () => logoutOAuth());
506 $("#btn-load-lexicons").addEventListener("click", () => loadLexicons());
507 $("#btn-load-admin").addEventListener("click", () => loadAdmin());
508 $("#form-client").addEventListener("submit", createClient);
509}
510
511function maybeOAuthReturn() {
512 // If user landed after oauth callback page... callback is HTML. If they
513 // navigated home with ?oauth=1 we could show message. Optional: hash.
514 const params = new URLSearchParams(location.search);
515 if (params.get("signed_in")) {
516 toast("Signed in — check the Sign in tab", "ok");
517 setTab("oauth");
518 }
519}
520
521async function main() {
522 bindTabs();
523 bindActions();
524 bindSettings();
525 fillSettingsForm();
526 const s = loadSettings();
527 if (s.did) {
528 $("#compose-did").value = s.did;
529 if (!s.did.startsWith("did:")) $("#oauth-handle").value = s.did;
530 }
531 maybeOAuthReturn();
532 await refreshStatus();
533 loadFeed().catch(() => {});
534}
535
536main();