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 });