bzl

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

client.js (11964B)


      1 window.BzlPluginHost.register("directory-server", (ctx) => {
      2   ctx.devLog("info", "directory-server client loaded");
      3 
      4   const pluginId = ctx.id;
      5   const apiBase = `/api/plugins/${encodeURIComponent(pluginId)}`;
      6 
      7   let panelMount = null;
      8   let modMount = null;
      9   let lastList = [];
     10   let lastConfig = null;
     11   let lastEntries = [];
     12 
     13   const el = (tag, props = {}, children = []) => {
     14     const node = document.createElement(tag);
     15     for (const [k, v] of Object.entries(props || {})) {
     16       if (k === "className") node.className = String(v || "");
     17       else if (k === "text") node.textContent = String(v ?? "");
     18       else if (k === "html") node.innerHTML = String(v ?? "");
     19       else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2).toLowerCase(), v);
     20       else if (v === false || v == null) continue;
     21       else node.setAttribute(k, String(v));
     22     }
     23     for (const c of children) {
     24       if (c instanceof Node) node.appendChild(c);
     25     }
     26     return node;
     27   };
     28 
     29   const fmtTime = (t) => {
     30     const n = Number(t || 0);
     31     if (!n) return "-";
     32     try {
     33       return new Date(n).toLocaleString();
     34     } catch {
     35       return String(n);
     36     }
     37   };
     38 
     39   const getHost = (rawUrl) => {
     40     try {
     41       return new URL(String(rawUrl || "")).hostname || "";
     42     } catch {
     43       return "";
     44     }
     45   };
     46 
     47   async function loadPublicList() {
     48     try {
     49       const r = await fetch(`${apiBase}/list`, { cache: "no-store" });
     50       const j = await r.json();
     51       if (!j?.ok) throw new Error(j?.error || "Failed to load list");
     52       lastList = Array.isArray(j.entries) ? j.entries : [];
     53     } catch (e) {
     54       ctx.devLog("warn", "directory list fetch failed", { error: e?.message || String(e) });
     55       lastList = [];
     56     }
     57     renderDirectoryPanel();
     58   }
     59 
     60   function renderDirectoryPanel() {
     61     if (!panelMount) return;
     62     panelMount.innerHTML = "";
     63 
     64     const top = el("div", { className: "row", style: "justify-content:space-between; align-items:center; gap:10px; margin-bottom:10px" }, [
     65       el("div", { className: "row", style: "gap:10px; align-items:center" }, [
     66         el("div", { text: "Directory", style: "font-weight:700" }),
     67         el("div", { className: "muted small", text: `${lastList.length} instance${lastList.length === 1 ? "" : "s"}` }),
     68       ]),
     69       el("div", { className: "row", style: "gap:8px; align-items:center" }, [
     70         el("button", {
     71           type: "button",
     72           className: "ghost",
     73           text: "Refresh",
     74           onclick: () => loadPublicList(),
     75         }),
     76       ]),
     77     ]);
     78 
     79     const list = el("div", { style: "display:flex; flex-direction:column; gap:10px" });
     80     if (!lastList.length) {
     81       list.appendChild(el("div", { className: "muted", text: "No directory entries yet." }));
     82     } else {
     83       for (const entry of lastList) {
     84         const inst = entry?.instance || {};
     85         const name = String(inst.name || inst.id || "Instance").slice(0, 80);
     86         const url = String(inst.url || "");
     87         const desc = String(inst.description || "").slice(0, 240);
     88         const hives = Array.isArray(entry?.publicHives) ? entry.publicHives : [];
     89 
     90         const card = el("div", { className: "panel", style: "padding:12px" });
     91         card.appendChild(
     92           el("div", { className: "row", style: "justify-content:space-between; gap:10px; align-items:flex-start" }, [
     93             el("div", {}, [
     94               el("div", { text: name, style: "font-weight:700; margin-bottom:2px" }),
     95               el("div", { className: "muted small", text: url }),
     96             ]),
     97             el("a", { className: "ghost smallBtn", href: url || "#", target: "_blank", rel: "noreferrer", text: "Open" }),
     98           ])
     99         );
    100         if (desc) card.appendChild(el("div", { className: "small", text: desc, style: "margin-top:8px" }));
    101         card.appendChild(el("div", { className: "muted small", text: `Last seen: ${fmtTime(entry?.lastSeenAt)}`, style: "margin-top:8px" }));
    102 
    103         if (hives.length) {
    104           const ul = el("ul", { style: "margin:8px 0 0 18px" });
    105           for (const h of hives.slice(0, 10)) {
    106             const li = el("li", { className: "small" });
    107             const a = el("a", { href: String(h?.url || "#"), target: "_blank", rel: "noreferrer", text: String(h?.title || "Hive") });
    108             li.appendChild(a);
    109             const hd = String(h?.description || "").trim();
    110             if (hd) li.appendChild(el("span", { className: "muted", text: ` — ${hd}` }));
    111             ul.appendChild(li);
    112           }
    113           card.appendChild(el("div", { className: "muted small", text: "Public hives:", style: "margin-top:10px" }));
    114           card.appendChild(ul);
    115         }
    116 
    117         list.appendChild(card);
    118       }
    119     }
    120 
    121     panelMount.appendChild(top);
    122     panelMount.appendChild(list);
    123   }
    124 
    125   function renderMod() {
    126     if (!modMount) return;
    127     modMount.innerHTML = "";
    128 
    129     const hiddenIds = Array.isArray(lastConfig?.hiddenIds) ? lastConfig.hiddenIds : [];
    130     const blockedHosts = Array.isArray(lastConfig?.blockedHosts) ? lastConfig.blockedHosts : [];
    131     const pendingCount = Number(lastConfig?.pendingCount || 0);
    132 
    133     modMount.appendChild(
    134       el("div", { className: "panel", style: "padding:12px; margin-bottom:12px" }, [
    135         el("div", { className: "row", style: "justify-content:space-between; align-items:center; gap:10px" }, [
    136           el("div", {}, [
    137             el("div", { text: "Directory review settings", style: "font-weight:700" }),
    138             el("div", { className: "muted small", text: `${pendingCount} pending review` }),
    139           ]),
    140           el("button", { type: "button", className: "ghost", text: "Refresh", onclick: () => ctx.send("getConfig", {}) }),
    141         ]),
    142         el("div", { className: "muted small", text: "Announcements are open. Approve or reject each instance here.", style: "margin-top:10px" }),
    143       ])
    144     );
    145 
    146     const blockedWrap = el("div", { className: "panel", style: "padding:12px; margin-bottom:12px" });
    147     blockedWrap.appendChild(el("div", { text: "Blocked hosts", style: "font-weight:700; margin-bottom:6px" }));
    148     if (!blockedHosts.length) blockedWrap.appendChild(el("div", { className: "muted small", text: "None." }));
    149     else {
    150       const ul = el("ul", { style: "margin:0 0 0 18px" });
    151       for (const host of blockedHosts.slice(0, 200)) {
    152         const li = el("li", { className: "small" });
    153         li.appendChild(el("span", { text: host }));
    154         li.appendChild(
    155           el("button", {
    156             type: "button",
    157             className: "ghost smallBtn",
    158             text: "Unblock",
    159             style: "margin-left:10px",
    160             onclick: () => ctx.send("setBlockedHost", { host, blocked: false }),
    161           })
    162         );
    163         ul.appendChild(li);
    164       }
    165       blockedWrap.appendChild(ul);
    166     }
    167     modMount.appendChild(blockedWrap);
    168 
    169     const entriesWrap = el("div", { className: "panel", style: "padding:12px" });
    170     entriesWrap.appendChild(
    171       el("div", { className: "row", style: "justify-content:space-between; align-items:center; gap:10px; margin-bottom:8px" }, [
    172         el("div", { text: "Entries", style: "font-weight:700" }),
    173         el("button", { type: "button", className: "ghost", text: "Refresh", onclick: () => ctx.send("getEntries", {}) }),
    174       ])
    175     );
    176 
    177     if (!lastEntries.length) {
    178       entriesWrap.appendChild(el("div", { className: "muted small", text: "No entries yet." }));
    179     } else {
    180       const list = el("div", { style: "display:flex; flex-direction:column; gap:10px" });
    181       for (const entry of lastEntries) {
    182         const inst = entry?.instance || {};
    183         const id = String(inst.id || "").trim().toLowerCase();
    184         const name = String(inst.name || inst.id || "Instance").slice(0, 60);
    185         const url = String(inst.url || "");
    186         const status = String(entry?.status || "pending");
    187         const host = getHost(url).toLowerCase();
    188         const isHidden = Boolean(id && hiddenIds.includes(id));
    189         const isBlocked = Boolean(host && blockedHosts.includes(host));
    190 
    191         const row = el("div", { className: "row", style: "gap:10px; align-items:flex-start; justify-content:space-between" });
    192         const left = el("div", { style: "flex:1; min-width:0" }, [
    193           el("div", { text: name, style: "font-weight:700" }),
    194           el("div", { className: "muted small", text: url }),
    195           el("div", { className: "muted small", text: `Last seen: ${fmtTime(entry?.lastSeenAt)}` }),
    196           el("div", { className: "muted small", text: `Status: ${status}${entry?.reviewedAt ? ` (reviewed ${fmtTime(entry.reviewedAt)})` : ""}` }),
    197           entry?.rejectedReason ? el("div", { className: "muted small", text: `Rejection note: ${String(entry.rejectedReason)}` }) : null,
    198         ]);
    199 
    200         const controls = el("div", { className: "row", style: "gap:8px; align-items:center; flex-wrap:wrap; justify-content:flex-end" });
    201         const hideCb = el("input", {
    202           type: "checkbox",
    203           checked: isHidden ? "checked" : null,
    204           onchange: (e) => ctx.send("setHidden", { id, hidden: Boolean(e?.target?.checked) }),
    205         });
    206         controls.appendChild(el("label", { className: "row small", style: "gap:8px; align-items:center" }, [hideCb, el("span", { text: "Hide" })]));
    207         controls.appendChild(
    208           el("button", {
    209             type: "button",
    210             className: status === "approved" ? "primary" : "ghost",
    211             text: "Approve",
    212             onclick: () => ctx.send("approveEntry", { id }),
    213           })
    214         );
    215         controls.appendChild(
    216           el("button", {
    217             type: "button",
    218             className: status === "rejected" ? "danger" : "ghost",
    219             text: "Reject",
    220             onclick: () => {
    221               const reason = prompt("Optional rejection note:");
    222               ctx.send("rejectEntry", { id, reason: reason == null ? "" : String(reason) });
    223             },
    224           })
    225         );
    226         if (host) {
    227           controls.appendChild(
    228             el("button", {
    229               type: "button",
    230               className: isBlocked ? "ghost" : "ghost",
    231               text: isBlocked ? "Blocked" : "Block host",
    232               onclick: () => ctx.send("setBlockedHost", { host, blocked: true }),
    233               disabled: isBlocked ? "disabled" : null,
    234             })
    235           );
    236         }
    237         controls.appendChild(
    238           el("button", {
    239             type: "button",
    240             className: "ghost",
    241             text: "Delete",
    242             onclick: () => {
    243               if (!confirm(`Delete entry \"${name}\"?`)) return;
    244               ctx.send("deleteEntry", { id });
    245             },
    246           })
    247         );
    248 
    249         row.appendChild(left);
    250         row.appendChild(controls);
    251         list.appendChild(el("div", { style: "border-top:1px solid rgba(255,255,255,0.06); padding-top:10px" }, [row]));
    252       }
    253       entriesWrap.appendChild(list);
    254     }
    255 
    256     modMount.appendChild(entriesWrap);
    257   }
    258 
    259   ctx.on("updated", () => {
    260     loadPublicList();
    261     if (ctx.getRole() === "owner") ctx.send("getEntries", {});
    262   });
    263   ctx.on("config", (msg) => {
    264     lastConfig = msg && typeof msg === "object" ? msg : null;
    265     renderMod();
    266   });
    267   ctx.on("entries", (msg) => {
    268     lastEntries = Array.isArray(msg?.entries) ? msg.entries : [];
    269     renderMod();
    270   });
    271   ctx.on("configUpdated", () => {
    272     if (ctx.getRole() === "owner") {
    273       ctx.send("getConfig", {});
    274       ctx.send("getEntries", {});
    275     }
    276   });
    277 
    278   ctx.ui.registerPanel({
    279     id: "directory",
    280     title: "Directory",
    281     icon: "📡",
    282     defaultRack: "main",
    283     role: "primary",
    284     render(mount) {
    285       panelMount = mount;
    286       loadPublicList();
    287       return () => {
    288         if (panelMount === mount) panelMount = null;
    289       };
    290     },
    291   });
    292 
    293   ctx.ui.registerModTab({
    294     id: "directory",
    295     title: "Directory feed",
    296     ownerOnly: true,
    297     render(mount) {
    298       modMount = mount;
    299       renderMod();
    300       ctx.send("getConfig", {});
    301       ctx.send("getEntries", {});
    302     },
    303   });
    304 });