Lexicon-driven ATProto AppView in Gleam — port of HappyView
0

Configure Feed

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

gleamview / priv / public / app.js
16 kB 536 lines
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("&", "&amp;") 128 .replaceAll("<", "&lt;") 129 .replaceAll(">", "&gt;") 130 .replaceAll('"', "&quot;"); 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();