bzl

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

client.js (11254B)


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