bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

client.js (5632B)


      1 (() => {
      2   if (!window?.BzlPluginHost?.register) return;
      3 
      4   const PLUGIN_ID = "dice";
      5 
      6   function esc(s) {
      7     return String(s ?? "")
      8       .replace(/&/g, "&")
      9       .replace(/</g, "&lt;")
     10       .replace(/>/g, "&gt;")
     11       .replace(/\"/g, "&quot;")
     12       .replace(/'/g, "&#39;");
     13   }
     14 
     15   function safeJsonParse(str) {
     16     try {
     17       return JSON.parse(str);
     18     } catch {
     19       return null;
     20     }
     21   }
     22 
     23   function fmtTime(t) {
     24     try {
     25       return new Date(Number(t || Date.now())).toLocaleTimeString();
     26     } catch {
     27       return "";
     28     }
     29   }
     30 
     31   window.BzlPluginHost.register(PLUGIN_ID, (ctx) => {
     32     const ws = window.__bzlWs;
     33     let mountEl = null;
     34     let inputEl = null;
     35     let rollBtnEl = null;
     36     let listEl = null;
     37     let errEl = null;
     38     let history = [];
     39 
     40     function setError(msg) {
     41       if (!errEl) return;
     42       errEl.textContent = String(msg || "");
     43       errEl.classList.toggle("hidden", !errEl.textContent);
     44     }
     45 
     46     function renderList() {
     47       if (!listEl) return;
     48       if (!history.length) {
     49         listEl.innerHTML = `<div class="small muted" style="padding:10px">No rolls yet.</div>`;
     50         return;
     51       }
     52       const rows = history
     53         .slice()
     54         .reverse()
     55         .slice(0, 60)
     56         .map((r) => {
     57           const rolls = Array.isArray(r.rolls) ? r.rolls.join(", ") : "";
     58           return `<div class="diceRow">
     59             <div class="diceTop">
     60               <span class="diceUser">@${esc(r.user || "unknown")}</span>
     61               <span class="muted">•</span>
     62               <span class="diceExpr">${esc(r.expr || "")}</span>
     63               <span class="muted">•</span>
     64               <span class="muted">${esc(fmtTime(r.createdAt))}</span>
     65             </div>
     66             <div class="diceBottom">
     67               <span class="muted">rolls:</span> <span>${esc(rolls)}</span>
     68               <span class="muted">→</span>
     69               <span class="diceTotal">total: ${esc(String(r.total))}</span>
     70             </div>
     71           </div>`;
     72         })
     73         .join("");
     74       listEl.innerHTML = rows;
     75     }
     76 
     77     function doRoll() {
     78       const expr = String(inputEl?.value || "").trim();
     79       if (!expr) return;
     80       setError("");
     81       ctx.send("roll", { expr });
     82     }
     83 
     84     function ensureUi() {
     85       if (!mountEl) return;
     86       mountEl.innerHTML = `
     87         <style>
     88           .diceWrap { display:flex; flex-direction:column; gap:10px; }
     89           .diceControls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
     90           .diceControls input { flex: 1; min-width: 140px; }
     91           .diceErr { color: color-mix(in srgb, var(--bad) 80%, var(--text)); }
     92           .diceList { max-height: 340px; overflow:auto; border:1px solid rgba(246,240,255,0.10); border-radius:12px; }
     93           .diceRow { padding:10px 12px; border-bottom:1px solid rgba(246,240,255,0.06); }
     94           .diceRow:last-child { border-bottom:0; }
     95           .diceTop { display:flex; gap:8px; flex-wrap:wrap; align-items:baseline; }
     96           .diceBottom { display:flex; gap:8px; flex-wrap:wrap; margin-top:4px; }
     97           .diceUser { font-weight: 900; }
     98           .diceExpr { font-weight: 900; letter-spacing: 0.2px; }
     99           .diceTotal { font-weight: 900; color: color-mix(in srgb, var(--warn) 70%, var(--text)); }
    100         </style>
    101         <div class="diceWrap">
    102           <div class="diceControls">
    103             <input type="text" maxlength="40" placeholder="XdY+Z (e.g. 2d6+1, d20, 4d8-2)" data-diceexpr="1" />
    104             <button type="button" class="primary smallBtn" data-diceroll="1">Roll</button>
    105           </div>
    106           <div class="small muted">Rolls are server-generated and broadcast to everyone.</div>
    107           <div class="small diceErr hidden" data-diceerr="1"></div>
    108           <div class="diceList" data-dicelist="1"></div>
    109         </div>
    110       `;
    111       inputEl = mountEl.querySelector("[data-diceexpr='1']");
    112       rollBtnEl = mountEl.querySelector("[data-diceroll='1']");
    113       listEl = mountEl.querySelector("[data-dicelist='1']");
    114       errEl = mountEl.querySelector("[data-diceerr='1']");
    115 
    116       if (rollBtnEl) rollBtnEl.addEventListener("click", doRoll);
    117       if (inputEl) {
    118         inputEl.addEventListener("keydown", (e) => {
    119           if (e.key === "Enter") {
    120             e.preventDefault();
    121             doRoll();
    122           }
    123         });
    124       }
    125       renderList();
    126     }
    127 
    128     function onWsMessage(evt) {
    129       const msg = safeJsonParse(evt.data);
    130       if (!msg || typeof msg !== "object") return;
    131       const type = String(msg.type || "");
    132       if (type === "plugin:dice:history") {
    133         history = Array.isArray(msg.history) ? msg.history : [];
    134         renderList();
    135         return;
    136       }
    137       if (type === "plugin:dice:rolled") {
    138         if (msg.roll && typeof msg.roll === "object") {
    139           history = [...history, msg.roll].slice(-120);
    140           renderList();
    141         }
    142         return;
    143       }
    144       if (type === "plugin:dice:error") {
    145         setError(String(msg.message || "Error."));
    146       }
    147     }
    148 
    149     ctx.ui?.registerPanel?.({
    150       id: "dice",
    151       title: "Dice",
    152       icon: "🎲",
    153       defaultRack: "right",
    154       role: "utility",
    155       presetHints: {
    156         defaultSocial: { place: "docked.bottom" },
    157         ops: { place: "docked.bottom" }
    158       },
    159       render(mount) {
    160         mountEl = mount;
    161         history = [];
    162         ensureUi();
    163         if (ws && ws.addEventListener) ws.addEventListener("message", onWsMessage);
    164         ctx.send("stateReq", {});
    165         return () => {
    166           if (ws && ws.removeEventListener) ws.removeEventListener("message", onWsMessage);
    167           mountEl = null;
    168         };
    169       }
    170     });
    171   });
    172 })();
    173