bzl

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

commit 691dce96f6ef52194dc75749457b58fcc7335167
parent 5267fcf9780e1b9268adfdc7fdf5a4c56f7b2533
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date:   Wed, 18 Feb 2026 10:56:13 -0700

radio plugin, dice plugin, plugin rack, and connectifier

made some cool nifty plugins and a plugin rack to hold em, finally solved the issue where we kept disconnecting

Diffstat:
MCLEAN_INSTALL/public/app.js | 431+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
MCLEAN_INSTALL/public/index.html | 8++++++--
MCLEAN_INSTALL/public/styles.css | 29+++++++++++++++++++++++++++++
MCLEAN_INSTALL/server.js | 9+++++++++
Mdocs/PLUGINS.md | 6++++++
Aplugins_dev/dice/client.js | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplugins_dev/dice/plugin.json | 10++++++++++
Aplugins_dev/dice/server.js | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplugins_dev/radio/client.js | 486+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplugins_dev/radio/plugin.json | 9+++++++++
Aplugins_dev/radio/server.js | 222+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpublic/app.js | 431+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mpublic/index.html | 8++++++--
Mpublic/styles.css | 29+++++++++++++++++++++++++++++
Ascripts/build-dice-plugin.js | 23+++++++++++++++++++++++
Ascripts/build-radio-plugin.js | 23+++++++++++++++++++++++
Mserver.js | 9+++++++++
17 files changed, 2008 insertions(+), 62 deletions(-)

