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