diff --git a/CLEAN_INSTALL/public/app.js b/CLEAN_INSTALL/public/app.js @@ -27,6 +27,7 @@ const toggleRightRackEl = document.getElementById("toggleRightRack"); const layoutPresetEl = document.getElementById("layoutPreset"); const uiScaleEl = document.getElementById("uiScale"); const deviceLayoutEl = document.getElementById("deviceLayout"); +const stayConnectedEl = document.getElementById("stayConnected"); const dockHotbarEl = document.getElementById("dockHotbar"); const showSideRackBtn = document.getElementById("showSideRack"); const showRightRackBtn = document.getElementById("showRightRack"); @@ -667,6 +668,173 @@ registerCorePanel({ id: "moderation", title: "Moderation", icon: "🛡️", role registerCorePanel({ id: "profile", title: "Profile", icon: "👤", role: "transient", defaultRack: "main", element: profileViewPanel }); registerCorePanel({ id: "composer", title: "New Hive", icon: "✍️", role: "aux", defaultRack: "main", element: pollinatePanel }); +let pluginRackPanelEl = null; +let pluginRackWidgetsRackEl = null; +let pluginRackAddMenuEl = null; + +function closePluginRackAddMenu() { + if (!pluginRackAddMenuEl) return; + try { + pluginRackAddMenuEl.remove(); + } catch { + // ignore + } + pluginRackAddMenuEl = null; +} + +function panelIsPluginOwned(panelId) { + const id = String(panelId || "").trim(); + if (!id) return false; + if (id.startsWith("chat:")) return false; + const entry = panelRegistry.get(id); + const src = typeof entry?.source === "string" ? entry.source : ""; + return src.startsWith("plugin:"); +} + +function panelIsHostableInPluginRack(panelId) { + const id = String(panelId || "").trim(); + if (!id) return false; + if (id === "pluginRack") return false; + if (!panelIsPluginOwned(id)) return false; + // Widgets should be small, stackable tools (not full workspace surfaces like Maps). + if (panelRole(id) === "primary") return false; + return true; +} + +function ensurePluginRackPanel() { + if (pluginRackPanelEl instanceof HTMLElement && pluginRackPanelEl.isConnected) return pluginRackPanelEl; + + if (!(pluginRackPanelEl instanceof HTMLElement)) { + const shell = document.createElement("section"); + shell.className = "panel panelFill pluginRackPanel rackPanel"; + shell.dataset.panelId = "pluginRack"; + shell.innerHTML = ` + <div class="panelHeader"> + <div class="panelTitle">${escapeHtml("Plugin Rack")}</div> + <div class="row"></div> + </div> + <div class="panelBody pluginRackBody"> + <div class="pluginRackToolbar"> + <button type="button" class="ghost smallBtn" data-pluginrackadd="1">+ Add widget</button> + <div class="small muted pluginRackHint">Drop plugin panels here to stack them.</div> + </div> + <div id="pluginRackWidgetsRack" class="pluginRackWidgets" aria-label="Plugin widgets"></div> + </div> + `; + pluginRackPanelEl = shell; + pluginRackWidgetsRackEl = shell.querySelector("#pluginRackWidgetsRack"); + + shell.querySelector("[data-pluginrackadd]")?.addEventListener("click", (e) => { + const anchor = e.currentTarget; + if (pluginRackAddMenuEl) closePluginRackAddMenu(); + else openPluginRackAddMenu(anchor); + }); + } + + // Ensure it's registered as a core panel for docking + layout state. + registerCorePanel({ id: "pluginRack", title: "Plugin Rack", icon: "🧰", role: "aux", defaultRack: "main", element: pluginRackPanelEl }); + + // Append into the DOM so it can be docked/restored. (It will typically live in the hotbar.) + const side = ensureMainSideRack(); + if (side && pluginRackPanelEl.parentElement !== side) side.appendChild(pluginRackPanelEl); + + return pluginRackPanelEl; +} + +function ensurePluginRackWidgetsRack() { + ensurePluginRackPanel(); + return pluginRackWidgetsRackEl instanceof HTMLElement ? pluginRackWidgetsRackEl : null; +} + +function readPluginRackWidgetsOrder() { + const rack = ensurePluginRackWidgetsRack(); + return rack ? readRackOrder(rack) : []; +} + +function removePanelFromPluginRack(panelId) { + const id = String(panelId || "").trim(); + if (!id) return; + rackLayoutState.pluginRackWidgets = Array.isArray(rackLayoutState.pluginRackWidgets) + ? rackLayoutState.pluginRackWidgets.filter((x) => x !== id) + : []; + const el = getPanelElement(id); + if (el) el.classList.remove("pluginRackWidget"); + const rack = ensurePluginRackWidgetsRack(); + if (rack && el && el.parentElement === rack) rack.removeChild(el); + const side = ensureMainSideRack(); + if (side && el && !el.parentElement) side.appendChild(el); +} + +function hostPanelInPluginRack(panelId) { + const id = String(panelId || "").trim(); + if (!id) return; + if (!rackLayoutEnabled) return; + if (!panelIsHostableInPluginRack(id)) { + toast("Can't add widget", `${panelTitle(id)} can't be hosted in Plugin Rack.`); + return; + } + + const rack = ensurePluginRackWidgetsRack(); + const el = getPanelElement(id); + if (!rack || !el) return; + + // Hosting implies it should be visible in the rack, not docked. + if (isDocked(id)) undockPanel(id); + + const lastRack = rackIdForPanelElement(el); + if (lastRack) rememberPanelLastRack(id, lastRack); + + el.classList.add("pluginRackWidget"); + if (el.parentElement !== rack) rack.appendChild(el); + + const next = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []); + next.add(id); + rackLayoutState.pluginRackWidgets = Array.from(next); + saveRackLayoutState(); + syncRackStateFromDom(); + enforceWorkspaceRules(); +} + +function openPluginRackAddMenu(anchorEl) { + closePluginRackAddMenu(); + if (!(anchorEl instanceof HTMLElement)) return; + if (!rackLayoutEnabled) return; + + const hosted = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []); + const candidates = Array.from(panelRegistry.keys()) + .filter((id) => panelIsHostableInPluginRack(id) && !hosted.has(id)) + .sort((a, b) => panelTitle(a).localeCompare(panelTitle(b))); + + const items = candidates + .map((id) => `<button type="button" class="ghost smallBtn" data-pluginrackhost="${escapeHtml(id)}">${escapeHtml(panelTitle(id))}</button>`) + .join(""); + + const menu = document.createElement("div"); + menu.className = "hotbarAddMenu pluginRackAddMenu"; + menu.innerHTML = ` + <div class="small muted" style="padding:6px 8px 4px;">Add widget</div> + <div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No plugin widgets available.</div>`}</div> + `; + + const rect = anchorEl.getBoundingClientRect(); + const left = Math.max(12, Math.min(window.innerWidth - 260, rect.left)); + const top = Math.max(12, Math.min(window.innerHeight - 320, rect.bottom + 8)); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + + menu.addEventListener("click", (e) => { + const btn = e.target.closest?.("[data-pluginrackhost]"); + if (!btn) return; + const id = String(btn.getAttribute("data-pluginrackhost") || "").trim(); + if (!id) return; + hostPanelInPluginRack(id); + closePluginRackAddMenu(); + }); + + document.body.appendChild(menu); + pluginRackAddMenuEl = menu; +} + // Rack mode: Profile should behave like a normal dockable panel (not a flow that replaces Hives). // Override the role after the initial core registration (Map#set will replace the previous entry). panelRegistry.set("profile", { ...(panelRegistry.get("profile") || { id: "profile", source: "core" }), role: "aux" }); @@ -686,7 +854,7 @@ const PRESET_DEFS = { sideOrder: ["profile", "composer"], sideCollapsed: true, rightOrder: ["people"], - dockBottom: ["maps", "library"], + dockBottom: ["pluginRack", "maps", "library"], }, chatFocus: { presetId: "chatFocus", @@ -698,7 +866,7 @@ const PRESET_DEFS = { sideOrder: ["profile"], sideCollapsed: true, rightOrder: ["people"], - dockBottom: ["hives", "composer", "maps", "library"], + dockBottom: ["pluginRack", "hives", "composer", "maps", "library"], }, browse: { presetId: "browse", @@ -710,7 +878,7 @@ const PRESET_DEFS = { sideOrder: ["chat"], sideCollapsed: true, rightOrder: ["profile"], - dockBottom: ["people", "composer", "maps", "library"], + dockBottom: ["pluginRack", "people", "composer", "maps", "library"], }, creator: { presetId: "creator", @@ -722,7 +890,7 @@ const PRESET_DEFS = { sideOrder: ["people"], sideCollapsed: true, rightOrder: ["profile"], - dockBottom: ["chat", "maps", "library"], + dockBottom: ["pluginRack", "chat", "maps", "library"], }, mapsSession: { presetId: "mapsSession", @@ -733,7 +901,7 @@ const PRESET_DEFS = { sideOrder: ["hives"], sideCollapsed: true, rightOrder: ["people"], - dockBottom: ["profile", "composer", "library"], + dockBottom: ["pluginRack", "profile", "composer", "library"], }, quiet: { presetId: "quiet", @@ -745,7 +913,7 @@ const PRESET_DEFS = { sideCollapsed: true, rightOrder: [], rightCollapsed: true, - dockBottom: ["chat", "people", "maps", "library"], + dockBottom: ["pluginRack", "chat", "people", "maps", "library"], }, ops: { presetId: "ops", @@ -757,7 +925,7 @@ const PRESET_DEFS = { sideOrder: ["hives"], sideCollapsed: true, rightOrder: ["people"], - dockBottom: ["profile", "composer", "maps", "library"], + dockBottom: ["pluginRack", "profile", "composer", "maps", "library"], }, reportsFocus: { presetId: "reportsFocus", @@ -770,7 +938,7 @@ const PRESET_DEFS = { sideOrder: ["people"], sideCollapsed: true, rightOrder: ["chat"], - dockBottom: ["hives", "profile", "composer", "maps", "library"], + dockBottom: ["pluginRack", "hives", "profile", "composer", "maps", "library"], }, communityWatch: { presetId: "communityWatch", @@ -782,7 +950,7 @@ const PRESET_DEFS = { sideOrder: ["chat"], sideCollapsed: true, rightOrder: ["people"], - dockBottom: ["profile", "composer", "maps", "library"], + dockBottom: ["pluginRack", "profile", "composer", "maps", "library"], }, serverAdmin: { presetId: "serverAdmin", @@ -794,7 +962,7 @@ const PRESET_DEFS = { sideOrder: ["chat"], sideCollapsed: true, rightOrder: ["people"], - dockBottom: ["profile", "composer", "maps", "library"], + dockBottom: ["pluginRack", "profile", "composer", "maps", "library"], }, }; @@ -885,6 +1053,7 @@ function loadRackLayoutState() { presetId: "discordLike", docked: { bottom: [] }, racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, + pluginRackWidgets: [], lastRackByPanelId: {}, }; const parsed = JSON.parse(raw); @@ -894,9 +1063,13 @@ function loadRackLayoutState() { presetId: "discordLike", docked: { bottom: [] }, racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, + pluginRackWidgets: [], lastRackByPanelId: {}, }; const bottom = Array.isArray(parsed?.docked?.bottom) ? parsed.docked.bottom.map((x) => String(x || "")).filter(Boolean) : []; + const pluginRackWidgets = Array.isArray(parsed?.pluginRackWidgets) + ? parsed.pluginRackWidgets.map((x) => String(x || "")).filter(Boolean) + : []; const presetId = typeof parsed?.presetId === "string" ? parsed.presetId : "discordLike"; const workspaceLeft = Array.isArray(parsed?.racks?.workspaceLeft) ? parsed.racks.workspaceLeft.map((x) => String(x || "")).filter(Boolean) : []; const workspaceRight = Array.isArray(parsed?.racks?.workspaceRight) ? parsed.racks.workspaceRight.map((x) => String(x || "")).filter(Boolean) : []; @@ -910,13 +1083,14 @@ function loadRackLayoutState() { if (!id || !rackId) continue; lastRackByPanelId[id] = rackId; } - return { version: 2, presetId, docked: { bottom }, racks: { workspaceLeft, workspaceRight, side, right }, lastRackByPanelId }; + return { version: 2, presetId, docked: { bottom }, racks: { workspaceLeft, workspaceRight, side, right }, pluginRackWidgets, lastRackByPanelId }; } catch { return { version: 2, presetId: "discordLike", docked: { bottom: [] }, racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, + pluginRackWidgets: [], lastRackByPanelId: {}, }; } @@ -1000,12 +1174,12 @@ function panelCanExpand(panelId) { if (id.startsWith("chat:")) return true; if (panelRole(id) === "primary") return true; // Allow a few core panels to take over the workspace even though they aren't "primary" by default. - return id === "moderation" || id === "composer"; + return id === "moderation" || id === "composer" || id === "pluginRack"; } // Panels that are allowed to live in "skinny" columns (side rack / right rack). // These panels should be able to render in a narrow width without breaking layout. -const SKINNY_CAPABLE_PANELS = new Set(["people", "profile", "composer", "hives", "chat"]); +const SKINNY_CAPABLE_PANELS = new Set(["people", "profile", "composer", "hives", "chat", "dice"]); function panelIsSkinnyCapable(panelId) { const id = String(panelId || "").trim(); @@ -1046,6 +1220,8 @@ function rememberPanelLastRack(panelId, rackId) { function dockPanel(panelId) { const id = String(panelId || "").trim(); if (!id) return; + // Docking a hosted widget should implicitly un-host it. + removePanelFromPluginRack(id); const el = getPanelElement(id); const lastRack = rackIdForPanelElement(el); if (lastRack) rememberPanelLastRack(id, lastRack); @@ -1265,6 +1441,8 @@ function readRackOrder(rackEl) { function applyRackStateToDom() { if (!rackLayoutEnabled) return; + // Ensure core "virtual" panels exist before we try to place them. + ensurePluginRackPanel(); const left = ensureWorkspaceLeftRack(); const rightWorkspace = ensureWorkspaceRightRack(); const side = ensureMainSideRack(); @@ -1291,6 +1469,18 @@ function applyRackStateToDom() { const el = getPanelElement(panelId); if (el) right.appendChild(el); } + + // Hosted plugin widgets live inside Plugin Rack, not a top-level rack. + const widgetsOrder = Array.isArray(rackLayoutState?.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []; + const widgetsRack = ensurePluginRackWidgetsRack(); + if (widgetsRack) { + for (const panelId of widgetsOrder) { + const el = getPanelElement(panelId); + if (!el) continue; + el.classList.add("pluginRackWidget"); + widgetsRack.appendChild(el); + } + } } function readWorkspaceActivePrimary() { @@ -1448,6 +1638,14 @@ function syncRackStateFromDom() { side: readRackOrder(side), right: readRackOrder(right), }; + rackLayoutState.pluginRackWidgets = readPluginRackWidgetsOrder(); + const hosted = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []); + for (const [id, entry] of panelRegistry.entries()) { + const el = entry?.element; + if (!(el instanceof HTMLElement)) continue; + if (!el.classList.contains("pluginRackWidget") && hosted.has(id)) el.classList.add("pluginRackWidget"); + if (el.classList.contains("pluginRackWidget") && !hosted.has(id)) el.classList.remove("pluginRackWidget"); + } saveRackLayoutState(); } @@ -1598,6 +1796,11 @@ function applyPreset(presetId) { return; } + // Presets are hard-applied: clear any hosted widgets so placement remains deterministic. + closePluginRackAddMenu(); + for (const id of readPluginRackWidgetsOrder()) removePanelFromPluginRack(id); + rackLayoutState.pluginRackWidgets = []; + rackLayoutState.presetId = def.presetId || key; const workspaceLeftOrder = Array.isArray(def.workspaceLeftOrder) ? def.workspaceLeftOrder.map((x) => String(x || "")).filter(Boolean) : []; @@ -1744,6 +1947,8 @@ function installPanelMinimizeButtons() { addMinBtn(hivesPanelEl?.querySelector(".panelHeader"), "hives"); addMinBtn(profileViewPanel?.querySelector(".panelHeader"), "profile"); addMinBtn(pollinatePanel?.querySelector(".panelHeader"), "composer"); + ensurePluginRackPanel(); + addMinBtn(pluginRackPanelEl?.querySelector(".panelHeader"), "pluginRack"); } function ensurePluginPanelShell(panelId, title, icon, defaultRack, role) { @@ -2147,7 +2352,8 @@ function enableRackDnD() { const rightWorkspace = ensureWorkspaceRightRack(); const side = ensureMainSideRack(); if (!right || !left || !rightWorkspace || !side) return; - const racks = [left, rightWorkspace, side, right]; + const pluginWidgets = ensurePluginRackWidgetsRack(); + const racks = [left, rightWorkspace, side, right, pluginWidgets].filter((x) => x instanceof HTMLElement); // Guard against double-install if initRackLayout is called more than once. if (appRoot?.dataset?.rackDnd === "1") return; @@ -2245,9 +2451,22 @@ function enableRackDnD() { const isWorkspaceSlot = targetRack.id === "workspaceLeftSlot" || targetRack.id === "workspaceRightSlot"; const isRightRackSlot = targetRack.id === "rightRack"; const isSideRackSlot = targetRack.id === "mainSideRack"; + const isPluginRackWidgets = targetRack.id === "pluginRackWidgetsRack"; const isSkinnyRackSlot = isRightRackSlot || isSideRackSlot; const skinnyOk = panelIsSkinnyCapable(draggingPanelId); + if (isPluginRackWidgets && !panelIsHostableInPluginRack(draggingPanelId)) { + toast("Can't place there", `${panelTitle(draggingPanelId)} can't be hosted in Plugin Rack.`); + if (originRack) { + if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(draggingEl, originBefore); + else originRack.appendChild(draggingEl); + } + cleanup(); + syncRackStateFromDom(); + enforceWorkspaceRules(); + return; + } + // Only skinny-capable panels may live in skinny columns (side / right racks). if (isSkinnyRackSlot && !skinnyOk) { toast("Can't place there", `${panelTitle(draggingPanelId)} can't be placed in a skinny rack.`); @@ -2272,6 +2491,7 @@ function enableRackDnD() { } else { targetRack.insertBefore(draggingEl, placeholderEl); } + if (isPluginRackWidgets) draggingEl.classList.add("pluginRackWidget"); } const shouldDock = Boolean(dockHotbarEl && e.clientY > window.innerHeight - 90); const dockId = draggingPanelId; @@ -2393,6 +2613,17 @@ function initRackLayout() { enableRackLayoutDom(); + // Ensure Plugin Rack exists and is accessible (defaults to hotbar unless explicitly placed). + ensurePluginRackPanel(); + const pluginRackPlaced = + isDocked("pluginRack") || + ["workspaceLeft", "workspaceRight", "side", "right"].some((k) => Array.isArray(rackLayoutState?.racks?.[k]) && rackLayoutState.racks[k].includes("pluginRack")); + if (!pluginRackPlaced) { + rackLayoutState.docked.bottom = Array.isArray(rackLayoutState?.docked?.bottom) ? rackLayoutState.docked.bottom : []; + if (!rackLayoutState.docked.bottom.includes("pluginRack")) rackLayoutState.docked.bottom.push("pluginRack"); + saveRackLayoutState(); + } + // Side racks behave like summonable hotbars: hide/show without changing panel layout state. toggleSideRackEl && (toggleSideRackEl.disabled = false); toggleRightRackEl && (toggleRightRackEl.disabled = false); @@ -2447,10 +2678,15 @@ function initRackLayout() { if (appRoot && appRoot.dataset.hotbarPlusClose !== "1") { appRoot.dataset.hotbarPlusClose = "1"; document.addEventListener("pointerdown", (e) => { - if (!hotbarPlusMenuEl) return; + if (!hotbarPlusMenuEl && !pluginRackAddMenuEl) return; const t = e.target; - if (t && (hotbarPlusMenuEl.contains(t) || dockHotbarEl?.contains(t))) return; + if (t) { + if (hotbarPlusMenuEl && hotbarPlusMenuEl.contains(t)) return; + if (pluginRackAddMenuEl && pluginRackAddMenuEl.contains(t)) return; + if (dockHotbarEl && dockHotbarEl.contains(t)) return; + } closeHotbarPlusMenu(); + closePluginRackAddMenu(); }); } @@ -2482,6 +2718,14 @@ function initRackLayout() { const resolveOrbDropRack = (panelId, rackEl) => { const id = String(panelId || "").trim(); if (!id) return rackEl; + if (rackEl && rackEl.id === "pluginRackWidgetsRack") { + if (panelIsHostableInPluginRack(id)) return rackEl; + const left = ensureWorkspaceLeftRack(); + const right = ensureWorkspaceRightRack(); + const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; + const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; + return leftEmpty ? left : rightEmpty ? right : left; + } // Skinny racks (side/right) only allow skinny-capable panels. if (rackEl && (rackEl.id === "mainSideRack" || rackEl.id === "rightRack")) { if (panelIsSkinnyCapable(id)) return rackEl; @@ -2520,7 +2764,8 @@ function initRackLayout() { const rightWorkspaceRack = ensureWorkspaceRightRack(); const sideRack = ensureMainSideRack(); const rightRack = ensureRightRack(); - return [leftRack, rightWorkspaceRack, sideRack, rightRack].filter((x) => x instanceof HTMLElement); + const pluginWidgetsRack = ensurePluginRackWidgetsRack(); + return [leftRack, rightWorkspaceRack, sideRack, rightRack, pluginWidgetsRack].filter((x) => x instanceof HTMLElement); }; const rackAtPoint = (x, y) => { @@ -2570,6 +2815,7 @@ function initRackLayout() { if (insertBefore) rack.insertBefore(panelEl, insertBefore); else rack.appendChild(panelEl); } + if (rack.id === "pluginRackWidgetsRack") panelEl.classList.add("pluginRackWidget"); rememberPanelLastRack(id, rack.id); saveRackLayoutState(); syncRackStateFromDom(); @@ -2688,6 +2934,14 @@ const PEOPLE_WIDTH_DEFAULT = 360; let editContext = null; let mentionState = { open: false, query: "", selected: 0, items: [], anchorRect: null }; +const STAY_CONNECTED_KEY = "bzl_stayConnected"; +function readStayConnectedPref() { + return readBoolPref(STAY_CONNECTED_KEY, false); +} +function writeStayConnectedPref(on) { + writeBoolPref(STAY_CONNECTED_KEY, Boolean(on)); +} + let instanceBranding = { title: "Bzl", subtitle: "Ephemeral hives + chat", allowMemberPermanentPosts: false, appearance: {} }; let serverInfo = null; let serverHealth = null; @@ -6372,6 +6626,26 @@ function openChat(postId) { // Rack mode: hive chats live in dedicated chat panels (instances). Don't also open the legacy main chat panel. if (rackLayoutEnabled) { + const mainChatPanelIsIdle = Boolean( + chatPanelEl && + typeof isDocked === "function" && + !isDocked("chat") && + !activeDmThreadId && + !activeChatPostId && + !isMapChatActive() + ); + if (mainChatPanelIsIdle) { + activeChatPostId = postId; + markRead(postId); + renderFeed(); + ws.send(JSON.stringify({ type: "getChat", postId })); + renderChatPanel(true); + renderTypingIndicator(); + if (isMobileSwipeMode()) setMobilePanel("chat"); + chatEditor.focus(); + return; + } + markRead(postId); renderFeed(); ws.send(JSON.stringify({ type: "getChat", postId })); @@ -8280,18 +8554,100 @@ playSfx("open", { volume: 0.34 }).then((ok) => { if (ok) pendingOpenSfx = false; }); -setConn("connecting"); -const ws = new WebSocket(wsUrl()); -window.__bzlWs = ws; -ws.addEventListener("open", () => { - setConn("open"); - const token = getSessionToken(); - if (token) ws.send(JSON.stringify({ type: "resumeSession", token })); -}); -ws.addEventListener("close", () => setConn("closed")); -ws.addEventListener("error", () => setConn("closed")); +let ws = null; +let wsKeepaliveTimer = null; +let wsReconnectTimer = null; +let wsReconnectAttempt = 0; + +function clearWsKeepalive() { + if (!wsKeepaliveTimer) return; + try { + clearInterval(wsKeepaliveTimer); + } catch { + // ignore + } + wsKeepaliveTimer = null; +} + +function clearWsReconnect() { + if (!wsReconnectTimer) return; + try { + clearTimeout(wsReconnectTimer); + } catch { + // ignore + } + wsReconnectTimer = null; +} -ws.addEventListener("message", (evt) => { +function startWsKeepalive(sock) { + clearWsKeepalive(); + if (!readStayConnectedPref()) return; + wsKeepaliveTimer = setInterval(() => { + if (!sock || sock !== ws) return; + if (sock.readyState !== WebSocket.OPEN) return; + try { + sock.send(JSON.stringify({ type: "ping" })); + } catch { + // ignore + } + }, 25_000); +} + +function scheduleWsReconnect() { + clearWsReconnect(); + if (!readStayConnectedPref()) return; + const attempt = Math.min(6, Math.max(0, wsReconnectAttempt)); + const base = 1000 * Math.pow(2, attempt); + const jitter = Math.floor(Math.random() * 250); + const delay = Math.min(15_000, base) + jitter; + wsReconnectAttempt += 1; + setConn("connecting"); + wsReconnectTimer = setTimeout(() => { + wsReconnectTimer = null; + connectWs(); + }, delay); +} + +function connectWs() { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; + clearWsKeepalive(); + setConn("connecting"); + const sock = new WebSocket(wsUrl()); + ws = sock; + window.__bzlWs = sock; + + sock.addEventListener("open", () => { + if (sock !== ws) return; + setConn("open"); + wsReconnectAttempt = 0; + clearWsReconnect(); + startWsKeepalive(sock); + const token = getSessionToken(); + if (token) { + try { + sock.send(JSON.stringify({ type: "resumeSession", token })); + } catch { + // ignore + } + } + }); + + sock.addEventListener("close", () => { + if (sock !== ws) return; + setConn("closed"); + clearWsKeepalive(); + scheduleWsReconnect(); + }); + + sock.addEventListener("error", () => { + if (sock !== ws) return; + setConn("closed"); + }); + + sock.addEventListener("message", onWsMessage); +} + +function onWsMessage(evt) { let msg; try { msg = JSON.parse(evt.data); @@ -9000,10 +9356,27 @@ ws.addEventListener("message", (evt) => { } renderChatInstancesForPost(msg.postId); } -}); +} + +setConn("connecting"); +connectWs(); renderLanHint(); initDisplayPrefsUi(); +if (stayConnectedEl) { + stayConnectedEl.checked = readStayConnectedPref(); + stayConnectedEl.addEventListener("change", () => { + const on = Boolean(stayConnectedEl.checked); + writeStayConnectedPref(on); + if (on) { + if (!ws || ws.readyState === WebSocket.CLOSED) connectWs(); + startWsKeepalive(ws); + } else { + clearWsReconnect(); + clearWsKeepalive(); + } + }); +} renderPeoplePanel(); setPeopleOpen(getPeopleOpen()); composerOpen = getComposerOpen(); diff --git a/CLEAN_INSTALL/public/index.html b/CLEAN_INSTALL/public/index.html @@ -4,7 +4,7 @@ <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Bzl - Hives</title> - <link rel="stylesheet" href="/styles.css?v=103" /> + <link rel="stylesheet" href="/styles.css?v=104" /> </head> <body> <div class="app"> @@ -57,6 +57,10 @@ <span>Show reactions bar</span> <input id="toggleReactions" type="checkbox" /> </label> + <label class="checkRow" style="margin-top:8px;"> + <span>Stay connected</span> + <input id="stayConnected" type="checkbox" /> + </label> <details style="margin-top:10px;"> <summary class="small muted" style="cursor:pointer;user-select:none;">Advanced display</summary> @@ -530,6 +534,6 @@ </div> <div id="dockHotbar" class="dockHotbar hidden" aria-label="Docked panels"></div> - <script src="/app.js?v=116"></script> + <script src="/app.js?v=120"></script> </body> </html> diff --git a/CLEAN_INSTALL/public/styles.css b/CLEAN_INSTALL/public/styles.css @@ -361,6 +361,35 @@ body { min-height: 0; } +.pluginRackPanel .pluginRackBody { + display: flex; + flex-direction: column; + min-height: 0; +} + +.pluginRackToolbar { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-bottom: 1px solid color-mix(in srgb, var(--text) 10%, transparent); +} + +.pluginRackWidgets { + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: var(--app-gap); + padding: 10px; +} + +.pluginRackWidgets > .rackPanel { + flex: 0 0 auto; + min-height: 0; +} + .app.rackMode.hasMod { /* In rack mode, mod is just another panel inside the right rack. */ grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px 1fr 10px minmax(var(--people-min), var(--people-width)); diff --git a/CLEAN_INSTALL/server.js b/CLEAN_INSTALL/server.js @@ -4128,6 +4128,15 @@ wss.on("connection", (ws, req) => { if (!msg || typeof msg !== "object") return; + if (msg.type === "ping") { + try { + ws.send(JSON.stringify({ type: "pong", serverTime: now() })); + } catch { + // ignore + } + return; + } + const msgType = typeof msg.type === "string" ? msg.type : ""; const pluginMatch = msgType.match(/^plugin:([a-z0-9_.-]{1,32}):([a-zA-Z0-9_.-]{1,64})$/); if (pluginMatch) { diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md @@ -24,6 +24,12 @@ This repo includes starter plugins and zip builders: - Library: `plugins_dev/library/` - Build: `node scripts/build-library-plugin.js` - Upload: `dist/plugins/library.zip` +- Radio: `plugins_dev/radio/` + - Build: `node scripts/build-radio-plugin.js` + - Upload: `dist/plugins/radio.zip` +- Dice: `plugins_dev/dice/` + - Build: `node scripts/build-dice-plugin.js` + - Upload: `dist/plugins/dice.zip` - Directory Server (draft): `plugins_dev/directory-server/` - Build: `node scripts/build-directory-server-plugin.js` - Upload: `dist/plugins/directory-server.zip` diff --git a/plugins_dev/dice/client.js b/plugins_dev/dice/client.js @@ -0,0 +1,173 @@ +(() => { + if (!window?.BzlPluginHost?.register) return; + + const PLUGIN_ID = "dice"; + + function esc(s) { + return String(s ?? "") + .replace(/&/g, "&amp;") + .replace(/</g, "&lt;") + .replace(/>/g, "&gt;") + .replace(/\"/g, "&quot;") + .replace(/'/g, "&#39;"); + } + + function safeJsonParse(str) { + try { + return JSON.parse(str); + } catch { + return null; + } + } + + function fmtTime(t) { + try { + return new Date(Number(t || Date.now())).toLocaleTimeString(); + } catch { + return ""; + } + } + + window.BzlPluginHost.register(PLUGIN_ID, (ctx) => { + const ws = window.__bzlWs; + let mountEl = null; + let inputEl = null; + let rollBtnEl = null; + let listEl = null; + let errEl = null; + let history = []; + + function setError(msg) { + if (!errEl) return; + errEl.textContent = String(msg || ""); + errEl.classList.toggle("hidden", !errEl.textContent); + } + + function renderList() { + if (!listEl) return; + if (!history.length) { + listEl.innerHTML = `<div class="small muted" style="padding:10px">No rolls yet.</div>`; + return; + } + const rows = history + .slice() + .reverse() + .slice(0, 60) + .map((r) => { + const rolls = Array.isArray(r.rolls) ? r.rolls.join(", ") : ""; + return `<div class="diceRow"> + <div class="diceTop"> + <span class="diceUser">@${esc(r.user || "unknown")}</span> + <span class="muted">•</span> + <span class="diceExpr">${esc(r.expr || "")}</span> + <span class="muted">•</span> + <span class="muted">${esc(fmtTime(r.createdAt))}</span> + </div> + <div class="diceBottom"> + <span class="muted">rolls:</span> <span>${esc(rolls)}</span> + <span class="muted">→</span> + <span class="diceTotal">total: ${esc(String(r.total))}</span> + </div> + </div>`; + }) + .join(""); + listEl.innerHTML = rows; + } + + function doRoll() { + const expr = String(inputEl?.value || "").trim(); + if (!expr) return; + setError(""); + ctx.send("roll", { expr }); + } + + function ensureUi() { + if (!mountEl) return; + mountEl.innerHTML = ` + <style> + .diceWrap { display:flex; flex-direction:column; gap:10px; } + .diceControls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; } + .diceControls input { flex: 1; min-width: 140px; } + .diceErr { color: color-mix(in srgb, var(--bad) 80%, var(--text)); } + .diceList { max-height: 340px; overflow:auto; border:1px solid rgba(246,240,255,0.10); border-radius:12px; } + .diceRow { padding:10px 12px; border-bottom:1px solid rgba(246,240,255,0.06); } + .diceRow:last-child { border-bottom:0; } + .diceTop { display:flex; gap:8px; flex-wrap:wrap; align-items:baseline; } + .diceBottom { display:flex; gap:8px; flex-wrap:wrap; margin-top:4px; } + .diceUser { font-weight: 900; } + .diceExpr { font-weight: 900; letter-spacing: 0.2px; } + .diceTotal { font-weight: 900; color: color-mix(in srgb, var(--warn) 70%, var(--text)); } + </style> + <div class="diceWrap"> + <div class="diceControls"> + <input type="text" maxlength="40" placeholder="XdY+Z (e.g. 2d6+1, d20, 4d8-2)" data-diceexpr="1" /> + <button type="button" class="primary smallBtn" data-diceroll="1">Roll</button> + </div> + <div class="small muted">Rolls are server-generated and broadcast to everyone.</div> + <div class="small diceErr hidden" data-diceerr="1"></div> + <div class="diceList" data-dicelist="1"></div> + </div> + `; + inputEl = mountEl.querySelector("[data-diceexpr='1']"); + rollBtnEl = mountEl.querySelector("[data-diceroll='1']"); + listEl = mountEl.querySelector("[data-dicelist='1']"); + errEl = mountEl.querySelector("[data-diceerr='1']"); + + if (rollBtnEl) rollBtnEl.addEventListener("click", doRoll); + if (inputEl) { + inputEl.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + doRoll(); + } + }); + } + renderList(); + } + + function onWsMessage(evt) { + const msg = safeJsonParse(evt.data); + if (!msg || typeof msg !== "object") return; + const type = String(msg.type || ""); + if (type === "plugin:dice:history") { + history = Array.isArray(msg.history) ? msg.history : []; + renderList(); + return; + } + if (type === "plugin:dice:rolled") { + if (msg.roll && typeof msg.roll === "object") { + history = [...history, msg.roll].slice(-120); + renderList(); + } + return; + } + if (type === "plugin:dice:error") { + setError(String(msg.message || "Error.")); + } + } + + ctx.ui?.registerPanel?.({ + id: "dice", + title: "Dice", + icon: "🎲", + defaultRack: "right", + role: "utility", + presetHints: { + defaultSocial: { place: "docked.bottom" }, + ops: { place: "docked.bottom" } + }, + render(mount) { + mountEl = mount; + history = []; + ensureUi(); + if (ws && ws.addEventListener) ws.addEventListener("message", onWsMessage); + ctx.send("stateReq", {}); + return () => { + if (ws && ws.removeEventListener) ws.removeEventListener("message", onWsMessage); + mountEl = null; + }; + } + }); + }); +})(); + diff --git a/plugins_dev/dice/plugin.json b/plugins_dev/dice/plugin.json @@ -0,0 +1,10 @@ +{ + "id": "dice", + "name": "Dice", + "version": "0.1.0", + "description": "Shared dice roller (XdY+Z) that broadcasts results to everyone.", + "entryClient": "client.js", + "entryServer": "server.js", + "permissions": ["ui", "ws"] +} + diff --git a/plugins_dev/dice/server.js b/plugins_dev/dice/server.js @@ -0,0 +1,164 @@ +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +const HISTORY_MAX = Number(process.env.DICE_HISTORY_MAX || 120); +const MAX_DICE = Number(process.env.DICE_MAX_DICE || 100); +const MAX_SIDES = Number(process.env.DICE_MAX_SIDES || 1000); +const MAX_MOD_ABS = Number(process.env.DICE_MAX_MOD_ABS || 10000); + +function safeJsonParse(str) { + try { + return JSON.parse(str); + } catch { + return null; + } +} + +function readJsonOrNull(filePath) { + try { + const raw = fs.readFileSync(filePath, "utf8"); + return safeJsonParse(raw); + } catch { + return null; + } +} + +function writeFileAtomic(filePath, content) { + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + const tmp = `${filePath}.tmp.${crypto.randomBytes(6).toString("hex")}`; + fs.writeFileSync(tmp, content, "utf8"); + fs.renameSync(tmp, filePath); +} + +function nowMs() { + return Date.now(); +} + +function toId(bytes = 6) { + return crypto.randomBytes(Math.max(3, Math.min(16, bytes))).toString("hex"); +} + +function normUser(u) { + return String(u || "").trim().toLowerCase(); +} + +function parseExpr(raw) { + const s = String(raw || "").trim(); + if (!s) return { ok: false, error: "Empty roll." }; + const m = s.match(/^\s*(\d*)\s*d\s*(\d+)\s*([+-]\s*\d+)?\s*$/i); + if (!m) return { ok: false, error: "Invalid format. Use XdY+Z (example: 2d6+1)." }; + const countRaw = m[1]; + const sidesRaw = m[2]; + const modRaw = m[3] || ""; + const count = countRaw ? Number(countRaw) : 1; + const sides = Number(sidesRaw); + const mod = modRaw ? Number(String(modRaw).replace(/\s+/g, "")) : 0; + if (!Number.isFinite(count) || !Number.isFinite(sides) || !Number.isFinite(mod)) { + return { ok: false, error: "Invalid numbers in roll." }; + } + const dice = Math.floor(count); + const sds = Math.floor(sides); + const md = Math.trunc(mod); + if (dice <= 0) return { ok: false, error: "Dice count must be >= 1." }; + if (dice > MAX_DICE) return { ok: false, error: `Too many dice (max ${MAX_DICE}).` }; + if (sds < 2) return { ok: false, error: "Sides must be >= 2." }; + if (sds > MAX_SIDES) return { ok: false, error: `Too many sides (max ${MAX_SIDES}).` }; + if (Math.abs(md) > MAX_MOD_ABS) return { ok: false, error: `Modifier too large (max ±${MAX_MOD_ABS}).` }; + return { ok: true, dice, sides: sds, mod: md, expr: `${dice}d${sds}${md === 0 ? "" : md > 0 ? `+${md}` : `${md}`}` }; +} + +function rollOnce(sides) { + // inclusive 1..sides + return crypto.randomInt(1, sides + 1); +} + +module.exports = function init(api) { + const dataFile = path.join(__dirname, "dice.json"); + let history = []; + + function loadHistory() { + const parsed = readJsonOrNull(dataFile); + const arr = Array.isArray(parsed?.history) ? parsed.history : []; + history = arr + .map((r) => { + const id = String(r?.id || "").trim().toLowerCase(); + const user = normUser(r?.user); + const expr = String(r?.expr || "").trim().slice(0, 40); + const dice = Number(r?.dice || 0) || 0; + const sides = Number(r?.sides || 0) || 0; + const mod = Number(r?.mod || 0) || 0; + const rolls = Array.isArray(r?.rolls) ? r.rolls.map((n) => Math.max(1, Math.floor(Number(n || 0) || 0))) : []; + const total = Number(r?.total || 0) || 0; + const createdAt = Number(r?.createdAt || 0) || 0; + if (!id || !user || !expr || !createdAt) return null; + return { id, user, expr, dice, sides, mod, rolls: rolls.slice(0, MAX_DICE), total, createdAt }; + }) + .filter(Boolean) + .slice(-HISTORY_MAX); + } + + function saveHistory() { + writeFileAtomic(dataFile, JSON.stringify({ version: 1, savedAt: nowMs(), history }, null, 2) + "\n"); + } + + function broadcastRoll(entry) { + api.broadcast({ type: "plugin:dice:rolled", roll: entry }); + } + + function send(ws, msg) { + try { + ws.send(JSON.stringify(msg)); + return true; + } catch { + return false; + } + } + + function sendError(ws, message) { + send(ws, { type: "plugin:dice:error", message: String(message || "Error.") }); + } + + loadHistory(); + + api.registerWs("stateReq", (ws) => { + send(ws, { type: "plugin:dice:history", history }); + }); + + api.registerWs("roll", (ws, msg) => { + const user = normUser(ws?.user?.username); + if (!user) { + sendError(ws, "Sign in required to roll dice."); + return; + } + const parsed = parseExpr(msg?.expr); + if (!parsed.ok) { + sendError(ws, parsed.error); + return; + } + const rolls = []; + let sum = 0; + for (let i = 0; i < parsed.dice; i++) { + const r = rollOnce(parsed.sides); + rolls.push(r); + sum += r; + } + const total = sum + parsed.mod; + const entry = { + id: toId(6), + user, + expr: parsed.expr, + dice: parsed.dice, + sides: parsed.sides, + mod: parsed.mod, + rolls, + total, + createdAt: nowMs() + }; + history = [...history, entry].slice(-HISTORY_MAX); + saveHistory(); + broadcastRoll(entry); + }); +}; + diff --git a/plugins_dev/radio/client.js b/plugins_dev/radio/client.js @@ -0,0 +1,486 @@ +(() => { + if (!window?.BzlPluginHost?.register) return; + + const PLUGIN_ID = "radio"; + const LS_ON = "bzl_radio_on"; + const LS_TUNED = "bzl_radio_stationId"; + + function esc(s) { + return String(s ?? "") + .replace(/&/g, "&amp;") + .replace(/</g, "&lt;") + .replace(/>/g, "&gt;") + .replace(/\"/g, "&quot;") + .replace(/'/g, "&#39;"); + } + + function safeJsonParse(str) { + try { + return JSON.parse(str); + } catch { + return null; + } + } + + function getSessionTokenSafe() { + try { + // Core stores the session token in localStorage. + return localStorage.getItem("bzl_session_token") || ""; + } catch { + return ""; + } + } + + async function uploadMp3(file, toast) { + if (!file) return ""; + const lowered = String(file.name || "").toLowerCase(); + const isMp3 = lowered.endsWith(".mp3") || String(file.type || "").toLowerCase() === "audio/mpeg"; + if (!isMp3) { + toast("Radio", "Only MP3 files are allowed."); + return ""; + } + const token = getSessionTokenSafe(); + if (!token) { + toast("Radio", "Sign in required to upload MP3s."); + return ""; + } + try { + const res = await fetch("/api/upload?kind=audio", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "audio/mpeg" + }, + body: file + }); + const payload = await res.json().catch(() => ({})); + if (!res.ok) { + toast("Radio", payload?.error || "Upload failed."); + return ""; + } + const url = String(payload?.url || ""); + if (!url) { + toast("Radio", "Upload failed (no URL)."); + return ""; + } + if (!/\.mp3(\?|#|$)/i.test(url)) { + toast("Radio", "Upload rejected (not an MP3 URL)."); + return ""; + } + return url; + } catch { + toast("Radio", "Upload failed (network error)."); + return ""; + } + } + + window.BzlPluginHost.register(PLUGIN_ID, (ctx) => { + const ws = window.__bzlWs; + let mountEl = null; + let audioEl = null; + let fileInputEl = null; + + let stations = []; + let isOn = false; + let tunedStationId = ""; + let playingStationId = ""; + let lastPlayedTrackUrl = ""; + let lastPlayedTrackSrc = ""; + let needsManualPlay = false; + + function readPrefs() { + try { + isOn = localStorage.getItem(LS_ON) === "1"; + tunedStationId = String(localStorage.getItem(LS_TUNED) || "").trim().toLowerCase(); + } catch { + isOn = false; + tunedStationId = ""; + } + } + + function writeOn(next) { + isOn = Boolean(next); + try { + localStorage.setItem(LS_ON, isOn ? "1" : "0"); + } catch {} + } + + function writeTuned(id) { + tunedStationId = String(id || "").trim().toLowerCase(); + try { + localStorage.setItem(LS_TUNED, tunedStationId); + } catch {} + } + + function stationById(id) { + const sid = String(id || "").trim().toLowerCase(); + return stations.find((s) => String(s?.id || "") === sid) || null; + } + + function ensureTunedExists() { + if (!stations.length) { + tunedStationId = ""; + return; + } + if (tunedStationId && stationById(tunedStationId)) return; + tunedStationId = String(stations[0]?.id || ""); + } + + function pickRandomTrack(station) { + const tracks = Array.isArray(station?.tracks) ? station.tracks : []; + if (!tracks.length) return null; + if (tracks.length === 1) return tracks[0]; + for (let i = 0; i < 6; i++) { + const t = tracks[Math.floor(Math.random() * tracks.length)]; + if (t && String(t.url || "") !== lastPlayedTrackUrl) return t; + } + return tracks[Math.floor(Math.random() * tracks.length)]; + } + + function stopPlayback() { + playingStationId = ""; + lastPlayedTrackUrl = ""; + lastPlayedTrackSrc = ""; + needsManualPlay = false; + if (!audioEl) return; + try { + audioEl.pause(); + audioEl.removeAttribute("src"); + audioEl.load(); + } catch {} + updateNowEl(); + } + + async function startPlaybackFor(stationId) { + if (!audioEl) return; + const station = stationById(stationId); + if (!station) return; + const track = pickRandomTrack(station); + if (!track || !track.url) { + stopPlayback(); + return; + } + playingStationId = String(station.id || ""); + lastPlayedTrackUrl = String(track.url || ""); + needsManualPlay = false; + try { + lastPlayedTrackSrc = new URL(String(track.url || ""), window.location.origin).href; + } catch { + lastPlayedTrackSrc = String(track.url || ""); + } + audioEl.src = lastPlayedTrackSrc; + try { + audioEl.load(); + } catch {} + try { + await audioEl.play(); + } catch { + // Autoplay can be blocked; keep controls visible and prompt user to press play. + needsManualPlay = true; + } + updateNowEl(); + } + + function updateNowEl() { + if (!mountEl) return; + const nowWrap = mountEl.querySelector(".radioNow"); + if (!nowWrap) return; + ensureTunedExists(); + const tuned = tunedStationId ? stationById(tunedStationId) : null; + const trackCount = tuned ? Number(tuned.trackCount || (tuned.tracks || []).length || 0) : 0; + const isPlaying = Boolean(isOn && playingStationId && playingStationId === tunedStationId); + const nowTrack = isPlaying ? (tuned?.tracks || []).find((t) => String(t.url || "") === lastPlayedTrackUrl) : null; + + nowWrap.innerHTML = ` + ${ + !tuned + ? `<span class="muted">No stations available.</span>` + : !isOn + ? `<span class="muted">Radio is off. Tune around, then toggle it on to listen.</span>` + : trackCount === 0 + ? `<span class="muted">This station has no tracks yet. Upload some MP3s.</span>` + : nowTrack + ? `Now playing: <b>${esc(nowTrack.title || "Track")}</b> <span class="muted">(${esc( + nowTrack.addedBy ? `@${nowTrack.addedBy}` : "unknown" + )})</span>` + : needsManualPlay + ? `<span class="muted">Press play to start listening (browser blocked autoplay).</span>` + : `<span class="muted">Ready.</span>` + } + `; + } + + function stepTune(dir) { + if (!stations.length) return; + const currentIdx = Math.max( + 0, + stations.findIndex((s) => String(s?.id || "") === tunedStationId) + ); + const nextIdx = (currentIdx + (dir < 0 ? -1 : 1) + stations.length) % stations.length; + const nextId = String(stations[nextIdx]?.id || ""); + writeTuned(nextId); + render(); + if (isOn) { + stopPlayback(); + startPlaybackFor(nextId); + } + } + + function render() { + if (!mountEl) return; + ensureTunedExists(); + + const tuned = tunedStationId ? stationById(tunedStationId) : null; + const canUpload = Boolean(ctx.getUser && ctx.getUser()); + const tunedName = tuned ? tuned.name : "No stations yet"; + const tunedAuthor = tuned ? tuned.author : ""; + const trackCount = tuned ? Number(tuned.trackCount || (tuned.tracks || []).length || 0) : 0; + const isPlaying = Boolean(isOn && playingStationId && playingStationId === tunedStationId); + const nowTrack = isPlaying ? (tuned?.tracks || []).find((t) => String(t.url || "") === lastPlayedTrackUrl) : null; + + mountEl.innerHTML = ` + <style> + .radioWrap { display:flex; flex-direction:column; gap:10px; } + .radioTop { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; } + .radioRow { display:flex; align-items:center; gap:8px; flex-wrap:wrap; } + .radioStation { display:flex; flex-direction:column; gap:2px; padding:10px 12px; border:1px solid rgba(246,240,255,0.12); background:rgba(255,255,255,0.02); border-radius:14px; } + .radioStationName { font-weight:900; letter-spacing:0.2px; } + .radioMeta { opacity:0.75; } + .radioNow { padding:8px 10px; border:1px dashed rgba(246,240,255,0.16); border-radius:12px; background:rgba(0,0,0,0.12); } + .radioBtns button { padding:7px 10px; border-radius:10px; } + .radioArrow { font-size:16px; line-height:1; } + .radioSmall { font-size: 12px; opacity: 0.8; } + .radioList { max-height: 220px; overflow:auto; border:1px solid rgba(246,240,255,0.10); border-radius:12px; } + .radioStationItem { padding:8px 10px; display:flex; justify-content:space-between; gap:10px; border-bottom:1px solid rgba(246,240,255,0.06); cursor:pointer; } + .radioStationItem:last-child { border-bottom:0; } + .radioStationItem.active { background: rgba(255, 184, 77, 0.08); } + </style> + <div class="radioWrap"> + <div class="radioTop"> + <div class="radioRow"> + <label class="row" style="gap:8px;align-items:center"> + <input type="checkbox" data-radioon="1" ${isOn ? "checked" : ""} /> + <span style="font-weight:900">Radio</span> + </label> + <div class="radioBtns radioRow"> + <button type="button" class="ghost smallBtn radioArrow" data-tune="-1" title="Tune left">←</button> + <button type="button" class="ghost smallBtn radioArrow" data-tune="1" title="Tune right">→</button> + </div> + </div> + <div class="radioRow"> + <button type="button" class="ghost smallBtn" data-newstation="1">New station</button> + <button type="button" class="ghost smallBtn" data-upload="1" ${!tuned ? "disabled" : ""}>Upload MP3s</button> + <input type="file" accept=\".mp3,audio/mpeg\" multiple style="display:none" data-filepick="1" /> + </div> + </div> + + <div class="radioStation"> + <div class="radioStationName">${esc(tunedName)}</div> + <div class="radioMeta small muted">${tunedAuthor ? `by @${esc(tunedAuthor)} • ${trackCount} track${trackCount === 1 ? "" : "s"}` : "Create a station to get started."}</div> + </div> + + <div class="radioNow small"> + ${ + !tuned + ? `<span class="muted">No stations available.</span>` + : !isOn + ? `<span class="muted">Radio is off. Tune around, then toggle it on to listen.</span>` + : trackCount === 0 + ? `<span class="muted">This station has no tracks yet. Upload some MP3s.</span>` + : nowTrack + ? `Now playing: <b>${esc(nowTrack.title || "Track")}</b> <span class="muted">(${esc(nowTrack.addedBy ? `@${nowTrack.addedBy}` : "unknown")})</span>` + : needsManualPlay + ? `<span class="muted">Press play to start listening (browser blocked autoplay).</span>` + : `<span class="muted">Ready.</span>` + } + </div> + + <audio controls preload="none" style="width:100%"></audio> + + <div class="radioSmall muted">Stations</div> + <div class="radioList"> + ${ + stations.length + ? stations + .map((s) => { + const active = String(s.id || "") === tunedStationId; + const count = Number(s.trackCount || (s.tracks || []).length || 0); + return `<div class="radioStationItem ${active ? "active" : ""}" data-station="${esc(s.id)}"> + <span><b>${esc(s.name || "Station")}</b> <span class="muted">by @${esc(s.author || "")}</span></span> + <span class="muted">${count}</span> + </div>`; + }) + .join("") + : `<div class="small muted" style="padding:10px">No stations yet.</div>` + } + </div> + </div> + `; + + audioEl = mountEl.querySelector("audio"); + fileInputEl = mountEl.querySelector('[data-filepick="1"]'); + + if (audioEl) { + // This plugin re-renders via innerHTML. Preserve the last selected track across renders so + // the native audio controls don't end up disabled (no src). + if (lastPlayedTrackSrc) { + const current = String(audioEl.getAttribute("src") || ""); + if (current !== lastPlayedTrackSrc) { + audioEl.src = lastPlayedTrackSrc; + try { + audioEl.load(); + } catch {} + } + } + } + + const onEl = mountEl.querySelector('[data-radioon="1"]'); + const uploadBtn = mountEl.querySelector('[data-upload="1"]'); + + if (onEl) { + onEl.addEventListener("change", () => { + writeOn(Boolean(onEl.checked)); + render(); + if (!isOn) stopPlayback(); + else if (tunedStationId) startPlaybackFor(tunedStationId); + }); + } + + mountEl.querySelectorAll("[data-tune]").forEach((btn) => { + btn.addEventListener("click", () => stepTune(Number(btn.getAttribute("data-tune") || "0"))); + }); + + mountEl.querySelectorAll("[data-station]").forEach((row) => { + row.addEventListener("click", () => { + const id = String(row.getAttribute("data-station") || "").trim().toLowerCase(); + if (!id) return; + writeTuned(id); + render(); + if (isOn) { + stopPlayback(); + startPlaybackFor(id); + } + }); + }); + + const newBtn = mountEl.querySelector('[data-newstation="1"]'); + if (newBtn) { + newBtn.addEventListener("click", () => { + const name = prompt("Station name:"); + if (!name) return; + ctx.send("createStation", { name: String(name).trim() }); + }); + } + + if (uploadBtn && fileInputEl) { + uploadBtn.addEventListener("click", () => { + if (!canUpload) { + ctx.toast("Radio", "Sign in required to upload MP3s."); + return; + } + if (!tunedStationId) return; + fileInputEl.value = ""; + fileInputEl.click(); + }); + fileInputEl.addEventListener("change", async () => { + const files = Array.from(fileInputEl.files || []); + if (!files.length || !tunedStationId) return; + const uploaded = []; + for (const f of files) { + const url = await uploadMp3(f, ctx.toast); + if (!url) continue; + uploaded.push({ url, title: String(f.name || "track").replace(/\.mp3$/i, "") }); + } + if (!uploaded.length) return; + ctx.send("addTracks", { stationId: tunedStationId, tracks: uploaded }); + }); + } + + if (audioEl) { + audioEl.addEventListener("ended", () => { + if (!isOn) return; + if (!tunedStationId) return; + startPlaybackFor(tunedStationId); + }); + audioEl.addEventListener("error", () => { + if (!isOn) return; + stopPlayback(); + updateNowEl(); + }); + audioEl.addEventListener("play", () => { + needsManualPlay = false; + updateNowEl(); + }); + } + + // Keep audio element in sync with current state. + if (!isOn) stopPlayback(); + else if (tunedStationId && (!playingStationId || playingStationId !== tunedStationId) && !needsManualPlay) { + stopPlayback(); + startPlaybackFor(tunedStationId); + } + } + + function onWsMessage(evt) { + let msg; + try { + msg = safeJsonParse(evt.data); + } catch { + return; + } + if (!msg || typeof msg !== "object") return; + const type = String(msg.type || ""); + if (type === "plugin:radio:stations") { + stations = Array.isArray(msg.stations) ? msg.stations : []; + stations.sort((a, b) => Number(a.createdAt || 0) - Number(b.createdAt || 0)); + ensureTunedExists(); + render(); + return; + } + if (type === "plugin:radio:createOk") { + const id = String(msg.stationId || "").trim().toLowerCase(); + if (id) { + writeTuned(id); + render(); + } + return; + } + if (type === "plugin:radio:addOk") { + ctx.toast("Radio", `Added ${Number(msg.added || 0)} track(s).`); + return; + } + if (type === "plugin:radio:error") { + ctx.toast("Radio", String(msg.message || "Radio error.")); + } + } + + readPrefs(); + + ctx.ui?.registerPanel?.({ + id: "radio", + title: "Radio", + icon: "📻", + defaultRack: "right", + role: "aux", + presetHints: { + defaultSocial: { place: "docked.bottom" }, + browse: { place: "docked.bottom" }, + mapsSession: { place: "right" } + }, + render(mount) { + mountEl = mount; + stations = []; + playingStationId = ""; + lastPlayedTrackUrl = ""; + if (ws && ws.addEventListener) ws.addEventListener("message", onWsMessage); + ctx.send("stateReq", {}); + render(); + return () => { + if (ws && ws.removeEventListener) ws.removeEventListener("message", onWsMessage); + stopPlayback(); + mountEl = null; + }; + } + }); + }); +})(); diff --git a/plugins_dev/radio/plugin.json b/plugins_dev/radio/plugin.json @@ -0,0 +1,9 @@ +{ + "id": "radio", + "name": "Radio", + "version": "0.1.6", + "description": "A playful community radio: create stations, upload MP3 tracks, and tune in.", + "entryClient": "client.js", + "entryServer": "server.js", + "permissions": ["ui", "ws", "uploads"] +} diff --git a/plugins_dev/radio/server.js b/plugins_dev/radio/server.js @@ -0,0 +1,222 @@ +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +const MAX_STATIONS = Number(process.env.RADIO_MAX_STATIONS || 500); +const MAX_TRACKS_PER_STATION = Number(process.env.RADIO_MAX_TRACKS_PER_STATION || 500); + +function safeJsonParse(str) { + try { + return JSON.parse(str); + } catch { + return null; + } +} + +function readJsonOrNull(filePath) { + try { + const raw = fs.readFileSync(filePath, "utf8"); + return safeJsonParse(raw); + } catch { + return null; + } +} + +function writeFileAtomic(filePath, content) { + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + const tmp = `${filePath}.tmp.${crypto.randomBytes(6).toString("hex")}`; + fs.writeFileSync(tmp, content, "utf8"); + fs.renameSync(tmp, filePath); +} + +function nowMs() { + return Date.now(); +} + +function toId(bytes = 6) { + return crypto.randomBytes(Math.max(3, Math.min(16, bytes))).toString("hex"); +} + +function normUser(u) { + return String(u || "").trim().toLowerCase(); +} + +function normTitle(t, fallback) { + const s = String(t || "").replace(/\s+/g, " ").trim().slice(0, 40); + return s || fallback || "Untitled"; +} + +function normUrl(u) { + const s = String(u || "").trim(); + if (!s) return ""; + if (!s.startsWith("/uploads/")) return ""; + if (!/\.mp3(\?|#|$)/i.test(s)) return ""; + if (s.length > 280) return ""; + return s; +} + +module.exports = function init(api) { + const dataFile = path.join(__dirname, "radio.json"); + + function loadState() { + const parsed = readJsonOrNull(dataFile); + const stations = Array.isArray(parsed?.stations) ? parsed.stations : []; + const cleaned = stations + .map((s) => { + const id = String(s?.id || "").trim().toLowerCase(); + const name = normTitle(s?.name, "Station"); + const author = normUser(s?.author); + const createdAt = Number(s?.createdAt || 0) || 0; + const tracks = Array.isArray(s?.tracks) ? s.tracks : []; + const cleanTracks = tracks + .map((t) => { + const tid = String(t?.id || "").trim().toLowerCase(); + const title = normTitle(t?.title, "Track"); + const url = normUrl(t?.url); + const addedBy = normUser(t?.addedBy); + const addedAt = Number(t?.addedAt || 0) || 0; + if (!tid || !url) return null; + return { id: tid, title, url, addedBy, addedAt }; + }) + .filter(Boolean) + .slice(0, MAX_TRACKS_PER_STATION); + if (!id || !author) return null; + return { id, name, author, createdAt, tracks: cleanTracks }; + }) + .filter(Boolean) + .slice(0, MAX_STATIONS); + cleaned.sort((a, b) => Number(a.createdAt || 0) - Number(b.createdAt || 0)); + return { stations: cleaned }; + } + + function saveState(state) { + const stations = Array.isArray(state?.stations) ? state.stations : []; + writeFileAtomic(dataFile, JSON.stringify({ version: 1, savedAt: nowMs(), stations }, null, 2) + "\n"); + } + + function listForClient() { + const state = loadState(); + return state.stations.map((s) => ({ + id: s.id, + name: s.name, + author: s.author, + createdAt: s.createdAt, + trackCount: Array.isArray(s.tracks) ? s.tracks.length : 0, + tracks: (Array.isArray(s.tracks) ? s.tracks : []).map((t) => ({ + id: t.id, + title: t.title, + url: t.url, + addedBy: t.addedBy, + addedAt: t.addedAt + })) + })); + } + + function send(ws, msg) { + try { + ws.send(JSON.stringify(msg)); + return true; + } catch { + return false; + } + } + + function sendError(ws, message, data) { + send(ws, { type: "plugin:radio:error", message: String(message || "Error."), data: data || null }); + } + + function broadcastStations() { + api.broadcast({ type: "plugin:radio:stations", stations: listForClient(), at: api.now() }); + } + + api.registerWs("stateReq", (ws) => { + send(ws, { type: "plugin:radio:stations", stations: listForClient(), at: api.now() }); + }); + + api.registerWs("createStation", (ws, msg) => { + const user = normUser(ws?.user?.username); + if (!user) { + sendError(ws, "Sign in required to create a station."); + return; + } + const name = normTitle(msg?.name, ""); + if (!name) { + sendError(ws, "Station name required."); + return; + } + const state = loadState(); + if (state.stations.length >= MAX_STATIONS) { + sendError(ws, "Station limit reached."); + return; + } + const station = { + id: toId(6), + name, + author: user, + createdAt: nowMs(), + tracks: [] + }; + state.stations.push(station); + saveState(state); + broadcastStations(); + send(ws, { type: "plugin:radio:createOk", stationId: station.id }); + }); + + api.registerWs("addTracks", (ws, msg) => { + const user = normUser(ws?.user?.username); + if (!user) { + sendError(ws, "Sign in required to upload tracks."); + return; + } + const stationId = String(msg?.stationId || "").trim().toLowerCase(); + if (!stationId) { + sendError(ws, "Missing stationId."); + return; + } + const incoming = Array.isArray(msg?.tracks) ? msg.tracks : []; + if (!incoming.length) { + sendError(ws, "No tracks provided."); + return; + } + const state = loadState(); + const idx = state.stations.findIndex((s) => String(s?.id || "") === stationId); + if (idx < 0) { + sendError(ws, "Station not found."); + return; + } + const station = state.stations[idx]; + const existingUrls = new Set((station.tracks || []).map((t) => String(t.url || ""))); + const space = Math.max(0, MAX_TRACKS_PER_STATION - (Array.isArray(station.tracks) ? station.tracks.length : 0)); + if (space <= 0) { + sendError(ws, "Track limit reached for this station."); + return; + } + + const clean = []; + for (const raw of incoming) { + if (clean.length >= space) break; + const url = normUrl(raw?.url); + if (!url || existingUrls.has(url)) continue; + const title = normTitle(raw?.title, "Track"); + clean.push({ + id: toId(6), + title, + url, + addedBy: user, + addedAt: nowMs() + }); + existingUrls.add(url); + } + if (!clean.length) { + sendError(ws, "No valid MP3 tracks to add."); + return; + } + station.tracks = [...(Array.isArray(station.tracks) ? station.tracks : []), ...clean].slice(0, MAX_TRACKS_PER_STATION); + state.stations[idx] = station; + saveState(state); + broadcastStations(); + send(ws, { type: "plugin:radio:addOk", stationId, added: clean.length }); + }); +}; + diff --git a/public/app.js b/public/app.js @@ -27,6 +27,7 @@ const toggleRightRackEl = document.getElementById("toggleRightRack"); const layoutPresetEl = document.getElementById("layoutPreset"); const uiScaleEl = document.getElementById("uiScale"); const deviceLayoutEl = document.getElementById("deviceLayout"); +const stayConnectedEl = document.getElementById("stayConnected"); const dockHotbarEl = document.getElementById("dockHotbar"); const showSideRackBtn = document.getElementById("showSideRack"); const showRightRackBtn = document.getElementById("showRightRack"); @@ -667,6 +668,173 @@ registerCorePanel({ id: "moderation", title: "Moderation", icon: "🛡️", role registerCorePanel({ id: "profile", title: "Profile", icon: "👤", role: "transient", defaultRack: "main", element: profileViewPanel }); registerCorePanel({ id: "composer", title: "New Hive", icon: "✍️", role: "aux", defaultRack: "main", element: pollinatePanel }); +let pluginRackPanelEl = null; +let pluginRackWidgetsRackEl = null; +let pluginRackAddMenuEl = null; + +function closePluginRackAddMenu() { + if (!pluginRackAddMenuEl) return; + try { + pluginRackAddMenuEl.remove(); + } catch { + // ignore + } + pluginRackAddMenuEl = null; +} + +function panelIsPluginOwned(panelId) { + const id = String(panelId || "").trim(); + if (!id) return false; + if (id.startsWith("chat:")) return false; + const entry = panelRegistry.get(id); + const src = typeof entry?.source === "string" ? entry.source : ""; + return src.startsWith("plugin:"); +} + +function panelIsHostableInPluginRack(panelId) { + const id = String(panelId || "").trim(); + if (!id) return false; + if (id === "pluginRack") return false; + if (!panelIsPluginOwned(id)) return false; + // Widgets should be small, stackable tools (not full workspace surfaces like Maps). + if (panelRole(id) === "primary") return false; + return true; +} + +function ensurePluginRackPanel() { + if (pluginRackPanelEl instanceof HTMLElement && pluginRackPanelEl.isConnected) return pluginRackPanelEl; + + if (!(pluginRackPanelEl instanceof HTMLElement)) { + const shell = document.createElement("section"); + shell.className = "panel panelFill pluginRackPanel rackPanel"; + shell.dataset.panelId = "pluginRack"; + shell.innerHTML = ` + <div class="panelHeader"> + <div class="panelTitle">${escapeHtml("Plugin Rack")}</div> + <div class="row"></div> + </div> + <div class="panelBody pluginRackBody"> + <div class="pluginRackToolbar"> + <button type="button" class="ghost smallBtn" data-pluginrackadd="1">+ Add widget</button> + <div class="small muted pluginRackHint">Drop plugin panels here to stack them.</div> + </div> + <div id="pluginRackWidgetsRack" class="pluginRackWidgets" aria-label="Plugin widgets"></div> + </div> + `; + pluginRackPanelEl = shell; + pluginRackWidgetsRackEl = shell.querySelector("#pluginRackWidgetsRack"); + + shell.querySelector("[data-pluginrackadd]")?.addEventListener("click", (e) => { + const anchor = e.currentTarget; + if (pluginRackAddMenuEl) closePluginRackAddMenu(); + else openPluginRackAddMenu(anchor); + }); + } + + // Ensure it's registered as a core panel for docking + layout state. + registerCorePanel({ id: "pluginRack", title: "Plugin Rack", icon: "🧰", role: "aux", defaultRack: "main", element: pluginRackPanelEl }); + + // Append into the DOM so it can be docked/restored. (It will typically live in the hotbar.) + const side = ensureMainSideRack(); + if (side && pluginRackPanelEl.parentElement !== side) side.appendChild(pluginRackPanelEl); + + return pluginRackPanelEl; +} + +function ensurePluginRackWidgetsRack() { + ensurePluginRackPanel(); + return pluginRackWidgetsRackEl instanceof HTMLElement ? pluginRackWidgetsRackEl : null; +} + +function readPluginRackWidgetsOrder() { + const rack = ensurePluginRackWidgetsRack(); + return rack ? readRackOrder(rack) : []; +} + +function removePanelFromPluginRack(panelId) { + const id = String(panelId || "").trim(); + if (!id) return; + rackLayoutState.pluginRackWidgets = Array.isArray(rackLayoutState.pluginRackWidgets) + ? rackLayoutState.pluginRackWidgets.filter((x) => x !== id) + : []; + const el = getPanelElement(id); + if (el) el.classList.remove("pluginRackWidget"); + const rack = ensurePluginRackWidgetsRack(); + if (rack && el && el.parentElement === rack) rack.removeChild(el); + const side = ensureMainSideRack(); + if (side && el && !el.parentElement) side.appendChild(el); +} + +function hostPanelInPluginRack(panelId) { + const id = String(panelId || "").trim(); + if (!id) return; + if (!rackLayoutEnabled) return; + if (!panelIsHostableInPluginRack(id)) { + toast("Can't add widget", `${panelTitle(id)} can't be hosted in Plugin Rack.`); + return; + } + + const rack = ensurePluginRackWidgetsRack(); + const el = getPanelElement(id); + if (!rack || !el) return; + + // Hosting implies it should be visible in the rack, not docked. + if (isDocked(id)) undockPanel(id); + + const lastRack = rackIdForPanelElement(el); + if (lastRack) rememberPanelLastRack(id, lastRack); + + el.classList.add("pluginRackWidget"); + if (el.parentElement !== rack) rack.appendChild(el); + + const next = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []); + next.add(id); + rackLayoutState.pluginRackWidgets = Array.from(next); + saveRackLayoutState(); + syncRackStateFromDom(); + enforceWorkspaceRules(); +} + +function openPluginRackAddMenu(anchorEl) { + closePluginRackAddMenu(); + if (!(anchorEl instanceof HTMLElement)) return; + if (!rackLayoutEnabled) return; + + const hosted = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []); + const candidates = Array.from(panelRegistry.keys()) + .filter((id) => panelIsHostableInPluginRack(id) && !hosted.has(id)) + .sort((a, b) => panelTitle(a).localeCompare(panelTitle(b))); + + const items = candidates + .map((id) => `<button type="button" class="ghost smallBtn" data-pluginrackhost="${escapeHtml(id)}">${escapeHtml(panelTitle(id))}</button>`) + .join(""); + + const menu = document.createElement("div"); + menu.className = "hotbarAddMenu pluginRackAddMenu"; + menu.innerHTML = ` + <div class="small muted" style="padding:6px 8px 4px;">Add widget</div> + <div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No plugin widgets available.</div>`}</div> + `; + + const rect = anchorEl.getBoundingClientRect(); + const left = Math.max(12, Math.min(window.innerWidth - 260, rect.left)); + const top = Math.max(12, Math.min(window.innerHeight - 320, rect.bottom + 8)); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + + menu.addEventListener("click", (e) => { + const btn = e.target.closest?.("[data-pluginrackhost]"); + if (!btn) return; + const id = String(btn.getAttribute("data-pluginrackhost") || "").trim(); + if (!id) return; + hostPanelInPluginRack(id); + closePluginRackAddMenu(); + }); + + document.body.appendChild(menu); + pluginRackAddMenuEl = menu; +} + // Rack mode: Profile should behave like a normal dockable panel (not a flow that replaces Hives). // Override the role after the initial core registration (Map#set will replace the previous entry). panelRegistry.set("profile", { ...(panelRegistry.get("profile") || { id: "profile", source: "core" }), role: "aux" }); @@ -686,7 +854,7 @@ const PRESET_DEFS = { sideOrder: ["profile", "composer"], sideCollapsed: true, rightOrder: ["people"], - dockBottom: ["maps", "library"], + dockBottom: ["pluginRack", "maps", "library"], }, chatFocus: { presetId: "chatFocus", @@ -698,7 +866,7 @@ const PRESET_DEFS = { sideOrder: ["profile"], sideCollapsed: true, rightOrder: ["people"], - dockBottom: ["hives", "composer", "maps", "library"], + dockBottom: ["pluginRack", "hives", "composer", "maps", "library"], }, browse: { presetId: "browse", @@ -710,7 +878,7 @@ const PRESET_DEFS = { sideOrder: ["chat"], sideCollapsed: true, rightOrder: ["profile"], - dockBottom: ["people", "composer", "maps", "library"], + dockBottom: ["pluginRack", "people", "composer", "maps", "library"], }, creator: { presetId: "creator", @@ -722,7 +890,7 @@ const PRESET_DEFS = { sideOrder: ["people"], sideCollapsed: true, rightOrder: ["profile"], - dockBottom: ["chat", "maps", "library"], + dockBottom: ["pluginRack", "chat", "maps", "library"], }, mapsSession: { presetId: "mapsSession", @@ -733,7 +901,7 @@ const PRESET_DEFS = { sideOrder: ["hives"], sideCollapsed: true, rightOrder: ["people"], - dockBottom: ["profile", "composer", "library"], + dockBottom: ["pluginRack", "profile", "composer", "library"], }, quiet: { presetId: "quiet", @@ -745,7 +913,7 @@ const PRESET_DEFS = { sideCollapsed: true, rightOrder: [], rightCollapsed: true, - dockBottom: ["chat", "people", "maps", "library"], + dockBottom: ["pluginRack", "chat", "people", "maps", "library"], }, ops: { presetId: "ops", @@ -757,7 +925,7 @@ const PRESET_DEFS = { sideOrder: ["hives"], sideCollapsed: true, rightOrder: ["people"], - dockBottom: ["profile", "composer", "maps", "library"], + dockBottom: ["pluginRack", "profile", "composer", "maps", "library"], }, reportsFocus: { presetId: "reportsFocus", @@ -770,7 +938,7 @@ const PRESET_DEFS = { sideOrder: ["people"], sideCollapsed: true, rightOrder: ["chat"], - dockBottom: ["hives", "profile", "composer", "maps", "library"], + dockBottom: ["pluginRack", "hives", "profile", "composer", "maps", "library"], }, communityWatch: { presetId: "communityWatch", @@ -782,7 +950,7 @@ const PRESET_DEFS = { sideOrder: ["chat"], sideCollapsed: true, rightOrder: ["people"], - dockBottom: ["profile", "composer", "maps", "library"], + dockBottom: ["pluginRack", "profile", "composer", "maps", "library"], }, serverAdmin: { presetId: "serverAdmin", @@ -794,7 +962,7 @@ const PRESET_DEFS = { sideOrder: ["chat"], sideCollapsed: true, rightOrder: ["people"], - dockBottom: ["profile", "composer", "maps", "library"], + dockBottom: ["pluginRack", "profile", "composer", "maps", "library"], }, }; @@ -885,6 +1053,7 @@ function loadRackLayoutState() { presetId: "discordLike", docked: { bottom: [] }, racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, + pluginRackWidgets: [], lastRackByPanelId: {}, }; const parsed = JSON.parse(raw); @@ -894,9 +1063,13 @@ function loadRackLayoutState() { presetId: "discordLike", docked: { bottom: [] }, racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, + pluginRackWidgets: [], lastRackByPanelId: {}, }; const bottom = Array.isArray(parsed?.docked?.bottom) ? parsed.docked.bottom.map((x) => String(x || "")).filter(Boolean) : []; + const pluginRackWidgets = Array.isArray(parsed?.pluginRackWidgets) + ? parsed.pluginRackWidgets.map((x) => String(x || "")).filter(Boolean) + : []; const presetId = typeof parsed?.presetId === "string" ? parsed.presetId : "discordLike"; const workspaceLeft = Array.isArray(parsed?.racks?.workspaceLeft) ? parsed.racks.workspaceLeft.map((x) => String(x || "")).filter(Boolean) : []; const workspaceRight = Array.isArray(parsed?.racks?.workspaceRight) ? parsed.racks.workspaceRight.map((x) => String(x || "")).filter(Boolean) : []; @@ -910,13 +1083,14 @@ function loadRackLayoutState() { if (!id || !rackId) continue; lastRackByPanelId[id] = rackId; } - return { version: 2, presetId, docked: { bottom }, racks: { workspaceLeft, workspaceRight, side, right }, lastRackByPanelId }; + return { version: 2, presetId, docked: { bottom }, racks: { workspaceLeft, workspaceRight, side, right }, pluginRackWidgets, lastRackByPanelId }; } catch { return { version: 2, presetId: "discordLike", docked: { bottom: [] }, racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, + pluginRackWidgets: [], lastRackByPanelId: {}, }; } @@ -1000,12 +1174,12 @@ function panelCanExpand(panelId) { if (id.startsWith("chat:")) return true; if (panelRole(id) === "primary") return true; // Allow a few core panels to take over the workspace even though they aren't "primary" by default. - return id === "moderation" || id === "composer"; + return id === "moderation" || id === "composer" || id === "pluginRack"; } // Panels that are allowed to live in "skinny" columns (side rack / right rack). // These panels should be able to render in a narrow width without breaking layout. -const SKINNY_CAPABLE_PANELS = new Set(["people", "profile", "composer", "hives", "chat"]); +const SKINNY_CAPABLE_PANELS = new Set(["people", "profile", "composer", "hives", "chat", "dice"]); function panelIsSkinnyCapable(panelId) { const id = String(panelId || "").trim(); @@ -1046,6 +1220,8 @@ function rememberPanelLastRack(panelId, rackId) { function dockPanel(panelId) { const id = String(panelId || "").trim(); if (!id) return; + // Docking a hosted widget should implicitly un-host it. + removePanelFromPluginRack(id); const el = getPanelElement(id); const lastRack = rackIdForPanelElement(el); if (lastRack) rememberPanelLastRack(id, lastRack); @@ -1265,6 +1441,8 @@ function readRackOrder(rackEl) { function applyRackStateToDom() { if (!rackLayoutEnabled) return; + // Ensure core "virtual" panels exist before we try to place them. + ensurePluginRackPanel(); const left = ensureWorkspaceLeftRack(); const rightWorkspace = ensureWorkspaceRightRack(); const side = ensureMainSideRack(); @@ -1291,6 +1469,18 @@ function applyRackStateToDom() { const el = getPanelElement(panelId); if (el) right.appendChild(el); } + + // Hosted plugin widgets live inside Plugin Rack, not a top-level rack. + const widgetsOrder = Array.isArray(rackLayoutState?.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []; + const widgetsRack = ensurePluginRackWidgetsRack(); + if (widgetsRack) { + for (const panelId of widgetsOrder) { + const el = getPanelElement(panelId); + if (!el) continue; + el.classList.add("pluginRackWidget"); + widgetsRack.appendChild(el); + } + } } function readWorkspaceActivePrimary() { @@ -1448,6 +1638,14 @@ function syncRackStateFromDom() { side: readRackOrder(side), right: readRackOrder(right), }; + rackLayoutState.pluginRackWidgets = readPluginRackWidgetsOrder(); + const hosted = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []); + for (const [id, entry] of panelRegistry.entries()) { + const el = entry?.element; + if (!(el instanceof HTMLElement)) continue; + if (!el.classList.contains("pluginRackWidget") && hosted.has(id)) el.classList.add("pluginRackWidget"); + if (el.classList.contains("pluginRackWidget") && !hosted.has(id)) el.classList.remove("pluginRackWidget"); + } saveRackLayoutState(); } @@ -1598,6 +1796,11 @@ function applyPreset(presetId) { return; } + // Presets are hard-applied: clear any hosted widgets so placement remains deterministic. + closePluginRackAddMenu(); + for (const id of readPluginRackWidgetsOrder()) removePanelFromPluginRack(id); + rackLayoutState.pluginRackWidgets = []; + rackLayoutState.presetId = def.presetId || key; const workspaceLeftOrder = Array.isArray(def.workspaceLeftOrder) ? def.workspaceLeftOrder.map((x) => String(x || "")).filter(Boolean) : []; @@ -1744,6 +1947,8 @@ function installPanelMinimizeButtons() { addMinBtn(hivesPanelEl?.querySelector(".panelHeader"), "hives"); addMinBtn(profileViewPanel?.querySelector(".panelHeader"), "profile"); addMinBtn(pollinatePanel?.querySelector(".panelHeader"), "composer"); + ensurePluginRackPanel(); + addMinBtn(pluginRackPanelEl?.querySelector(".panelHeader"), "pluginRack"); } function ensurePluginPanelShell(panelId, title, icon, defaultRack, role) { @@ -2147,7 +2352,8 @@ function enableRackDnD() { const rightWorkspace = ensureWorkspaceRightRack(); const side = ensureMainSideRack(); if (!right || !left || !rightWorkspace || !side) return; - const racks = [left, rightWorkspace, side, right]; + const pluginWidgets = ensurePluginRackWidgetsRack(); + const racks = [left, rightWorkspace, side, right, pluginWidgets].filter((x) => x instanceof HTMLElement); // Guard against double-install if initRackLayout is called more than once. if (appRoot?.dataset?.rackDnd === "1") return; @@ -2245,9 +2451,22 @@ function enableRackDnD() { const isWorkspaceSlot = targetRack.id === "workspaceLeftSlot" || targetRack.id === "workspaceRightSlot"; const isRightRackSlot = targetRack.id === "rightRack"; const isSideRackSlot = targetRack.id === "mainSideRack"; + const isPluginRackWidgets = targetRack.id === "pluginRackWidgetsRack"; const isSkinnyRackSlot = isRightRackSlot || isSideRackSlot; const skinnyOk = panelIsSkinnyCapable(draggingPanelId); + if (isPluginRackWidgets && !panelIsHostableInPluginRack(draggingPanelId)) { + toast("Can't place there", `${panelTitle(draggingPanelId)} can't be hosted in Plugin Rack.`); + if (originRack) { + if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(draggingEl, originBefore); + else originRack.appendChild(draggingEl); + } + cleanup(); + syncRackStateFromDom(); + enforceWorkspaceRules(); + return; + } + // Only skinny-capable panels may live in skinny columns (side / right racks). if (isSkinnyRackSlot && !skinnyOk) { toast("Can't place there", `${panelTitle(draggingPanelId)} can't be placed in a skinny rack.`); @@ -2272,6 +2491,7 @@ function enableRackDnD() { } else { targetRack.insertBefore(draggingEl, placeholderEl); } + if (isPluginRackWidgets) draggingEl.classList.add("pluginRackWidget"); } const shouldDock = Boolean(dockHotbarEl && e.clientY > window.innerHeight - 90); const dockId = draggingPanelId; @@ -2393,6 +2613,17 @@ function initRackLayout() { enableRackLayoutDom(); + // Ensure Plugin Rack exists and is accessible (defaults to hotbar unless explicitly placed). + ensurePluginRackPanel(); + const pluginRackPlaced = + isDocked("pluginRack") || + ["workspaceLeft", "workspaceRight", "side", "right"].some((k) => Array.isArray(rackLayoutState?.racks?.[k]) && rackLayoutState.racks[k].includes("pluginRack")); + if (!pluginRackPlaced) { + rackLayoutState.docked.bottom = Array.isArray(rackLayoutState?.docked?.bottom) ? rackLayoutState.docked.bottom : []; + if (!rackLayoutState.docked.bottom.includes("pluginRack")) rackLayoutState.docked.bottom.push("pluginRack"); + saveRackLayoutState(); + } + // Side racks behave like summonable hotbars: hide/show without changing panel layout state. toggleSideRackEl && (toggleSideRackEl.disabled = false); toggleRightRackEl && (toggleRightRackEl.disabled = false); @@ -2447,10 +2678,15 @@ function initRackLayout() { if (appRoot && appRoot.dataset.hotbarPlusClose !== "1") { appRoot.dataset.hotbarPlusClose = "1"; document.addEventListener("pointerdown", (e) => { - if (!hotbarPlusMenuEl) return; + if (!hotbarPlusMenuEl && !pluginRackAddMenuEl) return; const t = e.target; - if (t && (hotbarPlusMenuEl.contains(t) || dockHotbarEl?.contains(t))) return; + if (t) { + if (hotbarPlusMenuEl && hotbarPlusMenuEl.contains(t)) return; + if (pluginRackAddMenuEl && pluginRackAddMenuEl.contains(t)) return; + if (dockHotbarEl && dockHotbarEl.contains(t)) return; + } closeHotbarPlusMenu(); + closePluginRackAddMenu(); }); } @@ -2482,6 +2718,14 @@ function initRackLayout() { const resolveOrbDropRack = (panelId, rackEl) => { const id = String(panelId || "").trim(); if (!id) return rackEl; + if (rackEl && rackEl.id === "pluginRackWidgetsRack") { + if (panelIsHostableInPluginRack(id)) return rackEl; + const left = ensureWorkspaceLeftRack(); + const right = ensureWorkspaceRightRack(); + const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; + const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; + return leftEmpty ? left : rightEmpty ? right : left; + } // Skinny racks (side/right) only allow skinny-capable panels. if (rackEl && (rackEl.id === "mainSideRack" || rackEl.id === "rightRack")) { if (panelIsSkinnyCapable(id)) return rackEl; @@ -2520,7 +2764,8 @@ function initRackLayout() { const rightWorkspaceRack = ensureWorkspaceRightRack(); const sideRack = ensureMainSideRack(); const rightRack = ensureRightRack(); - return [leftRack, rightWorkspaceRack, sideRack, rightRack].filter((x) => x instanceof HTMLElement); + const pluginWidgetsRack = ensurePluginRackWidgetsRack(); + return [leftRack, rightWorkspaceRack, sideRack, rightRack, pluginWidgetsRack].filter((x) => x instanceof HTMLElement); }; const rackAtPoint = (x, y) => { @@ -2570,6 +2815,7 @@ function initRackLayout() { if (insertBefore) rack.insertBefore(panelEl, insertBefore); else rack.appendChild(panelEl); } + if (rack.id === "pluginRackWidgetsRack") panelEl.classList.add("pluginRackWidget"); rememberPanelLastRack(id, rack.id); saveRackLayoutState(); syncRackStateFromDom(); @@ -2688,6 +2934,14 @@ const PEOPLE_WIDTH_DEFAULT = 360; let editContext = null; let mentionState = { open: false, query: "", selected: 0, items: [], anchorRect: null }; +const STAY_CONNECTED_KEY = "bzl_stayConnected"; +function readStayConnectedPref() { + return readBoolPref(STAY_CONNECTED_KEY, false); +} +function writeStayConnectedPref(on) { + writeBoolPref(STAY_CONNECTED_KEY, Boolean(on)); +} + let instanceBranding = { title: "Bzl", subtitle: "Ephemeral hives + chat", allowMemberPermanentPosts: false, appearance: {} }; let serverInfo = null; let serverHealth = null; @@ -6372,6 +6626,26 @@ function openChat(postId) { // Rack mode: hive chats live in dedicated chat panels (instances). Don't also open the legacy main chat panel. if (rackLayoutEnabled) { + const mainChatPanelIsIdle = Boolean( + chatPanelEl && + typeof isDocked === "function" && + !isDocked("chat") && + !activeDmThreadId && + !activeChatPostId && + !isMapChatActive() + ); + if (mainChatPanelIsIdle) { + activeChatPostId = postId; + markRead(postId); + renderFeed(); + ws.send(JSON.stringify({ type: "getChat", postId })); + renderChatPanel(true); + renderTypingIndicator(); + if (isMobileSwipeMode()) setMobilePanel("chat"); + chatEditor.focus(); + return; + } + markRead(postId); renderFeed(); ws.send(JSON.stringify({ type: "getChat", postId })); @@ -8280,18 +8554,100 @@ playSfx("open", { volume: 0.34 }).then((ok) => { if (ok) pendingOpenSfx = false; }); -setConn("connecting"); -const ws = new WebSocket(wsUrl()); -window.__bzlWs = ws; -ws.addEventListener("open", () => { - setConn("open"); - const token = getSessionToken(); - if (token) ws.send(JSON.stringify({ type: "resumeSession", token })); -}); -ws.addEventListener("close", () => setConn("closed")); -ws.addEventListener("error", () => setConn("closed")); +let ws = null; +let wsKeepaliveTimer = null; +let wsReconnectTimer = null; +let wsReconnectAttempt = 0; + +function clearWsKeepalive() { + if (!wsKeepaliveTimer) return; + try { + clearInterval(wsKeepaliveTimer); + } catch { + // ignore + } + wsKeepaliveTimer = null; +} + +function clearWsReconnect() { + if (!wsReconnectTimer) return; + try { + clearTimeout(wsReconnectTimer); + } catch { + // ignore + } + wsReconnectTimer = null; +} -ws.addEventListener("message", (evt) => { +function startWsKeepalive(sock) { + clearWsKeepalive(); + if (!readStayConnectedPref()) return; + wsKeepaliveTimer = setInterval(() => { + if (!sock || sock !== ws) return; + if (sock.readyState !== WebSocket.OPEN) return; + try { + sock.send(JSON.stringify({ type: "ping" })); + } catch { + // ignore + } + }, 25_000); +} + +function scheduleWsReconnect() { + clearWsReconnect(); + if (!readStayConnectedPref()) return; + const attempt = Math.min(6, Math.max(0, wsReconnectAttempt)); + const base = 1000 * Math.pow(2, attempt); + const jitter = Math.floor(Math.random() * 250); + const delay = Math.min(15_000, base) + jitter; + wsReconnectAttempt += 1; + setConn("connecting"); + wsReconnectTimer = setTimeout(() => { + wsReconnectTimer = null; + connectWs(); + }, delay); +} + +function connectWs() { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; + clearWsKeepalive(); + setConn("connecting"); + const sock = new WebSocket(wsUrl()); + ws = sock; + window.__bzlWs = sock; + + sock.addEventListener("open", () => { + if (sock !== ws) return; + setConn("open"); + wsReconnectAttempt = 0; + clearWsReconnect(); + startWsKeepalive(sock); + const token = getSessionToken(); + if (token) { + try { + sock.send(JSON.stringify({ type: "resumeSession", token })); + } catch { + // ignore + } + } + }); + + sock.addEventListener("close", () => { + if (sock !== ws) return; + setConn("closed"); + clearWsKeepalive(); + scheduleWsReconnect(); + }); + + sock.addEventListener("error", () => { + if (sock !== ws) return; + setConn("closed"); + }); + + sock.addEventListener("message", onWsMessage); +} + +function onWsMessage(evt) { let msg; try { msg = JSON.parse(evt.data); @@ -9000,10 +9356,27 @@ ws.addEventListener("message", (evt) => { } renderChatInstancesForPost(msg.postId); } -}); +} + +setConn("connecting"); +connectWs(); renderLanHint(); initDisplayPrefsUi(); +if (stayConnectedEl) { + stayConnectedEl.checked = readStayConnectedPref(); + stayConnectedEl.addEventListener("change", () => { + const on = Boolean(stayConnectedEl.checked); + writeStayConnectedPref(on); + if (on) { + if (!ws || ws.readyState === WebSocket.CLOSED) connectWs(); + startWsKeepalive(ws); + } else { + clearWsReconnect(); + clearWsKeepalive(); + } + }); +} renderPeoplePanel(); setPeopleOpen(getPeopleOpen()); composerOpen = getComposerOpen(); diff --git a/public/index.html b/public/index.html @@ -4,7 +4,7 @@ <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Bzl - Hives</title> - <link rel="stylesheet" href="/styles.css?v=103" /> + <link rel="stylesheet" href="/styles.css?v=104" /> </head> <body> <div class="app"> @@ -57,6 +57,10 @@ <span>Show reactions bar</span> <input id="toggleReactions" type="checkbox" /> </label> + <label class="checkRow" style="margin-top:8px;"> + <span>Stay connected</span> + <input id="stayConnected" type="checkbox" /> + </label> <details style="margin-top:10px;"> <summary class="small muted" style="cursor:pointer;user-select:none;">Advanced display</summary> @@ -530,6 +534,6 @@ </div> <div id="dockHotbar" class="dockHotbar hidden" aria-label="Docked panels"></div> - <script src="/app.js?v=116"></script> + <script src="/app.js?v=120"></script> </body> </html> diff --git a/public/styles.css b/public/styles.css @@ -361,6 +361,35 @@ body { min-height: 0; } +.pluginRackPanel .pluginRackBody { + display: flex; + flex-direction: column; + min-height: 0; +} + +.pluginRackToolbar { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-bottom: 1px solid color-mix(in srgb, var(--text) 10%, transparent); +} + +.pluginRackWidgets { + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: var(--app-gap); + padding: 10px; +} + +.pluginRackWidgets > .rackPanel { + flex: 0 0 auto; + min-height: 0; +} + .app.rackMode.hasMod { /* In rack mode, mod is just another panel inside the right rack. */ grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px 1fr 10px minmax(var(--people-min), var(--people-width)); diff --git a/scripts/build-dice-plugin.js b/scripts/build-dice-plugin.js @@ -0,0 +1,23 @@ +const fs = require("fs"); +const path = require("path"); +const AdmZip = require("adm-zip"); + +const root = path.resolve(__dirname, ".."); +const pluginDir = path.join(root, "plugins_dev", "dice"); +const distDir = path.join(root, "dist", "plugins"); +const outZip = path.join(distDir, "dice.zip"); + +function main() { + if (!fs.existsSync(pluginDir)) { + console.error("Missing plugin folder:", pluginDir); + process.exit(1); + } + fs.mkdirSync(distDir, { recursive: true }); + const zip = new AdmZip(); + zip.addLocalFolder(pluginDir, ""); + zip.writeZip(outZip); + console.log("Built:", outZip); +} + +main(); + diff --git a/scripts/build-radio-plugin.js b/scripts/build-radio-plugin.js @@ -0,0 +1,23 @@ +const fs = require("fs"); +const path = require("path"); +const AdmZip = require("adm-zip"); + +const root = path.resolve(__dirname, ".."); +const pluginDir = path.join(root, "plugins_dev", "radio"); +const distDir = path.join(root, "dist", "plugins"); +const outZip = path.join(distDir, "radio.zip"); + +function main() { + if (!fs.existsSync(pluginDir)) { + console.error("Missing plugin folder:", pluginDir); + process.exit(1); + } + fs.mkdirSync(distDir, { recursive: true }); + const zip = new AdmZip(); + zip.addLocalFolder(pluginDir, ""); + zip.writeZip(outZip); + console.log("Built:", outZip); +} + +main(); + diff --git a/server.js b/server.js @@ -4128,6 +4128,15 @@ wss.on("connection", (ws, req) => { if (!msg || typeof msg !== "object") return; + if (msg.type === "ping") { + try { + ws.send(JSON.stringify({ type: "pong", serverTime: now() })); + } catch { + // ignore + } + return; + } + const msgType = typeof msg.type === "string" ? msg.type : ""; const pluginMatch = msgType.match(/^plugin:([a-z0-9_.-]{1,32}):([a-zA-Z0-9_.-]{1,64})$/); if (pluginMatch) {