bzl

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

commit 5267fcf9780e1b9268adfdc7fdf5a4c56f7b2533
parent 388e5800550e0f8b463063e276b658f46d02e32e
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date:   Tue, 17 Feb 2026 21:17:32 -0700

UI OVERHAUL PART 3:2 - The Resolution of DOOM

okay edge cases be damned, just use your mouse wheel and set the scroll size on super tiny resolutions and then buy a new monitor plz

Diffstat:
MCLEAN_INSTALL/public/app.js | 4071++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
MCLEAN_INSTALL/public/index.html | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
MCLEAN_INSTALL/public/styles.css | 786+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
MCLEAN_INSTALL/server.js | 340++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mplugins_dev/maps/server.js | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpublic/app.js | 1240++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mpublic/index.html | 61++++++++++++++++++++++++++++++++++++++++++++++---------------
Mpublic/styles.css | 346+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mserver.js | 25+++++++++++++++++--------
9 files changed, 6570 insertions(+), 493 deletions(-)

diff --git a/CLEAN_INSTALL/public/app.js b/CLEAN_INSTALL/public/app.js @@ -20,6 +20,18 @@ const mobileModBtn = document.getElementById("mobileModBtn"); const enableNotifsBtn = document.getElementById("enableNotifs"); const notifStatus = document.getElementById("notifStatus"); const toggleReactionsEl = document.getElementById("toggleReactions"); +const hivesViewModeEl = document.getElementById("hivesViewMode"); +const toggleRackLayoutEl = document.getElementById("toggleRackLayout"); +const toggleSideRackEl = document.getElementById("toggleSideRack"); +const toggleRightRackEl = document.getElementById("toggleRightRack"); +const layoutPresetEl = document.getElementById("layoutPreset"); +const uiScaleEl = document.getElementById("uiScale"); +const deviceLayoutEl = document.getElementById("deviceLayout"); +const dockHotbarEl = document.getElementById("dockHotbar"); +const showSideRackBtn = document.getElementById("showSideRack"); +const showRightRackBtn = document.getElementById("showRightRack"); +const chatModToggleWrapEl = document.getElementById("chatModToggleWrap"); +const chatModToggleEl = document.getElementById("chatModToggle"); const authHint = document.getElementById("authHint"); const userLabel = document.getElementById("userLabel"); @@ -37,16 +49,7 @@ const removeProfileImageBtn = document.getElementById("removeProfileImage"); const nameColorInput = document.getElementById("nameColor"); const saveProfileBtn = document.getElementById("saveProfile"); const profileStatus = document.getElementById("profileStatus"); -const instancePanelEl = document.getElementById("instancePanel"); -const instanceTitleInput = document.getElementById("instanceTitleInput"); -const instanceSubtitleInput = document.getElementById("instanceSubtitleInput"); -const instanceAllowPermanentPostsEl = document.getElementById("instanceAllowPermanentPosts"); -const saveInstanceBrandingBtn = document.getElementById("saveInstanceBranding"); -const instanceStatusEl = document.getElementById("instanceStatus"); -const pluginZipInput = document.getElementById("pluginZipInput"); -const pluginInstallBtn = document.getElementById("pluginInstallBtn"); -const pluginsListEl = document.getElementById("pluginsList"); -const pluginStatusEl = document.getElementById("pluginStatus"); +// Instance + plugin admin UI lives in Moderation β†’ Server tab (rendered dynamically). const modPanelEl = document.getElementById("modPanel"); const modBodyEl = document.getElementById("modBody"); const modRefreshBtn = document.getElementById("modRefresh"); @@ -70,6 +73,10 @@ const newPostForm = document.getElementById("newPostForm"); const pollinatePanel = document.getElementById("pollinatePanel"); const toggleComposerBtn = document.getElementById("toggleComposer"); const toggleComposerInlineBtn = document.getElementById("toggleComposerInline"); +const mainRackEl = document.getElementById("mainRack"); +const mainWorkspaceRackEl = document.getElementById("mainWorkspaceRack"); +const mainSideRackEl = document.getElementById("mainSideRack"); +const hivesPanelEl = document.getElementById("hivesPanel"); const postTitleInput = document.getElementById("postTitle"); const postImageInput = document.getElementById("postImage"); const postAudioInput = document.getElementById("postAudio"); @@ -122,6 +129,10 @@ const chatEditor = document.getElementById("chatEditor"); const mentionMenuEl = document.getElementById("mentionMenu"); const chatImageInput = document.getElementById("chatImage"); const chatAudioInput = document.getElementById("chatAudio"); + +// When selecting images/audio for chat, route the insertion to the most-recently focused rich editor +// (main chat panel or a chat instance panel). +let chatUploadTargetEditor = chatEditor; const walkieBarEl = document.getElementById("walkieBar"); const walkieRecordBtn = document.getElementById("walkieRecordBtn"); const walkieStatusEl = document.getElementById("walkieStatus"); @@ -144,6 +155,7 @@ const editModalPostMeta = document.getElementById("editModalPostMeta"); const editModalKeywordsInput = document.getElementById("editModalKeywords"); const editModalCollectionSelect = document.getElementById("editModalCollection"); const editModalProtectedToggle = document.getElementById("editModalProtected"); +const editModalWalkieToggle = document.getElementById("editModalWalkie"); const editModalPasswordRow = document.getElementById("editModalPasswordRow"); const editModalPasswordInput = document.getElementById("editModalPassword"); const editModalToolbar = document.getElementById("editModalToolbar"); @@ -151,80 +163,2506 @@ const editModalEditor = document.getElementById("editModalEditor"); const editModalImageInput = document.getElementById("editModalImage"); const editModalAudioInput = document.getElementById("editModalAudio"); -/** @type {Map<string, any>} */ -const posts = new Map(); -/** @type {Record<string, {image?: string, color?: string}>} */ -let profiles = {}; +// Temporarily force rack mode on (hide toggle) while the feature stabilizes. +const FORCE_RACK_MODE = true; + +// Display prefs (device layout + text scale) +const UI_SCALE_KEY = "bzl_uiScale"; // "auto" | "xs" | "sm" | "md" | "lg" +const DEVICE_LAYOUT_KEY = "bzl_deviceLayout"; // "auto" | "widescreen" | "fourThree" | "threeTwo" | "ultrawide" | "portrait" + +/** @type {Map<string, any>} */ +const posts = new Map(); +/** @type {Record<string, {image?: string, color?: string}>} */ +let profiles = {}; + +/** @type {Map<string, any[]>} */ +const chatByPost = new Map(); +/** @type {Map<string, number>} */ +const unreadByPostId = new Map(); +/** @type {Map<string, Set<string>>} */ +const typingUsersByPostId = new Map(); +/** @type {Set<string>} */ +const myReacts = new Set(); +/** @type {Map<string, number>} */ +const reactPulseByKey = new Map(); +let allowedReactions = ["πŸ‘", "❀️", "😑", "😭", "πŸ₯Ί", "πŸ˜‚"]; + +let clientId = null; +let loggedInUser = null; +let loggedInRole = "member"; +let canModerate = false; +let canRegisterFirstUser = false; +let registrationEnabled = false; +let activeChatPostId = null; +let activeMapsRoomId = ""; +let activeMapsRoomTitle = ""; +let activeMapsChatScope = "local"; // "local" | "global" +/** @type {Map<string, any[]>} */ +const mapsChatGlobalByMapId = new Map(); +/** @type {Map<string, any[]>} */ +const mapsChatLocalByMapId = new Map(); +let pendingProfileImage = ""; +let windowFocused = true; +let typingStopTimer = null; +let lastTypingSentAt = 0; +let modTab = "reports"; +let modReports = []; +let modUsers = []; +let modLog = []; +let devLog = []; +let modLogView = localStorage.getItem("bzl_modLogView") || "dev"; // "dev" | "moderation" +let devLogAutoScroll = localStorage.getItem("bzl_devLogAutoScroll") !== "0"; +let modModalContext = null; +let lanUrls = []; +let mobilePanel = "main"; +let composerOpen = false; +let touchStartX = 0; +let touchStartY = 0; +let touchTracking = false; +let peopleOpen = false; +let peopleTab = "members"; +let peopleMembers = []; +let openPostMenuId = ""; + +// Multi-instance chat panels (MVP: per-hive/post chat panels). +/** @type {Map<string, {postId:string}>} */ +const chatPanelInstances = new Map(); + +function isChatInstancePanelId(panelId) { + const id = String(panelId || ""); + return id.startsWith("chat:post:"); +} + +function chatInstancePanelIdForPost(postId) { + const pid = String(postId || "").trim(); + if (!pid) return ""; + return `chat:post:${pid}`; +} +let dmThreads = []; +/** @type {Map<string, any>} */ +let dmThreadsById = new Map(); +/** @type {Map<string, any[]>} */ +const dmMessagesByThreadId = new Map(); +let activeDmThreadId = null; +let walkieRecording = false; +let walkieStartAt = 0; +let walkieRecorder = null; +let walkieChunks = []; +let walkieCtx = null; +let walkieMicStream = null; +let walkieMixNode = null; +let walkieDestNode = null; +let walkieDispatchBuffer = null; +const SESSION_TOKEN_KEY = "bzl_session_token"; +const CLIENT_IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024; +const CLIENT_AUDIO_UPLOAD_MAX_BYTES = 150 * 1024 * 1024; +let allowedPostReactions = ["πŸ‘", "❀️", "😑", "😭", "πŸ₯Ί", "πŸ˜‚", "⭐"]; +let allowedChatReactions = ["πŸ‘", "❀️", "😑", "😭", "πŸ₯Ί", "πŸ˜‚"]; +let userPrefs = { starredPostIds: [], hiddenPostIds: [], ignoredUsers: [], blockedUsers: [] }; +let showReactions = localStorage.getItem("bzl_showReactions") !== "0"; +let chatDock = localStorage.getItem("bzl_chatDock") === "right" ? "right" : "left"; +let activeHiveView = "all"; +let collections = []; +let customRoles = []; +let plugins = []; +const loadedPluginClientVersionById = new Map(); // pluginId -> version string +let centerView = "hives"; +const HIVES_VIEW_MODE_KEY = "bzl_hivesViewMode"; +const HIVES_LIST_AUTO_THRESHOLD_PX = 520; +let lastHivesWidthPx = 0; +let hivesResizeObserver = null; + +// --- Rack layout (experimental) ------------------------------------------------ + +const RACK_LAYOUT_ENABLED_KEY = "bzl_rackLayout_enabled"; +const RACK_LAYOUT_STATE_KEY = "bzl_rackLayout_state_v2"; +const RACK_SIDE_COLLAPSED_KEY = "bzl_rackLayout_sideCollapsed"; +const RACK_RIGHT_COLLAPSED_KEY = "bzl_rackLayout_rightCollapsed"; +const WORKSPACE_EXPANDED_PRIMARY_KEY = "bzl_workspace_expandedPrimary"; +const WORKSPACE_EXPANDED_DISPLACED_KEY = "bzl_workspace_expandedDisplaced"; + +/** + * @typedef {{ + * version: 2, + * presetId: string, + * docked: { bottom: string[] }, + * racks?: { workspaceLeft?: string[], workspaceRight?: string[], side?: string[], right?: string[] }, + * }} RackLayoutState + */ + +/** @type {RackLayoutState} */ +let rackLayoutState = { + version: 2, + presetId: "discordLike", + docked: { bottom: [] }, + racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, +}; +let rackLayoutEnabled = false; +let rightRackEl = null; +let mainRack = null; +let mainSideRack = null; +const WORKSPACE_ACTIVE_PRIMARY_KEY = "bzl_workspace_activePrimary"; + +function readBoolPref(key, fallback = false) { + try { + const raw = localStorage.getItem(key); + if (raw == null) return fallback; + return raw === "1" || raw === "true"; + } catch { + return fallback; + } +} + +function writeBoolPref(key, value) { + try { + localStorage.setItem(key, value ? "1" : "0"); + } catch { + // ignore + } +} + +function readWorkspaceExpandedPrimary() { + return readStringPref(WORKSPACE_EXPANDED_PRIMARY_KEY, "").trim(); +} + +function writeWorkspaceExpandedPrimary(panelId) { + writeStringPref(WORKSPACE_EXPANDED_PRIMARY_KEY, String(panelId || "").trim()); +} + +function readWorkspaceExpandedDisplaced() { + return readStringPref(WORKSPACE_EXPANDED_DISPLACED_KEY, "").trim(); +} + +function writeWorkspaceExpandedDisplaced(panelId) { + writeStringPref(WORKSPACE_EXPANDED_DISPLACED_KEY, String(panelId || "").trim()); +} + +function clearWorkspaceExpandedState() { + writeWorkspaceExpandedPrimary(""); + writeWorkspaceExpandedDisplaced(""); +} + +function togglePrimaryExpand(panelId) { + if (!rackLayoutEnabled) return; + const id = String(panelId || "").trim(); + if (!id) return; + if (!panelCanExpand(id)) return; + + const current = readWorkspaceExpandedPrimary(); + const left = ensureWorkspaceLeftRack(); + const right = ensureWorkspaceRightRack(); + if (!left || !right) return; + + // If the panel isn't in a workspace slot, pull it into the workspace first. + const panelEl = getPanelElement(id); + if (panelEl) { + const inWorkspace = panelEl.parentElement === left || panelEl.parentElement === right; + if (!inWorkspace) { + const leftExisting = left.querySelector?.(":scope > .rackPanel:not(.hidden)"); + const rightExisting = right.querySelector?.(":scope > .rackPanel:not(.hidden)"); + const leftEmpty = !leftExisting; + const rightEmpty = !rightExisting; + // Prefer the right slot for "aux" expandables like Moderation/Composer. + const target = rightEmpty ? right : leftEmpty ? left : right; + const existing = target === left ? leftExisting : rightExisting; + if (existing instanceof HTMLElement && existing !== panelEl) { + const existingId = String(existing.dataset?.panelId || "").trim(); + if (existingId) dockPanel(existingId); + } + target.appendChild(panelEl); + syncRackStateFromDom(); + enforceWorkspaceRules(); + } + } + + const leftPanel = left.querySelector?.(":scope > .rackPanel"); + const rightPanel = right.querySelector?.(":scope > .rackPanel"); + const leftId = String(leftPanel?.dataset?.panelId || "").trim(); + const rightId = String(rightPanel?.dataset?.panelId || "").trim(); + + if (current && current === id) { + // Collapse: try to restore the displaced panel (if any) back into the now-visible other slot. + const displaced = readWorkspaceExpandedDisplaced(); + clearWorkspaceExpandedState(); + if (displaced && isDocked(displaced)) { + undockPanel(displaced); + const el = getPanelElement(displaced); + if (el) { + if (leftId === id && !rightId) right.appendChild(el); + else if (rightId === id && !leftId) left.appendChild(el); + } + } + enforceWorkspaceRules(); + return; + } + + // Expand: if the other slot is occupied, dock it so it stays accessible via hotbar. + writeWorkspaceExpandedPrimary(id); + let displaced = ""; + if (leftId === id && rightId) displaced = rightId; + if (rightId === id && leftId) displaced = leftId; + if (displaced && displaced !== id) { + writeWorkspaceExpandedDisplaced(displaced); + dockPanel(displaced); + } else { + writeWorkspaceExpandedDisplaced(""); + } + enforceWorkspaceRules(); +} + +function readStringPref(key, fallback = "") { + try { + const raw = localStorage.getItem(key); + if (raw == null) return fallback; + return String(raw); + } catch { + return fallback; + } +} + +function normalizeUiScale(raw) { + const v = String(raw || "").trim().toLowerCase(); + if (v === "auto") return "auto"; + if (v === "xs" || v === "compact") return "xs"; + if (v === "sm" || v === "small") return "sm"; + if (v === "lg" || v === "large") return "lg"; + return "md"; +} + +function normalizeDeviceLayout(raw) { + const v = String(raw || "").trim().toLowerCase(); + if (v === "widescreen") return "widescreen"; + if (v === "fourthree" || v === "fourThree".toLowerCase() || v === "4:3" || v === "4x3") return "fourThree"; + if (v === "threetwo" || v === "threeTwo".toLowerCase() || v === "3:2" || v === "3x2") return "threeTwo"; + if (v === "ultrawide") return "ultrawide"; + if (v === "portrait") return "portrait"; + return "auto"; +} + +function detectViewportSize() { + const w = Math.max(1, Number(window.innerWidth) || 1); + const h = Math.max(1, Number(window.innerHeight) || 1); + // Keep this intentionally simple: we mostly care about β€œcan we fit columns sanely?” + // Consider both width and height so low-res (ex: 1280x720) can auto-compact. + if (w <= 1100 || h <= 720) return "xs"; + if (w <= 1400 || h <= 820) return "sm"; + if (w <= 1800) return "md"; + return "lg"; +} + +function detectAspectLayout() { + const w = Math.max(1, Number(window.innerWidth) || 1); + const h = Math.max(1, Number(window.innerHeight) || 1); + const ratio = w / h; + // Heuristics: + // - Portrait: <= ~1.25 + // - 4:3-ish: 1.25..1.38 + // - 3:2-ish: 1.38..1.62 (covers 3:2 and nearby) + // - Widescreen: 1.62..1.95 (16:10..~2:1) + // - Ultrawide: >= 1.95 + if (ratio <= 1.25) return "portrait"; + if (ratio < 1.38) return "fourThree"; + if (ratio >= 1.38 && ratio < 1.62) return "threeTwo"; + if (ratio >= 1.95) return "ultrawide"; + return "widescreen"; +} + +function applyDisplayPrefs() { + const root = document.documentElement; + if (!root) return; + const scalePref = normalizeUiScale(readStringPref(UI_SCALE_KEY, "auto")); + const layoutPref = normalizeDeviceLayout(readStringPref(DEVICE_LAYOUT_KEY, "auto")); + const layout = layoutPref === "auto" ? detectAspectLayout() : layoutPref; + const viewport = detectViewportSize(); + const scale = + scalePref === "auto" ? (viewport === "xs" ? "xs" : viewport === "sm" ? "sm" : "md") : scalePref; + + root.dataset.uiScale = scale; + root.dataset.uiScalePref = scalePref; + root.dataset.deviceLayout = layoutPref; + root.dataset.aspect = layout; + root.dataset.viewport = viewport; + + if (uiScaleEl) uiScaleEl.value = scalePref; + if (deviceLayoutEl) deviceLayoutEl.value = layoutPref; +} + +function initDisplayPrefsUi() { + applyDisplayPrefs(); + if (uiScaleEl) { + uiScaleEl.value = normalizeUiScale(readStringPref(UI_SCALE_KEY, "auto")); + uiScaleEl.addEventListener("change", () => { + const next = normalizeUiScale(uiScaleEl.value); + try { + localStorage.setItem(UI_SCALE_KEY, next); + } catch { + // ignore + } + applyDisplayPrefs(); + }); + } + if (deviceLayoutEl) { + deviceLayoutEl.value = normalizeDeviceLayout(readStringPref(DEVICE_LAYOUT_KEY, "auto")); + deviceLayoutEl.addEventListener("change", () => { + const next = normalizeDeviceLayout(deviceLayoutEl.value); + try { + localStorage.setItem(DEVICE_LAYOUT_KEY, next); + } catch { + // ignore + } + applyDisplayPrefs(); + }); + } + + let resizeTimer = null; + window.addEventListener("resize", () => { + if (resizeTimer) window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + resizeTimer = null; + // Always re-apply (viewport changes matter even when layout is manually pinned). + applyDisplayPrefs(); + }, 90); + }); +} + +function writeStringPref(key, value) { + try { + localStorage.setItem(key, String(value)); + } catch { + // ignore + } +} + +function resolveHivesViewMode() { + const pref = readStringPref(HIVES_VIEW_MODE_KEY, "list"); + const normalized = String(pref || "auto").toLowerCase(); + if (normalized === "list") return "list"; + if (normalized === "cards") return "cards"; + // auto (currently treated as list by default; we can reintroduce responsive modes later) + return "list"; +} + +function applyHivesViewMode() { + const mode = resolveHivesViewMode(); + const list = mode === "list"; + feedEl?.classList.toggle("hivesListView", list); + hivesPanelEl?.classList.toggle("hivesListView", list); +} + +function installHivesAutoViewMode() { + if (!hivesPanelEl) return; + if (typeof ResizeObserver === "undefined") { + window.addEventListener("resize", () => applyHivesViewMode()); + return; + } + if (hivesResizeObserver) return; + hivesResizeObserver = new ResizeObserver((entries) => { + const entry = entries && entries[0]; + const w = Number(entry?.contentRect?.width || 0); + if (!w) return; + const rounded = Math.round(w); + if (rounded === lastHivesWidthPx) return; + lastHivesWidthPx = rounded; + applyHivesViewMode(); + }); + try { + hivesResizeObserver.observe(hivesPanelEl); + } catch { + // ignore + } +} + +function setSideCollapsed(collapsed, opts) { + const options = opts && typeof opts === "object" ? opts : {}; + const persist = options.persist !== false; + const updateControls = options.updateControls !== false; + if (!appRoot) return; + appRoot.classList.toggle("sideCollapsed", Boolean(collapsed)); + if (persist) writeBoolPref(RACK_SIDE_COLLAPSED_KEY, Boolean(collapsed)); + if (updateControls && toggleSideRackEl) toggleSideRackEl.checked = !Boolean(collapsed); + updateSideRackEmptyState(); +} + +function setRightCollapsed(collapsed, opts) { + const options = opts && typeof opts === "object" ? opts : {}; + const persist = options.persist !== false; + const updateControls = options.updateControls !== false; + if (!appRoot) return; + appRoot.classList.toggle("rightCollapsed", Boolean(collapsed)); + if (persist) writeBoolPref(RACK_RIGHT_COLLAPSED_KEY, Boolean(collapsed)); + if (updateControls && toggleRightRackEl) toggleRightRackEl.checked = !Boolean(collapsed); +} + +function updateSideRackEmptyState() { + if (!appRoot) return; + const side = mainSideRackEl || mainSideRack || document.getElementById("mainSideRack"); + if (!(side instanceof HTMLElement)) return; + const hasVisible = Boolean(side.querySelector?.(".rackPanel:not(.hidden)")); + appRoot.classList.toggle("sideRackEmpty", !hasVisible); +} + +// Panel registry (skeleton): this will become the primary way core + plugins register UI panels. +// For now, it powers rack mode (docking + ordering + workspace rules) and plugin panel shells. +/** @type {Map<string, {id:string,title:string,icon?:string,source:string,role:string,defaultRack:string,element?:HTMLElement|null}>} */ +const panelRegistry = new Map(); + +function registerCorePanel(def) { + const id = String(def?.id || "").trim(); + if (!id) return; + const title = String(def?.title || id).trim(); + const icon = typeof def?.icon === "string" ? def.icon : ""; + const role = typeof def?.role === "string" ? def.role : "aux"; + const defaultRack = typeof def?.defaultRack === "string" ? def.defaultRack : "right"; + const element = def?.element instanceof HTMLElement ? def.element : null; + panelRegistry.set(id, { id, title, icon, source: "core", role, defaultRack, element }); +} + +function togglePanelSkinny(panelId) { + if (!rackLayoutEnabled) return; + const id = String(panelId || "").trim(); + if (!id) return; + if (!panelIsSkinnyCapable(id)) return; + const panelEl = getPanelElement(id); + if (!panelEl) return; + + const left = ensureWorkspaceLeftRack(); + const right = ensureWorkspaceRightRack(); + const side = ensureMainSideRack(); + if (!left || !right || !side) return; + + const parentId = rackIdForPanelElement(panelEl); + const inSkinny = parentId === "mainSideRack" || parentId === "rightRack"; + + if (inSkinny) { + // Move to workspace (prefer an empty slot; otherwise prefer right). + const leftExisting = left.querySelector?.(":scope > .rackPanel:not(.hidden)"); + const rightExisting = right.querySelector?.(":scope > .rackPanel:not(.hidden)"); + const target = !rightExisting ? right : !leftExisting ? left : right; + const existing = target === left ? leftExisting : rightExisting; + if (existing instanceof HTMLElement && existing !== panelEl) { + const existingId = String(existing.dataset?.panelId || "").trim(); + if (existingId) dockPanel(existingId); + } + target.appendChild(panelEl); + rememberPanelLastRack(id, target.id); + saveRackLayoutState(); + syncRackStateFromDom(); + enforceWorkspaceRules(); + return; + } + + // Move to side rack (skinny). + setSideCollapsed(false); + side.prepend(panelEl); + rememberPanelLastRack(id, side.id); + saveRackLayoutState(); + syncRackStateFromDom(); + enforceWorkspaceRules(); +} + +registerCorePanel({ id: "chat", title: "Chat", icon: "πŸ’¬", role: "primary", defaultRack: "main", element: chatPanelEl }); +registerCorePanel({ id: "hives", title: "Hives", icon: "🐝", role: "primary", defaultRack: "main", element: hivesPanelEl }); +registerCorePanel({ id: "people", title: "People", icon: "πŸ‘₯", role: "aux", defaultRack: "right", element: peopleDrawerEl }); +registerCorePanel({ id: "moderation", title: "Moderation", icon: "πŸ›‘οΈ", role: "aux", defaultRack: "right", element: modPanelEl }); +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 }); + +// 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" }); + +// Expose for quick inspection in the browser console while iterating. +window.__bzlPanels = { panelRegistry }; + +const PRESET_DEFS = { + // Presets are hard-applied (exact placement). Anything not explicitly placed starts in the hotbar. + // Workspace uses two full-height primary slots (left + right). No vertical splits. + social: { + presetId: "social", + label: "Default (Social)", + group: "user", + workspaceLeftOrder: ["hives"], + workspaceRightOrder: ["chat"], + sideOrder: ["profile", "composer"], + sideCollapsed: true, + rightOrder: ["people"], + dockBottom: ["maps", "library"], + }, + chatFocus: { + presetId: "chatFocus", + label: "Chat Focus", + group: "user", + workspaceLeftOrder: ["chat"], + workspaceRightOrder: [], + expandedPrimary: "chat", + sideOrder: ["profile"], + sideCollapsed: true, + rightOrder: ["people"], + dockBottom: ["hives", "composer", "maps", "library"], + }, + browse: { + presetId: "browse", + label: "Browse", + group: "user", + workspaceLeftOrder: ["hives"], + workspaceRightOrder: [], + expandedPrimary: "hives", + sideOrder: ["chat"], + sideCollapsed: true, + rightOrder: ["profile"], + dockBottom: ["people", "composer", "maps", "library"], + }, + creator: { + presetId: "creator", + label: "Creator", + group: "user", + workspaceLeftOrder: ["hives"], + workspaceRightOrder: ["composer"], + composerOpen: true, + sideOrder: ["people"], + sideCollapsed: true, + rightOrder: ["profile"], + dockBottom: ["chat", "maps", "library"], + }, + mapsSession: { + presetId: "mapsSession", + label: "Maps Session", + group: "user", + workspaceLeftOrder: ["maps"], // if installed + workspaceRightOrder: ["chat"], + sideOrder: ["hives"], + sideCollapsed: true, + rightOrder: ["people"], + dockBottom: ["profile", "composer", "library"], + }, + quiet: { + presetId: "quiet", + label: "Quiet (No People)", + group: "user", + workspaceLeftOrder: ["hives"], + workspaceRightOrder: ["profile"], + sideOrder: ["composer"], + sideCollapsed: true, + rightOrder: [], + rightCollapsed: true, + dockBottom: ["chat", "people", "maps", "library"], + }, + ops: { + presetId: "ops", + label: "Ops", + group: "mod", + modOnly: true, + workspaceLeftOrder: ["moderation"], + workspaceRightOrder: ["chat"], + sideOrder: ["hives"], + sideCollapsed: true, + rightOrder: ["people"], + dockBottom: ["profile", "composer", "maps", "library"], + }, + reportsFocus: { + presetId: "reportsFocus", + label: "Reports Focus", + group: "mod", + modOnly: true, + workspaceLeftOrder: ["moderation"], + workspaceRightOrder: [], + expandedPrimary: "moderation", + sideOrder: ["people"], + sideCollapsed: true, + rightOrder: ["chat"], + dockBottom: ["hives", "profile", "composer", "maps", "library"], + }, + communityWatch: { + presetId: "communityWatch", + label: "Community Watch", + group: "mod", + modOnly: true, + workspaceLeftOrder: ["hives"], + workspaceRightOrder: ["moderation"], + sideOrder: ["chat"], + sideCollapsed: true, + rightOrder: ["people"], + dockBottom: ["profile", "composer", "maps", "library"], + }, + serverAdmin: { + presetId: "serverAdmin", + label: "Server Admin", + group: "mod", + modOnly: true, + workspaceLeftOrder: ["moderation"], + workspaceRightOrder: ["hives"], + sideOrder: ["chat"], + sideCollapsed: true, + rightOrder: ["people"], + dockBottom: ["profile", "composer", "maps", "library"], + }, +}; + +const PRESET_ALIASES = { + // Back-compat for older preset ids. + discordLike: "social", + chat: "chatFocus", + browsing: "browse", + maps: "mapsSession", + focus: "quiet", + clean: "social", + moderation: "ops", +}; + +function resolvePresetKey(presetId) { + const raw = String(presetId || "").trim(); + const mapped = Object.prototype.hasOwnProperty.call(PRESET_ALIASES, raw) ? PRESET_ALIASES[raw] : raw; + return Object.prototype.hasOwnProperty.call(PRESET_DEFS, mapped) ? mapped : "social"; +} + +function updateLayoutPresetOptions() { + if (!layoutPresetEl) return; + const current = resolvePresetKey(rackLayoutState?.presetId || layoutPresetEl.value || "social"); + + const defs = Object.values(PRESET_DEFS).filter((d) => d && typeof d === "object"); + const userDefs = defs.filter((d) => d.group === "user"); + const modDefs = defs.filter((d) => d.group === "mod"); + + const makeOpt = (def) => { + const opt = document.createElement("option"); + opt.value = String(def.presetId || ""); + opt.textContent = String(def.label || def.presetId || "Preset"); + return opt; + }; + + layoutPresetEl.innerHTML = ""; + + const userGroup = document.createElement("optgroup"); + userGroup.label = "Presets"; + for (const def of userDefs) userGroup.appendChild(makeOpt(def)); + layoutPresetEl.appendChild(userGroup); + + if (canModerate) { + const modGroup = document.createElement("optgroup"); + modGroup.label = "Moderation (mods)"; + for (const def of modDefs) modGroup.appendChild(makeOpt(def)); + layoutPresetEl.appendChild(modGroup); + } + + const nextValue = canModerate ? current : (PRESET_DEFS[current]?.modOnly ? "social" : current); + layoutPresetEl.value = Object.prototype.hasOwnProperty.call(PRESET_DEFS, nextValue) ? nextValue : "social"; +} + +function readRackLayoutEnabled() { + if (FORCE_RACK_MODE) return true; + try { + return localStorage.getItem(RACK_LAYOUT_ENABLED_KEY) === "1"; + } catch { + return false; + } +} + +function writeRackLayoutEnabled(enabled) { + if (FORCE_RACK_MODE) { + rackLayoutEnabled = true; + try { + localStorage.setItem(RACK_LAYOUT_ENABLED_KEY, "1"); + } catch { + // ignore + } + return; + } + rackLayoutEnabled = Boolean(enabled); + try { + localStorage.setItem(RACK_LAYOUT_ENABLED_KEY, rackLayoutEnabled ? "1" : "0"); + } catch { + // ignore + } +} + +/** @returns {RackLayoutState} */ +function loadRackLayoutState() { + try { + const raw = localStorage.getItem(RACK_LAYOUT_STATE_KEY); + if (!raw) + return { + version: 2, + presetId: "discordLike", + docked: { bottom: [] }, + racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, + lastRackByPanelId: {}, + }; + const parsed = JSON.parse(raw); + if (!parsed || parsed.version !== 2) + return { + version: 2, + presetId: "discordLike", + docked: { bottom: [] }, + racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, + lastRackByPanelId: {}, + }; + const bottom = Array.isArray(parsed?.docked?.bottom) ? parsed.docked.bottom.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) : []; + const side = Array.isArray(parsed?.racks?.side) ? parsed.racks.side.map((x) => String(x || "")).filter(Boolean) : []; + const right = Array.isArray(parsed?.racks?.right) ? parsed.racks.right.map((x) => String(x || "")).filter(Boolean) : []; + const lastRackByPanelIdRaw = parsed?.lastRackByPanelId && typeof parsed.lastRackByPanelId === "object" ? parsed.lastRackByPanelId : {}; + const lastRackByPanelId = {}; + for (const [k, v] of Object.entries(lastRackByPanelIdRaw)) { + const id = String(k || "").trim(); + const rackId = typeof v === "string" ? v.trim() : ""; + if (!id || !rackId) continue; + lastRackByPanelId[id] = rackId; + } + return { version: 2, presetId, docked: { bottom }, racks: { workspaceLeft, workspaceRight, side, right }, lastRackByPanelId }; + } catch { + return { + version: 2, + presetId: "discordLike", + docked: { bottom: [] }, + racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, + lastRackByPanelId: {}, + }; + } +} + +function saveRackLayoutState() { + try { + localStorage.setItem(RACK_LAYOUT_STATE_KEY, JSON.stringify(rackLayoutState)); + } catch { + // ignore + } +} + +function ensureWorkspaceSlots() { + const workspace = mainWorkspaceRackEl || document.getElementById("mainWorkspaceRack"); + if (!workspace) return { left: null, right: null }; + + let left = workspace.querySelector?.("#workspaceLeftSlot"); + let right = workspace.querySelector?.("#workspaceRightSlot"); + + if (!left) { + left = document.createElement("div"); + left.id = "workspaceLeftSlot"; + left.className = "workspaceSlot workspaceSlotLeft"; + left.setAttribute("aria-label", "Workspace left"); + workspace.prepend(left); + } + if (!right) { + right = document.createElement("div"); + right.id = "workspaceRightSlot"; + right.className = "workspaceSlot workspaceSlotRight"; + right.setAttribute("aria-label", "Workspace right"); + const afterLeft = workspace.querySelector?.("#workspaceLeftSlot"); + if (afterLeft && afterLeft.nextSibling) workspace.insertBefore(right, afterLeft.nextSibling); + else workspace.appendChild(right); + } + return { left, right }; +} + +function panelTitle(panelId) { + const entry = panelRegistry.get(panelId); + if (entry?.title) return entry.title; + if (panelId === "maps") return "Maps"; + if (panelId === "library") return "Library"; + return String(panelId || ""); +} + +function chatRailClass({ fromUser, isModMessage }) { + const from = String(fromUser || "").trim(); + const isSystem = !from || from.toLowerCase() === "system"; + const isModMsg = Boolean(isModMessage); + const isYou = Boolean(loggedInUser && from && from === loggedInUser); + if (isSystem || isModMsg) return "railLeft"; + if (isYou) return "railRight"; + return "railCenter"; +} + +function updateChatModToggleVisibility() { + if (!chatModToggleWrapEl) return; + const canUse = Boolean(canModerate && activeChatPostId && !activeDmThreadId && !isMapChatActive()); + chatModToggleWrapEl.classList.toggle("hidden", !canUse); + if (!canUse && chatModToggleEl) chatModToggleEl.checked = false; +} + +function panelIcon(panelId) { + const entry = panelRegistry.get(panelId); + if (entry?.icon) return entry.icon; + if (panelId === "maps") return "πŸ—ΊοΈ"; + if (panelId === "library") return "πŸ“š"; + return "β€’"; +} + +function panelRole(panelId) { + const entry = panelRegistry.get(panelId); + return typeof entry?.role === "string" ? entry.role : "aux"; +} + +function panelCanExpand(panelId) { + const id = String(panelId || "").trim(); + if (!id) return false; + 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"; +} + +// 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"]); + +function panelIsSkinnyCapable(panelId) { + const id = String(panelId || "").trim(); + if (!id) return false; + if (id.startsWith("chat:")) return true; + return SKINNY_CAPABLE_PANELS.has(id); +} + +function isDocked(panelId) { + return rackLayoutState.docked.bottom.includes(panelId); +} + +function getPanelElement(panelId) { + const id = String(panelId || "").trim(); + if (!id) return null; + const entry = panelRegistry.get(id); + const el = entry?.element; + return el instanceof HTMLElement ? el : null; +} + +function rackIdForPanelElement(panelEl) { + const el = panelEl instanceof HTMLElement ? panelEl : null; + if (!el) return ""; + const parent = el.parentElement; + const id = parent && typeof parent.id === "string" ? parent.id : ""; + if (id === "workspaceLeftSlot" || id === "workspaceRightSlot" || id === "mainSideRack" || id === "rightRack") return id; + return ""; +} + +function rememberPanelLastRack(panelId, rackId) { + const id = String(panelId || "").trim(); + const rack = String(rackId || "").trim(); + if (!id || !rack) return; + if (!rackLayoutState.lastRackByPanelId || typeof rackLayoutState.lastRackByPanelId !== "object") rackLayoutState.lastRackByPanelId = {}; + rackLayoutState.lastRackByPanelId[id] = rack; +} + +function dockPanel(panelId) { + const id = String(panelId || "").trim(); + if (!id) return; + const el = getPanelElement(id); + const lastRack = rackIdForPanelElement(el); + if (lastRack) rememberPanelLastRack(id, lastRack); + if (!isDocked(id)) rackLayoutState.docked.bottom.push(id); + saveRackLayoutState(); + applyDockState(); +} + +function undockPanel(panelId) { + const id = String(panelId || "").trim(); + if (!id) return; + rackLayoutState.docked.bottom = rackLayoutState.docked.bottom.filter((x) => x !== id); + saveRackLayoutState(); + applyDockState(); +} + +function restorePanelFromHotbar(panelId) { + const id = String(panelId || "").trim(); + if (!id) return; + if (!rackLayoutEnabled) return; + + const panelEl = getPanelElement(id); + if (!panelEl) return; + + // Decide where to restore the panel. + const lastRackId = + rackLayoutState?.lastRackByPanelId && typeof rackLayoutState.lastRackByPanelId === "object" + ? String(rackLayoutState.lastRackByPanelId[id] || "") + : ""; + const lastRack = lastRackId ? document.getElementById(lastRackId) : null; + + const leftSlot = ensureWorkspaceLeftRack(); + const rightSlot = ensureWorkspaceRightRack(); + const sideRack = ensureMainSideRack(); + const rightRack = ensureRightRack(); + + const pickWorkspaceSlot = () => { + const leftEmpty = leftSlot ? leftSlot.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; + const rightEmpty = rightSlot ? rightSlot.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; + return leftEmpty ? leftSlot : rightEmpty ? rightSlot : leftSlot; + }; + + let targetRack = null; + if (lastRack instanceof HTMLElement) { + targetRack = lastRack; + } else if (panelIsSkinnyCapable(id)) { + // Heuristic: aux-like panels default to side rack; "right" defaults to the right rack. + const defRack = String(panelRegistry.get(id)?.defaultRack || ""); + targetRack = defRack === "right" ? rightRack : sideRack; + } else { + targetRack = pickWorkspaceSlot(); + } + + // If restoring into a collapsed rack, uncollapse it (hotbar acts like a summonable launcher). + if (targetRack && targetRack.id === "mainSideRack") setSideCollapsed(false); + if (targetRack && targetRack.id === "rightRack") setRightCollapsed(false); + + // If the panel already lives in a rack, keep its place and just reveal it. + const currentRackId = rackIdForPanelElement(panelEl); + const currentRack = currentRackId ? document.getElementById(currentRackId) : null; + + undockPanel(id); + + if (!(currentRack instanceof HTMLElement)) { + const rack = targetRack instanceof HTMLElement ? targetRack : null; + if (rack) { + // Right rack + workspace slots are single-slot: docking the existing occupant is the least surprising behavior. + const isWorkspaceSlot = rack.id === "workspaceLeftSlot" || rack.id === "workspaceRightSlot"; + const isRightRackSlot = rack.id === "rightRack"; + if (isWorkspaceSlot || isRightRackSlot) { + const existing = rack.querySelector?.(":scope > .rackPanel:not(.hidden)"); + if (existing instanceof HTMLElement && existing !== panelEl) { + const existingId = String(existing.dataset.panelId || "").trim(); + if (existingId) dockPanel(existingId); + } + } + rack.appendChild(panelEl); + rememberPanelLastRack(id, rack.id); + saveRackLayoutState(); + } + } else { + // Ensure the rack is visible if we restored into it. + if (currentRack.id === "mainSideRack") setSideCollapsed(false); + if (currentRack.id === "rightRack") setRightCollapsed(false); + } + + syncRackStateFromDom(); + enforceWorkspaceRules(); +} + +function showHotbar(show) { + if (!dockHotbarEl) return; + if (!show && dockHotbarEl.dataset.lockVisible === "1") return; + dockHotbarEl.classList.toggle("hidden", !show); + dockHotbarEl.classList.toggle("show", Boolean(show)); +} + +function renderHotbar() { + if (!dockHotbarEl) return; + const items = rackLayoutState.docked.bottom.slice().filter((id) => getPanelElement(id)); + const includePlus = Boolean(rackLayoutEnabled); + if (!items.length && !includePlus) { + dockHotbarEl.classList.add("hidden"); + dockHotbarEl.classList.remove("show"); + dockHotbarEl.innerHTML = ""; + return; + } + + const orbsHtml = items + .map( + (id) => ` + <button type="button" class="dockOrb" data-undock="${escapeHtml(id)}" title="Restore ${escapeHtml(panelTitle(id))}"> + <span class="dockOrbIcon" aria-hidden="true">${escapeHtml(panelIcon(id))}</span> + <span>${escapeHtml(panelTitle(id))}</span> + </button> + ` + ) + .join(""); + + const plusHtml = includePlus + ? ` + <button type="button" class="dockOrb dockOrbPlus" data-hotbarplus="1" title="Add panel"> + <span class="dockOrbIcon" aria-hidden="true">οΌ‹</span> + <span>Add</span> + </button> + ` + : ""; + + dockHotbarEl.innerHTML = `${orbsHtml}${plusHtml}`; + dockHotbarEl.classList.remove("hidden"); + requestAnimationFrame(() => showHotbar(true)); +} + +let hotbarPlusMenuEl = null; + +function closeHotbarPlusMenu() { + if (!hotbarPlusMenuEl) return; + try { + hotbarPlusMenuEl.remove(); + } catch { + // ignore + } + hotbarPlusMenuEl = null; +} + +function openHotbarPlusMenu(anchorEl) { + closeHotbarPlusMenu(); + if (!dockHotbarEl) return; + if (!(anchorEl instanceof HTMLElement)) return; + + const list = sortPosts(Array.from(posts.values())).slice(0, 8); + const items = list + .map((p) => { + const id = String(p?.id || "").trim(); + if (!id) return ""; + const title = postTitle(p); + return `<button type="button" class="ghost smallBtn" data-addchatpost="${escapeHtml(id)}">${escapeHtml(title)}</button>`; + }) + .filter(Boolean) + .join(""); + + const menu = document.createElement("div"); + menu.className = "hotbarAddMenu"; + menu.innerHTML = ` + <div class="small muted" style="padding:6px 8px 4px;">New chat panel for…</div> + <div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No hives yet.</div>`}</div> + `; + + const rect = anchorEl.getBoundingClientRect(); + const left = Math.max(12, Math.min(window.innerWidth - 260, rect.left - 200)); + const top = Math.max(12, rect.top - 260); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + + menu.addEventListener("click", (e) => { + const btn = e.target.closest?.("[data-addchatpost]"); + if (!btn) return; + const postId = String(btn.getAttribute("data-addchatpost") || "").trim(); + if (!postId) return; + ensureChatPostPanelInstance(postId, { docked: true }); + try { + ws.send(JSON.stringify({ type: "getChat", postId })); + } catch { + // ignore + } + closeHotbarPlusMenu(); + renderHotbar(); + }); + + document.body.appendChild(menu); + hotbarPlusMenuEl = menu; +} + +function applyDockState() { + // For the first implementation phase, we support docking any registered panel that has a DOM element. + for (const [id, p] of panelRegistry.entries()) { + const el = p?.element; + if (!(el instanceof HTMLElement)) continue; + if (id === "moderation" && !canModerate) { + el.classList.add("hidden"); + continue; + } + el.classList.toggle("hidden", isDocked(id)); + } + + renderHotbar(); + updateSideRackEmptyState(); +} + +function readRackOrder(rackEl) { + if (!(rackEl instanceof HTMLElement)) return []; + return Array.from(rackEl.querySelectorAll(".rackPanel")) + .filter((el) => el instanceof HTMLElement && !el.classList.contains("hidden")) + .map((el) => String(el?.dataset?.panelId || "").trim()) + .filter(Boolean); +} + +function applyRackStateToDom() { + if (!rackLayoutEnabled) return; + const left = ensureWorkspaceLeftRack(); + const rightWorkspace = ensureWorkspaceRightRack(); + const side = ensureMainSideRack(); + const right = ensureRightRack(); + if (!left || !rightWorkspace || !side || !right) return; + const leftOrder = Array.isArray(rackLayoutState?.racks?.workspaceLeft) ? rackLayoutState.racks.workspaceLeft : []; + const rightOrderW = Array.isArray(rackLayoutState?.racks?.workspaceRight) ? rackLayoutState.racks.workspaceRight : []; + const sideOrder = Array.isArray(rackLayoutState?.racks?.side) ? rackLayoutState.racks.side : []; + const rightOrder = Array.isArray(rackLayoutState?.racks?.right) ? rackLayoutState.racks.right : []; + + for (const panelId of leftOrder) { + const el = getPanelElement(panelId); + if (el) left.appendChild(el); + } + for (const panelId of rightOrderW) { + const el = getPanelElement(panelId); + if (el) rightWorkspace.appendChild(el); + } + for (const panelId of sideOrder) { + const el = getPanelElement(panelId); + if (el) side.appendChild(el); + } + for (const panelId of rightOrder) { + const el = getPanelElement(panelId); + if (el) right.appendChild(el); + } +} + +function readWorkspaceActivePrimary() { + try { + const raw = localStorage.getItem(WORKSPACE_ACTIVE_PRIMARY_KEY); + return raw ? String(raw) : ""; + } catch { + return ""; + } +} + +function writeWorkspaceActivePrimary(panelId) { + const id = String(panelId || "").trim(); + if (!id) return; + try { + localStorage.setItem(WORKSPACE_ACTIVE_PRIMARY_KEY, id); + } catch { + // ignore + } +} + +function enforceWorkspaceRules() { + if (!rackLayoutEnabled) return; + const left = ensureWorkspaceLeftRack(); + const rightWorkspace = ensureWorkspaceRightRack(); + const side = ensureMainSideRack(); + const rightRack = ensureRightRack(); + if (!left || !rightWorkspace || !side || !rightRack) return; + + // Primary panels: allow up to 2 visible (one per workspace slot). Enforce max 1 per slot. + const cleanupSlot = (slotEl) => { + const kids = Array.from(slotEl.querySelectorAll(":scope > .rackPanel:not(.hidden)")); + if (kids.length <= 1) return; + for (const extra of kids.slice(1)) side.appendChild(extra); + }; + cleanupSlot(left); + cleanupSlot(rightWorkspace); + + // Side rack and right rack are "skinny columns": only allow skinny-capable panels. + const enforceSkinny = (rackEl) => { + const kids = Array.from(rackEl.querySelectorAll(":scope > .rackPanel:not(.hidden)")); + for (const kid of kids) { + const id = String(kid?.dataset?.panelId || "").trim(); + if (!id) continue; + if (!panelIsSkinnyCapable(id)) dockPanel(id); + } + }; + enforceSkinny(side); + enforceSkinny(rightRack); + + // Right rack is single-slot: keep at most one visible panel. + const rightKids = Array.from(rightRack.querySelectorAll(":scope > .rackPanel:not(.hidden)")); + if (rightKids.length > 1) { + for (const extra of rightKids.slice(1)) { + const id = String(extra?.dataset?.panelId || "").trim(); + if (id) dockPanel(id); + } + } + + // Panels that live in the workspace slots should be "full" by default (especially primaries). + for (const slot of [left, rightWorkspace]) { + const panel = slot.querySelector?.(":scope > .rackPanel:not(.hidden)"); + if (!(panel instanceof HTMLElement)) continue; + const id = String(panel.dataset.panelId || "").trim(); + if (!id) continue; + panel.classList.remove("panelCollapsed"); + panel.dataset.panelDisplay = "full"; + } + + // If only one workspace slot is occupied, allow it to expand to full width to avoid blank space. + // (We temporarily disable this during drag so the empty slot remains a visible drop target.) + const leftPanel = left.querySelector?.(":scope > .rackPanel:not(.hidden)"); + const rightPanel = rightWorkspace.querySelector?.(":scope > .rackPanel:not(.hidden)"); + const leftId = String(leftPanel?.dataset?.panelId || "").trim(); + const rightId = String(rightPanel?.dataset?.panelId || "").trim(); + + // Workspace expansion (explicit maximize for primaries). + const expandedId = readWorkspaceExpandedPrimary(); + const expandedInLeft = Boolean(expandedId && expandedId === leftId); + const expandedInRight = Boolean(expandedId && expandedId === rightId); + const expandedValid = expandedInLeft || expandedInRight; + if (appRoot) { + appRoot.classList.toggle("workspaceExpandedLeft", expandedInLeft); + appRoot.classList.toggle("workspaceExpandedRight", expandedInRight); + if (!expandedValid) appRoot.classList.remove("workspaceExpandedLeft", "workspaceExpandedRight"); + } + if (expandedId && !expandedValid) clearWorkspaceExpandedState(); + + // If expanded and the other slot is occupied, keep it accessible via hotbar. + if (expandedInLeft && rightId && rightId !== expandedId) { + if (!readWorkspaceExpandedDisplaced()) writeWorkspaceExpandedDisplaced(rightId); + dockPanel(rightId); + } + if (expandedInRight && leftId && leftId !== expandedId) { + if (!readWorkspaceExpandedDisplaced()) writeWorkspaceExpandedDisplaced(leftId); + dockPanel(leftId); + } + + // Auto-expand single-primary only when not explicitly expanded. + if (appRoot && !appRoot.classList.contains("rackIsDragging") && !expandedValid) { + const leftOnly = Boolean(leftPanel && !rightPanel); + const rightOnly = Boolean(!leftPanel && rightPanel); + appRoot.classList.toggle("workspaceSingleLeft", leftOnly); + appRoot.classList.toggle("workspaceSingleRight", rightOnly); + } else if (appRoot) { + appRoot.classList.remove("workspaceSingleLeft", "workspaceSingleRight"); + } + + // Transient panels should live in the side column and be collapsed by default. + for (const el of Array.from(appRoot.querySelectorAll("#mainWorkspaceRack .rackPanel, #mainSideRack .rackPanel"))) { + const id = String(el?.dataset?.panelId || "").trim(); + if (!id) continue; + if (panelRole(id) !== "transient") continue; + if (el.parentElement !== side) side.appendChild(el); + el.classList.add("panelCollapsed"); + el.dataset.panelDisplay = "collapsed"; + } + + syncRackStateFromDom(); +} + +function installWorkspaceInteractions() { + if (!rackLayoutEnabled) return; + if (!appRoot) return; + if (appRoot.dataset.workspaceClicks === "1") return; + appRoot.dataset.workspaceClicks = "1"; + + appRoot.addEventListener("click", (e) => { + if (!rackLayoutEnabled) return; + const target = e.target; + const interactive = target?.closest?.("button,a,input,select,textarea,label"); + if (interactive) return; + const panel = target?.closest?.(".rackPanel"); + if (!panel) return; + if (!(panel instanceof HTMLElement)) return; + if (!panel.closest?.("#mainRack")) return; + const panelId = String(panel.dataset.panelId || "").trim(); + if (!panelId) return; + if (panelRole(panelId) !== "primary") return; + writeWorkspaceActivePrimary(panelId); + enforceWorkspaceRules(); + }); +} + +function syncRackStateFromDom() { + if (!rackLayoutEnabled) return; + const left = ensureWorkspaceLeftRack(); + const rightWorkspace = ensureWorkspaceRightRack(); + const side = ensureMainSideRack(); + const right = ensureRightRack(); + if (!left || !rightWorkspace || !side || !right) return; + rackLayoutState.racks = { + workspaceLeft: readRackOrder(left), + workspaceRight: readRackOrder(rightWorkspace), + side: readRackOrder(side), + right: readRackOrder(right), + }; + saveRackLayoutState(); +} + +function ensureRightRack() { + if (!appRoot) return null; + if (rightRackEl && rightRackEl.isConnected) return rightRackEl; + const el = document.createElement("aside"); + el.id = "rightRack"; + el.className = "rightRack"; + appRoot.appendChild(el); + rightRackEl = el; + return el; +} + +function ensureMainRack() { + // In rack mode, "main rack" is the workspace column inside #mainRack. + if (mainRack && mainRack.isConnected) return mainRack; + if (mainWorkspaceRackEl) { + mainRack = mainWorkspaceRackEl; + return mainRack; + } + + const wrapper = mainRackEl || document.querySelector("#mainRack") || document.querySelector("main.main"); + if (!wrapper) return null; + + let workspace = wrapper.querySelector?.("#mainWorkspaceRack"); + let side = wrapper.querySelector?.("#mainSideRack"); + if (!workspace) { + const w = document.createElement("div"); + w.id = "mainWorkspaceRack"; + w.className = "workspaceRack"; + w.setAttribute("aria-label", "Workspace"); + wrapper.appendChild(w); + workspace = w; + } + if (!side) { + const s = document.createElement("div"); + s.id = "mainSideRack"; + s.className = "sideRack"; + s.setAttribute("aria-label", "Side panels"); + wrapper.appendChild(s); + side = s; + } + mainSideRack = side; + mainRack = workspace; + return mainRack; +} + +function ensureMainSideRack() { + if (mainSideRack && mainSideRack.isConnected) return mainSideRack; + if (mainSideRackEl) { + mainSideRack = mainSideRackEl; + return mainSideRack; + } + // Ensure the workspace rack exists too (creates both columns if missing). + ensureMainRack(); + return mainSideRack instanceof HTMLElement ? mainSideRack : null; +} + +function ensureWorkspaceLeftRack() { + const { left } = ensureWorkspaceSlots(); + return left instanceof HTMLElement ? left : null; +} + +function ensureWorkspaceRightRack() { + const { right } = ensureWorkspaceSlots(); + return right instanceof HTMLElement ? right : null; +} + +function enableRackLayoutDom() { + if (!appRoot) return; + appRoot.classList.add("rackMode"); + const rack = ensureRightRack(); + if (!rack) return; + const main = ensureMainRack(); + const left = ensureWorkspaceLeftRack(); + const rightWorkspace = ensureWorkspaceRightRack(); + const side = ensureMainSideRack(); + + const mark = (el, panelId) => { + if (!el) return; + el.classList.add("rackPanel"); + el.dataset.panelId = panelId; + }; + + // Move right-side panels into the rack so they become stackable. + // (This is a stepping stone toward full dockable panels.) + if (chatPanelEl) { + mark(chatPanelEl, "chat"); + // Chat is a workspace primary in rack mode by default; enforceWorkspaceRules will manage if moved. + if (rightWorkspace && chatPanelEl.parentElement !== rightWorkspace) rightWorkspace.appendChild(chatPanelEl); + } + if (peopleDrawerEl) { + mark(peopleDrawerEl, "people"); + if (peopleDrawerEl.parentElement !== rack) rack.appendChild(peopleDrawerEl); + } + if (modPanelEl) { + mark(modPanelEl, "moderation"); + if (modPanelEl.parentElement !== rack) rack.appendChild(modPanelEl); + } + + // Mark center panels as rack panels too (they already live in mainRack in normal DOM). + if (main) { + if (hivesPanelEl) { + mark(hivesPanelEl, "hives"); + if (left && hivesPanelEl.parentElement !== left) left.appendChild(hivesPanelEl); + } + if (profileViewPanel) { + mark(profileViewPanel, "profile"); + if (side && profileViewPanel.parentElement !== side) side.appendChild(profileViewPanel); + // In rack mode, profile is its own panel; don't keep it hidden behind the legacy center-view toggle. + profileViewPanel.classList.remove("hidden"); + } + if (pollinatePanel) { + mark(pollinatePanel, "composer"); + if (side && pollinatePanel.parentElement !== side) side.appendChild(pollinatePanel); + } + } + + // Hide old resizers in rack mode (we'll replace with rack-aware resizing later). + chatResizeHandle?.classList.add("hidden"); + peopleResizeHandle?.classList.add("hidden"); + + // People drawer chrome: hide the close button (panel is now a rack item). + closePeopleBtn?.classList.add("hidden"); + // People drawer toggle button is obsolete in rack mode. + togglePeopleBtn?.classList.add("hidden"); + // Ensure people panel isn't hidden by legacy state. + peopleDrawerEl?.classList.remove("hidden"); + peopleOpen = true; + + // Profile panel no longer "replaces" the feed in rack mode, so the back button is confusing. + profileBackBtn?.classList.add("hidden"); +} + +function disableRackLayoutDom() { + if (!appRoot) return; + appRoot.classList.remove("rackMode"); + // No attempt to move elements back (yet). Disable is meant for page reload use. +} + +function applyPreset(presetId) { + const key = resolvePresetKey(presetId); + const def = PRESET_DEFS[key]; + if (!def) return; + if (def.modOnly && !canModerate) { + applyPreset("social"); + return; + } + + rackLayoutState.presetId = def.presetId || key; + + const workspaceLeftOrder = Array.isArray(def.workspaceLeftOrder) ? def.workspaceLeftOrder.map((x) => String(x || "")).filter(Boolean) : []; + const workspaceRightOrder = Array.isArray(def.workspaceRightOrder) ? def.workspaceRightOrder.map((x) => String(x || "")).filter(Boolean) : []; + const sideOrder = Array.isArray(def.sideOrder) ? def.sideOrder.map((x) => String(x || "")).filter(Boolean) : []; + const rightOrderRaw = Array.isArray(def.rightOrder) ? def.rightOrder.map((x) => String(x || "")).filter(Boolean) : []; + // Right rack is a single skinny-capable panel. + const rightOrder = rightOrderRaw.length ? [rightOrderRaw[0]] : []; + + // Applying a preset should be deterministic even after the user has rearranged panels. + clearWorkspaceExpandedState(); + const expandedPrimary = typeof def.expandedPrimary === "string" ? def.expandedPrimary.trim() : ""; + if (expandedPrimary) writeWorkspaceExpandedPrimary(expandedPrimary); + + if (typeof def.composerOpen === "boolean") setComposerOpen(def.composerOpen); + setSideCollapsed(Boolean(def.sideCollapsed), { persist: true }); + setRightCollapsed(Boolean(def.rightCollapsed), { persist: true }); + + const leftRack = ensureWorkspaceLeftRack(); + const rightWorkspaceRack = ensureWorkspaceRightRack(); + const sideRack = ensureMainSideRack(); + const rightRack = ensureRightRack(); + if (!leftRack || !rightWorkspaceRack || !sideRack || !rightRack) return; + + const placed = new Set([...workspaceLeftOrder, ...workspaceRightOrder, ...sideOrder, ...rightOrder]); + const docked = new Set(Array.isArray(def.dockBottom) ? def.dockBottom.map((x) => String(x || "")).filter(Boolean) : []); + for (const id of placed) docked.delete(id); + + // Default: anything not explicitly placed by the preset goes to the hotbar. + for (const id of Array.from(panelRegistry.keys())) { + if (!placed.has(id)) docked.add(id); + } + + // Moderation panel should not be forced visible for non-mods. + if (!canModerate) { + docked.add("moderation"); + // Also ensure moderation isn't placed anywhere. + workspaceLeftOrder.splice(0, workspaceLeftOrder.length, ...workspaceLeftOrder.filter((x) => x !== "moderation")); + workspaceRightOrder.splice(0, workspaceRightOrder.length, ...workspaceRightOrder.filter((x) => x !== "moderation")); + sideOrder.splice(0, sideOrder.length, ...sideOrder.filter((x) => x !== "moderation")); + } + + rackLayoutState.docked.bottom = Array.from(docked); + + saveRackLayoutState(); + applyDockState(); + + // Detach all known panels before re-placing, so we don't end up with "stale" panels sticking in old racks. + const elsById = new Map(); + for (const id of Array.from(panelRegistry.keys())) { + const el = getPanelElement(id); + if (el) elsById.set(id, el); + } + for (const el of elsById.values()) { + if (el.parentElement) el.parentElement.removeChild(el); + } + + if (leftRack) { + for (const panelId of workspaceLeftOrder) { + if (docked.has(panelId)) continue; + const el = elsById.get(panelId) || getPanelElement(panelId); + if (el) leftRack.appendChild(el); + } + } + if (rightWorkspaceRack) { + for (const panelId of workspaceRightOrder) { + if (docked.has(panelId)) continue; + const el = elsById.get(panelId) || getPanelElement(panelId); + if (el) rightWorkspaceRack.appendChild(el); + } + } + if (sideRack) { + for (const panelId of sideOrder) { + if (docked.has(panelId)) continue; + const el = elsById.get(panelId) || getPanelElement(panelId); + if (el) sideRack.appendChild(el); + } + } + if (rightRack) { + for (const panelId of rightOrder) { + if (docked.has(panelId)) continue; + const el = elsById.get(panelId) || getPanelElement(panelId); + if (el) rightRack.appendChild(el); + } + } + + syncRackStateFromDom(); + enforceWorkspaceRules(); + updateLayoutPresetOptions(); +} + +function installPanelMinimizeButtons() { + const addMinBtn = (headerEl, panelId) => { + if (!headerEl) return; + const row = headerEl.querySelector(".row") || headerEl.querySelector(".filters") || headerEl; + + if (!headerEl.querySelector(`[data-rackdrag="${panelId}"]`)) { + const drag = document.createElement("button"); + drag.type = "button"; + drag.className = "ghost smallBtn rackDragHandle"; + drag.textContent = "☰"; + drag.title = "Drag to reorder"; + drag.setAttribute("data-rackdrag", panelId); + row.appendChild(drag); + } + + if (panelIsSkinnyCapable(panelId) && !headerEl.querySelector(`[data-skinny="${panelId}"]`)) { + const skinny = document.createElement("button"); + skinny.type = "button"; + skinny.className = "ghost smallBtn"; + skinny.textContent = "]["; + skinny.title = "Toggle skinny/full"; + skinny.setAttribute("data-skinny", panelId); + skinny.onclick = () => togglePanelSkinny(panelId); + row.appendChild(skinny); + } + + if (panelCanExpand(panelId) && !headerEl.querySelector(`[data-expand="${panelId}"]`)) { + const expand = document.createElement("button"); + expand.type = "button"; + expand.className = "ghost smallBtn"; + expand.textContent = "[]"; + expand.title = "Expand workspace"; + expand.setAttribute("data-expand", panelId); + expand.onclick = () => togglePrimaryExpand(panelId); + row.appendChild(expand); + } + + if (!headerEl.querySelector(`[data-minimize="${panelId}"]`)) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "ghost smallBtn"; + btn.textContent = "β€”"; + btn.title = "Minimize to hotbar"; + btn.setAttribute("data-minimize", panelId); + btn.onclick = () => dockPanel(panelId); + row.appendChild(btn); + } + }; + + addMinBtn(chatHeaderEl, "chat"); + addMinBtn(modPanelEl?.querySelector(".panelHeader"), "moderation"); + addMinBtn(peopleDrawerEl?.querySelector(".panelHeader"), "people"); + addMinBtn(hivesPanelEl?.querySelector(".panelHeader"), "hives"); + addMinBtn(profileViewPanel?.querySelector(".panelHeader"), "profile"); + addMinBtn(pollinatePanel?.querySelector(".panelHeader"), "composer"); +} + +function ensurePluginPanelShell(panelId, title, icon, defaultRack, role) { + const wantsMain = String(defaultRack || "").toLowerCase() === "main"; + const isPrimary = String(role || "").toLowerCase() === "primary"; + let preferred = null; + if (wantsMain && isPrimary) { + // Primary panels should live inside a workspace slot, not as loose items in the workspace grid. + const left = ensureWorkspaceLeftRack(); + const right = ensureWorkspaceRightRack(); + const side = ensureMainSideRack(); + const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel").length === 0 : false; + const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel").length === 0 : false; + preferred = leftEmpty ? left : rightEmpty ? right : side; + } else if (wantsMain) { + preferred = ensureMainSideRack(); + } else { + preferred = ensureRightRack(); + } + const rack = preferred || ensureRightRack() || ensureMainSideRack() || ensureWorkspaceLeftRack() || ensureWorkspaceRightRack() || ensureMainRack(); + if (!rack) return null; + + const existing = document.querySelector?.(`.panel.pluginPanel[data-panel-id="${CSS.escape(panelId)}"]`); + if (existing instanceof HTMLElement) { + if (existing.parentElement !== rack) rack.appendChild(existing); + return existing; + } + + const shell = document.createElement("section"); + shell.className = "panel panelFill pluginPanel rackPanel"; + shell.dataset.panelId = panelId; + shell.innerHTML = ` + <div class="panelHeader"> + <div class="panelTitle">${escapeHtml(title || panelId)}</div> + <div class="row"> + <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">☰</button> + <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">β€”</button> + </div> + </div> + <div class="panelBody" data-pluginmount="1"></div> + `; + + const minBtn = shell.querySelector(`[data-minimize="${panelId}"]`); + if (isPrimary || panelCanExpand(panelId)) { + const headerRow = shell.querySelector(".panelHeader .row"); + if (headerRow && !headerRow.querySelector(`[data-expand="${panelId}"]`)) { + const expand = document.createElement("button"); + expand.type = "button"; + expand.className = "ghost smallBtn"; + expand.textContent = "[]"; + expand.title = "Expand workspace"; + expand.setAttribute("data-expand", panelId); + expand.addEventListener("click", () => togglePrimaryExpand(panelId)); + if (minBtn && minBtn.parentElement === headerRow) headerRow.insertBefore(expand, minBtn); + else headerRow.appendChild(expand); + } + } + if (minBtn) minBtn.addEventListener("click", () => dockPanel(panelId)); + + rack.appendChild(shell); + return shell; +} + +function ensureChatPostPanelInstance(postId, opts) { + if (!rackLayoutEnabled) return ""; + const pid = String(postId || "").trim(); + if (!pid) return ""; + const post = posts.get(pid) || null; + const panelId = chatInstancePanelIdForPost(pid); + if (!panelId) return ""; + + if (panelRegistry.has(panelId)) return panelId; + + const title = post?.title ? `Chat: ${String(post.title).slice(0, 32)}` : "Chat"; + const shell = document.createElement("section"); + shell.className = "panel panelFill rackPanel chat chatInstance"; + shell.dataset.panelId = panelId; + shell.innerHTML = ` + <div class="panelHeader"> + <div> + <div class="panelTitle">${escapeHtml(title)}</div> + <div class="small muted chatMeta"></div> + </div> + <div class="row"> + <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">☰</button> + <button type="button" class="ghost smallBtn" data-skinny="${escapeHtml(panelId)}" title="Toggle skinny/full">][</button> + <button type="button" class="ghost smallBtn" data-expand="${escapeHtml(panelId)}" title="Expand workspace">[]</button> + <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">β€”</button> + </div> + </div> + <div class="chatMessages"></div> + <div class="typingIndicator small muted"></div> + <form class="chatForm"> + <div class="chatComposer"> + <div class="toolbar" role="toolbar" aria-label="Chat formatting"> + <button type="button" data-chatcmd="bold"><b>B</b></button> + <button type="button" data-chatcmd="italic"><i>I</i></button> + <button type="button" data-chatcmd="underline"><u>U</u></button> + <button type="button" data-chatcmd="strikeThrough"><s>S</s></button> + <span class="sep"></span> + <button type="button" data-chatcmd="insertUnorderedList">List</button> + <button type="button" data-chatcmd="insertOrderedList">1. List</button> + <button type="button" data-chatlink="1">Link</button> + <button type="button" data-chatimg="1">GIF/Image</button> + <button type="button" data-chataudio="1">Audio</button> + <button type="button" data-chatemoji="1">Emoji</button> + <button type="button" data-chatcmd="removeFormat">Clear</button> + </div> + <div class="chatInstanceTools"> + <label class="checkRow chatModToggle chatInstModToggle hidden" title="Send as moderator/system message (left rail)"> + <span>Mod</span> + <input class="chatInstModToggleInput" type="checkbox" /> + </label> + </div> + <div class="editor chatEditor" contenteditable="true" aria-label="Chat editor"></div> + </div> + <button class="primary" type="submit">Send</button> + </form> + `; + + const metaEl = shell.querySelector(".chatMeta"); + const messagesEl = shell.querySelector(".chatMessages"); + const typingEl = shell.querySelector(".typingIndicator"); + const formEl = shell.querySelector("form.chatForm"); + const editorEl = shell.querySelector(".chatEditor"); + const modToggleWrapEl = shell.querySelector(".chatInstModToggle"); + const modToggleEl = shell.querySelector(".chatInstModToggleInput"); + + shell.querySelector(`[data-minimize="${cssEscape(panelId)}"]`)?.addEventListener("click", () => dockPanel(panelId)); + shell.querySelector(`[data-expand="${cssEscape(panelId)}"]`)?.addEventListener("click", () => togglePrimaryExpand(panelId)); + shell.querySelector(`[data-skinny="${cssEscape(panelId)}"]`)?.addEventListener("click", () => togglePanelSkinny(panelId)); + + if (formEl && editorEl) { + formEl.addEventListener("submit", (e) => { + e.preventDefault(); + const html = String(editorEl.innerHTML || "").trim(); + const text = String(editorEl.innerText || "").trim(); + const hasImg = Boolean(editorEl.querySelector("img")); + const hasAudio = Boolean(editorEl.querySelector("audio")); + if (!text && !hasImg && !hasAudio) return; + if (!loggedInUser) { + toast("Sign in required", "Sign in to chat."); + return; + } + const currentPost = posts.get(pid) || null; + if (currentPost && String(currentPost.mode || currentPost.chatMode || "").toLowerCase() === "walkie") { + toast("Walkie Talkie", "This hive is walkie-only. Hold ~ to talk."); + return; + } + if (currentPost?.readOnly && !(loggedInRole === "owner" || loggedInRole === "moderator")) { + toast("Read-only", "This hive is read-only."); + return; + } + if (currentPost?.deleted) { + toast("Unavailable", "This post was deleted."); + return; + } + const wantsMod = Boolean(canModerate && modToggleEl instanceof HTMLInputElement && modToggleEl.checked); + ws.send(JSON.stringify({ type: "typing", postId: pid, isTyping: false })); + ws.send(JSON.stringify({ type: "chatMessage", postId: pid, text, html, replyToId: "", asMod: wantsMod })); + editorEl.innerHTML = ""; + // Leave global reply-to state alone; this instance panel is independent (MVP). + }); + + editorEl.addEventListener("focus", () => { + chatUploadTargetEditor = editorEl; + }); + + editorEl.addEventListener("keydown", (e) => { + if (e.key !== "Enter") return; + if (!(e.ctrlKey || e.metaKey)) return; + e.preventDefault(); + formEl.requestSubmit(); + }); + + // Allow drag/drop uploads in instance chats too. + try { + installDropUpload(editorEl, { allowImages: true, allowAudio: true }); + } catch { + // ignore + } + } + + if (modToggleWrapEl) modToggleWrapEl.classList.toggle("hidden", !canModerate); + + // Register + insert. + panelRegistry.set(panelId, { + id: panelId, + title, + icon: "πŸ’¬", + source: "core", + role: "aux", + defaultRack: "main", + element: shell, + }); + chatPanelInstances.set(panelId, { postId: pid }); + + const options = opts && typeof opts === "object" ? opts : {}; + const docked = Boolean(options.docked); + const sideRack = ensureMainSideRack(); + if (docked) { + // Keep it out of layout; show as orb. + if (sideRack) sideRack.appendChild(shell); + dockPanel(panelId); + } else { + setSideCollapsed(false); + if (sideRack) sideRack.prepend(shell); + rememberPanelLastRack(panelId, "mainSideRack"); + saveRackLayoutState(); + applyDockState(); + syncRackStateFromDom(); + enforceWorkspaceRules(); + } + + renderChatPostPanelInstance(panelId, true); + return panelId; +} + +function renderTypingIndicatorForPost(postId, targetEl) { + if (!(targetEl instanceof HTMLElement)) return; + const id = String(postId || "").trim(); + if (!id) { + targetEl.textContent = ""; + return; + } + const set = typingUsersByPostId.get(id); + if (!set || set.size === 0) { + targetEl.textContent = ""; + return; + } + const names = Array.from(set.values()).slice(0, 3); + const more = set.size > names.length ? ` +${set.size - names.length}` : ""; + targetEl.textContent = `${names.map((u) => `@${u}`).join(", ")}${more} typing…`; +} + +function renderChatPostPanelInstance(panelId, forceScroll) { + const id = String(panelId || "").trim(); + if (!id) return; + const inst = chatPanelInstances.get(id); + if (!inst) return; + const postId = String(inst.postId || "").trim(); + const post = postId ? posts.get(postId) : null; + const root = getPanelElement(id); + if (!(root instanceof HTMLElement)) return; + const metaEl = root.querySelector(".chatMeta"); + const messagesEl = root.querySelector(".chatMessages"); + const typingEl = root.querySelector(".typingIndicator"); + const editorEl = root.querySelector(".chatEditor"); + const sendBtn = root.querySelector("form.chatForm button[type='submit']"); + + if (metaEl) { + if (!post) metaEl.textContent = "Hive not found."; + else { + const tags = (post.keywords || []).map((k) => `#${k}`).join(" "); + const author = post.author ? `by @${post.author}` : ""; + const exp = formatCountdown(post.expiresAt); + const ro = post.readOnly ? " | read-only" : ""; + metaEl.textContent = `${author}${ro} | ${exp === "permanent" ? "permanent" : `expires in ${exp}`} | ${tags}`.trim(); + } + } + + if (!(messagesEl instanceof HTMLElement)) return; + const atBottomBefore = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 24; + + if (!post) { + messagesEl.innerHTML = `<div class="small muted">Hive not found.</div>`; + if (typingEl) typingEl.textContent = ""; + return; + } + if (post.deleted) { + messagesEl.innerHTML = `<div class="small muted">Post was deleted.</div>`; + if (typingEl) typingEl.textContent = ""; + return; + } + + const isWalkie = String(post.mode || post.chatMode || "").toLowerCase() === "walkie"; + const canChatWrite = Boolean(loggedInRole === "owner" || loggedInRole === "moderator" || !post.readOnly); + if (editorEl) editorEl.contentEditable = String(Boolean(canChatWrite && !isWalkie)); + if (sendBtn instanceof HTMLButtonElement) sendBtn.disabled = !(loggedInUser && canChatWrite && !isWalkie); + + const modToggleWrapEl = root.querySelector(".chatInstModToggle"); + const modToggleEl = root.querySelector(".chatInstModToggleInput"); + if (modToggleWrapEl) modToggleWrapEl.classList.toggle("hidden", !canModerate); + if (!canModerate && modToggleEl instanceof HTMLInputElement) modToggleEl.checked = false; + + const messages = chatByPost.get(post.id) || []; + const ignoreUserSet = new Set( + [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase()) + ); + const selfLower = String(loggedInUser || "").toLowerCase(); + const visibleMessages = messages.filter((m) => { + const fromLower = String(m?.fromUser || "").toLowerCase(); + if (!fromLower || fromLower === selfLower) return true; + return !ignoreUserSet.has(fromLower); + }); + + messagesEl.innerHTML = visibleMessages + .map((m, index) => { + const isModMsg = Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod"; + const from = isModMsg ? "MOD" : m.fromUser || ""; + const isYou = loggedInUser && from && from === loggedInUser; + const rail = chatRailClass({ fromUser: from, isModMessage: isModMsg }); + const prev = index > 0 ? visibleMessages[index - 1] : null; + const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); + const mentions = Array.isArray(m.mentions) ? m.mentions.map((u) => String(u || "").toLowerCase()) : []; + const mentionMe = Boolean(loggedInUser && mentions.includes(loggedInUser)); + const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || ""); + const youTag = !isModMsg && isYou ? `<span class="muted">(you)</span>` : ""; + const time = new Date(m.createdAt).toLocaleTimeString(); + const tint = isModMsg ? "" : tintStylesFromHex(getProfile(from).color); + const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; + const content = html ? html : highlightMentionsInText(m.text || ""); + const replyMeta = m.replyTo && typeof m.replyTo === "object" ? m.replyTo : null; + const replyBlock = replyMeta + ? `<div class="chatReplyRef"><span class="small muted">@${escapeHtml(replyMeta.fromUser || "unknown")}</span><div class="small">${escapeHtml( + String(replyMeta.text || "[media]").slice(0, 120) + )}</div></div>` + : ""; + const reacts = renderReactionButtons({ kind: "chat", id: m.id, reactions: m.reactions || {}, postId: post.id }); + const deletedLine = m.deleted + ? `<div class="small muted">message deleted${ + m.deletedBy ? ` by @${escapeHtml(m.deletedBy)}` : "" + } at ${escapeHtml(new Date(Number(m.deletedAt || m.createdAt || Date.now())).toLocaleString())}</div>` + : ""; + const editedLine = + !m.deleted && Number(m.editCount || 0) > 0 + ? `<div class="small muted">edited (${Number(m.editCount || 0)}) at ${escapeHtml( + new Date(Number(m.editedAt || m.createdAt || Date.now())).toLocaleTimeString() + )}</div>` + : ""; + const reportAction = + loggedInUser && !m.deleted + ? `<button type="button" class="ghost smallBtn" data-reportchat="${escapeHtml(m.id)}" data-postid="${escapeHtml( + post.id + )}">Report</button>` + : ""; + const deleteAction = + loggedInUser && !m.deleted && (loggedInRole === "owner" || loggedInRole === "moderator" || from === loggedInUser) + ? `<button type="button" class="ghost smallBtn" data-delchat="${escapeHtml(m.id)}" data-postid="${escapeHtml( + post.id + )}">Delete</button>` + : ""; + const actions = + reportAction || deleteAction + ? `<div class="chatTools">${reportAction}${deleteAction}</div>` + : ""; + return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""} ${rail} ${isModMsg ? "isModMsg" : ""}" data-msgid="${escapeHtml( + m.id + )}" ${tint}> + <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> + ${replyBlock} + <div class="content">${content}</div> + ${deletedLine}${editedLine} + <div class="chatActionsRow">${reacts}${actions}</div> + </div>`; + }) + .join(""); + + for (const contentEl of messagesEl.querySelectorAll(".chatMsg .content")) { + decorateMentionNodesInElement(contentEl); + decorateYouTubeEmbedsInElement(contentEl); + } + + renderTypingIndicatorForPost(post.id, typingEl); + + if (forceScroll || atBottomBefore) messagesEl.scrollTop = messagesEl.scrollHeight; +} + +function renderChatInstancesForPost(postId) { + const pid = String(postId || "").trim(); + if (!pid) return; + for (const [panelId, inst] of chatPanelInstances.entries()) { + if (String(inst?.postId || "") !== pid) continue; + renderChatPostPanelInstance(panelId); + } +} + +function applyPluginPresetHint(panelDef) { + if (!rackLayoutEnabled) return; + const id = String(panelDef?.id || "").trim(); + if (!id) return; + if (isDocked(id)) return; + const presetId = rackLayoutState?.presetId || ""; + const hint = panelDef?.presetHints && typeof panelDef.presetHints === "object" ? panelDef.presetHints[presetId] : null; + const place = hint && typeof hint === "object" ? String(hint.place || "") : ""; + if (place === "docked.bottom") { + dockPanel(id); + return; + } + if (place === "main" || place === "right") { + const rack = place === "main" ? ensureMainSideRack() : ensureRightRack(); + const el = getPanelElement(id); + if (rack && el) rack.appendChild(el); + } +} + +function enableRackDnD() { + if (!rackLayoutEnabled) return; + const right = ensureRightRack(); + const left = ensureWorkspaceLeftRack(); + const rightWorkspace = ensureWorkspaceRightRack(); + const side = ensureMainSideRack(); + if (!right || !left || !rightWorkspace || !side) return; + const racks = [left, rightWorkspace, side, right]; + + // Guard against double-install if initRackLayout is called more than once. + if (appRoot?.dataset?.rackDnd === "1") return; + if (appRoot) appRoot.dataset.rackDnd = "1"; + + let draggingEl = null; + let placeholderEl = null; + let pointerId = null; + let dragOffset = { x: 0, y: 0 }; + let draggingPanelId = ""; + let activeRack = null; + let originRack = null; + let originBefore = null; + + const cancelDrag = () => { + if (!draggingEl) return; + cleanup(); + enforceWorkspaceRules(); + }; + + const cleanup = () => { + if (appRoot) appRoot.classList.remove("rackIsDragging"); + if (draggingEl) { + draggingEl.classList.remove("rackDragging"); + draggingEl.style.position = ""; + draggingEl.style.left = ""; + draggingEl.style.top = ""; + draggingEl.style.width = ""; + draggingEl.style.zIndex = ""; + draggingEl.style.pointerEvents = ""; + } + if (dockHotbarEl) dockHotbarEl.classList.remove("dockTarget"); + if (placeholderEl && placeholderEl.parentElement) placeholderEl.parentElement.removeChild(placeholderEl); + draggingEl = null; + placeholderEl = null; + pointerId = null; + draggingPanelId = ""; + activeRack = null; + originRack = null; + originBefore = null; + }; + + const siblings = (rack) => Array.from(rack.querySelectorAll(".rackPanel")).filter((el) => el !== draggingEl && el !== placeholderEl); + + const insertPlaceholderAt = (rack, y) => { + const items = siblings(rack); + for (const el of items) { + const r = el.getBoundingClientRect(); + const mid = r.top + r.height / 2; + if (y < mid) { + rack.insertBefore(placeholderEl, el); + return; + } + } + rack.appendChild(placeholderEl); + }; + + const rackAtPoint = (x, y) => { + for (const r of racks) { + const rect = r.getBoundingClientRect(); + if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return r; + } + return null; + }; + + const onMove = (e) => { + if (!draggingEl || e.pointerId !== pointerId) return; + e.preventDefault(); + const x = e.clientX - dragOffset.x; + const y = e.clientY - dragOffset.y; + draggingEl.style.left = `${x}px`; + draggingEl.style.top = `${y}px`; + + const targetRack = rackAtPoint(e.clientX, e.clientY) || activeRack; + if (targetRack && placeholderEl && placeholderEl.parentElement !== targetRack) { + targetRack.appendChild(placeholderEl); + } + if (targetRack) { + activeRack = targetRack; + insertPlaceholderAt(targetRack, e.clientY); + } + + if (dockHotbarEl) { + const nearBottom = e.clientY > window.innerHeight - 90; + dockHotbarEl.classList.toggle("dockTarget", Boolean(nearBottom)); + if (nearBottom) showHotbar(true); + } + }; + + const onUp = (e) => { + if (!draggingEl || e.pointerId !== pointerId) return; + e.preventDefault(); + const targetRack = placeholderEl?.parentElement || activeRack; + if (targetRack && placeholderEl && placeholderEl.parentElement === targetRack) { + const isWorkspaceSlot = targetRack.id === "workspaceLeftSlot" || targetRack.id === "workspaceRightSlot"; + const isRightRackSlot = targetRack.id === "rightRack"; + const isSideRackSlot = targetRack.id === "mainSideRack"; + const isSkinnyRackSlot = isRightRackSlot || isSideRackSlot; + const skinnyOk = panelIsSkinnyCapable(draggingPanelId); + + // 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.`); + if (originRack) { + if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(draggingEl, originBefore); + else originRack.appendChild(draggingEl); + } + cleanup(); + syncRackStateFromDom(); + enforceWorkspaceRules(); + return; + } + + if (isWorkspaceSlot || isRightRackSlot) { + const existing = Array.from(targetRack.querySelectorAll(":scope > .rackPanel")).find((x) => x !== draggingEl); + targetRack.insertBefore(draggingEl, placeholderEl); + // Swap if occupied: send the previous occupant back to the origin rack position. + if (existing && originRack) { + if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(existing, originBefore); + else originRack.appendChild(existing); + } + } else { + targetRack.insertBefore(draggingEl, placeholderEl); + } + } + const shouldDock = Boolean(dockHotbarEl && e.clientY > window.innerHeight - 90); + const dockId = draggingPanelId; + cleanup(); + if (shouldDock && dockId) dockPanel(dockId); + syncRackStateFromDom(); + enforceWorkspaceRules(); + }; + + // Use window-level listeners so cross-rack dragging stays responsive even when the cursor passes over gaps/resizers. + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + // Extra safety: pointer events can fail to deliver pointerup if the mouse is released outside the window. + window.addEventListener("blur", cancelDrag); + window.addEventListener("mouseup", cancelDrag); + window.addEventListener("touchend", cancelDrag, { passive: true }); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState !== "visible") cancelDrag(); + }); + window.addEventListener("keydown", (e) => { + if (e.key === "Escape") cancelDrag(); + }); + + const onDown = (e) => { + const btn = e.target.closest?.("[data-rackdrag]"); + if (!btn) return; + const el = btn.closest?.(".rackPanel"); + if (!(el instanceof HTMLElement)) return; + if (el.classList.contains("hidden")) return; + + e.preventDefault(); + // If a drag somehow got stuck, start clean. + cleanup(); + if (appRoot) appRoot.classList.add("rackIsDragging"); + draggingEl = el; + draggingPanelId = String(el.dataset.panelId || ""); + pointerId = e.pointerId; + draggingEl.setPointerCapture?.(pointerId); + + activeRack = el.parentElement; + originRack = activeRack; + originBefore = draggingEl.nextSibling; + const rect = draggingEl.getBoundingClientRect(); + dragOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + + placeholderEl = document.createElement("div"); + placeholderEl.className = "rackPlaceholder"; + placeholderEl.style.height = `${Math.max(40, Math.round(rect.height))}px`; + + (activeRack || main).insertBefore(placeholderEl, draggingEl.nextSibling); + + draggingEl.classList.add("rackDragging"); + draggingEl.style.position = "fixed"; + draggingEl.style.left = `${rect.left}px`; + draggingEl.style.top = `${rect.top}px`; + draggingEl.style.width = `${rect.width}px`; + draggingEl.style.zIndex = "80"; + draggingEl.style.pointerEvents = "none"; + }; + + // Delegate to the app root so panels can be dragged regardless of which rack they're currently in. + (appRoot || document).addEventListener("pointerdown", onDown); +} + +function initRackLayout() { + rackLayoutEnabled = readRackLayoutEnabled(); + let hadState = false; + try { + hadState = Boolean(localStorage.getItem(RACK_LAYOUT_STATE_KEY)); + } catch { + hadState = false; + } + rackLayoutState = loadRackLayoutState(); + // Normalize older preset ids in persisted state. + rackLayoutState.presetId = resolvePresetKey(rackLayoutState.presetId); + + if (toggleRackLayoutEl) { + toggleRackLayoutEl.checked = rackLayoutEnabled; + // Hide/disable the toggle while rack mode is forced on. + if (FORCE_RACK_MODE) { + toggleRackLayoutEl.checked = true; + toggleRackLayoutEl.disabled = true; + const row = toggleRackLayoutEl.closest?.("label"); + if (row) row.classList.add("hidden"); + const toggleBtn = document.getElementById("toggleRackLayoutBtn"); + if (toggleBtn) toggleBtn.classList.add("hidden"); + } else { + toggleRackLayoutEl.onchange = () => { + writeRackLayoutEnabled(Boolean(toggleRackLayoutEl.checked)); + // Reload is the simplest safe path while the feature is in flux. + location.reload(); + }; + } + } + + if (layoutPresetEl) { + updateLayoutPresetOptions(); + layoutPresetEl.value = resolvePresetKey(rackLayoutState.presetId || "social"); + layoutPresetEl.disabled = !rackLayoutEnabled; + layoutPresetEl.onchange = () => { + if (!rackLayoutEnabled) return; + const next = String(layoutPresetEl.value || "social"); + applyPreset(next); + }; + } -/** @type {Map<string, any[]>} */ -const chatByPost = new Map(); -/** @type {Map<string, number>} */ -const unreadByPostId = new Map(); -/** @type {Map<string, Set<string>>} */ -const typingUsersByPostId = new Map(); -/** @type {Set<string>} */ -const myReacts = new Set(); -/** @type {Map<string, number>} */ -const reactPulseByKey = new Map(); -let allowedReactions = ["πŸ‘", "❀️", "😑", "😭", "πŸ₯Ί", "πŸ˜‚"]; + if (!rackLayoutEnabled) { + disableRackLayoutDom(); + setSideCollapsed(false, { persist: false, updateControls: false }); + setRightCollapsed(false, { persist: false, updateControls: false }); + toggleSideRackEl && (toggleSideRackEl.disabled = true); + toggleRightRackEl && (toggleRightRackEl.disabled = true); + showSideRackBtn?.classList.add("hidden"); + showRightRackBtn?.classList.add("hidden"); + showHotbar(false); + return; + } -let clientId = null; -let loggedInUser = null; -let loggedInRole = "member"; -let canModerate = false; -let canRegisterFirstUser = false; -let registrationEnabled = false; -let activeChatPostId = null; -let pendingProfileImage = ""; -let windowFocused = true; -let typingStopTimer = null; -let lastTypingSentAt = 0; -let modTab = "reports"; -let modReports = []; -let modUsers = []; -let modLog = []; -let modModalContext = null; -let lanUrls = []; -let mobilePanel = "main"; -let composerOpen = false; -let touchStartX = 0; -let touchStartY = 0; -let touchTracking = false; -let peopleOpen = false; -let peopleTab = "members"; -let peopleMembers = []; -let openPostMenuId = ""; -let dmThreads = []; -/** @type {Map<string, any>} */ -let dmThreadsById = new Map(); -/** @type {Map<string, any[]>} */ -const dmMessagesByThreadId = new Map(); -let activeDmThreadId = null; -let walkieRecording = false; -let walkieStartAt = 0; -let walkieRecorder = null; -let walkieChunks = []; -let walkieCtx = null; -let walkieMicStream = null; -let walkieMixNode = null; -let walkieDestNode = null; -let walkieDispatchBuffer = null; -const SESSION_TOKEN_KEY = "bzl_session_token"; -const CLIENT_IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024; -const CLIENT_AUDIO_UPLOAD_MAX_BYTES = 150 * 1024 * 1024; -let allowedPostReactions = ["πŸ‘", "❀️", "😑", "😭", "πŸ₯Ί", "πŸ˜‚", "⭐"]; -let allowedChatReactions = ["πŸ‘", "❀️", "😑", "😭", "πŸ₯Ί", "πŸ˜‚"]; -let userPrefs = { starredPostIds: [], hiddenPostIds: [], ignoredUsers: [], blockedUsers: [] }; -let showReactions = localStorage.getItem("bzl_showReactions") !== "0"; -let chatDock = localStorage.getItem("bzl_chatDock") === "right" ? "right" : "left"; -let activeHiveView = "all"; -let collections = []; -let customRoles = []; -let plugins = []; -const loadedPluginClientIds = new Set(); -let centerView = "hives"; + enableRackLayoutDom(); + + // Side racks behave like summonable hotbars: hide/show without changing panel layout state. + toggleSideRackEl && (toggleSideRackEl.disabled = false); + toggleRightRackEl && (toggleRightRackEl.disabled = false); + + if (showSideRackBtn) { + showSideRackBtn.classList.remove("hidden"); + showSideRackBtn.onclick = () => setSideCollapsed(false); + } + if (showRightRackBtn) { + showRightRackBtn.classList.remove("hidden"); + showRightRackBtn.onclick = () => setRightCollapsed(false); + } + + if (toggleSideRackEl) { + toggleSideRackEl.onchange = () => { + if (!rackLayoutEnabled) return; + setSideCollapsed(!Boolean(toggleSideRackEl.checked)); + }; + } + if (toggleRightRackEl) { + toggleRightRackEl.onchange = () => { + if (!rackLayoutEnabled) return; + setRightCollapsed(!Boolean(toggleRightRackEl.checked)); + }; + } + + setSideCollapsed(readBoolPref(RACK_SIDE_COLLAPSED_KEY, false), { persist: false }); + setRightCollapsed(readBoolPref(RACK_RIGHT_COLLAPSED_KEY, false), { persist: false }); + + applyRackStateToDom(); + installPanelMinimizeButtons(); + enableRackDnD(); + installWorkspaceInteractions(); + enforceWorkspaceRules(); + renderProfilePanel(); + + // Hotbar interactions + if (dockHotbarEl) { + dockHotbarEl.onmouseenter = () => showHotbar(true); + dockHotbarEl.onmouseleave = () => showHotbar(false); + // Docked items must be restored via drag-and-drop (click does nothing), but the "+" orb is clickable. + dockHotbarEl.onclick = (e) => { + if (dockHotbarEl.dataset.dragging === "1") return; + const plus = e.target.closest?.("[data-hotbarplus]"); + if (!plus) return; + if (hotbarPlusMenuEl) closeHotbarPlusMenu(); + else openHotbarPlusMenu(plus); + }; + } + + // Close the "+" menu when clicking elsewhere. + if (appRoot && appRoot.dataset.hotbarPlusClose !== "1") { + appRoot.dataset.hotbarPlusClose = "1"; + document.addEventListener("pointerdown", (e) => { + if (!hotbarPlusMenuEl) return; + const t = e.target; + if (t && (hotbarPlusMenuEl.contains(t) || dockHotbarEl?.contains(t))) return; + closeHotbarPlusMenu(); + }); + } + + // Drag orbs back into the rack to restore (MVP: restore to end of rack). + if (dockHotbarEl) { + let orbDragId = ""; + let orbPointer = null; + let orbStart = null; + let orbMoved = false; + let orbPlaceholder = null; + let orbActiveRack = null; + + const lockHotbarVisible = (lock) => { + dockHotbarEl.dataset.lockVisible = lock ? "1" : "0"; + dockHotbarEl.dataset.dragging = lock ? "1" : "0"; + // While dragging an orb, keep both workspace slots visible as drop targets. + if (appRoot) { + if (lock) { + appRoot.classList.add("rackIsDragging"); + appRoot.dataset.orbDragging = "1"; + } else if (appRoot.dataset.orbDragging === "1") { + delete appRoot.dataset.orbDragging; + appRoot.classList.remove("rackIsDragging"); + } + } + if (lock) showHotbar(true); + }; + + const resolveOrbDropRack = (panelId, rackEl) => { + const id = String(panelId || "").trim(); + if (!id) return rackEl; + // Skinny racks (side/right) only allow skinny-capable panels. + if (rackEl && (rackEl.id === "mainSideRack" || rackEl.id === "rightRack")) { + if (panelIsSkinnyCapable(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; + } + if (panelRole(id) !== "primary") return rackEl; + const isWorkspaceSlot = rackEl && (rackEl.id === "workspaceLeftSlot" || rackEl.id === "workspaceRightSlot"); + if (isWorkspaceSlot) 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; + }; + + const insertOrbPlaceholderAt = (rack, y) => { + if (!(rack instanceof HTMLElement) || !(orbPlaceholder instanceof HTMLElement)) return; + const items = Array.from(rack.querySelectorAll(":scope > .rackPanel")).filter((el) => el !== orbPlaceholder); + for (const el of items) { + const r = el.getBoundingClientRect(); + const mid = r.top + r.height / 2; + if (y < mid) { + rack.insertBefore(orbPlaceholder, el); + return; + } + } + rack.appendChild(orbPlaceholder); + }; + + const orbRacks = () => { + const leftRack = ensureWorkspaceLeftRack(); + const rightWorkspaceRack = ensureWorkspaceRightRack(); + const sideRack = ensureMainSideRack(); + const rightRack = ensureRightRack(); + return [leftRack, rightWorkspaceRack, sideRack, rightRack].filter((x) => x instanceof HTMLElement); + }; + + const rackAtPoint = (x, y) => { + for (const r of orbRacks()) { + const rect = r.getBoundingClientRect(); + if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return r; + } + return null; + }; + + const dropOrbIntoRack = (panelId, targetRack, beforeEl) => { + const id = String(panelId || "").trim(); + if (!id) return; + const rack = resolveOrbDropRack(id, targetRack); + if (!(rack instanceof HTMLElement)) return; + const panelEl = getPanelElement(id); + if (!panelEl) return; + + // Restoring into a collapsed rack should uncollapse it (hotbar is a summonable launcher). + if (rack.id === "mainSideRack") setSideCollapsed(false); + if (rack.id === "rightRack") setRightCollapsed(false); + + undockPanel(id); + + const isWorkspaceSlot = rack.id === "workspaceLeftSlot" || rack.id === "workspaceRightSlot"; + const isRightRackSlot = rack.id === "rightRack"; + if (isWorkspaceSlot) { + const existing = rack.querySelector?.(":scope > .rackPanel:not(.hidden)"); + if (existing instanceof HTMLElement && existing !== panelEl) { + const existingId = String(existing.dataset.panelId || "").trim(); + if (existingId) dockPanel(existingId); + } + } + if (isRightRackSlot) { + const existing = rack.querySelector?.(":scope > .rackPanel:not(.hidden)"); + if (existing instanceof HTMLElement && existing !== panelEl) { + const existingId = String(existing.dataset.panelId || "").trim(); + if (existingId) dockPanel(existingId); + } + } + + const insertBefore = + beforeEl instanceof HTMLElement && beforeEl.parentElement === rack && beforeEl.classList.contains("rackPanel") + ? beforeEl + : null; + if (panelEl.parentElement !== rack) { + if (insertBefore) rack.insertBefore(panelEl, insertBefore); + else rack.appendChild(panelEl); + } + rememberPanelLastRack(id, rack.id); + saveRackLayoutState(); + syncRackStateFromDom(); + enforceWorkspaceRules(); + }; + + dockHotbarEl.addEventListener("pointerdown", (e) => { + const orb = e.target.closest?.("[data-undock]"); + if (!orb) return; + orbDragId = String(orb.getAttribute("data-undock") || ""); + if (!orbDragId) return; + orbPointer = e.pointerId; + orbStart = { x: e.clientX, y: e.clientY }; + orbMoved = false; + orbActiveRack = null; + orb.classList.add("dragging"); + orb.setPointerCapture?.(orbPointer); + lockHotbarVisible(true); + e.preventDefault(); + + // Placeholder shows drop position while dragging. + orbPlaceholder = document.createElement("div"); + orbPlaceholder.className = "rackPlaceholder"; + orbPlaceholder.style.height = "52px"; + }); + window.addEventListener("pointermove", (e) => { + if (!orbDragId || e.pointerId !== orbPointer) return; + if (!orbStart) return; + const dx = Math.abs(e.clientX - orbStart.x); + const dy = Math.abs(e.clientY - orbStart.y); + if (dx + dy > 6) orbMoved = true; + + if (orbMoved && orbPlaceholder) { + const r = rackAtPoint(e.clientX, e.clientY) || orbActiveRack; + if (r && orbPlaceholder.parentElement !== r) r.appendChild(orbPlaceholder); + if (r) { + orbActiveRack = r; + insertOrbPlaceholderAt(r, e.clientY); + } + } + }); + dockHotbarEl.addEventListener("pointerup", (e) => { + if (!orbDragId || e.pointerId !== orbPointer) return; + const orb = dockHotbarEl.querySelector(`[data-undock="${CSS.escape(orbDragId)}"]`); + if (orb) orb.classList.remove("dragging"); + const targetRack = orbMoved ? (rackAtPoint(e.clientX, e.clientY) || orbActiveRack) : null; + const beforeEl = + orbMoved && orbPlaceholder && targetRack instanceof HTMLElement && orbPlaceholder.parentElement === targetRack + ? orbPlaceholder.nextSibling + : null; + if (orbMoved && targetRack) dropOrbIntoRack(orbDragId, targetRack, beforeEl); + orbDragId = ""; + orbPointer = null; + orbStart = null; + orbMoved = false; + orbActiveRack = null; + if (orbPlaceholder && orbPlaceholder.parentElement) orbPlaceholder.parentElement.removeChild(orbPlaceholder); + orbPlaceholder = null; + lockHotbarVisible(false); + }); + dockHotbarEl.addEventListener("pointercancel", () => { + orbDragId = ""; + orbPointer = null; + orbStart = null; + orbMoved = false; + orbActiveRack = null; + if (orbPlaceholder && orbPlaceholder.parentElement) orbPlaceholder.parentElement.removeChild(orbPlaceholder); + orbPlaceholder = null; + lockHotbarVisible(false); + dockHotbarEl.querySelectorAll(".dockOrb.dragging").forEach((x) => x.classList.remove("dragging")); + }); + } + + // Reveal hotbar when cursor is near bottom if there are docked items. + window.addEventListener("mousemove", (e) => { + if (!dockHotbarEl) return; + if (!rackLayoutEnabled) return; + const nearBottom = e.clientY > window.innerHeight - 80; + showHotbar(Boolean(nearBottom)); + }); + + // First enable: seed state from the selected preset so users immediately get a sensible layout. + if (!hadState) { + const preset = resolvePresetKey(rackLayoutState.presetId || (layoutPresetEl ? String(layoutPresetEl.value || "") : "") || "social"); + applyPreset(preset); + } + + applyDockState(); + enforceWorkspaceRules(); +} let activeProfileUsername = ""; let activeProfile = null; +let lastRequestedProfileUsername = ""; let isEditingProfile = false; let replyToMessage = null; let chatResizeDragging = false; @@ -250,7 +2688,142 @@ const PEOPLE_WIDTH_DEFAULT = 360; let editContext = null; let mentionState = { open: false, query: "", selected: 0, items: [], anchorRect: null }; -let instanceBranding = { title: "Bzl", subtitle: "Ephemeral hives + chat", allowMemberPermanentPosts: false }; +let instanceBranding = { title: "Bzl", subtitle: "Ephemeral hives + chat", allowMemberPermanentPosts: false, appearance: {} }; +let serverInfo = null; +let serverHealth = null; +let serverInfoStatus = { loading: false, at: 0, error: "" }; +let pluginAdminStatus = ""; +let pluginAdminBusy = false; +const pluginEnableInFlight = new Set(); + +const THEME_PRESETS = [ + { + id: "bzl_original", + name: "Bzl (Original)", + appearance: { + bg: "#060611", + panel: "#0c0c18", + text: "#f6f0ff", + accent: "#ff3ea5", + accent2: "#b84bff", + good: "#3ddc97", + bad: "#ff4d8a", + fontBody: "system", + fontMono: "mono", + mutedPct: 65, + linePct: 10, + panel2Pct: 2 + } + }, + { + id: "midnight_cyan", + name: "Midnight Cyan", + appearance: { + bg: "#060a12", + panel: "#0a1220", + text: "#eaf4ff", + accent: "#2bf5d6", + accent2: "#4aa0ff", + good: "#2bf5d6", + bad: "#ff4d8a", + fontBody: "system", + fontMono: "mono", + mutedPct: 64, + linePct: 10, + panel2Pct: 2 + } + }, + { + id: "warm_amber", + name: "Warm Amber", + appearance: { + bg: "#0b0706", + panel: "#17100e", + text: "#fff2ea", + accent: "#ffb020", + accent2: "#ff3ea5", + good: "#3ddc97", + bad: "#ff4d8a", + fontBody: "serif", + fontMono: "mono", + mutedPct: 66, + linePct: 11, + panel2Pct: 3 + } + }, + { + id: "slate_violet", + name: "Slate Violet", + appearance: { + bg: "#080a10", + panel: "#101522", + text: "#eef0ff", + accent: "#9b8cff", + accent2: "#ff3ea5", + good: "#3ddc97", + bad: "#ff4d8a", + fontBody: "system", + fontMono: "mono", + mutedPct: 62, + linePct: 9, + panel2Pct: 2 + } + }, + { + id: "terminal_green", + name: "Terminal Green", + appearance: { + bg: "#040805", + panel: "#070f08", + text: "#d7ffe6", + accent: "#2bff88", + accent2: "#20d3ff", + good: "#2bff88", + bad: "#ff4d8a", + fontBody: "mono", + fontMono: "mono", + mutedPct: 58, + linePct: 12, + panel2Pct: 2 + } + }, + { + id: "high_contrast", + name: "High Contrast", + appearance: { + bg: "#000000", + panel: "#0a0a0a", + text: "#ffffff", + accent: "#ffd300", + accent2: "#00d3ff", + good: "#00ff85", + bad: "#ff2d55", + fontBody: "system", + fontMono: "mono", + mutedPct: 70, + linePct: 16, + panel2Pct: 3 + } + }, + { + id: "lavender_mist", + name: "Lavender Mist", + appearance: { + bg: "#070611", + panel: "#120c1b", + text: "#f7f3ff", + accent: "#c9a3ff", + accent2: "#ff79c6", + good: "#3ddc97", + bad: "#ff4d8a", + fontBody: "system", + fontMono: "mono", + mutedPct: 68, + linePct: 10, + panel2Pct: 3 + } + } +]; const SFX = { open: "/assets/sfx/Select_B7.wav", @@ -295,11 +2868,55 @@ function normalizeInstanceBranding(raw) { const title = String(raw?.title || "").replace(/\s+/g, " ").trim().slice(0, 32); const subtitle = String(raw?.subtitle || "").replace(/\s+/g, " ").trim().slice(0, 80); const allowMemberPermanentPosts = Boolean(raw?.allowMemberPermanentPosts); + const appearanceRaw = raw?.appearance && typeof raw.appearance === "object" ? raw.appearance : {}; + const bg = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.bg || "")) ? String(appearanceRaw.bg).toLowerCase() : "#060611"; + const panel = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.panel || "")) ? String(appearanceRaw.panel).toLowerCase() : "#0c0c18"; + const text = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.text || "")) ? String(appearanceRaw.text).toLowerCase() : "#f6f0ff"; + const accent = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.accent || "")) ? String(appearanceRaw.accent).toLowerCase() : "#ff3ea5"; + const accent2 = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.accent2 || "")) ? String(appearanceRaw.accent2).toLowerCase() : "#b84bff"; + const good = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.good || "")) ? String(appearanceRaw.good).toLowerCase() : "#3ddc97"; + const bad = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.bad || "")) ? String(appearanceRaw.bad).toLowerCase() : "#ff4d8a"; + const fontBody = ["system", "serif", "mono"].includes(String(appearanceRaw.fontBody || "")) ? String(appearanceRaw.fontBody) : "system"; + const fontMono = ["mono", "system"].includes(String(appearanceRaw.fontMono || "")) ? String(appearanceRaw.fontMono) : "mono"; + const clampPct = (n, fallback) => { + const v = Math.floor(Number(n)); + if (!Number.isFinite(v)) return fallback; + return Math.max(0, Math.min(100, v)); + }; + const mutedPct = clampPct(appearanceRaw.mutedPct, 65); + const linePct = clampPct(appearanceRaw.linePct, 10); + const panel2Pct = clampPct(appearanceRaw.panel2Pct, 2); return { title: title || "Bzl", subtitle: subtitle || "Ephemeral hives + chat", allowMemberPermanentPosts, + appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct }, + }; +} + +function applyInstanceAppearance(appearanceOverride = null) { + const b = normalizeInstanceBranding(appearanceOverride ? { ...instanceBranding, appearance: appearanceOverride } : instanceBranding); + const a = b.appearance || {}; + const fontStacks = { + system: + 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"', + serif: 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif', + mono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', }; + const fontBodyStack = fontStacks[a.fontBody] || fontStacks.system; + const fontMonoStack = fontStacks[a.fontMono] || fontStacks.mono; + document.documentElement.style.setProperty("--bg", a.bg || "#060611"); + document.documentElement.style.setProperty("--panel", a.panel || "#0c0c18"); + document.documentElement.style.setProperty("--text", a.text || "#f6f0ff"); + document.documentElement.style.setProperty("--accent", a.accent || "#ff3ea5"); + document.documentElement.style.setProperty("--accent2", a.accent2 || "#b84bff"); + document.documentElement.style.setProperty("--good", a.good || "#3ddc97"); + document.documentElement.style.setProperty("--bad", a.bad || "#ff4d8a"); + document.documentElement.style.setProperty("--font-body", fontBodyStack); + document.documentElement.style.setProperty("--font-mono", fontMonoStack); + document.documentElement.style.setProperty("--muted-pct", String(Number(a.mutedPct ?? 65))); + document.documentElement.style.setProperty("--line-pct", String(Number(a.linePct ?? 10))); + document.documentElement.style.setProperty("--panel2-pct", String(Number(a.panel2Pct ?? 2))); } function renderInstanceBranding() { @@ -308,6 +2925,37 @@ function renderInstanceBranding() { if (instanceSubtitleEl) instanceSubtitleEl.textContent = b.subtitle; } +function formatLocalTime(ts) { + const n = Number(ts || 0); + if (!n) return ""; + try { + return new Date(n).toLocaleString(); + } catch { + return ""; + } +} + +async function requestServerInfo() { + if (serverInfoStatus.loading) return; + serverInfoStatus = { loading: true, at: Date.now(), error: "" }; + renderModPanel(); + try { + const [infoRes, healthRes] = await Promise.all([ + fetch("/api/info", { cache: "no-store" }), + fetch("/api/health", { cache: "no-store" }) + ]); + if (!infoRes.ok) throw new Error(`Failed to load /api/info (${infoRes.status})`); + if (!healthRes.ok) throw new Error(`Failed to load /api/health (${healthRes.status})`); + serverInfo = await infoRes.json(); + serverHealth = await healthRes.json(); + serverInfoStatus = { loading: false, at: Date.now(), error: "" }; + renderModPanel(); + } catch (e) { + serverInfoStatus = { loading: false, at: Date.now(), error: e?.message || "Failed to load server info." }; + renderModPanel(); + } +} + function normalizeDmThread(raw) { if (!raw || typeof raw !== "object") return null; const id = String(raw.id || "").trim(); @@ -623,14 +3271,24 @@ function getSidebarHidden() { } function setPeopleOpen(open) { - peopleOpen = Boolean(open); - if (!peopleDrawerEl || !togglePeopleBtn) return; - peopleDrawerEl.classList.toggle("hidden", !peopleOpen); - togglePeopleBtn.textContent = peopleOpen ? "Hide people" : "People"; - togglePeopleBtn.title = peopleOpen ? "Hide people" : "Show people"; + const inRackMode = Boolean(appRoot?.classList.contains("rackMode")); + peopleOpen = inRackMode ? true : Boolean(open); + if (!peopleDrawerEl) return; + // In rack mode, "People" is a normal dockable panel; don't hide it behind a special toggle. + peopleDrawerEl.classList.toggle("hidden", !peopleOpen && !inRackMode); + if (togglePeopleBtn) { + if (inRackMode) { + togglePeopleBtn.classList.add("hidden"); + } else { + togglePeopleBtn.classList.remove("hidden"); + togglePeopleBtn.textContent = peopleOpen ? "Hide people" : "People"; + togglePeopleBtn.title = peopleOpen ? "Hide people" : "Show people"; + } + } if (peopleOpen && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "peopleList" })); } + if (inRackMode) return; try { localStorage.setItem("bzl_peopleOpen", peopleOpen ? "1" : "0"); } catch { @@ -654,6 +3312,7 @@ function setComposerOpen(open) { toggleComposerBtn.title = composerOpen ? "Hide hive creator" : "Open hive creator"; } renderCenterPanels(); + updateSideRackEmptyState(); try { localStorage.setItem("bzl_composerOpen", composerOpen ? "1" : "0"); } catch { @@ -849,6 +3508,7 @@ function setEditModalOpen(open) { if (editModalKeywordsInput) editModalKeywordsInput.value = ""; if (editModalCollectionSelect) editModalCollectionSelect.innerHTML = ""; if (editModalProtectedToggle) editModalProtectedToggle.checked = false; + if (editModalWalkieToggle) editModalWalkieToggle.checked = false; if (editModalPasswordInput) editModalPasswordInput.value = ""; if (editModalPasswordRow) editModalPasswordRow.classList.add("hidden"); } @@ -892,6 +3552,7 @@ function openEditModalForPost(post) { if (editModalKeywordsInput) editModalKeywordsInput.value = (post.keywords || []).join(", "); fillCollectionSelect(editModalCollectionSelect, String(post.collectionId || "general")); if (editModalProtectedToggle) editModalProtectedToggle.checked = Boolean(post.protected); + if (editModalWalkieToggle) editModalWalkieToggle.checked = String(post.mode || post.chatMode || "").toLowerCase() === "walkie"; if (editModalPasswordRow) editModalPasswordRow.classList.toggle("hidden", !Boolean(post.protected)); if (editModalPasswordInput) editModalPasswordInput.value = ""; if (editModalEditor) editModalEditor.innerHTML = String(post.contentHtml || "").trim() || escapeHtml(post.content || ""); @@ -910,6 +3571,7 @@ function openEditModalForChatMessage(message, postId) { if (editModalKeywordsInput) editModalKeywordsInput.value = ""; if (editModalCollectionSelect) editModalCollectionSelect.innerHTML = ""; if (editModalProtectedToggle) editModalProtectedToggle.checked = false; + if (editModalWalkieToggle) editModalWalkieToggle.checked = false; if (editModalPasswordInput) editModalPasswordInput.value = ""; if (editModalPasswordRow) editModalPasswordRow.classList.add("hidden"); if (editModalEditor) editModalEditor.innerHTML = String(message.html || "").trim() || escapeHtml(message.text || ""); @@ -1064,6 +3726,18 @@ function renderProfileEditor() { } function renderCenterPanels() { + // In rack mode, panels are independent. Profile shouldn't "replace" the Hives panel. + if (rackLayoutEnabled) { + if (pollinatePanel) { + pollinatePanel.classList.remove("hidden"); + pollinatePanel.classList.toggle("panelCollapsed", !composerOpen); + pollinatePanel.dataset.panelDisplay = composerOpen ? "full" : "collapsed"; + } + renderProfilePanel(); + updateSideRackEmptyState(); + return; + } + const profileMode = centerView === "profile"; if (profileViewPanel) profileViewPanel.classList.toggle("hidden", !profileMode); if (feedEl?.closest("section")) feedEl.closest("section").classList.toggle("hidden", profileMode); @@ -1072,7 +3746,37 @@ function renderCenterPanels() { else pollinatePanel.classList.toggle("hidden", !composerOpen); } if (!profileMode) return; - const username = activeProfile?.username || activeProfileUsername || ""; + renderProfilePanel(); +} + +function renderProfilePanel() { + if (!profileViewPanel) return; + if (!activeProfileUsername && !activeProfile && loggedInUser) { + activeProfileUsername = String(loggedInUser || "").trim().toLowerCase(); + } + + const username = String(activeProfile?.username || activeProfileUsername || "") + .trim() + .toLowerCase(); + + if (username) { + // Ensure we always have *some* profile data to show immediately. + if (!activeProfile || String(activeProfile.username || "").toLowerCase() !== username) { + const basic = getProfile(username); + activeProfile = normalizeProfileData({ username, image: basic.image || "", color: basic.color || "" }); + } + + // Pull the full profile from the server (bio/links/song) once per username selection. + try { + if (ws?.readyState === WebSocket.OPEN && lastRequestedProfileUsername !== username) { + lastRequestedProfileUsername = username; + ws.send(JSON.stringify({ type: "getUserProfile", username })); + } + } catch { + // ignore + } + } + if (profileViewTitle) profileViewTitle.textContent = username ? `@${username}` : "Profile"; if (profileViewMeta) profileViewMeta.textContent = username === loggedInUser ? "Your profile" : "Community profile"; renderProfileCard(); @@ -1080,6 +3784,32 @@ function renderCenterPanels() { } function setCenterView(next, username = "") { + if (rackLayoutEnabled) { + // Keep the legacy centerView on "hives" in rack mode; just update profile context. + const wantsProfile = next === "profile"; + if (wantsProfile) { + activeProfileUsername = String(username || activeProfileUsername || "") + .trim() + .toLowerCase(); + isEditingProfile = false; + if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile"; + + // Make sure the profile panel is actually visible as its own panel. + undockPanel("profile"); + profileViewPanel.classList.remove("panelCollapsed"); + profileViewPanel.dataset.panelDisplay = "full"; + enforceWorkspaceRules(); + renderProfilePanel(); + } else { + activeProfileUsername = ""; + activeProfile = null; + isEditingProfile = false; + if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile"; + renderProfilePanel(); + } + return; + } + centerView = next === "profile" ? "profile" : "hives"; if (centerView === "hives") { activeProfileUsername = ""; @@ -1165,12 +3895,26 @@ function toast(title, body, timeoutMs = 2800) { setTimeout(() => el.remove(), timeoutMs); } +function sendDevLog(level, scope, message, data) { + try { + if (!canModerate) return false; + const wsRef = window.__bzlWs; + if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false; + wsRef.send(JSON.stringify({ type: "devLogClient", level, scope, message, data })); + return true; + } catch { + return false; + } +} + +window.bzlDevLog = sendDevLog; + // Minimal plugin host (client-side). Plugins are trusted by the owner who installs them. // Plugin scripts can call `window.BzlPluginHost.register("pluginId", (ctx) => { ... })`. if (!window.BzlPluginHost) { const pluginInits = new Map(); window.BzlPluginHost = { - apiVersion: 1, + apiVersion: 2, register(pluginId, initFn) { const id = String(pluginId || "").trim().toLowerCase(); if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(id)) throw new Error("Invalid plugin id"); @@ -1183,6 +3927,85 @@ if (!window.BzlPluginHost) { toast, getUser: () => loggedInUser, getRole: () => loggedInRole, + ui: { + registerPanel(panelDef) { + const panelId = String(panelDef?.id || id).trim().toLowerCase(); + if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(panelId)) throw new Error("Invalid panel id"); + const title = typeof panelDef?.title === "string" ? panelDef.title.trim().slice(0, 40) : panelId; + const icon = typeof panelDef?.icon === "string" ? panelDef.icon.trim().slice(0, 10) : ""; + const defaultRack = + typeof panelDef?.defaultRack === "string" && /^(main|right)$/i.test(panelDef.defaultRack) + ? panelDef.defaultRack.toLowerCase() + : "right"; + const role = + typeof panelDef?.role === "string" && /^(primary|aux|transient|utility)$/i.test(panelDef.role) + ? panelDef.role.toLowerCase() + : "aux"; + const source = `plugin:${id}`; + + // Create a visible shell only when rack layout is enabled (for now). + // Otherwise, plugins should continue using their existing DOM hooks. + let element = null; + if (rackLayoutEnabled) { + const shell = ensurePluginPanelShell(panelId, title, icon, defaultRack, role); + element = shell; + const mount = shell ? shell.querySelector("[data-pluginmount]") : null; + if (mount) { + mount.innerHTML = ""; + const api = { + toast, + send: (eventName, payload) => { + const ev = String(eventName || "").trim(); + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false; + const wsRef = window.__bzlWs; + if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false; + const msg = payload && typeof payload === "object" ? payload : {}; + wsRef.send(JSON.stringify({ ...msg, type: `plugin:${id}:${ev}` })); + return true; + }, + getUser: () => loggedInUser, + getRole: () => loggedInRole, + storage: { + get(key) { + try { + return localStorage.getItem(`bzl_panel_${panelId}_${String(key || "")}`); + } catch { + return null; + } + }, + set(key, value) { + try { + localStorage.setItem(`bzl_panel_${panelId}_${String(key || "")}`, String(value ?? "")); + return true; + } catch { + return false; + } + }, + }, + }; + try { + const cleanup = typeof panelDef?.render === "function" ? panelDef.render(mount, api) : null; + if (typeof cleanup === "function") { + // Store cleanup on the shell so future hot-reload / uninstall can call it. + shell.__panelCleanup = cleanup; + } + } catch (e) { + console.warn(`Plugin ${id} panel render failed:`, e?.message || e); + mount.textContent = `Failed to render panel "${panelId}".`; + } + } + + enableRackDnD(); + } + + panelRegistry.set(panelId, { id: panelId, title, icon, source, role, defaultRack, element }); + applyPluginPresetHint(panelDef); + applyDockState(); + syncRackStateFromDom(); + return true; + }, + }, + devLog: (level, message, data) => sendDevLog(level, `plugin:${id}`, message, data), send(eventName, payload) { const ev = String(eventName || "").trim(); if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false; @@ -1686,19 +4509,13 @@ function isOwnerUser() { return Boolean(loggedInUser && loggedInRole === "owner"); } -function renderPluginsPanel() { - if (!pluginsListEl) return; - if (!isOwnerUser()) { - pluginsListEl.innerHTML = `<div class="muted small">Owner only.</div>`; - return; - } - - if (!plugins.length) { - pluginsListEl.innerHTML = `<div class="muted small">No plugins installed yet.</div>`; - return; - } - - pluginsListEl.innerHTML = plugins +function renderPluginsAdminHtml() { + if (!isOwnerUser()) return `<div class="muted small">Owner only.</div>`; + const status = pluginAdminStatus ? `<div class="small muted">${escapeHtml(pluginAdminStatus)}</div>` : ""; + const busyLine = pluginAdminBusy ? `<div class="small muted">Working…</div>` : ""; + const listHtml = !plugins.length + ? `<div class="muted small">No plugins installed yet.</div>` + : plugins .map((p) => { const badges = []; if (p.entryClient) badges.push(`<span class="pluginBadge">client</span>`); @@ -1715,13 +4532,26 @@ function renderPluginsPanel() { <div class="pluginRight"> <label class="checkRow" style="justify-content:flex-end; gap:10px"> <span>Enabled</span> - <input type="checkbox" data-pluginenable="${escapeHtml(p.id)}" ${p.enabled ? "checked" : ""} /> + <input type="checkbox" data-pluginenable="${escapeHtml(p.id)}" ${p.enabled ? "checked" : ""} ${ + pluginEnableInFlight.has(p.id) || pluginAdminBusy ? "disabled" : "" + } /> </label> <button type="button" class="danger smallBtn" data-pluginuninstall="${escapeHtml(p.id)}">Uninstall</button> </div> </div>`; }) .join(""); + return ` + <div class="small muted">Owner-only. Install optional plugins to extend your instance.</div> + <div class="pluginInstallRow" style="margin-top:10px"> + <input data-pluginzip="1" type="file" accept=".zip,application/zip" /> + <button data-plugininstall="1" class="ghost" type="button">Install</button> + <button data-pluginreload="1" class="ghost" type="button">Reload</button> + </div> + ${busyLine} + ${status} + <div class="pluginsList">${listHtml}</div> + `; } function ensureEnabledPluginClientScripts() { @@ -1729,16 +4559,20 @@ function ensureEnabledPluginClientScripts() { for (const p of plugins) { if (!p || !p.enabled) continue; if (!p.entryClient) continue; - if (loadedPluginClientIds.has(p.id)) continue; + const wantVersion = String(p.version || "0"); + const loadedVersion = loadedPluginClientVersionById.get(p.id) || ""; + if (loadedVersion && loadedVersion === wantVersion) continue; const src = `/plugins/${encodeURIComponent(p.id)}/${p.entryClient}?v=${encodeURIComponent(p.version || "0")}`; const script = document.createElement("script"); script.src = src; script.defer = true; script.onload = () => { - loadedPluginClientIds.add(p.id); + loadedPluginClientVersionById.set(p.id, wantVersion); }; script.onerror = () => { - if (pluginStatusEl) pluginStatusEl.textContent = `Failed to load plugin "${p.id}".`; + pluginAdminStatus = `Failed to load plugin "${p.id}".`; + toast("Plugins", pluginAdminStatus); + renderModPanel(); }; document.head.appendChild(script); } @@ -1746,8 +4580,8 @@ function ensureEnabledPluginClientScripts() { function setPlugins(rawList) { plugins = normalizePlugins(rawList); - renderPluginsPanel(); ensureEnabledPluginClientScripts(); + if (canModerate && modTab === "server") renderModPanel(); } function roleDefByKey(key) { @@ -2180,7 +5014,7 @@ function renderFeed() { `.trim(); const hasMenu = Boolean(menuItems); const kebabBtn = hasMenu - ? `<button type="button" class="ghost smallBtn kebabBtn" data-postmenu="${p.id}" aria-haspopup="menu" aria-expanded="false" title="More">Γ’β€ΉΒ―</button>` + ? `<button type="button" class="ghost smallBtn kebabBtn" data-postmenu="${p.id}" aria-haspopup="menu" aria-expanded="false" title="More">&#8942;</button>` : ""; const postMenu = hasMenu ? `<div class="postMenu hidden" role="menu" data-postmenu-panel="${p.id}">${menuItems}</div>` @@ -2193,6 +5027,10 @@ function renderFeed() { const buzzClass = buzzTimers.has(p.id) ? " isBuzz" : ""; const lockLine = p.locked ? `<div class="small muted">πŸ”’ password protected</div>` : ""; const cardTint = p.author ? cardTintStylesFromHex(getProfile(p.author).color) : ""; + const contentHtml = typeof p.contentHtml === "string" && p.contentHtml.trim() ? p.contentHtml : ""; + const contentText = typeof p.content === "string" && p.content.trim() ? escapeHtml(p.content) : ""; + const content = contentHtml ? contentHtml : contentText ? `<div class="muted">${contentText}</div>` : ""; + const contentBlock = content ? `<div class="postContent">${content}</div>` : ""; return ` <article class="post${unreadClass}${newClass}${buzzClass}" data-id="${p.id}" ${cardTint}> @@ -2217,11 +5055,18 @@ function renderFeed() { </div> ${deletedLine} ${editedLine} + ${contentBlock} <div class="postMeta">${collectionTag}${tags ? ` ${tags}` : ""}</div> ${reactionsHtml} </article>`; }) .join(""); + + try { + feedEl.querySelectorAll?.(".postContent").forEach((el) => decorateYouTubeEmbedsInElement(el)); + } catch { + // ignore + } } function setAuthUi() { @@ -2239,16 +5084,7 @@ function setAuthUi() { ? "No users exist yet. Create the first user from this computer." : "Sign in to post, chat, and boost."; } - - const isOwner = Boolean(loggedInUser && loggedInRole === "owner"); - if (instancePanelEl) instancePanelEl.classList.toggle("hidden", !isOwner); - if (isOwner) { - const b = normalizeInstanceBranding(instanceBranding); - if (instanceTitleInput && document.activeElement !== instanceTitleInput) instanceTitleInput.value = b.title; - if (instanceSubtitleInput && document.activeElement !== instanceSubtitleInput) instanceSubtitleInput.value = b.subtitle; - if (instanceAllowPermanentPostsEl) instanceAllowPermanentPostsEl.checked = Boolean(b.allowMemberPermanentPosts); - } - renderPluginsPanel(); + applyInstanceAppearance(); const canMakePermanent = Boolean(loggedInUser) && @@ -2434,6 +5270,7 @@ function requestModData() { if (!canModerate) return; ws.send(JSON.stringify({ type: "modListUsers", limit: 200 })); ws.send(JSON.stringify({ type: "modListLog", limit: 200 })); + ws.send(JSON.stringify({ type: "devLogList", limit: 300 })); const status = modReportStatusEl ? modReportStatusEl.value : "open"; ws.send(JSON.stringify({ type: "modListReports", status, limit: 200 })); } @@ -2457,6 +5294,206 @@ function renderModPanel() { btn.classList.toggle("ghost", !on); } + if (modTab === "server") { + const isOwner = loggedInRole === "owner"; + const canEditAppearance = loggedInRole === "owner" || loggedInRole === "moderator"; + const b = normalizeInstanceBranding(instanceBranding); + const a = b.appearance || {}; + const loading = Boolean(serverInfoStatus.loading); + const err = String(serverInfoStatus.error || ""); + const info = serverInfo && typeof serverInfo === "object" ? serverInfo : null; + const health = serverHealth && typeof serverHealth === "object" ? serverHealth : null; + const stats = health?.stats && typeof health.stats === "object" ? health.stats : null; + const rl = info?.config?.rateLimits && typeof info.config.rateLimits === "object" ? info.config.rateLimits : null; + const updatedAt = serverInfoStatus.at ? formatLocalTime(serverInfoStatus.at) : ""; + + const statusLine = loading + ? `<span class="muted">Loading…</span>` + : err + ? `<span class="bad">${escapeHtml(err)}</span>` + : updatedAt + ? `<span class="muted">Updated: ${escapeHtml(updatedAt)}</span>` + : `<span class="muted">Not loaded yet.</span>`; + + const fontBodyOptions = [ + { value: "system", label: "System (sans)" }, + { value: "serif", label: "Serif" }, + { value: "mono", label: "Monospace" }, + ] + .map((o) => `<option value="${o.value}" ${a.fontBody === o.value ? "selected" : ""}>${escapeHtml(o.label)}</option>`) + .join(""); + const fontMonoOptions = [ + { value: "mono", label: "Monospace" }, + { value: "system", label: "System" }, + ] + .map((o) => `<option value="${o.value}" ${a.fontMono === o.value ? "selected" : ""}>${escapeHtml(o.label)}</option>`) + .join(""); + + const instanceOwnerControls = `<label> + <span>Title</span> + <input data-instance-title maxlength="32" value="${escapeHtml(b.title)}" /> + </label> + <label> + <span>Subtitle</span> + <input data-instance-subtitle maxlength="80" value="${escapeHtml(b.subtitle)}" /> + </label> + <label class="row" style="gap:10px; align-items:center"> + <input data-instance-allowpermanent type="checkbox" ${b.allowMemberPermanentPosts ? "checked" : ""} /> + <span>Allow members to create permanent hives</span> + </label>`; + + const themePresetRow = ` + <div class="row" style="gap:10px"> + <label style="flex:1"> + <span>Theme preset</span> + <select data-theme-preset> + <option value="">(choose…)</option> + ${THEME_PRESETS.map((p) => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)}</option>`).join("")} + </select> + </label> + <div class="row" style="align-items:flex-end"> + <button type="button" class="ghost" data-theme-reset="1">Reset</button> + </div> + </div> + `; + + const appearanceControls = ` + ${themePresetRow} + <div class="row" style="gap:10px"> + <label style="flex:1"> + <span>Background</span> + <input data-instance-bg type="color" value="${escapeHtml(a.bg || "#060611")}" /> + </label> + <label style="flex:1"> + <span>Panel</span> + <input data-instance-panel type="color" value="${escapeHtml(a.panel || "#0c0c18")}" /> + </label> + </div> + <div class="row" style="gap:10px"> + <label style="flex:1"> + <span>Text</span> + <input data-instance-text type="color" value="${escapeHtml(a.text || "#f6f0ff")}" /> + </label> + <label style="flex:1"> + <span>Success / Danger</span> + <div class="row" style="gap:10px"> + <input data-instance-good type="color" value="${escapeHtml(a.good || "#3ddc97")}" /> + <input data-instance-bad type="color" value="${escapeHtml(a.bad || "#ff4d8a")}" /> + </div> + </label> + </div> + <div class="row" style="gap:10px"> + <label style="flex:1"> + <span>Accent</span> + <input data-instance-accent type="color" value="${escapeHtml(a.accent || "#ff3ea5")}" /> + </label> + <label style="flex:1"> + <span>Accent 2</span> + <input data-instance-accent2 type="color" value="${escapeHtml(a.accent2 || "#b84bff")}" /> + </label> + </div> + <div class="row" style="gap:10px"> + <label style="flex:1"> + <span>Muted %</span> + <input data-instance-mutedpct type="number" min="0" max="100" value="${escapeHtml(String(a.mutedPct ?? 65))}" /> + </label> + <label style="flex:1"> + <span>Divider %</span> + <input data-instance-linepct type="number" min="0" max="100" value="${escapeHtml(String(a.linePct ?? 10))}" /> + </label> + <label style="flex:1"> + <span>Panel tint %</span> + <input data-instance-panel2pct type="number" min="0" max="100" value="${escapeHtml(String(a.panel2Pct ?? 2))}" /> + </label> + </div> + <div class="row" style="gap:10px"> + <label style="flex:1"> + <span>Body font</span> + <select data-instance-fontbody>${fontBodyOptions}</select> + </label> + <label style="flex:1"> + <span>Mono font</span> + <select data-instance-fontmono>${fontMonoOptions}</select> + </label> + </div> + `; + + const instanceControls = isOwner + ? `${instanceOwnerControls} + ${appearanceControls} + <div class="row" style="gap:8px"> + <button type="button" class="primary" data-instance-save="1">Save</button> + <button type="button" class="ghost" data-server-refresh="1">Refresh server</button> + </div>` + : canEditAppearance + ? `<div class="small muted">Owner-only: title/subtitle and permanent-hive setting.</div> + <div class="small">Title: <b>${escapeHtml(b.title)}</b></div> + <div class="small">Subtitle: <b>${escapeHtml(b.subtitle)}</b></div> + <div class="small">Members can create permanent hives: <b>${b.allowMemberPermanentPosts ? "yes" : "no"}</b></div> + <div class="panelDivider"></div> + ${appearanceControls} + <div class="row" style="gap:8px"> + <button type="button" class="primary" data-instance-saveappearance="1">Save theme</button> + <button type="button" class="ghost" data-server-refresh="1">Refresh server</button> + </div>` + : `<div class="small muted">Only moderators can edit appearance. Only the owner can edit core instance settings.</div> + <div class="small">Title: <b>${escapeHtml(b.title)}</b></div> + <div class="small">Subtitle: <b>${escapeHtml(b.subtitle)}</b></div> + <div class="small">Members can create permanent hives: <b>${b.allowMemberPermanentPosts ? "yes" : "no"}</b></div> + <div class="row" style="gap:8px; margin-top:8px"> + <button type="button" class="ghost" data-server-refresh="1">Refresh server</button> + </div>`; + + const serverLines = [ + info?.port ? `Port: ${Number(info.port)}` : "", + typeof info?.registrationEnabled === "boolean" ? `Registration enabled: ${info.registrationEnabled ? "yes" : "no"}` : "", + typeof health?.uptimeSec === "number" ? `Uptime: ${Math.floor(health.uptimeSec)}s` : "", + typeof stats?.sockets === "number" ? `Sockets: ${Math.floor(stats.sockets)}` : "", + typeof stats?.activePosts === "number" ? `Active hives: ${Math.floor(stats.activePosts)}` : "", + typeof stats?.users === "number" ? `Users: ${Math.floor(stats.users)}` : "", + typeof stats?.activeRateLimitBuckets === "number" ? `Active rate limit buckets: ${Math.floor(stats.activeRateLimitBuckets)}` : "", + ].filter(Boolean); + + const rlLines = rl + ? [ + `Mod actions: ${rl.mod?.max ?? "?"} / ${rl.mod?.windowMs ?? "?"}ms`, + `Login: ${rl.login?.max ?? "?"} / ${rl.login?.windowMs ?? "?"}ms`, + `Register: ${rl.register?.max ?? "?"} / ${rl.register?.windowMs ?? "?"}ms`, + `Resume: ${rl.resume?.max ?? "?"} / ${rl.resume?.windowMs ?? "?"}ms`, + `Reports: ${rl.report?.max ?? "?"} / ${rl.report?.windowMs ?? "?"}ms`, + ] + : []; + + modBodyEl.innerHTML = ` + <div class="modCard"> + <div class="modRowTop"> + <div><b>Server</b></div> + <div class="small">${statusLine}</div> + </div> + <div class="small muted">Server status, appearance, and plugins.</div> + </div> + <div class="modCard"> + <div class="modRowTop"><div><b>Instance settings</b></div></div> + <div class="modActions">${instanceControls}</div> + </div> + <div class="modCard"> + <div class="modRowTop"><div><b>Plugins</b></div></div> + <div class="modActions">${renderPluginsAdminHtml()}</div> + </div> + <div class="modCard"> + <div class="modRowTop"><div><b>Runtime</b></div></div> + <div class="small">${serverLines.length ? serverLines.map((x) => `<div>${escapeHtml(x)}</div>`).join("") : `<div class="muted">No data yet.</div>`}</div> + ${ + rlLines.length + ? `<div class="small muted" style="margin-top:10px">Rate limits</div> + <div class="small">${rlLines.map((x) => `<div>${escapeHtml(x)}</div>`).join("")}</div>` + : "" + } + </div> + `; + return; + } + if (modTab === "users") { const roleList = customRoles.length ? customRoles @@ -2533,13 +5570,17 @@ function renderModPanel() { <button type="button" data-modaction="user_unban" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Unban</button> ${canResetPassword ? `<button type="button" data-modaction="user_password_reset" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Reset password</button>` : ""} ${ - canPromote - ? `<button type="button" data-modaction="user_role_set" data-targettype="user" data-targetid="${escapeHtml( + canPromote && role === "member" + ? `<button type="button" data-modaction="user_role_set" data-targettype="user" data-targetid="${escapeHtml( + u.username + )}" data-role="moderator">Make mod</button>` + : "" + } + ${ + canPromote && role === "moderator" + ? `<button type="button" class="danger" data-modaction="user_role_set" data-targettype="user" data-targetid="${escapeHtml( u.username - )}" data-role="moderator">Make mod</button> - <button type="button" data-modaction="user_role_set" data-targettype="user" data-targetid="${escapeHtml( - u.username - )}" data-role="member">Make member</button>` + )}" data-role="member">Remove mod</button>` : "" } ${ @@ -2669,6 +5710,12 @@ function renderModPanel() { if (modTab === "log") { const isOwner = loggedInRole === "owner"; + const viewTabs = ` + <div class="row" style="gap:10px; flex-wrap:wrap; margin-bottom:10px;"> + <button type="button" class="${modLogView === "dev" ? "primary" : "ghost"} smallBtn" data-modlogview="dev">Server dev log</button> + <button type="button" class="${modLogView === "moderation" ? "primary" : "ghost"} smallBtn" data-modlogview="moderation">Moderation log</button> + </div> + `; const nukeCard = isOwner ? `<div class="modCard"> <div class="modRowTop"> @@ -2680,14 +5727,55 @@ function renderModPanel() { <input type="checkbox" data-nukeconfirm="1" /> <span>ARE YOU SURE?</span> </label> - </div>` + </div>` : ""; + if (modLogView === "dev") { + const lines = devLog + .slice(0, 300) + .reverse() + .map((e) => { + const ts = e?.createdAt ? new Date(e.createdAt).toLocaleString() : ""; + const lvl = String(e?.level || "info").toUpperCase(); + const scope = String(e?.scope || "server"); + const msg = String(e?.message || ""); + const data = String(e?.data || ""); + const extra = data ? ` ${data}` : ""; + return `[${ts}] ${lvl} ${scope}: ${msg}${extra}`; + }) + .join("\n"); + + modBodyEl.innerHTML = ` + ${viewTabs} + <div class="modCard"> + <div class="modRowTop"> + <div><b>Dev log</b></div> + <div class="row" style="gap:10px; flex-wrap:wrap; justify-content:flex-end"> + <button type="button" class="ghost smallBtn" data-devlogrefresh="1">Refresh</button> + <button type="button" class="ghost smallBtn" data-devlogcopy="1">Copy</button> + ${isOwner ? `<button type="button" class="danger smallBtn" data-devlogclear="1">Clear</button>` : ""} + </div> + </div> + <label class="row small muted" style="gap:10px; align-items:center; justify-content:flex-start; margin-bottom:10px;"> + <input type="checkbox" data-devlogautoscroll="1" ${devLogAutoScroll ? "checked" : ""} /> + <span>Auto-scroll</span> + <button type="button" class="ghost smallBtn" data-devlogtest="1" style="margin-left:auto;">Test log</button> + </label> + <pre class="devLogPre" id="devLogPre">${escapeHtml(lines || "(empty)")}</pre> + </div> + `; + + const pre = document.getElementById("devLogPre"); + if (pre && devLogAutoScroll) pre.scrollTop = pre.scrollHeight; + return; + } + if (!modLog.length) { - modBodyEl.innerHTML = `${nukeCard}<div class="muted">No moderation log entries yet.</div>`; + modBodyEl.innerHTML = `${viewTabs}${nukeCard}<div class="muted">No moderation log entries yet.</div>`; return; } modBodyEl.innerHTML = + viewTabs + nukeCard + modLog .map( @@ -2762,7 +5850,38 @@ function renderModPanel() { .join(""); } +function isMapChatActive() { + return Boolean(!activeDmThreadId && !activeChatPostId && activeMapsRoomId); +} + +function normalizeMapChatScope(scope) { + const s = String(scope || "").trim().toLowerCase(); + return s === "global" ? "global" : "local"; +} + +function mapChatListFor(mapId, scope) { + const mid = String(mapId || "").trim().toLowerCase(); + if (!mid) return []; + const sc = normalizeMapChatScope(scope); + const store = sc === "global" ? mapsChatGlobalByMapId : mapsChatLocalByMapId; + const arr = store.get(mid); + return Array.isArray(arr) ? arr : []; +} + +function pushMapChatMessage(mapId, scope, message) { + const mid = String(mapId || "").trim().toLowerCase(); + if (!mid) return; + const sc = normalizeMapChatScope(scope); + const store = sc === "global" ? mapsChatGlobalByMapId : mapsChatLocalByMapId; + const prev = store.get(mid); + const arr = Array.isArray(prev) ? prev.slice() : []; + arr.push(message); + if (arr.length > 240) arr.splice(0, arr.length - 240); + store.set(mid, arr); +} + function renderChatPanel(forceScroll = false) { + updateChatModToggleVisibility(); const mediaState = captureMediaState(chatMessagesEl); if (activeDmThreadId) { const thread = dmThreadsById.get(activeDmThreadId) || null; @@ -2804,15 +5923,20 @@ function renderChatPanel(forceScroll = false) { .map((m, index) => { const from = m.fromUser || ""; const isYou = loggedInUser && from && from === loggedInUser; + const rail = chatRailClass({ + fromUser: from, + isModMessage: Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod" + }); const prev = index > 0 ? messages[index - 1] : null; const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); - const who = isYou ? `<span>you</span>` : renderUserPill(from || ""); + const who = renderUserPill(from || ""); + const youTag = isYou ? `<span class="muted">(you)</span>` : ""; const time = new Date(m.createdAt).toLocaleTimeString(); const tint = tintStylesFromHex(getProfile(from).color); const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; const content = html ? html : highlightMentionsInText(m.text || ""); - return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""}" data-msgid="${escapeHtml(m.id)}" ${tint}> - <div class="meta"><span class="chatHeaderInline">${who}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> + return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${rail}" data-msgid="${escapeHtml(m.id)}" ${tint}> + <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> <div class="content">${content}</div> </div>`; }) @@ -2829,6 +5953,68 @@ function renderChatPanel(forceScroll = false) { const post = activeChatPostId ? posts.get(activeChatPostId) : null; if (!post) { + if (isMapChatActive()) { + const mapId = String(activeMapsRoomId || "").trim().toLowerCase(); + const scope = normalizeMapChatScope(activeMapsChatScope); + const atBottomBefore = + chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24; + + const title = activeMapsRoomTitle ? `Map: ${activeMapsRoomTitle}` : `Map: ${mapId}`; + chatTitle.textContent = "Chat"; + chatMeta.innerHTML = ` + <span class="muted">${escapeHtml(title)}</span> + <span class="muted">|</span> + <span class="mapChatToggle"> + <button type="button" class="${scope === "local" ? "primary" : "ghost"} smallBtn" data-mapchatscope="local" title="Local chat (nearby)">Local</button> + <button type="button" class="${scope === "global" ? "primary" : "ghost"} smallBtn" data-mapchatscope="global" title="Global chat (entire map)">Global</button> + </span> + `; + + if (chatPanelEl) chatPanelEl.classList.remove("walkie"); + if (walkieBarEl) walkieBarEl.classList.add("hidden"); + if (chatForm) chatForm.classList.remove("hidden"); + + const messages = mapChatListFor(mapId, scope); + if (!messages.length) { + chatMessagesEl.innerHTML = `<div class="small muted">${ + scope === "local" ? "Local chat is proximity-based. Say something nearby." : "No messages yet. Say hello!" + }</div>`; + restoreMediaState(chatMessagesEl, mediaState); + setReplyToMessage(null); + return; + } + + chatMessagesEl.innerHTML = messages + .map((m, index) => { + const from = String(m.fromUser || ""); + const isYou = loggedInUser && from && from === loggedInUser; + const rail = chatRailClass({ + fromUser: from, + isModMessage: Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod" + }); + const prev = index > 0 ? messages[index - 1] : null; + const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); + const who = renderUserPill(from || ""); + const youTag = isYou ? `<span class="muted">(you)</span>` : ""; + const time = new Date(Number(m.createdAt || 0) || Date.now()).toLocaleTimeString(); + const tint = tintStylesFromHex(getProfile(from).color); + const content = highlightMentionsInText(String(m.text || "")); + return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${rail}" data-msgid="${escapeHtml(String(m.id || ""))}" ${tint}> + <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> + <div class="content">${content}</div> + </div>`; + }) + .join(""); + for (const contentEl of chatMessagesEl.querySelectorAll(".chatMsg .content")) { + decorateMentionNodesInElement(contentEl); + decorateYouTubeEmbedsInElement(contentEl); + } + restoreMediaState(chatMessagesEl, mediaState); + if (forceScroll || atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; + setReplyToMessage(null); + return; + } + chatTitle.textContent = "Chat"; chatMeta.textContent = "Select a post to chat."; if (chatPanelEl) chatPanelEl.classList.remove("walkie"); @@ -2840,6 +6026,7 @@ function renderChatPanel(forceScroll = false) { return; } + updateChatModToggleVisibility(); const isWalkie = String(post.mode || post.chatMode || "").toLowerCase() === "walkie"; if (chatPanelEl) chatPanelEl.classList.toggle("walkie", isWalkie); if (walkieBarEl) walkieBarEl.classList.toggle("hidden", !isWalkie); @@ -2880,15 +6067,17 @@ function renderChatPanel(forceScroll = false) { chatMessagesEl.innerHTML = visibleMessages .map((m, index) => { - const from = m.fromUser || ""; - const isYou = loggedInUser && from && from === loggedInUser; + const isModMsg = Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod"; + const from = isModMsg ? "MOD" : m.fromUser || ""; + const rail = chatRailClass({ fromUser: from, isModMessage: isModMsg }); const prev = index > 0 ? visibleMessages[index - 1] : null; const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); const mentions = Array.isArray(m.mentions) ? m.mentions.map((u) => String(u || "").toLowerCase()) : []; const mentionMe = Boolean(loggedInUser && mentions.includes(loggedInUser)); - const who = isYou ? `<span>you</span>` : renderUserPill(from || ""); + const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || ""); + const youTag = !isModMsg && loggedInUser && from && from === loggedInUser ? `<span class="muted">(you)</span>` : ""; const time = new Date(m.createdAt).toLocaleTimeString(); - const tint = tintStylesFromHex(getProfile(from).color); + const tint = isModMsg ? "" : tintStylesFromHex(getProfile(from).color); const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; const content = html ? html : highlightMentionsInText(m.text || ""); const replyMeta = m.replyTo && typeof m.replyTo === "object" ? m.replyTo : null; @@ -2924,8 +6113,8 @@ function renderChatPanel(forceScroll = false) { const ownDeleteAction = canManageOwnMessage ? `<button type="button" class="ghost smallBtn" data-deletemsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(post.id)}">Delete</button>` : ""; - return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""}" data-msgid="${escapeHtml(m.id)}" ${tint}> - <div class="meta"><span class="chatHeaderInline">${who}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> + return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""} ${rail} ${isModMsg ? "isModMsg" : ""}" data-msgid="${escapeHtml(m.id)}" ${tint}> + <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> ${replyBlock} ${deletedLine} ${editedLine} @@ -3028,14 +6217,17 @@ function appendPostChatMessageToDom(postId, message) { } const m = message; - const from = m?.fromUser || ""; + const isModMsg = Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod"; + const from = isModMsg ? "MOD" : m?.fromUser || ""; const isYou = loggedInUser && from && from === loggedInUser; + const rail = chatRailClass({ fromUser: from, isModMessage: isModMsg }); const sameAuthorAsPrev = Boolean(prevVisible && String(prevVisible.fromUser || "") === from); const mentions = Array.isArray(m?.mentions) ? m.mentions.map((u) => String(u || "").toLowerCase()) : []; const mentionMe = Boolean(loggedInUser && mentions.includes(loggedInUser)); - const who = isYou ? `<span>you</span>` : renderUserPill(from || ""); + const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || ""); + const youTag = !isModMsg && isYou ? `<span class="muted">(you)</span>` : ""; const time = new Date(m.createdAt).toLocaleTimeString(); - const tint = tintStylesFromHex(getProfile(from).color); + const tint = isModMsg ? "" : tintStylesFromHex(getProfile(from).color); const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; const content = html ? html : highlightMentionsInText(m.text || ""); const replyMeta = m.replyTo && typeof m.replyTo === "object" ? m.replyTo : null; @@ -3072,10 +6264,10 @@ function appendPostChatMessageToDom(postId, message) { ? `<button type="button" class="ghost smallBtn" data-deletemsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(postId)}">Delete</button>` : ""; - const msgHtml = `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""}" data-msgid="${escapeHtml( + const msgHtml = `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""} ${rail} ${isModMsg ? "isModMsg" : ""}" data-msgid="${escapeHtml( m.id )}" ${tint}> - <div class="meta"><span class="chatHeaderInline">${who}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> + <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> ${replyBlock} ${deletedLine} ${editedLine} @@ -3106,17 +6298,19 @@ function appendDmMessageToDom(threadId, message) { const m = message; const from = m.fromUser || ""; const isYou = loggedInUser && from && from === loggedInUser; + const rail = chatRailClass({ fromUser: from, isModMessage: false }); const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); - const who = isYou ? `<span>you</span>` : renderUserPill(from || ""); + const who = renderUserPill(from || ""); + const youTag = isYou ? `<span class="muted">(you)</span>` : ""; const time = new Date(m.createdAt).toLocaleTimeString(); const tint = tintStylesFromHex(getProfile(from).color); const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; const content = html ? html : highlightMentionsInText(m.text || ""); - const msgHtml = `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""}" data-msgid="${escapeHtml(m.id)}" ${tint}> - <div class="meta"><span class="chatHeaderInline">${who}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> - <div class="content">${content}</div> - </div>`; + const msgHtml = `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${rail}" data-msgid="${escapeHtml(m.id)}" ${tint}> + <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> + <div class="content">${content}</div> + </div>`; appendChatHtmlAndDecorate(msgHtml, atBottomBefore); return true; @@ -3175,6 +6369,15 @@ function openChat(postId) { unlockPostFlow(postId, true); return; } + + // Rack mode: hive chats live in dedicated chat panels (instances). Don't also open the legacy main chat panel. + if (rackLayoutEnabled) { + markRead(postId); + renderFeed(); + ws.send(JSON.stringify({ type: "getChat", postId })); + ensureChatPostPanelInstance(postId, { docked: false }); + return; + } if (activeChatPostId && activeChatPostId !== postId) { ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); setReplyToMessage(null); @@ -3187,6 +6390,7 @@ function openChat(postId) { renderTypingIndicator(); if (isMobileSwipeMode()) setMobilePanel("chat"); chatEditor.focus(); + } let pendingOpenChatAfterUnlock = null; @@ -3684,27 +6888,35 @@ document.querySelector(".editorShell .toolbar")?.addEventListener("click", (e) = if (btn.getAttribute("data-postemoji")) runEmoji(editor); }); -document.querySelector(".chatComposer .toolbar")?.addEventListener("click", (e) => { - const btn = e.target.closest("button"); +document.addEventListener("click", (e) => { + const btn = e.target.closest?.("button"); if (!btn) return; + const toolbar = btn.closest?.(".chatComposer .toolbar"); + if (!toolbar) return; + const composer = toolbar.closest?.(".chatComposer"); + if (!composer) return; + const targetEditor = composer.querySelector?.(".chatEditor") || chatEditor; + if (!(targetEditor instanceof HTMLElement)) return; + chatUploadTargetEditor = targetEditor; + const cmd = btn.getAttribute("data-chatcmd"); if (cmd) { - runCmd(chatEditor, cmd); + runCmd(targetEditor, cmd); return; } if (btn.getAttribute("data-chatlink")) { - runLink(chatEditor); + runLink(targetEditor); return; } if (btn.getAttribute("data-chatimg")) { - chatImageInput.click(); + chatImageInput?.click(); return; } if (btn.getAttribute("data-chataudio")) { chatAudioInput?.click(); return; } - if (btn.getAttribute("data-chatemoji")) runEmoji(chatEditor); + if (btn.getAttribute("data-chatemoji")) runEmoji(targetEditor); }); profileBioToolbar?.addEventListener("click", (e) => { @@ -3809,6 +7021,7 @@ editModalSaveBtn?.addEventListener("click", () => { } const keywords = parseKeywordsInput(editModalKeywordsInput?.value || ""); const collectionId = String(editModalCollectionSelect?.value || post?.collectionId || "general"); + const mode = Boolean(editModalWalkieToggle?.checked) ? "walkie" : "text"; ws.send( JSON.stringify({ type: "editPost", @@ -3819,7 +7032,8 @@ editModalSaveBtn?.addEventListener("click", () => { keywords, collectionId, protected: wantsProtected, - password: password.trim() + password: password.trim(), + mode }) ); setEditModalOpen(false); @@ -3874,78 +7088,6 @@ saveProfileBtn.addEventListener("click", () => { ws.send(JSON.stringify({ type: "updateProfile", image: pendingProfileImage, color })); }); -saveInstanceBrandingBtn?.addEventListener("click", () => { - if (!loggedInUser || loggedInRole !== "owner") return; - const title = String(instanceTitleInput?.value || "").replace(/\s+/g, " ").trim().slice(0, 32); - const subtitle = String(instanceSubtitleInput?.value || "").replace(/\s+/g, " ").trim().slice(0, 80); - const allowMemberPermanentPosts = Boolean(instanceAllowPermanentPostsEl?.checked); - if (!title) { - if (instanceStatusEl) instanceStatusEl.textContent = "Title is required."; - return; - } - if (instanceStatusEl) instanceStatusEl.textContent = ""; - ws.send(JSON.stringify({ type: "instanceSetBranding", title, subtitle, allowMemberPermanentPosts })); - toast("Instance", "Updating branding..."); -}); - -pluginInstallBtn?.addEventListener("click", async () => { - if (!isOwnerUser()) return; - if (pluginStatusEl) pluginStatusEl.textContent = ""; - const file = pluginZipInput?.files && pluginZipInput.files[0] ? pluginZipInput.files[0] : null; - if (!file) { - if (pluginStatusEl) pluginStatusEl.textContent = "Choose a .zip file first."; - return; - } - const token = getSessionToken(); - if (!token) { - if (pluginStatusEl) pluginStatusEl.textContent = "Session missing. Please sign out/in and try again."; - return; - } - if (pluginStatusEl) pluginStatusEl.textContent = "Uploading plugin..."; - try { - const res = await fetch("/api/plugin-install", { - method: "POST", - headers: { "Content-Type": "application/zip", Authorization: `Bearer ${token}` }, - body: file, - credentials: "same-origin", - }); - const json = await res.json().catch(() => null); - if (!res.ok || !json || !json.ok) { - if (pluginStatusEl) pluginStatusEl.textContent = String(json?.error || "Install failed."); - return; - } - if (pluginZipInput) pluginZipInput.value = ""; - if (pluginStatusEl) pluginStatusEl.textContent = `Installed "${json.plugin?.id || "plugin"}". Enable it below.`; - toast("Plugins", "Installed. Enable it to activate."); - } catch (e) { - if (pluginStatusEl) pluginStatusEl.textContent = "Install failed."; - } -}); - -instancePanelEl?.addEventListener("change", (e) => { - const toggle = e.target?.closest?.("input[type='checkbox'][data-pluginenable]"); - if (!toggle) return; - if (!isOwnerUser()) return; - const id = String(toggle.getAttribute("data-pluginenable") || "").trim().toLowerCase(); - if (!id) return; - const enabled = Boolean(toggle.checked); - ws.send(JSON.stringify({ type: "pluginSetEnabled", id, enabled })); - if (pluginStatusEl) { - pluginStatusEl.textContent = enabled ? "Enabled. (Some plugins may require refresh.)" : "Disabled. (Refresh may be required.)"; - } -}); - -instancePanelEl?.addEventListener("click", (e) => { - const btn = e.target?.closest?.("button[data-pluginuninstall]"); - if (!btn) return; - if (!isOwnerUser()) return; - const id = String(btn.getAttribute("data-pluginuninstall") || "").trim().toLowerCase(); - if (!id) return; - const ok = confirm(`Uninstall "${id}"? This deletes the plugin files from this server.`); - if (!ok) return; - ws.send(JSON.stringify({ type: "pluginUninstall", id })); -}); - profileBackBtn?.addEventListener("click", () => setCenterView("hives")); profileEditToggleBtn?.addEventListener("click", () => { @@ -4102,6 +7244,34 @@ function submitChat() { return; } + if (isMapChatActive()) { + if (!text && !hasImg && !hasAudio) return; + if (hasImg || hasAudio) { + toast("Maps chat", "Maps chat is text-only for now."); + return; + } + if (!loggedInUser) { + toast("Sign in required", "Sign in to chat in maps."); + return; + } + try { + ws.send(JSON.stringify({ type: "plugin:maps:chatSend", mapId: activeMapsRoomId, scope: normalizeMapChatScope(activeMapsChatScope), text })); + // Optimistic add so it feels instant (server will also echo back). + pushMapChatMessage(activeMapsRoomId, activeMapsChatScope, { + id: `local_${Date.now()}_${Math.random().toString(16).slice(2)}`, + fromUser: loggedInUser, + text, + createdAt: Date.now(), + }); + } catch { + // ignore + } + chatEditor.innerHTML = ""; + setReplyToMessage(null); + renderChatPanel(true); + return; + } + if (!activeChatPostId || (!text && !hasImg && !hasAudio)) return; const post = posts.get(activeChatPostId); if (post && String(post.mode || post.chatMode || "").toLowerCase() === "walkie") { @@ -4117,8 +7287,9 @@ function submitChat() { return; } const replyToId = replyToMessage?.id ? String(replyToMessage.id) : ""; + const wantsMod = Boolean(canModerate && chatModToggleEl instanceof HTMLInputElement && chatModToggleEl.checked); ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); - ws.send(JSON.stringify({ type: "chatMessage", postId: activeChatPostId, text, html, replyToId })); + ws.send(JSON.stringify({ type: "chatMessage", postId: activeChatPostId, text, html, replyToId, asMod: wantsMod })); chatEditor.innerHTML = ""; setReplyToMessage(null); } @@ -4411,12 +7582,17 @@ modPanelEl?.addEventListener("click", (e) => { const tabBtn = e.target.closest("[data-modtab]"); if (tabBtn) { modTab = tabBtn.getAttribute("data-modtab") || "reports"; + if (modTab === "server") requestServerInfo(); renderModPanel(); return; } }); -modRefreshBtn?.addEventListener("click", () => requestModData()); +modRefreshBtn?.addEventListener("click", () => { + if (!canModerate) return; + if (modTab === "server") requestServerInfo(); + else requestModData(); +}); modReportStatusEl?.addEventListener("change", () => { if (!canModerate) return; ws.send(JSON.stringify({ type: "modListReports", status: modReportStatusEl.value || "open", limit: 200 })); @@ -4477,6 +7653,208 @@ modModalPrimary?.addEventListener("click", () => { }); modBodyEl?.addEventListener("click", (e) => { + const modLogViewBtn = e.target.closest("button[data-modlogview]"); + if (modLogViewBtn) { + const next = String(modLogViewBtn.getAttribute("data-modlogview") || "dev"); + modLogView = next === "moderation" ? "moderation" : "dev"; + localStorage.setItem("bzl_modLogView", modLogView); + if (modLogView === "dev" && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "devLogList", limit: 300 })); + } + renderModPanel(); + return; + } + + const devLogRefreshBtn = e.target.closest("button[data-devlogrefresh]"); + if (devLogRefreshBtn) { + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "devLogList", limit: 300 })); + return; + } + + const devLogCopyBtn = e.target.closest("button[data-devlogcopy]"); + if (devLogCopyBtn) { + const text = String(document.getElementById("devLogPre")?.textContent || "").trim(); + if (!text) { + toast("Dev log", "Nothing to copy."); + return; + } + navigator.clipboard + .writeText(text) + .then(() => toast("Dev log", "Copied.")) + .catch(() => toast("Dev log", "Copy failed.")); + return; + } + + const devLogClearBtn = e.target.closest("button[data-devlogclear]"); + if (devLogClearBtn) { + if (!(canModerate && loggedInRole === "owner")) return; + const ok = confirm("Clear the server dev log?"); + if (!ok) return; + ws.send(JSON.stringify({ type: "devLogClear" })); + return; + } + + const devLogTestBtn = e.target.closest("button[data-devlogtest]"); + if (devLogTestBtn) { + sendDevLog("info", "ui", "Dev log test", { at: Date.now() }); + return; + } + + const devLogAutoScrollToggle = e.target.closest("input[data-devlogautoscroll]"); + if (devLogAutoScrollToggle) { + devLogAutoScroll = Boolean(devLogAutoScrollToggle.checked); + localStorage.setItem("bzl_devLogAutoScroll", devLogAutoScroll ? "1" : "0"); + renderModPanel(); + return; + } + + const serverRefreshBtn = e.target.closest("button[data-server-refresh]"); + if (serverRefreshBtn) { + requestServerInfo(); + return; + } + + const instanceSaveBtn = e.target.closest("button[data-instance-save]"); + if (instanceSaveBtn) { + if (!(canModerate && loggedInRole === "owner")) return; + const title = String(modBodyEl.querySelector("input[data-instance-title]")?.value || "").replace(/\s+/g, " ").trim().slice(0, 32); + const subtitle = String(modBodyEl.querySelector("input[data-instance-subtitle]")?.value || "").replace(/\s+/g, " ").trim().slice(0, 80); + const allowMemberPermanentPosts = Boolean(modBodyEl.querySelector("input[data-instance-allowpermanent]")?.checked); + const bg = String(modBodyEl.querySelector("input[data-instance-bg]")?.value || "").trim(); + const panel = String(modBodyEl.querySelector("input[data-instance-panel]")?.value || "").trim(); + const text = String(modBodyEl.querySelector("input[data-instance-text]")?.value || "").trim(); + const good = String(modBodyEl.querySelector("input[data-instance-good]")?.value || "").trim(); + const bad = String(modBodyEl.querySelector("input[data-instance-bad]")?.value || "").trim(); + const accent = String(modBodyEl.querySelector("input[data-instance-accent]")?.value || "").trim(); + const accent2 = String(modBodyEl.querySelector("input[data-instance-accent2]")?.value || "").trim(); + const fontBody = String(modBodyEl.querySelector("select[data-instance-fontbody]")?.value || "").trim(); + const fontMono = String(modBodyEl.querySelector("select[data-instance-fontmono]")?.value || "").trim(); + const mutedPct = String(modBodyEl.querySelector("input[data-instance-mutedpct]")?.value || "").trim(); + const linePct = String(modBodyEl.querySelector("input[data-instance-linepct]")?.value || "").trim(); + const panel2Pct = String(modBodyEl.querySelector("input[data-instance-panel2pct]")?.value || "").trim(); + if (!title) { + toast("Instance", "Title is required."); + return; + } + ws.send( + JSON.stringify({ + type: "instanceSetBranding", + title, + subtitle, + allowMemberPermanentPosts, + appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct } + }) + ); + toast("Instance", "Saving…"); + return; + } + + const instanceSaveAppearanceBtn = e.target.closest("button[data-instance-saveappearance]"); + if (instanceSaveAppearanceBtn) { + if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return; + const bg = String(modBodyEl.querySelector("input[data-instance-bg]")?.value || "").trim(); + const panel = String(modBodyEl.querySelector("input[data-instance-panel]")?.value || "").trim(); + const text = String(modBodyEl.querySelector("input[data-instance-text]")?.value || "").trim(); + const good = String(modBodyEl.querySelector("input[data-instance-good]")?.value || "").trim(); + const bad = String(modBodyEl.querySelector("input[data-instance-bad]")?.value || "").trim(); + const accent = String(modBodyEl.querySelector("input[data-instance-accent]")?.value || "").trim(); + const accent2 = String(modBodyEl.querySelector("input[data-instance-accent2]")?.value || "").trim(); + const fontBody = String(modBodyEl.querySelector("select[data-instance-fontbody]")?.value || "").trim(); + const fontMono = String(modBodyEl.querySelector("select[data-instance-fontmono]")?.value || "").trim(); + const mutedPct = String(modBodyEl.querySelector("input[data-instance-mutedpct]")?.value || "").trim(); + const linePct = String(modBodyEl.querySelector("input[data-instance-linepct]")?.value || "").trim(); + const panel2Pct = String(modBodyEl.querySelector("input[data-instance-panel2pct]")?.value || "").trim(); + ws.send( + JSON.stringify({ + type: "instanceSetAppearance", + appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct } + }) + ); + toast("Theme", "Saving…"); + return; + } + + const themeResetBtn = e.target.closest("button[data-theme-reset]"); + if (themeResetBtn) { + if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return; + applyInstanceAppearance(); + renderModPanel(); + toast("Theme", "Reset to saved theme."); + return; + } + + const pluginReloadBtn = e.target.closest("button[data-pluginreload]"); + if (pluginReloadBtn) { + if (!isOwnerUser()) return; + pluginAdminBusy = true; + pluginAdminStatus = "Reloading plugins…"; + renderModPanel(); + ws.send(JSON.stringify({ type: "pluginReload" })); + return; + } + + const pluginUninstallBtn = e.target.closest("button[data-pluginuninstall]"); + if (pluginUninstallBtn) { + if (!isOwnerUser()) return; + const id = String(pluginUninstallBtn.getAttribute("data-pluginuninstall") || "").trim().toLowerCase(); + if (!id) return; + const ok = confirm(`Uninstall "${id}"? This deletes the plugin files from this server.`); + if (!ok) return; + pluginAdminBusy = true; + pluginAdminStatus = `Uninstalling "${id}"…`; + renderModPanel(); + ws.send(JSON.stringify({ type: "pluginUninstall", id })); + return; + } + + const pluginInstallBtn = e.target.closest("button[data-plugininstall]"); + if (pluginInstallBtn) { + if (!isOwnerUser()) return; + const input = modBodyEl.querySelector("input[type='file'][data-pluginzip]") || null; + const file = input?.files && input.files[0] ? input.files[0] : null; + if (!file) { + pluginAdminStatus = "Choose a .zip file first."; + renderModPanel(); + return; + } + const token = getSessionToken(); + if (!token) { + pluginAdminStatus = "Session missing. Please sign out/in and try again."; + renderModPanel(); + return; + } + pluginAdminBusy = true; + pluginAdminStatus = "Uploading plugin…"; + renderModPanel(); + (async () => { + try { + const res = await fetch("/api/plugin-install", { + method: "POST", + headers: { "Content-Type": "application/zip", Authorization: `Bearer ${token}` }, + body: file, + credentials: "same-origin", + }); + const json = await res.json().catch(() => null); + if (!res.ok || !json || !json.ok) { + pluginAdminBusy = false; + pluginAdminStatus = String(json?.error || `Install failed (${res.status}).`); + renderModPanel(); + return; + } + if (input) input.value = ""; + pluginAdminBusy = false; + pluginAdminStatus = `Installed "${json.plugin?.id || "plugin"}". Enable it below.`; + toast("Plugins", "Installed. Enable it to activate."); + renderModPanel(); + } catch (err) { + pluginAdminBusy = false; + pluginAdminStatus = "Install failed."; + renderModPanel(); + } + })(); + return; + } + const nukeBtn = e.target.closest("button[data-nuke]"); if (nukeBtn) { if (!(canModerate && loggedInRole === "owner")) return; @@ -4647,6 +8025,61 @@ modBodyEl?.addEventListener("click", (e) => { }); modBodyEl?.addEventListener("change", (e) => { + const presetSelect = e.target?.closest?.("select[data-theme-preset]"); + if (presetSelect) { + if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return; + const id = String(presetSelect.value || "").trim(); + if (!id) return; + const preset = THEME_PRESETS.find((p) => p.id === id) || null; + if (!preset) return; + const a = preset.appearance || {}; + const setValue = (selector, value) => { + const el = modBodyEl.querySelector(selector); + if (!el) return; + el.value = String(value ?? ""); + }; + setValue("input[data-instance-bg]", a.bg); + setValue("input[data-instance-panel]", a.panel); + setValue("input[data-instance-text]", a.text); + setValue("input[data-instance-good]", a.good); + setValue("input[data-instance-bad]", a.bad); + setValue("input[data-instance-accent]", a.accent); + setValue("input[data-instance-accent2]", a.accent2); + setValue("input[data-instance-mutedpct]", a.mutedPct); + setValue("input[data-instance-linepct]", a.linePct); + setValue("input[data-instance-panel2pct]", a.panel2Pct); + setValue("select[data-instance-fontbody]", a.fontBody); + setValue("select[data-instance-fontmono]", a.fontMono); + applyInstanceAppearance(a); + toast("Theme", `Preset "${preset.name}" applied (preview). Click Save to persist.`); + return; + } + + const toggle = e.target?.closest?.("input[type='checkbox'][data-pluginenable]"); + if (toggle) { + if (!isOwnerUser()) return; + const id = String(toggle.getAttribute("data-pluginenable") || "").trim().toLowerCase(); + if (!id) return; + const enabled = Boolean(toggle.checked); + if (pluginEnableInFlight.has(id)) return; + const wsRef = window.__bzlWs; + if (!wsRef || wsRef.readyState !== WebSocket.OPEN) { + toast("Plugins", "Not connected."); + return; + } + pluginEnableInFlight.add(id); + // Optimistic UI update to avoid flicker/repeated toggles. + for (const p of plugins) { + if (p && String(p.id || "").toLowerCase() === id) p.enabled = enabled; + } + pluginAdminStatus = enabled ? "Enabling…" : "Disabling…"; + renderModPanel(); + wsRef.send(JSON.stringify({ type: "pluginSetEnabled", id, enabled })); + return; + } +}); + +modBodyEl?.addEventListener("change", (e) => { const toggle = e.target?.closest?.("input[data-nukeconfirm]"); if (!toggle) return; const btn = modBodyEl.querySelector("button[data-nuke]"); @@ -4659,6 +8092,25 @@ chatForm.addEventListener("submit", (e) => { submitChat(); }); +chatMeta?.addEventListener("click", (e) => { + const btn = e.target?.closest?.("button[data-mapchatscope]"); + if (!btn) return; + const scope = normalizeMapChatScope(btn.getAttribute("data-mapchatscope") || "local"); + activeMapsChatScope = scope; + // Fetch global history on-demand when switching to global. + if (scope === "global" && activeMapsRoomId) { + try { + const wsRef = window.__bzlWs; + if (wsRef && wsRef.readyState === WebSocket.OPEN) { + wsRef.send(JSON.stringify({ type: "plugin:maps:chatHistoryReq", mapId: activeMapsRoomId })); + } + } catch { + // ignore + } + } + renderChatPanel(true); +}); + chatEditor.addEventListener("keydown", (e) => { if (mentionState.open) { if (e.key === "ArrowDown") { @@ -4720,6 +8172,10 @@ chatEditor.addEventListener("input", () => { }, 1800); }); +chatEditor.addEventListener("focus", () => { + chatUploadTargetEditor = chatEditor; +}); + chatEditor.addEventListener("blur", () => { if (!activeChatPostId || !loggedInUser) return; ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); @@ -4740,7 +8196,8 @@ chatImageInput.addEventListener("change", async () => { try { const url = await uploadMediaFile(file, "image"); if (!url) return; - chatEditor.focus(); + const target = chatUploadTargetEditor instanceof HTMLElement ? chatUploadTargetEditor : chatEditor; + target.focus(); document.execCommand("insertImage", false, url); } catch { // ignore @@ -4768,7 +8225,8 @@ chatAudioInput?.addEventListener("change", async () => { try { const url = await uploadMediaFile(file, "audio"); if (!url) return; - insertAudioTag(chatEditor, url); + const target = chatUploadTargetEditor instanceof HTMLElement ? chatUploadTargetEditor : chatEditor; + insertAudioTag(target, url); } catch { // ignore } @@ -4856,6 +8314,7 @@ ws.addEventListener("message", (evt) => { modReports = []; modUsers = []; modLog = []; + devLog = []; profiles = msg.profiles && typeof msg.profiles === "object" ? msg.profiles : {}; instanceBranding = normalizeInstanceBranding(msg.instance || {}); renderInstanceBranding(); @@ -4883,10 +8342,78 @@ ws.addEventListener("message", (evt) => { return; } + if (msg.type === "plugin:maps:joinOk") { + const map = msg.map && typeof msg.map === "object" ? msg.map : null; + const mapId = map && typeof map.id === "string" ? map.id.trim().toLowerCase() : ""; + if (mapId) { + activeMapsRoomId = mapId; + activeMapsRoomTitle = map && typeof map.title === "string" ? map.title.trim().slice(0, 64) : mapId; + activeMapsChatScope = "local"; + try { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "plugin:maps:chatHistoryReq", mapId })); + } + } catch { + // ignore + } + if (isMapChatActive()) renderChatPanel(true); + } + return; + } + + if (msg.type === "plugin:maps:left") { + const wasActive = Boolean(activeMapsRoomId); + activeMapsRoomId = ""; + activeMapsRoomTitle = ""; + activeMapsChatScope = "local"; + if (wasActive && !activeDmThreadId && !activeChatPostId) renderChatPanel(true); + return; + } + + if (msg.type === "plugin:maps:chatHistory") { + const mapId = typeof msg.mapId === "string" ? msg.mapId.trim().toLowerCase() : ""; + const scope = normalizeMapChatScope(msg.scope || "global"); + const messages = Array.isArray(msg.messages) ? msg.messages : []; + if (mapId && scope === "global") { + mapsChatGlobalByMapId.set( + mapId, + messages + .map((m) => ({ + id: String(m?.id || ""), + fromUser: String(m?.fromUser || m?.username || ""), + text: String(m?.text || ""), + createdAt: Number(m?.createdAt || 0) || Date.now(), + })) + .filter((m) => m.id && m.fromUser && m.text) + .slice(-240) + ); + if (isMapChatActive()) renderChatPanel(false); + } + return; + } + + if (msg.type === "plugin:maps:chatMessage") { + const mapId = typeof msg.mapId === "string" ? msg.mapId.trim().toLowerCase() : ""; + const scope = normalizeMapChatScope(msg.scope || "local"); + const m = msg.message && typeof msg.message === "object" ? msg.message : null; + if (mapId && m) { + pushMapChatMessage(mapId, scope, { + id: String(m.id || ""), + fromUser: String(m.fromUser || m.username || ""), + text: String(m.text || ""), + createdAt: Number(m.createdAt || 0) || Date.now(), + }); + if (isMapChatActive()) renderChatPanel(false); + } + return; + } + if (msg.type === "collectionsUpdated") { + const prevView = activeHiveView; collections = normalizeCollections(msg.collections); renderCollectionSelect(); - renderFeed(); + ensureActiveCollectionView(); + if (activeHiveView !== prevView) renderFeed(); renderModPanel(); return; } @@ -4894,8 +8421,17 @@ ws.addEventListener("message", (evt) => { if (msg.type === "instanceUpdated" && msg.instance && typeof msg.instance === "object") { instanceBranding = normalizeInstanceBranding(msg.instance); renderInstanceBranding(); - if (instanceStatusEl) instanceStatusEl.textContent = "Saved."; + applyInstanceAppearance(); + setAuthUi(); + return; + } + + if (msg.type === "instanceOk" && msg.instance && typeof msg.instance === "object") { + instanceBranding = normalizeInstanceBranding(msg.instance); + renderInstanceBranding(); + applyInstanceAppearance(); setAuthUi(); + toast("Instance", "Saved."); return; } @@ -4932,7 +8468,6 @@ ws.addEventListener("message", (evt) => { if (msg.type === "rolesUpdated") { customRoles = normalizeRoleDefs(msg.roles); - renderFeed(); renderPeoplePanel(); renderModPanel(); return; @@ -5061,6 +8596,8 @@ ws.addEventListener("message", (evt) => { renderCenterPanels(); } if (canModerate) requestModData(); + if (rackLayoutEnabled) applyDockState(); + updateLayoutPresetOptions(); return; } @@ -5085,6 +8622,8 @@ ws.addEventListener("message", (evt) => { renderLanHint(); renderPeoplePanel(); renderCenterPanels(); + if (rackLayoutEnabled) applyDockState(); + updateLayoutPresetOptions(); return; } @@ -5096,8 +8635,10 @@ ws.addEventListener("message", (evt) => { if (msg.prefs && typeof msg.prefs === "object") setUserPrefs(msg.prefs); setAuthUi(); renderLanHint(); + if (rackLayoutEnabled) applyDockState(); renderPeoplePanel(); if (canModerate) requestModData(); + updateLayoutPresetOptions(); return; } @@ -5228,20 +8769,42 @@ ws.addEventListener("message", (evt) => { return; } + if (msg.type === "rateLimited") { + const m = msg.message || "Too many requests. Please wait and try again."; + toast("Rate limit", m); + return; + } + if (msg.type === "permissionDenied") { const m = msg.message || "Permission denied."; - if (/owner access required/i.test(m) && pluginStatusEl) pluginStatusEl.textContent = m; + if (/owner access required/i.test(m)) { + pluginAdminStatus = m; + pluginAdminBusy = false; + pluginEnableInFlight.clear(); + renderModPanel(); + } toast("Moderation", m); return; } + if (msg.type === "collectionOk") { + toast("Collections", "Collection created."); + return; + } + + if (msg.type === "roleOk") { + toast("Roles", "Role created."); + return; + } + if (msg.type === "pluginOk") { - if (pluginStatusEl) { - if (msg.uninstalled) pluginStatusEl.textContent = "Plugin uninstalled."; - else if (typeof msg.enabled === "boolean") pluginStatusEl.textContent = msg.enabled ? "Plugin enabled." : "Plugin disabled."; - else if (msg.reloaded) pluginStatusEl.textContent = "Plugins reloaded."; - else pluginStatusEl.textContent = "Plugin updated."; - } + if (msg.uninstalled) pluginAdminStatus = "Plugin uninstalled."; + else if (typeof msg.enabled === "boolean") pluginAdminStatus = msg.enabled ? "Plugin enabled." : "Plugin disabled."; + else if (msg.reloaded) pluginAdminStatus = "Plugins reloaded."; + else pluginAdminStatus = "Plugin updated."; + pluginAdminBusy = false; + if (msg.id) pluginEnableInFlight.delete(String(msg.id || "").trim().toLowerCase()); + if (modTab === "server") renderModPanel(); return; } @@ -5267,6 +8830,7 @@ ws.addEventListener("message", (evt) => { markRead(msg.postId); renderChatPanel(true); renderTypingIndicator(); + renderChatInstancesForPost(msg.postId); return; } @@ -5278,6 +8842,19 @@ ws.addEventListener("message", (evt) => { return; } + if (msg.type === "devLogSnapshot") { + if (Array.isArray(msg.log)) devLog = msg.log; + if (canModerate && modTab === "log" && modLogView === "dev") renderModPanel(); + return; + } + + if (msg.type === "devLogAppended" && msg.entry) { + devLog.unshift(msg.entry); + if (devLog.length > 300) devLog.splice(300); + if (canModerate && modTab === "log" && modLogView === "dev") renderModPanel(); + return; + } + if (msg.type === "modLogAppended" && msg.entry) { modLog.unshift(msg.entry); if (modLog.length > 200) modLog.splice(200); @@ -5331,6 +8908,7 @@ ws.addEventListener("message", (evt) => { const m = arr.find((x) => x && x.id === messageId); if (m) m.reactions = reactions; if (activeChatPostId === postId) renderChatPanel(); + renderChatInstancesForPost(postId); return; } @@ -5352,6 +8930,7 @@ ws.addEventListener("message", (evt) => { if (set.size === 0) typingUsersByPostId.delete(postId); else typingUsersByPostId.set(postId, set); if (activeChatPostId === postId) renderTypingIndicator(); + renderChatInstancesForPost(postId); return; } @@ -5376,6 +8955,7 @@ ws.addEventListener("message", (evt) => { ); if (!isFromYou && senderLower && senderLower !== selfLower && ignoreUserSet.has(senderLower)) { if (activeChatPostId === msg.postId) renderChatPanel(); + renderChatInstancesForPost(msg.postId); return; } const mentions = Array.isArray(msg.message?.mentions) ? msg.message.mentions.map((u) => String(u || "").toLowerCase()) : []; @@ -5418,10 +8998,12 @@ ws.addEventListener("message", (evt) => { } } } + renderChatInstancesForPost(msg.postId); } }); renderLanHint(); +initDisplayPrefsUi(); renderPeoplePanel(); setPeopleOpen(getPeopleOpen()); composerOpen = getComposerOpen(); @@ -5442,6 +9024,18 @@ if (toggleReactionsEl) { }); } +if (hivesViewModeEl) { + const pref = readStringPref(HIVES_VIEW_MODE_KEY, "auto"); + hivesViewModeEl.value = pref === "cards" || pref === "list" ? pref : "auto"; + hivesViewModeEl.addEventListener("change", () => { + const next = String(hivesViewModeEl.value || "auto").toLowerCase(); + writeStringPref(HIVES_VIEW_MODE_KEY, next === "cards" || next === "list" ? next : "auto"); + applyHivesViewMode(); + }); +} +installHivesAutoViewMode(); +applyHivesViewMode(); + if (chatHeaderEl && appRoot) { chatHeaderEl.setAttribute("draggable", "true"); chatHeaderEl.title = "Drag left/right to dock chat"; @@ -5825,6 +9419,9 @@ appRoot?.addEventListener( window.addEventListener("resize", applyMobileMode); applyMobileMode(); +// Initialize experimental rack layout (safe no-op when disabled). +initRackLayout(); + window.addEventListener("focus", () => { windowFocused = true; updateNotifUi(); diff --git a/CLEAN_INSTALL/public/index.html b/CLEAN_INSTALL/public/index.html @@ -4,13 +4,14 @@ <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=83" /> + <link rel="stylesheet" href="/styles.css?v=103" /> </head> <body> - <div class="app"> - <button id="showSidebar" class="ghost smallBtn sidebarToggle hidden" type="button" title="Show sidebar">Show</button> - <button id="togglePeople" class="ghost smallBtn peopleToggle" type="button" title="Show people">People</button> - <aside class="sidebar"> + <div class="app"> + <button id="showSidebar" class="ghost smallBtn sidebarToggle hidden" type="button" title="Show sidebar">Show</button> + <button id="togglePeople" class="ghost smallBtn peopleToggle" type="button" title="Show people">People</button> + <button id="showRightRack" class="ghost smallBtn rightRackToggle hidden" type="button" title="Show right rack">Right</button> + <aside class="sidebar"> <div class="sidebarScroll"> <div class="brand"> <div id="instanceTitle" class="logo">Bzl</div> @@ -27,10 +28,62 @@ <section class="panel"> <div class="panelTitle">View</div> - <label class="checkRow"> + <label class="checkRow" style="margin-top:8px;"> + <span>Rack layout (experimental)</span> + <input id="toggleRackLayout" type="checkbox" /> + </label> + <label style="margin-top:10px;"> + <span>Layout preset</span> + <select id="layoutPreset"> + <option value="discordLike">Discord-like</option> + <option value="chat">Chat</option> + <option value="browsing">Browsing</option> + <option value="maps">Maps</option> + <option value="moderation">Moderation</option> + <option value="focus">Focus</option> + <option value="clean">Clean</option> + <option value="ops">Ops</option> + </select> + </label> + <label class="checkRow" style="margin-top:8px;"> + <span>Side panels</span> + <input id="toggleSideRack" type="checkbox" checked /> + </label> + <label class="checkRow" style="margin-top:8px;"> + <span>Right rack</span> + <input id="toggleRightRack" type="checkbox" checked /> + </label> + <label class="checkRow" style="margin-top:8px;"> <span>Show reactions bar</span> <input id="toggleReactions" type="checkbox" /> </label> + + <details style="margin-top:10px;"> + <summary class="small muted" style="cursor:pointer;user-select:none;">Advanced display</summary> + <div style="margin-top:10px;"> + <label> + <span>Text size</span> + <select id="uiScale"> + <option value="auto" selected>Auto</option> + <option value="xs">Compact</option> + <option value="sm">Small</option> + <option value="md">Default</option> + <option value="lg">Large</option> + </select> + </label> + <label style="margin-top:10px;"> + <span>Device layout</span> + <select id="deviceLayout"> + <option value="auto" selected>Auto</option> + <option value="widescreen">16:9 / 16:10</option> + <option value="fourThree">4:3</option> + <option value="threeTwo">3:2</option> + <option value="ultrawide">Ultrawide</option> + <option value="portrait">Portrait</option> + </select> + </label> + </div> + </details> </section> <section class="panel"> @@ -87,35 +140,6 @@ <div id="profileStatus" class="small muted"></div> </section> - <section id="instancePanel" class="panel hidden" aria-label="Instance settings"> - <div class="panelTitle">Instance</div> - <div class="small muted">Owner-only. Rename the title/subtitle (Powered by Bzl stays).</div> - <label> - <span>Title</span> - <input id="instanceTitleInput" maxlength="32" placeholder="Your instance name" /> - </label> - <label> - <span>Subtitle</span> - <input id="instanceSubtitleInput" maxlength="80" placeholder="A short tagline" /> - </label> - <label class="checkRow"> - <span>Allow members to make permanent hives</span> - <input id="instanceAllowPermanentPosts" type="checkbox" /> - </label> - <button id="saveInstanceBranding" class="primary" type="button">Save instance</button> - <div id="instanceStatus" class="small muted"></div> - - <div class="panelDivider"></div> - <div class="panelTitleSub">Plugins</div> - <div class="small muted">Owner-only. Install optional plugins to extend your instance.</div> - <div class="pluginInstallRow"> - <input id="pluginZipInput" type="file" accept=".zip,application/zip" /> - <button id="pluginInstallBtn" class="ghost" type="button">Install</button> - </div> - <div id="pluginsList" class="pluginsList"></div> - <div id="pluginStatus" class="small muted"></div> - </section> - </div> <div class="sidebarFooter"> @@ -133,7 +157,10 @@ <div id="sidebarResizeHandle" class="panelResizeHandle sidebarResizeHandle" title="Drag to resize sidebar" aria-hidden="true"></div> <main class="main"> - <section class="panel panelFill"> + <div id="mainRack" class="mainRack"> + <button id="showSideRack" class="ghost smallBtn sideRackToggle hidden" type="button" title="Show side panels">Side</button> + <div id="mainWorkspaceRack" class="workspaceRack" aria-label="Workspace"> + <section id="hivesPanel" class="panel panelFill"> <div class="panelHeader"> <div class="panelTitle">Hives</div> <div class="filters"> @@ -278,6 +305,9 @@ </div> </form> </section> + </div> + <div id="mainSideRack" class="sideRack" aria-label="Side panels"></div> + </div> </main> <div id="chatResizeHandle" class="panelResizeHandle chatResizeHandle" title="Drag to resize chat" aria-hidden="true"></div> @@ -306,7 +336,7 @@ </div> <button id="chatReplyCancel" class="ghost smallBtn" type="button">Cancel</button> </div> - <div class="chatComposer"> + <div class="chatComposer"> <div class="toolbar" role="toolbar" aria-label="Chat formatting"> <button type="button" data-chatcmd="bold"><b>B</b></button> <button type="button" data-chatcmd="italic"><i>I</i></button> @@ -320,6 +350,10 @@ <button type="button" data-chataudio="1">Audio</button> <button type="button" data-chatemoji="1">Emoji</button> <button type="button" data-chatcmd="removeFormat">Clear</button> + <label id="chatModToggleWrap" class="checkRow chatModToggle hidden" title="Send as moderator/system message (left rail)"> + <span>Mod</span> + <input id="chatModToggle" type="checkbox" /> + </label> </div> <div id="chatEditor" class="editor chatEditor" contenteditable="true" aria-label="Chat editor"></div> <div id="mentionMenu" class="mentionMenu hidden" role="listbox" aria-label="Mention suggestions"></div> @@ -340,6 +374,7 @@ <button type="button" class="ghost" data-modtab="reports">Reports</button> <button type="button" class="ghost" data-modtab="users">Users</button> <button type="button" class="ghost" data-modtab="hives">Hives</button> + <button type="button" class="ghost" data-modtab="server">Server</button> <button type="button" class="ghost" data-modtab="log">Log</button> </div> <div class="modFilters"> @@ -410,6 +445,10 @@ <span>Password protected</span> <input id="editModalProtected" type="checkbox" /> </label> + <label class="checkRow"> + <span>Walkie-only</span> + <input id="editModalWalkie" type="checkbox" /> + </label> <label id="editModalPasswordRow" class="hidden"> <span>Password</span> <input @@ -490,6 +529,7 @@ </div> </div> - <script src="/app.js?v=83"></script> + <div id="dockHotbar" class="dockHotbar hidden" aria-label="Docked panels"></div> + <script src="/app.js?v=116"></script> </body> </html> diff --git a/CLEAN_INSTALL/public/styles.css b/CLEAN_INSTALL/public/styles.css @@ -1,18 +1,29 @@ :root { --bg: #060611; --panel: #0c0c18; - --panel2: rgba(255, 255, 255, 0.02); --text: #f6f0ff; - --muted: rgba(246, 240, 255, 0.65); - --line: rgba(246, 240, 255, 0.10); + --muted-pct: 65; + --line-pct: 10; + --panel2-pct: 2; + --muted: color-mix(in srgb, var(--text) calc(var(--muted-pct) * 1%), transparent); + --line: color-mix(in srgb, var(--text) calc(var(--line-pct) * 1%), transparent); + --panel2: color-mix(in srgb, var(--text) calc(var(--panel2-pct) * 1%), transparent); --accent: #ff3ea5; --accent2: #b84bff; + --warn: #ffb84d; + --warn2: #ff7a18; + --font-body: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --good: #3ddc97; --bad: #ff4d8a; --sidebar-width: 320px; + --sidebar-min: 240px; --chat-width: 640px; + --chat-min: 380px; --mod-width: 360px; + --mod-min: 280px; --people-width: 360px; + --people-min: 320px; --dur-fast: 110ms; --dur-med: 180ms; --dur-slow: 260ms; @@ -20,6 +31,158 @@ --ease-soft: cubic-bezier(0.2, 0.8, 0.2, 1); --shadow-panel: 0 20px 70px rgba(0, 0, 0, 0.45); --shadow-soft: 0 12px 34px rgba(0, 0, 0, 0.35); + --font-size-base: 15px; + --font-size-small: 11px; + --app-gap: 12px; + --app-pad: 12px; + --panel-pad: 14px; + --panel-header-pad-y: 10px; + --panel-header-pad-x: 12px; + --label-font-size: 12px; + --label-gap: 6px; + --control-pad-y: 10px; + --control-pad-x: 10px; + --btn-pad-y: 10px; + --btn-pad-x: 12px; + --chat-rail-inset: 12px; + --chat-rail-side-max: 66%; + --chat-rail-center-max: 70%; +} + +:root[data-ui-scale="xs"] { + --font-size-base: 12px; + --font-size-small: 9px; + --app-gap: 7px; + --app-pad: 7px; + --panel-pad: 10px; + --panel-header-pad-y: 8px; + --panel-header-pad-x: 10px; + --label-font-size: 11px; + --label-gap: 5px; + --control-pad-y: 8px; + --control-pad-x: 9px; + --btn-pad-y: 8px; + --btn-pad-x: 10px; +} + +:root[data-ui-scale="sm"] { + --font-size-base: 13px; + --font-size-small: 10px; + --app-gap: 9px; + --app-pad: 9px; + --panel-pad: 12px; + --panel-header-pad-y: 9px; + --panel-header-pad-x: 11px; + --label-font-size: 12px; + --label-gap: 6px; + --control-pad-y: 9px; + --control-pad-x: 10px; + --btn-pad-y: 9px; + --btn-pad-x: 11px; +} + +:root[data-ui-scale="md"] { + --font-size-base: 15px; + --font-size-small: 11px; + --app-gap: 12px; + --app-pad: 12px; +} + +:root[data-ui-scale="lg"] { + --font-size-base: 16px; + --font-size-small: 12px; + --app-gap: 14px; + --app-pad: 14px; +} + +:root[data-aspect="threeTwo"] { + --sidebar-width: 300px; + --sidebar-min: 230px; + --chat-width: 600px; + --chat-min: 360px; + --people-width: 340px; + --people-min: 300px; + --mod-width: 340px; + --mod-min: 280px; + --app-gap: 10px; + --app-pad: 10px; + --chat-rail-side-max: 70%; + --chat-rail-center-max: 74%; +} + +:root[data-aspect="fourThree"] { + --sidebar-width: 280px; + --sidebar-min: 220px; + --chat-width: 560px; + --chat-min: 340px; + --people-width: 320px; + --people-min: 280px; + --mod-width: 320px; + --mod-min: 260px; + --app-gap: 10px; + --app-pad: 10px; + --chat-rail-side-max: 74%; + --chat-rail-center-max: 78%; +} + +:root[data-aspect="portrait"] { + --sidebar-width: 300px; + --sidebar-min: 230px; + --chat-width: 520px; + --chat-min: 320px; + --people-width: 320px; + --people-min: 260px; + --mod-width: 320px; + --mod-min: 260px; + --app-gap: 10px; + --app-pad: 10px; + --chat-rail-inset: 10px; + --chat-rail-side-max: 88%; + --chat-rail-center-max: 88%; +} + +:root[data-aspect="ultrawide"] { + --sidebar-width: 340px; + --sidebar-min: 260px; + --chat-width: 720px; + --chat-min: 420px; + --people-width: 400px; + --people-min: 340px; + --mod-width: 400px; + --mod-min: 320px; + --chat-rail-side-max: 62%; + --chat-rail-center-max: 66%; +} + +:root[data-viewport="xs"] { + --sidebar-width: 230px; + --sidebar-min: 210px; + --chat-width: 480px; + --chat-min: 300px; + --people-width: 260px; + --people-min: 250px; + --mod-width: 260px; + --mod-min: 250px; + --app-gap: 8px; + --app-pad: 8px; + --chat-rail-inset: 10px; + --chat-rail-side-max: 92%; + --chat-rail-center-max: 92%; +} + +:root[data-viewport="sm"] { + --sidebar-width: 290px; + --sidebar-min: 220px; + --chat-width: 600px; + --chat-min: 340px; + --people-width: 320px; + --people-min: 280px; + --mod-width: 320px; + --mod-min: 260px; + --app-gap: 11px; + --app-pad: 11px; + --chat-rail-side-max: 78%; + --chat-rail-center-max: 82%; } * { @@ -29,10 +192,10 @@ body { margin: 0; height: 100vh; - font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", - "Segoe UI Emoji"; - background: radial-gradient(1200px 800px at 10% 0%, rgba(255, 62, 165, 0.18), transparent 55%), - radial-gradient(900px 700px at 90% 10%, rgba(184, 75, 255, 0.14), transparent 50%), var(--bg); + font-family: var(--font-body); + font-size: var(--font-size-base); + background: radial-gradient(1200px 800px at 10% 0%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 55%), + radial-gradient(900px 700px at 90% 10%, color-mix(in srgb, var(--accent2) 14%, transparent), transparent 50%), var(--bg); color: var(--text); } @@ -60,7 +223,7 @@ body { * { scrollbar-width: thin; - scrollbar-color: rgba(255, 62, 165, 0.28) rgba(0, 0, 0, 0); + scrollbar-color: color-mix(in srgb, var(--accent) 28%, transparent) rgba(0, 0, 0, 0); } ::-webkit-scrollbar { @@ -69,14 +232,14 @@ body { } ::-webkit-scrollbar-thumb { - background: rgba(255, 62, 165, 0.22); + background: color-mix(in srgb, var(--accent) 22%, transparent); border-radius: 999px; border: 2px solid rgba(0, 0, 0, 0); background-clip: content-box; } ::-webkit-scrollbar-thumb:hover { - background: rgba(255, 62, 165, 0.34); + background: color-mix(in srgb, var(--accent) 34%, transparent); border: 2px solid rgba(0, 0, 0, 0); background-clip: content-box; } @@ -86,7 +249,7 @@ body { } .app ::selection { - background: rgba(255, 62, 165, 0.25); + background: color-mix(in srgb, var(--accent) 25%, transparent); } .app a { @@ -100,7 +263,7 @@ body { .app a:focus-visible { outline: none; - box-shadow: 0 0 0 3px rgba(255, 62, 165, 0.18), 0 0 0 1px rgba(255, 62, 165, 0.35) inset; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent), 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent) inset; border-radius: 10px; } @@ -109,7 +272,7 @@ body { } .small { - font-size: 12px; + font-size: var(--font-size-small); } .muted { @@ -118,30 +281,113 @@ body { .app { display: grid; - grid-template-columns: minmax(240px, var(--sidebar-width)) 10px minmax(380px, var(--chat-width)) 10px 1fr; + grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px minmax(var(--chat-min), var(--chat-width)) 10px 1fr; grid-template-areas: "sidebar sidebarResize chat chatResize main"; - gap: 12px; - padding: 12px; + gap: 0; + padding: var(--app-pad); height: 100vh; overflow: hidden; position: relative; } +.app.rackMode { + grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px 1fr 10px minmax(var(--people-min), var(--people-width)); + grid-template-areas: "sidebar sidebarResize main mainResize rightRack"; +} + +.app.rackMode .peopleToggle { + display: none !important; +} + +.app.rackMode.rightCollapsed, +.app.rackMode.hasMod.rightCollapsed { + grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px 1fr; + grid-template-areas: "sidebar sidebarResize main"; +} + +.app.rackMode.rightCollapsed #rightRack { + display: none !important; +} + +.app.rackMode.rightCollapsed .mainResizeHandle { + display: none !important; +} + +.app.sideCollapsed #mainSideRack { + display: none !important; +} + +.app.sideRackEmpty #mainSideRack { + display: none !important; +} + +.sideRackToggle { + position: absolute; + top: 12px; + right: 12px; + z-index: 60; +} + +.app.rackMode.rightCollapsed .sideRackToggle { + right: 64px; +} + +.app.rackMode.sideCollapsed .sideRackToggle { + display: block !important; +} + +.app.rackMode:not(.sideCollapsed) .sideRackToggle { + display: none !important; +} + +.rightRackToggle { + position: absolute; + top: 12px; + right: 12px; + z-index: 70; +} + +.app.rackMode.rightCollapsed .rightRackToggle { + display: block !important; +} + +.app.rackMode:not(.rightCollapsed) .rightRackToggle { + display: none !important; +} + +.pluginPanel .panelBody { + display: flex; + flex-direction: column; + 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)); + grid-template-areas: "sidebar sidebarResize main mainResize rightRack"; +} + .app.hasMod { - grid-template-columns: minmax(240px, var(--sidebar-width)) 10px minmax(380px, var(--chat-width)) 10px 1fr 10px minmax(280px, var(--mod-width)); + grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px minmax(var(--chat-min), var(--chat-width)) 10px 1fr 10px minmax(var(--mod-min), var(--mod-width)); grid-template-areas: "sidebar sidebarResize chat chatResize main mainResize moderation"; } .app.chatRight { - grid-template-columns: minmax(240px, var(--sidebar-width)) 10px 1fr 10px minmax(380px, var(--chat-width)); + grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px 1fr 10px minmax(var(--chat-min), var(--chat-width)); grid-template-areas: "sidebar sidebarResize main chatResize chat"; } .app.hasMod.chatRight { - grid-template-columns: minmax(240px, var(--sidebar-width)) 10px 1fr 10px minmax(380px, var(--chat-width)) 10px minmax(280px, var(--mod-width)); + grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px 1fr 10px minmax(var(--chat-min), var(--chat-width)) 10px minmax(var(--mod-min), var(--mod-width)); grid-template-areas: "sidebar sidebarResize main chatResize chat mainResize moderation"; } +.app.rackMode.chatRight, +.app.rackMode.hasMod.chatRight { + grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px 1fr 10px minmax(var(--people-min), var(--people-width)); + grid-template-areas: "sidebar sidebarResize main mainResize rightRack"; +} + @media (max-width: 760px) { .app { grid-template-columns: 300px 1fr; @@ -149,6 +395,7 @@ body { grid-template-areas: "sidebar main" "chat chat"; + gap: var(--app-gap); } .app.hasMod { grid-template-columns: 300px 1fr; @@ -167,6 +414,15 @@ body { .peopleResizeHandle { display: none; } + + .mainRack { + flex-direction: column; + } + .sideRack { + flex: 0 0 auto; + min-width: 0; + max-width: none; + } } @media (max-width: 760px) { @@ -196,6 +452,191 @@ body { overflow: hidden; } +.rightRack { + grid-area: rightRack; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + gap: var(--app-gap); + overflow: hidden; +} + +.rightRack .rackPanel { + flex: 1; + min-height: 0; +} + +.sideRack .rackPanel { + flex: 1; + min-height: 0; +} + +.rightRack .rackPanel.panelCollapsed, +.sideRack .rackPanel.panelCollapsed { + flex: 0 0 auto; +} + +.rightRack .rackPanel > .panelBody, +.rightRack .rackPanel > .panelFill, +.rightRack .rackPanel .panelBody, +.sideRack .rackPanel > .panelBody, +.sideRack .rackPanel > .panelFill, +.sideRack .rackPanel .panelBody { + min-height: 0; +} + +.panel.panelCollapsed { + overflow: hidden; +} + +.panel.panelCollapsed > :not(.panelHeader) { + display: none !important; +} + +.rackDragHandle { + cursor: grab; + user-select: none; +} + +.rackDragHandle:active { + cursor: grabbing; +} + +.rackDragging { + opacity: 0.86; + box-shadow: 0 26px 90px rgba(0, 0, 0, 0.65); +} + +.rackPlaceholder { + border: 1px dashed color-mix(in srgb, var(--accent) 40%, transparent); + border-radius: 18px; + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.app.rackMode .panelHeader { + /* Slightly stronger header affordance in rack mode */ + background: linear-gradient(180deg, color-mix(in srgb, var(--text) 5%, transparent), transparent); +} + +.app.rackMode .chat, +.app.rackMode .moderation, +.app.rackMode .peopleDrawer { + position: static; + grid-area: unset !important; + height: auto; + min-height: 0; +} + +.app.rackMode .chatResizeHandle, +.app.rackMode .peopleResizeHandle { + display: none !important; +} + +.app.rackMode .peopleDrawer { + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 18px; + background: linear-gradient(180deg, color-mix(in srgb, var(--panel) 96%, transparent), color-mix(in srgb, var(--panel) 90%, transparent)); + box-shadow: var(--shadow-panel); +} + +.dockHotbar { + position: fixed; + left: 50%; + bottom: 10px; + transform: translateX(-50%) translateY(28px); + opacity: 0; + pointer-events: none; + z-index: 60; + display: flex; + gap: 10px; + padding: 8px 10px; + max-width: calc(100vw - 24px); + overflow-x: auto; + overscroll-behavior: contain; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--text) 12%, transparent); + background: color-mix(in srgb, var(--panel) 92%, transparent); + box-shadow: 0 16px 50px rgba(0, 0, 0, 0.45); + transition: transform var(--dur-med) var(--ease-out), opacity var(--dur-med) var(--ease-out); +} + +.dockHotbar.show { + transform: translateX(-50%) translateY(0); + opacity: 1; + pointer-events: auto; +} + +.dockHotbar.dockTarget { + border-color: color-mix(in srgb, var(--accent) 45%, transparent); + box-shadow: 0 22px 70px rgba(0, 0, 0, 0.6), 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent); +} + +.dockOrb.dragging { + opacity: 0.6; +} + +.dockOrb { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + max-width: 220px; + padding: 7px 12px; + border-radius: 999px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--text) 6%, transparent); + color: var(--text); + cursor: pointer; + user-select: none; + font-size: 12px; + white-space: nowrap; +} + +.dockOrb:hover { + background: color-mix(in srgb, var(--accent) 14%, transparent); + border-color: color-mix(in srgb, var(--accent) 25%, transparent); +} + +.dockOrb .dockOrbIcon { + width: 18px; + height: 18px; + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--accent) 22%, transparent); +} + +.hotbarAddMenu { + position: fixed; + z-index: 90; + width: 260px; + max-height: min(320px, 70vh); + overflow: hidden; + border: 1px solid rgba(246, 240, 255, 0.16); + border-radius: 14px; + background: rgba(8, 8, 16, 0.92); + backdrop-filter: blur(12px); + box-shadow: 0 16px 60px rgba(0, 0, 0, 0.45); + animation: popFloat var(--dur-med) var(--ease-out) both; +} + +.hotbarAddMenuList { + overflow: auto; + padding: 6px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.dockOrbPlus .dockOrbIcon { + background: color-mix(in srgb, var(--accent) 30%, transparent); +} + .sidebarScroll { flex: 1; min-height: 0; @@ -264,7 +705,104 @@ body { grid-area: main; display: flex; flex-direction: column; - gap: 12px; + gap: var(--app-gap); + overflow: hidden; +} + +.mainRack { + flex: 1; + min-height: 0; + display: flex; + flex-direction: row; + gap: var(--app-gap); + overflow: hidden; +} + +.workspaceRack { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + gap: var(--app-gap); + overflow: hidden; +} + +/* Workspace 4x2 grid (rack mode) */ +.app.rackMode .workspaceRack { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(0, 1fr)); + gap: var(--app-gap); +} + +.workspaceSlot { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.workspaceSlot > .rackPanel { + flex: 1; + min-height: 0; +} + +.workspaceSlot > .rackPanel > .panelBody, +.workspaceSlot > .rackPanel > .panelFill, +.workspaceSlot > .rackPanel .panelBody { + min-height: 0; +} + +.workspaceSlotLeft { + grid-column: 1 / span 2; + grid-row: 1 / span 2; +} + +.workspaceSlotRight { + grid-column: 3 / span 2; + grid-row: 1 / span 2; +} + +.app.rackMode.workspaceExpandedLeft #workspaceLeftSlot, +.app.rackMode.workspaceExpandedRight #workspaceRightSlot { + grid-column: 1 / span 4; +} + +.app.rackMode.workspaceExpandedLeft #workspaceRightSlot, +.app.rackMode.workspaceExpandedRight #workspaceLeftSlot { + display: none; +} + +.app.rackMode:not(.rackIsDragging).workspaceSingleLeft #workspaceLeftSlot { + grid-column: 1 / span 4; +} + +.app.rackMode:not(.rackIsDragging).workspaceSingleLeft #workspaceRightSlot { + display: none; +} + +.app.rackMode:not(.rackIsDragging).workspaceSingleRight #workspaceRightSlot { + grid-column: 1 / span 4; +} + +.app.rackMode:not(.rackIsDragging).workspaceSingleRight #workspaceLeftSlot { + display: none; +} + +.app:not(.rackMode) .workspaceSlot { + display: none; +} + +.sideRack { + flex: 0 0 min(380px, 30vw); + min-width: 260px; + max-width: 420px; + min-height: 0; + display: flex; + flex-direction: column; + gap: var(--app-gap); overflow: hidden; } @@ -371,6 +909,13 @@ body { border-radius: 999px; } +.mapChatToggle { + display: inline-flex; + gap: 6px; + margin-left: 6px; + vertical-align: middle; +} + .sidebarToggle { position: absolute; top: 12px; @@ -386,12 +931,12 @@ body { } .app.sidebarHidden { - grid-template-columns: minmax(380px, var(--chat-width)) 10px 1fr; + grid-template-columns: minmax(var(--chat-min), var(--chat-width)) 10px 1fr; grid-template-areas: "chat chatResize main"; } .app.sidebarHidden.hasMod { - grid-template-columns: minmax(380px, var(--chat-width)) 10px 1fr 10px minmax(280px, var(--mod-width)); + grid-template-columns: minmax(var(--chat-min), var(--chat-width)) 10px 1fr 10px minmax(var(--mod-min), var(--mod-width)); grid-template-areas: "chat chatResize main mainResize moderation"; } @@ -880,7 +1425,7 @@ body { background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 60%), var(--panel); border: 1px solid var(--line); border-radius: 16px; - padding: 14px; + padding: var(--panel-pad); box-shadow: var(--shadow-soft); } @@ -896,7 +1441,7 @@ body { justify-content: space-between; gap: 10px; align-items: center; - padding: 10px 12px 8px; + padding: var(--panel-header-pad-y) var(--panel-header-pad-x) calc(var(--panel-header-pad-y) - 2px); border-bottom: 1px solid var(--line); } @@ -994,8 +1539,8 @@ body { label span { display: block; color: var(--muted); - font-size: 12px; - margin-bottom: 6px; + font-size: var(--label-font-size); + margin-bottom: var(--label-gap); } input, @@ -1003,10 +1548,10 @@ textarea, select { width: 100%; color: var(--text); - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(246, 240, 255, 0.14); + background: color-mix(in srgb, var(--text) 3%, transparent); + border: 1px solid color-mix(in srgb, var(--text) 14%, transparent); border-radius: 12px; - padding: 10px 10px; + padding: var(--control-pad-y) var(--control-pad-x); outline: none; } @@ -1014,24 +1559,24 @@ input:focus, textarea:focus, select:focus, .editor:focus-within { - border-color: rgba(255, 62, 165, 0.45); - box-shadow: 0 0 0 4px rgba(255, 62, 165, 0.10); + border-color: color-mix(in srgb, var(--accent) 45%, transparent); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 10%, transparent); } button { - border: 1px solid rgba(246, 240, 255, 0.14); - background: rgba(255, 255, 255, 0.04); + border: 1px solid color-mix(in srgb, var(--text) 14%, transparent); + background: color-mix(in srgb, var(--text) 4%, transparent); color: var(--text); border-radius: 12px; - padding: 10px 12px; + padding: var(--btn-pad-y) var(--btn-pad-x); cursor: pointer; transition: transform var(--dur-fast) var(--ease-soft), border-color var(--dur-fast) var(--ease-soft), background var(--dur-fast) var(--ease-soft), box-shadow var(--dur-fast) var(--ease-soft), filter var(--dur-fast) var(--ease-soft); } button.primary { - background: linear-gradient(180deg, rgba(255, 62, 165, 0.95), rgba(184, 75, 255, 0.90)); - border-color: rgba(255, 62, 165, 0.30); + background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 95%, transparent), color-mix(in srgb, var(--accent2) 90%, transparent)); + border-color: color-mix(in srgb, var(--accent) 30%, transparent); color: #120013; font-weight: 900; } @@ -1042,12 +1587,12 @@ button.ghost { button.danger { border-color: rgba(255, 77, 138, 0.26); - background: rgba(255, 77, 138, 0.10); + background: color-mix(in srgb, var(--bad) 10%, transparent); } button.danger:hover:not(:disabled) { - background: rgba(255, 77, 138, 0.14); - border-color: rgba(255, 77, 138, 0.35); + background: color-mix(in srgb, var(--bad) 14%, transparent); + border-color: color-mix(in srgb, var(--bad) 35%, transparent); } button:hover { @@ -1060,8 +1605,8 @@ button:active { button:focus-visible { outline: none; - box-shadow: 0 0 0 4px rgba(255, 62, 165, 0.14); - border-color: rgba(255, 62, 165, 0.35); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 14%, transparent); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); } button:disabled { @@ -1077,25 +1622,25 @@ button:disabled { padding: 6px 10px; border-radius: 999px; font-size: 12px; - border: 1px solid rgba(246, 240, 255, 0.14); - background: rgba(255, 255, 255, 0.02); + border: 1px solid color-mix(in srgb, var(--text) 14%, transparent); + background: color-mix(in srgb, var(--text) 2%, transparent); } .badge-good { - border-color: rgba(61, 220, 151, 0.25); - background: rgba(61, 220, 151, 0.10); + border-color: color-mix(in srgb, var(--good) 25%, transparent); + background: color-mix(in srgb, var(--good) 10%, transparent); color: #baf6dd; } .badge-warn { - border-color: rgba(255, 62, 165, 0.30); - background: rgba(255, 62, 165, 0.10); + border-color: color-mix(in srgb, var(--accent) 30%, transparent); + background: color-mix(in srgb, var(--accent) 10%, transparent); color: #ffd2ea; } .badge-bad { - border-color: rgba(255, 77, 138, 0.30); - background: rgba(255, 77, 138, 0.10); + border-color: color-mix(in srgb, var(--bad) 30%, transparent); + background: color-mix(in srgb, var(--bad) 10%, transparent); color: #ffd2e2; } @@ -1105,9 +1650,9 @@ button:disabled { align-items: center; flex-wrap: nowrap; padding: 6px; - border: 1px solid rgba(246, 240, 255, 0.1); + border: 1px solid color-mix(in srgb, var(--text) 10%, transparent); border-radius: 12px; - background: rgba(255, 255, 255, 0.02); + background: color-mix(in srgb, var(--text) 2%, transparent); overflow-x: auto; max-width: 100%; } @@ -1360,6 +1905,48 @@ button:disabled { background var(--dur-med) var(--ease-out), box-shadow var(--dur-med) var(--ease-out); } +.feed.hivesListView .post { + padding: 10px; + gap: 6px; +} + +.feed.hivesListView .postTop { + align-items: center; +} + +.feed.hivesListView .postTitleRow { + gap: 0; + min-width: 0; +} + +.feed.hivesListView .postTitle { + margin-bottom: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.feed.hivesListView .postMeta, +.feed.hivesListView .reactionsRow, +.feed.hivesListView .boostControls, +.feed.hivesListView .countdown.boost { + display: none !important; +} + +.feed.hivesListView .postMeta { + display: flex !important; + margin-top: 6px; + flex-wrap: nowrap; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + gap: 6px; +} + +.feed.hivesListView .rightCol { + gap: 6px; +} + .post:hover { transform: translateY(-2px); border-color: rgba(255, 62, 165, 0.22); @@ -1515,8 +2102,8 @@ button:disabled { font-size: 12px; padding: 4px 8px; border-radius: 999px; - border: 1px solid rgba(255, 62, 165, 0.28); - background: rgba(255, 62, 165, 0.10); + border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent); + background: color-mix(in srgb, var(--accent) 10%, transparent); color: #ffd2ea; } @@ -1738,6 +2325,7 @@ button:disabled { gap: 0; flex: 1; min-height: 0; + container-type: inline-size; } .typingIndicator { @@ -1780,10 +2368,34 @@ button:disabled { border-radius: 14px; padding: 8px 9px; margin-top: 8px; + width: fit-content; + margin-left: auto; + margin-right: auto; + max-width: min(var(--chat-rail-center-max, 70%), calc(100% - (var(--chat-rail-inset, 12px) * 2))); transition: border-color var(--dur-med) var(--ease-out), background var(--dur-med) var(--ease-out), box-shadow var(--dur-med) var(--ease-out), transform var(--dur-med) var(--ease-out); } +.chatMsg.railLeft { + margin-left: var(--chat-rail-inset, 12px); + margin-right: auto; + max-width: min(var(--chat-rail-side-max, 66%), calc(100% - (var(--chat-rail-inset, 12px) * 2))); +} + +.chatMsg.railCenter { + margin-left: auto; + margin-right: auto; + max-width: min(var(--chat-rail-center-max, 70%), calc(100% - (var(--chat-rail-inset, 12px) * 2))); +} + +.chatMsg.railRight { + margin-left: auto; + margin-right: var(--chat-rail-inset, 12px); + max-width: min(var(--chat-rail-side-max, 66%), calc(100% - (var(--chat-rail-inset, 12px) * 2))); + background: color-mix(in srgb, var(--accent) 10%, rgba(255, 255, 255, 0.02)); + border-color: color-mix(in srgb, var(--accent) 30%, rgba(246, 240, 255, 0.12)); +} + .chatMsg:first-child { margin-top: 0; } @@ -1792,15 +2404,69 @@ button:disabled { margin-top: 4px; } +.chatMsg.railRight .meta { + justify-content: flex-end; +} + .chatMsg.mentionMe { border-color: rgba(255, 62, 165, 0.45); box-shadow: 0 0 0 2px rgba(255, 62, 165, 0.14); } +.chatMsg.isModMsg { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--warn) 22%, transparent), + rgba(255, 255, 255, 0.02) + ), + rgba(255, 255, 255, 0.02); + border-color: color-mix(in srgb, var(--warn2) 38%, rgba(246, 240, 255, 0.12)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--warn2) 10%, transparent); +} + +.chatMsg.isModMsg .meta { + color: color-mix(in srgb, var(--warn) 60%, var(--muted)); +} + +.modPill { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 900; + letter-spacing: 0.3px; + color: color-mix(in srgb, var(--warn) 88%, var(--text)); + border: 1px solid color-mix(in srgb, var(--warn2) 42%, transparent); + background: color-mix(in srgb, var(--warn2) 10%, transparent); + padding: 2px 8px; + border-radius: 999px; +} + .chatMsg.isNewMsg { animation: msgIn 520ms var(--ease-out) both; } +/* When the chat container gets narrow, collapse center-lane messages to the left lane. + Self stays right-aligned; mod/system keep their visual treatment. */ +@container (max-width: 520px) { + .chatMessages { + --chat-rail-side-max: 92%; + --chat-rail-center-max: 92%; + } + + .chatMsg.railCenter { + margin-left: var(--chat-rail-inset, 12px); + margin-right: auto; + } + + .chatMsg.railCenter .meta { + justify-content: flex-start; + } + + .chatMsg.isModMsg { + margin-left: calc(var(--chat-rail-inset, 12px) + 8px); + } +} + .chatMsg:hover { border-color: rgba(246, 240, 255, 0.18); background: rgba(255, 255, 255, 0.028); @@ -2073,6 +2739,20 @@ button:disabled { font-size: 12px; } +.devLogPre { + margin: 0; + padding: 10px; + border-radius: 12px; + border: 1px solid rgba(246, 240, 255, 0.10); + background: rgba(0, 0, 0, 0.18); + max-height: min(52vh, 520px); + overflow: auto; + font-size: 12px; + line-height: 1.2rem; + white-space: pre-wrap; + word-break: break-word; +} + .modStatus { display: inline-flex; align-items: center; diff --git a/CLEAN_INSTALL/server.js b/CLEAN_INSTALL/server.js @@ -182,13 +182,44 @@ let usersByName = new Map(); /** @type {any[]} */ let moderationLog = []; +let devLog = []; +let devLogSeq = 1; /** @type {any[]} */ let reports = []; /** @type {Map<string, any>} */ let sessionsById = new Map(); let collections = []; let customRoles = []; -let instanceBranding = { title: "Bzl", subtitle: "Ephemeral hives + chat", allowMemberPermanentPosts: false }; +let instanceBranding = { + title: "Bzl", + subtitle: "Ephemeral hives + chat", + allowMemberPermanentPosts: false, + appearance: { + bg: "#060611", + panel: "#0c0c18", + text: "#f6f0ff", + accent: "#ff3ea5", + accent2: "#b84bff", + good: "#3ddc97", + bad: "#ff4d8a", + fontBody: "system", + fontMono: "mono", + mutedPct: 65, + linePct: 10, + panel2Pct: 2 + } +}; +let lastInstanceBroadcastHash = ""; + +function broadcastInstanceUpdated(force = false) { + const clean = sanitizeInstanceBranding(instanceBranding); + instanceBranding = clean; + const hash = JSON.stringify(clean); + if (!force && hash === lastInstanceBroadcastHash) return false; + lastInstanceBroadcastHash = hash; + sendToSockets(() => true, { type: "instanceUpdated", instance: instanceBranding }); + return true; +} let dmKey = null; /** @type {Map<string, any>} */ let dmThreadsById = new Map(); @@ -443,6 +474,12 @@ function sanitizeColorHex(color) { return c.toLowerCase(); } +function sanitizePercentInt(value, fallback) { + const n = Math.floor(Number(value)); + if (!Number.isFinite(n)) return fallback; + return Math.max(0, Math.min(100, n)); +} + function sanitizeInstanceText(text, maxLen) { if (typeof text !== "string") return ""; const value = text.replace(/\s+/g, " ").trim(); @@ -455,7 +492,21 @@ function sanitizeInstanceBranding(raw) { const title = sanitizeInstanceText(raw?.title || "", INSTANCE_TITLE_MAX_LEN) || "Bzl"; const subtitle = sanitizeInstanceText(raw?.subtitle || "", INSTANCE_SUBTITLE_MAX_LEN) || "Ephemeral hives + chat"; const allowMemberPermanentPosts = Boolean(raw?.allowMemberPermanentPosts); - return { title, subtitle, allowMemberPermanentPosts }; + const appearanceRaw = raw?.appearance && typeof raw.appearance === "object" ? raw.appearance : {}; + const bg = sanitizeColorHex(appearanceRaw.bg) || "#060611"; + const panel = sanitizeColorHex(appearanceRaw.panel) || "#0c0c18"; + const text = sanitizeColorHex(appearanceRaw.text) || "#f6f0ff"; + const accent = sanitizeColorHex(appearanceRaw.accent) || "#ff3ea5"; + const accent2 = sanitizeColorHex(appearanceRaw.accent2) || "#b84bff"; + const good = sanitizeColorHex(appearanceRaw.good) || "#3ddc97"; + const bad = sanitizeColorHex(appearanceRaw.bad) || "#ff4d8a"; + const fontBody = ["system", "serif", "mono"].includes(String(appearanceRaw.fontBody || "")) ? String(appearanceRaw.fontBody) : "system"; + const fontMono = ["mono", "system"].includes(String(appearanceRaw.fontMono || "")) ? String(appearanceRaw.fontMono) : "mono"; + const mutedPct = sanitizePercentInt(appearanceRaw.mutedPct, 65); + const linePct = sanitizePercentInt(appearanceRaw.linePct, 10); + const panel2Pct = sanitizePercentInt(appearanceRaw.panel2Pct, 2); + const appearance = { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct }; + return { title, subtitle, allowMemberPermanentPosts, appearance }; } function sanitizeAvatar(avatar) { @@ -822,11 +873,13 @@ function verifyPostPassword(post, password) { function serializeChatMessageForWs(message) { if (!message || typeof message !== "object") return null; + const asMod = Boolean(message.asMod) || String(message.fromUser || "").trim().toLowerCase() === "mod"; return { id: typeof message.id === "string" ? message.id : "", postId: typeof message.postId === "string" ? message.postId : "", text: typeof message.text === "string" ? message.text : "", html: typeof message.html === "string" ? message.html : "", + asMod, mentions: Array.isArray(message.mentions) ? message.mentions : [], replyTo: message.replyTo || null, deleted: Boolean(message.deleted), @@ -837,8 +890,8 @@ function serializeChatMessageForWs(message) { editedAt: Number(message.editedAt || 0) || 0, reactions: message.reactions || {}, createdAt: Number(message.createdAt || 0) || 0, - fromClientId: typeof message.fromClientId === "string" ? message.fromClientId : "", - fromUser: normalizeUsername(message.fromUser || "") + fromClientId: !asMod && typeof message.fromClientId === "string" ? message.fromClientId : "", + fromUser: asMod ? "MOD" : normalizeUsername(message.fromUser || "") }; } @@ -1020,6 +1073,52 @@ function appendModLog(entry) { return clean; } +function safeDevLogText(value, maxLen = 800) { + const s = typeof value === "string" ? value : value == null ? "" : String(value); + const trimmed = s.replace(/\s+/g, " ").trim(); + return trimmed.length > maxLen ? `${trimmed.slice(0, maxLen)}…` : trimmed; +} + +function safeDevLogJson(value, maxLen = 2400) { + if (value == null) return ""; + try { + const raw = JSON.stringify(value); + if (!raw) return ""; + return raw.length > maxLen ? `${raw.slice(0, maxLen)}…` : raw; + } catch (e) { + return safeDevLogText(e?.message || e, maxLen); + } +} + +function appendDevLog(entry) { + const clean = { + id: devLogSeq++, + createdAt: now(), + level: safeDevLogText(entry?.level || "info", 16).toLowerCase(), + scope: safeDevLogText(entry?.scope || "server", 80), + message: safeDevLogText(entry?.message || "", 2000), + data: safeDevLogJson(entry?.data, 8000) + }; + + devLog.unshift(clean); + if (devLog.length > 2000) devLog.splice(2000); + + sendToSockets( + (ws) => ws.user?.username && hasRole(ws.user.username, ROLE_MODERATOR), + { type: "devLogAppended", entry: clean } + ); + return clean; +} + +function listDevLog(limit = 200) { + const n = Math.max(1, Math.min(1000, Math.floor(Number(limit) || 200))); + return devLog.slice(0, n); +} + +function sendDevLogForWs(ws, limit = 200) { + ws.send(JSON.stringify({ type: "devLogSnapshot", log: listDevLog(limit) })); +} + function writeUserPatch(username, patchFn) { const normalized = normalizeUsername(username); if (!normalized) return { ok: false, message: "User not found." }; @@ -1293,11 +1392,12 @@ function loadCollectionsFromDisk() { } } -function broadcastCollections() { +function broadcastCollections(opts = {}) { + const includePostsSnapshot = opts.includePostsSnapshot !== false; for (const ws of sockets) { if (ws.readyState !== ws.OPEN) continue; ws.send(JSON.stringify({ type: "collectionsUpdated", collections: listCollectionsForClient(ws.user?.username || "") })); - sendPostsSnapshot(ws); + if (includePostsSnapshot) sendPostsSnapshot(ws); } } @@ -1663,8 +1763,8 @@ function loadPluginsFromDisk() { if (!manifest) continue; pluginManifestsById.set(id, manifest); if (!pluginsStateById.has(id)) pluginsStateById.set(id, { enabled: false }); - pluginRuntimeById.set(id, { wsHandlers: new Map(), httpHandlers: new Map(), onCloseHandlers: [] }); - } + pluginRuntimeById.set(id, { wsHandlers: new Map(), httpHandlers: new Map(), onCloseHandlers: [] }); + } // Load enabled server plugins (optional). for (const [id, manifest] of pluginManifestsById.entries()) { @@ -1674,10 +1774,10 @@ function loadPluginsFromDisk() { const dir = pluginDirForId(id); const entryPath = path.resolve(dir, manifest.entryServer); const root = dir + path.sep; - if (!entryPath.startsWith(root)) { - pluginRuntimeById.set(id, { wsHandlers: new Map(), httpHandlers: new Map(), onCloseHandlers: [], error: "Invalid server entry path." }); - continue; - } + if (!entryPath.startsWith(root)) { + pluginRuntimeById.set(id, { wsHandlers: new Map(), httpHandlers: new Map(), onCloseHandlers: [], error: "Invalid server entry path." }); + continue; + } try { try { @@ -1689,12 +1789,7 @@ function loadPluginsFromDisk() { // eslint-disable-next-line global-require, import/no-dynamic-require const init = require(entryPath); if (typeof init !== "function") { - pluginRuntimeById.set(id, { - wsHandlers: new Map(), - httpHandlers: new Map(), - onCloseHandlers: [], - error: "Server entry must export a function." - }); + pluginRuntimeById.set(id, { wsHandlers: new Map(), httpHandlers: new Map(), onCloseHandlers: [], error: "Server entry must export a function." }); continue; } const runtime = pluginRuntimeById.get(id) || { wsHandlers: new Map(), httpHandlers: new Map(), onCloseHandlers: [] }; @@ -1844,8 +1939,15 @@ function loadInstanceFromDisk() { const data = readJsonFileOrNull(INSTANCE_FILE); if (!data || typeof data !== "object") { instanceBranding = sanitizeInstanceBranding(instanceBranding); + lastInstanceBroadcastHash = JSON.stringify(instanceBranding); return; } + const appearance = + data?.appearance && typeof data.appearance === "object" + ? data.appearance + : data?.instance?.appearance && typeof data.instance.appearance === "object" + ? data.instance.appearance + : {}; instanceBranding = sanitizeInstanceBranding({ title: typeof data.title === "string" ? data.title : data?.instance?.title, subtitle: typeof data.subtitle === "string" ? data.subtitle : data?.instance?.subtitle, @@ -1853,8 +1955,10 @@ function loadInstanceFromDisk() { Object.prototype.hasOwnProperty.call(data, "allowMemberPermanentPosts") ? data.allowMemberPermanentPosts : data?.instance?.allowMemberPermanentPosts - ) + ), + appearance }); + lastInstanceBroadcastHash = JSON.stringify(instanceBranding); } function persistInstanceToDisk() { @@ -2162,6 +2266,7 @@ function loadPostsFromDisk() { const html = htmlRaw ? sanitizeRichHtml(htmlRaw) : ""; const createdAtMsg = Number(m.createdAt || 0) || createdAt; const fromUser = normalizeUsername(m.fromUser || ""); + const asMod = Boolean(m.asMod) || String(fromUser || "").toLowerCase() === "mod"; const mentions = Array.isArray(m.mentions) ? m.mentions.map((x) => normalizeUsername(x)).filter(Boolean).slice(0, 16) : []; @@ -2188,8 +2293,9 @@ function loadPostsFromDisk() { editedAt, reactions: {}, createdAt: createdAtMsg, - fromClientId: typeof m.fromClientId === "string" ? m.fromClientId : "", - fromUser: fromUser || "" + asMod, + fromClientId: !asMod && typeof m.fromClientId === "string" ? m.fromClientId : "", + fromUser: asMod ? "MOD" : fromUser || "" }); } if (snapChat.length > CHAT_MAX_PER_POST) snapChat.splice(0, snapChat.length - CHAT_MAX_PER_POST); @@ -2581,6 +2687,7 @@ function markChatDeleted(messageRef, actor, roleOverride = "") { const message = messageRef.message; if (message.deleted) return { ok: false, message: "Message is already deleted." }; if (!message.deletedSnapshot) { + const asMod = Boolean(message.asMod) || String(message.fromUser || "").trim().toLowerCase() === "mod"; message.deletedSnapshot = { savedAt: now(), message: { @@ -2588,11 +2695,12 @@ function markChatDeleted(messageRef, actor, roleOverride = "") { postId: message.postId, text: typeof message.text === "string" ? message.text : "", html: typeof message.html === "string" ? message.html : "", + asMod, mentions: Array.isArray(message.mentions) ? [...message.mentions] : [], replyTo: message.replyTo || null, createdAt: Number(message.createdAt || 0) || 0, - fromClientId: typeof message.fromClientId === "string" ? message.fromClientId : "", - fromUser: normalizeUsername(message.fromUser || "") + fromClientId: !asMod && typeof message.fromClientId === "string" ? message.fromClientId : "", + fromUser: asMod ? "MOD" : normalizeUsername(message.fromUser || "") }, reactions: mapSetsToObj(chatReactionsByMessageId.get(message.id)) }; @@ -3122,7 +3230,15 @@ async function handlePluginInstall(req, res, url) { } // Move extracted plugin directory into place. - fs.renameSync(extractedRoot, destDir); + try { + fs.renameSync(extractedRoot, destDir); + } catch (renameErr) { + if (renameErr.code === "EXDEV") { + fs.cpSync(extractedRoot, destDir, { recursive: true }); + } else { + throw renameErr; + } + } // Ensure state entry exists and defaults to disabled. if (!pluginsStateById.has(manifest.id)) pluginsStateById.set(manifest.id, { enabled: false }); @@ -3316,7 +3432,23 @@ function serveStatic(req, res) { if (pathname === "/api/info") { res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); - res.end(JSON.stringify({ port: PORT, registrationEnabled: registrationEnabled() })); + res.end( + JSON.stringify({ + port: PORT, + host: HOST, + serverTime: now(), + registrationEnabled: registrationEnabled(), + config: { + rateLimits: { + mod: { windowMs: RL_MOD_WINDOW_MS, max: RL_MOD_MAX }, + login: { windowMs: RL_LOGIN_WINDOW_MS, max: RL_LOGIN_MAX }, + register: { windowMs: RL_REGISTER_WINDOW_MS, max: RL_REGISTER_MAX }, + resume: { windowMs: RL_RESUME_WINDOW_MS, max: RL_RESUME_MAX }, + report: { windowMs: RL_REPORT_WINDOW_MS, max: RL_REPORT_MAX } + } + } + }) + ); return; } @@ -3439,6 +3571,9 @@ function sendLoginOk(ws, username, sessionToken) { ); sendDmSnapshot(ws); sendPluginsForWs(ws); + if (ws?.user?.username && hasRole(ws.user.username, ROLE_MODERATOR)) { + sendDevLogForWs(ws, 200); + } } function modViewAllowed(ws) { @@ -3890,9 +4025,14 @@ loadInstanceFromDisk(); try { fs.mkdirSync(path.dirname(INSTANCE_FILE), { recursive: true }); if (fs.existsSync(INSTANCE_FILE)) { + let instanceWatchTimer = null; fs.watch(INSTANCE_FILE, { persistent: false }, () => { - loadInstanceFromDisk(); - sendToSockets(() => true, { type: "instanceUpdated", instance: instanceBranding }); + if (instanceWatchTimer) clearTimeout(instanceWatchTimer); + instanceWatchTimer = setTimeout(() => { + instanceWatchTimer = null; + loadInstanceFromDisk(); + broadcastInstanceUpdated(false); + }, 75); }); } } catch { @@ -3900,6 +4040,7 @@ try { } loadPluginsFromDisk(); +appendDevLog({ level: "info", scope: "server", message: "Server started", data: { port: PORT, host: HOST } }); try { fs.mkdirSync(path.dirname(PLUGINS_FILE), { recursive: true }); fs.mkdirSync(PLUGINS_DIR, { recursive: true }); @@ -4450,12 +4591,14 @@ wss.on("connection", (ws, req) => { const hasProtectedField = Object.prototype.hasOwnProperty.call(msg, "protected"); const hasCollectionField = Object.prototype.hasOwnProperty.call(msg, "collectionId"); const hasKeywordsField = Object.prototype.hasOwnProperty.call(msg, "keywords"); + const hasModeField = Object.prototype.hasOwnProperty.call(msg, "mode") || Object.prototype.hasOwnProperty.call(msg, "chatMode"); const beforeCollectionId = normalizeCollectionId(entry.post.collectionId || "") || DEFAULT_COLLECTION_ID; const beforeProtected = Boolean(entry.post.protected); const beforeKeywords = Array.isArray(entry.post.keywords) ? [...entry.post.keywords] : []; const beforeTitle = entry.post.title || ""; const beforeContent = textPreview(entry.post.content || ""); + const beforeMode = sanitizePostMode(entry.post.mode || entry.post.chatMode || ""); if (hasCollectionField) { const requestedCollectionId = normalizeCollectionId(msg.collectionId || ""); @@ -4475,6 +4618,10 @@ wss.on("connection", (ws, req) => { entry.post.keywords = normalizeKeywords(msg.keywords); } + if (hasModeField) { + entry.post.mode = sanitizePostMode(msg.mode || msg.chatMode || ""); + } + if (hasProtectedField) { if (!wantsProtected) { entry.post.protected = false; @@ -4523,6 +4670,8 @@ wss.on("connection", (ws, req) => { afterProtected: Boolean(entry.post.protected), beforeKeywords: beforeKeywords.join(", "), afterKeywords: (entry.post.keywords || []).join(", "), + beforeMode, + afterMode: sanitizePostMode(entry.post.mode || ""), editCount: entry.post.editCount, editedAt: entry.post.editedAt } @@ -4663,18 +4812,21 @@ wss.on("connection", (ws, req) => { } : null; const mentions = extractMentionUsernames(safeText); + const wantsMod = Boolean(msg.asMod); + const asMod = wantsMod && hasRole(ws.user.username, ROLE_MODERATOR); const message = { id: toId(), postId, text: safeText || "[media]", html: safeHtml, + asMod, mentions: sanitizePostMode(entry.post?.mode) === "walkie" ? [] : mentions, replyTo, reactions: {}, createdAt: now(), - fromClientId: ws.clientId, - fromUser: ws.user.username + fromClientId: asMod ? "" : ws.clientId, + fromUser: asMod ? "MOD" : ws.user.username }; appendChatMessage(postId, message); const t = message.createdAt; @@ -5142,6 +5294,48 @@ wss.on("connection", (ws, req) => { return; } + if (msg.type === "devLogList") { + if (!modViewAllowed(ws)) { + ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." })); + return; + } + sendDevLogForWs(ws, msg.limit || 200); + return; + } + + if (msg.type === "devLogClear") { + const actor = ws?.user?.username; + if (!actor || !hasRole(actor, ROLE_OWNER)) { + ws.send(JSON.stringify({ type: "permissionDenied", message: "Owner access required." })); + return; + } + devLog = []; + devLogSeq = 1; + sendToSockets( + (client) => client.user?.username && hasRole(client.user.username, ROLE_MODERATOR), + { type: "devLogSnapshot", log: [] } + ); + ws.send(JSON.stringify({ type: "devLogOk", cleared: true })); + appendDevLog({ level: "warn", scope: "server", message: "Dev log cleared", data: { by: actor } }); + return; + } + + if (msg.type === "devLogClient") { + if (!modViewAllowed(ws)) { + ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." })); + return; + } + const actor = normalizeUsername(ws.user?.username || "") || "unknown"; + appendDevLog({ + level: msg.level || "info", + scope: `client:${actor}${msg.scope ? `:${safeDevLogText(msg.scope, 80)}` : ""}`, + message: msg.message || "", + data: msg.data + }); + ws.send(JSON.stringify({ type: "devLogOk" })); + return; + } + if (msg.type === "collectionList") { sendCollectionsForWs(ws); return; @@ -5227,14 +5421,24 @@ wss.on("connection", (ws, req) => { } const subtitle = sanitizeInstanceText(msg.subtitle || "", INSTANCE_SUBTITLE_MAX_LEN); const allowMemberPermanentPosts = Boolean(msg.allowMemberPermanentPosts); - instanceBranding = sanitizeInstanceBranding({ title, subtitle, allowMemberPermanentPosts }); + const appearance = + msg?.appearance && typeof msg.appearance === "object" + ? msg.appearance + : { + accent: msg.accent, + accent2: msg.accent2, + fontBody: msg.fontBody, + fontMono: msg.fontMono + }; + instanceBranding = sanitizeInstanceBranding({ title, subtitle, allowMemberPermanentPosts, appearance }); try { persistInstanceToDisk(); } catch (e) { ws.send(JSON.stringify({ type: "error", message: e?.message || "Failed to save instance settings." })); return; } - sendToSockets(() => true, { type: "instanceUpdated", instance: instanceBranding }); + broadcastInstanceUpdated(true); + ws.send(JSON.stringify({ type: "instanceOk", instance: instanceBranding })); appendModLog({ actionType: "instance_branding_set", actor, @@ -5244,12 +5448,56 @@ wss.on("connection", (ws, req) => { metadata: { title: instanceBranding.title, subtitle: instanceBranding.subtitle, - allowMemberPermanentPosts: Boolean(instanceBranding.allowMemberPermanentPosts) + allowMemberPermanentPosts: Boolean(instanceBranding.allowMemberPermanentPosts), + appearance: instanceBranding.appearance } }); return; } + if (msg.type === "instanceSetAppearance") { + const actor = ws?.user?.username; + if (!actor || !hasRole(actor, ROLE_MODERATOR)) { + ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." })); + return; + } + const appearance = + msg?.appearance && typeof msg.appearance === "object" + ? msg.appearance + : { + bg: msg.bg, + panel: msg.panel, + text: msg.text, + accent: msg.accent, + accent2: msg.accent2, + good: msg.good, + bad: msg.bad, + fontBody: msg.fontBody, + fontMono: msg.fontMono, + mutedPct: msg.mutedPct, + linePct: msg.linePct, + panel2Pct: msg.panel2Pct + }; + instanceBranding = sanitizeInstanceBranding({ ...instanceBranding, appearance }); + try { + persistInstanceToDisk(); + } catch (e) { + ws.send(JSON.stringify({ type: "error", message: e?.message || "Failed to save instance appearance." })); + return; + } + broadcastInstanceUpdated(true); + ws.send(JSON.stringify({ type: "instanceOk", instance: instanceBranding })); + appendModLog({ + actionType: "instance_appearance_set", + actor, + targetType: "system", + targetId: "instance", + reason: "Updated instance appearance", + metadata: { appearance: instanceBranding.appearance } + }); + return; + } + if (msg.type === "collectionCreate") { if (!modViewAllowed(ws)) { ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." })); @@ -5290,7 +5538,14 @@ wss.on("connection", (ws, req) => { allowedRoles: [], archived: false }); - persistCollections(); + try { + persistCollections(); + } catch (e) { + // Roll back in-memory mutation so the UI doesn't "ghost create". + collections = collections.filter((c) => c && c.name !== name); + sendError(ws, e?.message || "Failed to save collections."); + return; + } appendModLog({ actionType: "collection_create", actor: ws.user?.username || "unknown", @@ -5299,7 +5554,9 @@ wss.on("connection", (ws, req) => { reason: "Created collection", metadata: { name } }); - broadcastCollections(); + // Creating an empty collection does not change post visibility; avoid expensive post snapshots. + ws.send(JSON.stringify({ type: "collectionOk", name })); + broadcastCollections({ includePostsSnapshot: false }); return; } @@ -5334,7 +5591,8 @@ wss.on("connection", (ws, req) => { reason: "Archived collection", metadata: { collectionId: id } }); - broadcastCollections(); + // Archiving a collection should not change post visibility; keep this lightweight. + broadcastCollections({ includePostsSnapshot: false }); return; } @@ -5406,7 +5664,13 @@ wss.on("connection", (ws, req) => { createdBy: ws.user?.username || "system", archived: false }); - persistCustomRoles(); + try { + persistCustomRoles(); + } catch (e) { + customRoles = customRoles.filter((r) => r && r.key !== key); + sendError(ws, e?.message || "Failed to save roles."); + return; + } appendModLog({ actionType: "custom_role_create", actor: ws.user?.username || "unknown", @@ -5415,8 +5679,10 @@ wss.on("connection", (ws, req) => { reason: "Created custom role", metadata: { key, label, color } }); + ws.send(JSON.stringify({ type: "roleOk", key })); broadcastCustomRoles(); - broadcastCollections(); + // Creating a role does not change post visibility; avoid expensive post snapshots. + broadcastCollections({ includePostsSnapshot: false }); broadcastPeopleSnapshot(); return; } diff --git a/plugins_dev/maps/server.js b/plugins_dev/maps/server.js @@ -2,6 +2,11 @@ const fs = require("fs"); const path = require("path"); module.exports = function init(api) { + const MAP_CHAT_GLOBAL_MAX = 200; + const MAP_CHAT_LOCAL_RADIUS = Number.isFinite(Number(process.env.MAP_CHAT_LOCAL_RADIUS)) + ? Math.max(0.01, Math.min(1.0, Number(process.env.MAP_CHAT_LOCAL_RADIUS))) + : 0.12; // positions are normalized 0..1 + const BUILTIN_MAPS = [ { id: "studio", @@ -31,7 +36,7 @@ module.exports = function init(api) { /** @type {Array<{id:string,title:string,owner:string,backgroundUrl:string,thumbUrl:string,world?:{w:number,h:number}|null,avatarSize?:number,cameraZoom?:number,collisions?:any[],masks?:any[],exits?:any[],ttrpgEnabled?:boolean,sprites?:any[],props?:any[],walkiesEnabled?:boolean}>} */ let customMaps = []; - /** @type {Map<string, {users: Map<string, {x:number,y:number,color:string,image:string,invisible?:boolean,seq?:number}>, lastListAt:number, walkies?: Map<string, {url:string, pending:Set<string>, createdAt:number, mapId:string}>}>} */ + /** @type {Map<string, {users: Map<string, {x:number,y:number,color:string,image:string,invisible?:boolean,seq?:number}>, lastListAt:number, walkies?: Map<string, {url:string, pending:Set<string>, createdAt:number, mapId:string}>, chatGlobal?: Array<{id:string,fromUser:string,text:string,createdAt:number}>}>} */ const rooms = new Map(); function normId(raw) { @@ -304,10 +309,21 @@ module.exports = function init(api) { function roomFor(mapId) { const mid = normId(mapId); if (!mid) return null; - if (!rooms.has(mid)) rooms.set(mid, { users: new Map(), lastListAt: 0, walkies: new Map() }); + if (!rooms.has(mid)) rooms.set(mid, { users: new Map(), lastListAt: 0, walkies: new Map(), chatGlobal: [] }); return rooms.get(mid) || null; } + function sanitizeMapChatText(text) { + const raw = typeof text === "string" ? text : ""; + return raw.replace(/\s+/g, " ").trim().slice(0, 420); + } + + function distance01(ax, ay, bx, by) { + const dx = Number(ax) - Number(bx); + const dy = Number(ay) - Number(by); + return Math.sqrt(dx * dx + dy * dy); + } + function userIdentity(ws) { const u = ws?.user?.username ? String(ws.user.username).trim().toLowerCase() : ""; return u && /^[a-z0-9][a-z0-9_.-]{0,31}$/.test(u) ? u : ""; @@ -980,6 +996,64 @@ module.exports = function init(api) { } }); + api.registerWs("chatHistoryReq", (ws, msg) => { + const username = userIdentity(ws); + if (!username) return; + const mapId = normId(msg?.mapId || ws.__mapsRoomId || ""); + if (!mapId) return; + const room = rooms.get(mapId); + if (!room) return; + if (!room.users.has(username)) return; + const list = Array.isArray(room.chatGlobal) ? room.chatGlobal : []; + ws.send(JSON.stringify({ type: "plugin:maps:chatHistory", mapId, scope: "global", messages: list.slice(-MAP_CHAT_GLOBAL_MAX) })); + }); + + api.registerWs("chatSend", (ws, msg) => { + const username = userIdentity(ws); + if (!username) return; + const mapId = normId(ws.__mapsRoomId || msg?.mapId || ""); + if (!mapId) return; + const room = rooms.get(mapId); + if (!room) return; + const u = room.users.get(username); + if (!u) return; + + const scopeRaw = typeof msg?.scope === "string" ? msg.scope.trim().toLowerCase() : "local"; + const scope = scopeRaw === "global" ? "global" : "local"; + const text = sanitizeMapChatText(msg?.text); + if (!text) return; + + const createdAt = api.now(); + const id = `${createdAt}_${Math.random().toString(16).slice(2)}`; + const message = { id, fromUser: username, text, createdAt }; + const payload = { type: "plugin:maps:chatMessage", mapId, scope, message }; + + // If invisible, only send to self (consistent with bubbles/movement). + if (u.invisible) { + api.sendToUsers([username], payload); + return; + } + + if (scope === "global") { + if (!Array.isArray(room.chatGlobal)) room.chatGlobal = []; + room.chatGlobal.push(message); + if (room.chatGlobal.length > MAP_CHAT_GLOBAL_MAX * 2) room.chatGlobal = room.chatGlobal.slice(-MAP_CHAT_GLOBAL_MAX); + api.sendToUsers(usersInRoom(mapId), payload); + return; + } + + // Local: deliver only to users within radius at send-time ("witnessing it"). + const recipients = []; + const all = Array.from(room.users.entries()); + for (const [otherName, other] of all) { + if (!other) continue; + const d = distance01(u.x, u.y, other.x, other.y); + if (d <= MAP_CHAT_LOCAL_RADIUS) recipients.push(otherName); + } + if (!recipients.includes(username)) recipients.push(username); + api.sendToUsers(recipients, payload); + }); + api.registerWs("say", (ws, msg) => { const username = userIdentity(ws); if (!username) return; diff --git a/public/app.js b/public/app.js @@ -25,9 +25,13 @@ const toggleRackLayoutEl = document.getElementById("toggleRackLayout"); const toggleSideRackEl = document.getElementById("toggleSideRack"); const toggleRightRackEl = document.getElementById("toggleRightRack"); const layoutPresetEl = document.getElementById("layoutPreset"); +const uiScaleEl = document.getElementById("uiScale"); +const deviceLayoutEl = document.getElementById("deviceLayout"); const dockHotbarEl = document.getElementById("dockHotbar"); const showSideRackBtn = document.getElementById("showSideRack"); const showRightRackBtn = document.getElementById("showRightRack"); +const chatModToggleWrapEl = document.getElementById("chatModToggleWrap"); +const chatModToggleEl = document.getElementById("chatModToggle"); const authHint = document.getElementById("authHint"); const userLabel = document.getElementById("userLabel"); @@ -125,6 +129,10 @@ const chatEditor = document.getElementById("chatEditor"); const mentionMenuEl = document.getElementById("mentionMenu"); const chatImageInput = document.getElementById("chatImage"); const chatAudioInput = document.getElementById("chatAudio"); + +// When selecting images/audio for chat, route the insertion to the most-recently focused rich editor +// (main chat panel or a chat instance panel). +let chatUploadTargetEditor = chatEditor; const walkieBarEl = document.getElementById("walkieBar"); const walkieRecordBtn = document.getElementById("walkieRecordBtn"); const walkieStatusEl = document.getElementById("walkieStatus"); @@ -155,6 +163,13 @@ const editModalEditor = document.getElementById("editModalEditor"); const editModalImageInput = document.getElementById("editModalImage"); const editModalAudioInput = document.getElementById("editModalAudio"); +// Temporarily force rack mode on (hide toggle) while the feature stabilizes. +const FORCE_RACK_MODE = true; + +// Display prefs (device layout + text scale) +const UI_SCALE_KEY = "bzl_uiScale"; // "auto" | "xs" | "sm" | "md" | "lg" +const DEVICE_LAYOUT_KEY = "bzl_deviceLayout"; // "auto" | "widescreen" | "fourThree" | "threeTwo" | "ultrawide" | "portrait" + /** @type {Map<string, any>} */ const posts = new Map(); /** @type {Record<string, {image?: string, color?: string}>} */ @@ -179,6 +194,13 @@ let canModerate = false; let canRegisterFirstUser = false; let registrationEnabled = false; let activeChatPostId = null; +let activeMapsRoomId = ""; +let activeMapsRoomTitle = ""; +let activeMapsChatScope = "local"; // "local" | "global" +/** @type {Map<string, any[]>} */ +const mapsChatGlobalByMapId = new Map(); +/** @type {Map<string, any[]>} */ +const mapsChatLocalByMapId = new Map(); let pendingProfileImage = ""; let windowFocused = true; let typingStopTimer = null; @@ -201,6 +223,21 @@ let peopleOpen = false; let peopleTab = "members"; let peopleMembers = []; let openPostMenuId = ""; + +// Multi-instance chat panels (MVP: per-hive/post chat panels). +/** @type {Map<string, {postId:string}>} */ +const chatPanelInstances = new Map(); + +function isChatInstancePanelId(panelId) { + const id = String(panelId || ""); + return id.startsWith("chat:post:"); +} + +function chatInstancePanelIdForPost(postId) { + const pid = String(postId || "").trim(); + if (!pid) return ""; + return `chat:post:${pid}`; +} let dmThreads = []; /** @type {Map<string, any>} */ let dmThreadsById = new Map(); @@ -383,6 +420,111 @@ function readStringPref(key, fallback = "") { } } +function normalizeUiScale(raw) { + const v = String(raw || "").trim().toLowerCase(); + if (v === "auto") return "auto"; + if (v === "xs" || v === "compact") return "xs"; + if (v === "sm" || v === "small") return "sm"; + if (v === "lg" || v === "large") return "lg"; + return "md"; +} + +function normalizeDeviceLayout(raw) { + const v = String(raw || "").trim().toLowerCase(); + if (v === "widescreen") return "widescreen"; + if (v === "fourthree" || v === "fourThree".toLowerCase() || v === "4:3" || v === "4x3") return "fourThree"; + if (v === "threetwo" || v === "threeTwo".toLowerCase() || v === "3:2" || v === "3x2") return "threeTwo"; + if (v === "ultrawide") return "ultrawide"; + if (v === "portrait") return "portrait"; + return "auto"; +} + +function detectViewportSize() { + const w = Math.max(1, Number(window.innerWidth) || 1); + const h = Math.max(1, Number(window.innerHeight) || 1); + // Keep this intentionally simple: we mostly care about β€œcan we fit columns sanely?” + // Consider both width and height so low-res (ex: 1280x720) can auto-compact. + if (w <= 1100 || h <= 720) return "xs"; + if (w <= 1400 || h <= 820) return "sm"; + if (w <= 1800) return "md"; + return "lg"; +} + +function detectAspectLayout() { + const w = Math.max(1, Number(window.innerWidth) || 1); + const h = Math.max(1, Number(window.innerHeight) || 1); + const ratio = w / h; + // Heuristics: + // - Portrait: <= ~1.25 + // - 4:3-ish: 1.25..1.38 + // - 3:2-ish: 1.38..1.62 (covers 3:2 and nearby) + // - Widescreen: 1.62..1.95 (16:10..~2:1) + // - Ultrawide: >= 1.95 + if (ratio <= 1.25) return "portrait"; + if (ratio < 1.38) return "fourThree"; + if (ratio >= 1.38 && ratio < 1.62) return "threeTwo"; + if (ratio >= 1.95) return "ultrawide"; + return "widescreen"; +} + +function applyDisplayPrefs() { + const root = document.documentElement; + if (!root) return; + const scalePref = normalizeUiScale(readStringPref(UI_SCALE_KEY, "auto")); + const layoutPref = normalizeDeviceLayout(readStringPref(DEVICE_LAYOUT_KEY, "auto")); + const layout = layoutPref === "auto" ? detectAspectLayout() : layoutPref; + const viewport = detectViewportSize(); + const scale = + scalePref === "auto" ? (viewport === "xs" ? "xs" : viewport === "sm" ? "sm" : "md") : scalePref; + + root.dataset.uiScale = scale; + root.dataset.uiScalePref = scalePref; + root.dataset.deviceLayout = layoutPref; + root.dataset.aspect = layout; + root.dataset.viewport = viewport; + + if (uiScaleEl) uiScaleEl.value = scalePref; + if (deviceLayoutEl) deviceLayoutEl.value = layoutPref; +} + +function initDisplayPrefsUi() { + applyDisplayPrefs(); + if (uiScaleEl) { + uiScaleEl.value = normalizeUiScale(readStringPref(UI_SCALE_KEY, "auto")); + uiScaleEl.addEventListener("change", () => { + const next = normalizeUiScale(uiScaleEl.value); + try { + localStorage.setItem(UI_SCALE_KEY, next); + } catch { + // ignore + } + applyDisplayPrefs(); + }); + } + if (deviceLayoutEl) { + deviceLayoutEl.value = normalizeDeviceLayout(readStringPref(DEVICE_LAYOUT_KEY, "auto")); + deviceLayoutEl.addEventListener("change", () => { + const next = normalizeDeviceLayout(deviceLayoutEl.value); + try { + localStorage.setItem(DEVICE_LAYOUT_KEY, next); + } catch { + // ignore + } + applyDisplayPrefs(); + }); + } + + let resizeTimer = null; + window.addEventListener("resize", () => { + if (resizeTimer) window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + resizeTimer = null; + // Always re-apply (viewport changes matter even when layout is manually pinned). + applyDisplayPrefs(); + }, 90); + }); +} + function writeStringPref(key, value) { try { localStorage.setItem(key, String(value)); @@ -475,6 +617,49 @@ function registerCorePanel(def) { panelRegistry.set(id, { id, title, icon, source: "core", role, defaultRack, element }); } +function togglePanelSkinny(panelId) { + if (!rackLayoutEnabled) return; + const id = String(panelId || "").trim(); + if (!id) return; + if (!panelIsSkinnyCapable(id)) return; + const panelEl = getPanelElement(id); + if (!panelEl) return; + + const left = ensureWorkspaceLeftRack(); + const right = ensureWorkspaceRightRack(); + const side = ensureMainSideRack(); + if (!left || !right || !side) return; + + const parentId = rackIdForPanelElement(panelEl); + const inSkinny = parentId === "mainSideRack" || parentId === "rightRack"; + + if (inSkinny) { + // Move to workspace (prefer an empty slot; otherwise prefer right). + const leftExisting = left.querySelector?.(":scope > .rackPanel:not(.hidden)"); + const rightExisting = right.querySelector?.(":scope > .rackPanel:not(.hidden)"); + const target = !rightExisting ? right : !leftExisting ? left : right; + const existing = target === left ? leftExisting : rightExisting; + if (existing instanceof HTMLElement && existing !== panelEl) { + const existingId = String(existing.dataset?.panelId || "").trim(); + if (existingId) dockPanel(existingId); + } + target.appendChild(panelEl); + rememberPanelLastRack(id, target.id); + saveRackLayoutState(); + syncRackStateFromDom(); + enforceWorkspaceRules(); + return; + } + + // Move to side rack (skinny). + setSideCollapsed(false); + side.prepend(panelEl); + rememberPanelLastRack(id, side.id); + saveRackLayoutState(); + syncRackStateFromDom(); + enforceWorkspaceRules(); +} + registerCorePanel({ id: "chat", title: "Chat", icon: "πŸ’¬", role: "primary", defaultRack: "main", element: chatPanelEl }); registerCorePanel({ id: "hives", title: "Hives", icon: "🐝", role: "primary", defaultRack: "main", element: hivesPanelEl }); registerCorePanel({ id: "people", title: "People", icon: "πŸ‘₯", role: "aux", defaultRack: "right", element: peopleDrawerEl }); @@ -499,6 +684,7 @@ const PRESET_DEFS = { workspaceLeftOrder: ["hives"], workspaceRightOrder: ["chat"], sideOrder: ["profile", "composer"], + sideCollapsed: true, rightOrder: ["people"], dockBottom: ["maps", "library"], }, @@ -510,6 +696,7 @@ const PRESET_DEFS = { workspaceRightOrder: [], expandedPrimary: "chat", sideOrder: ["profile"], + sideCollapsed: true, rightOrder: ["people"], dockBottom: ["hives", "composer", "maps", "library"], }, @@ -521,6 +708,7 @@ const PRESET_DEFS = { workspaceRightOrder: [], expandedPrimary: "hives", sideOrder: ["chat"], + sideCollapsed: true, rightOrder: ["profile"], dockBottom: ["people", "composer", "maps", "library"], }, @@ -532,6 +720,7 @@ const PRESET_DEFS = { workspaceRightOrder: ["composer"], composerOpen: true, sideOrder: ["people"], + sideCollapsed: true, rightOrder: ["profile"], dockBottom: ["chat", "maps", "library"], }, @@ -542,6 +731,7 @@ const PRESET_DEFS = { workspaceLeftOrder: ["maps"], // if installed workspaceRightOrder: ["chat"], sideOrder: ["hives"], + sideCollapsed: true, rightOrder: ["people"], dockBottom: ["profile", "composer", "library"], }, @@ -552,6 +742,7 @@ const PRESET_DEFS = { workspaceLeftOrder: ["hives"], workspaceRightOrder: ["profile"], sideOrder: ["composer"], + sideCollapsed: true, rightOrder: [], rightCollapsed: true, dockBottom: ["chat", "people", "maps", "library"], @@ -564,6 +755,7 @@ const PRESET_DEFS = { workspaceLeftOrder: ["moderation"], workspaceRightOrder: ["chat"], sideOrder: ["hives"], + sideCollapsed: true, rightOrder: ["people"], dockBottom: ["profile", "composer", "maps", "library"], }, @@ -576,6 +768,7 @@ const PRESET_DEFS = { workspaceRightOrder: [], expandedPrimary: "moderation", sideOrder: ["people"], + sideCollapsed: true, rightOrder: ["chat"], dockBottom: ["hives", "profile", "composer", "maps", "library"], }, @@ -587,6 +780,7 @@ const PRESET_DEFS = { workspaceLeftOrder: ["hives"], workspaceRightOrder: ["moderation"], sideOrder: ["chat"], + sideCollapsed: true, rightOrder: ["people"], dockBottom: ["profile", "composer", "maps", "library"], }, @@ -598,6 +792,7 @@ const PRESET_DEFS = { workspaceLeftOrder: ["moderation"], workspaceRightOrder: ["hives"], sideOrder: ["chat"], + sideCollapsed: true, rightOrder: ["people"], dockBottom: ["profile", "composer", "maps", "library"], }, @@ -654,6 +849,7 @@ function updateLayoutPresetOptions() { } function readRackLayoutEnabled() { + if (FORCE_RACK_MODE) return true; try { return localStorage.getItem(RACK_LAYOUT_ENABLED_KEY) === "1"; } catch { @@ -662,6 +858,15 @@ function readRackLayoutEnabled() { } function writeRackLayoutEnabled(enabled) { + if (FORCE_RACK_MODE) { + rackLayoutEnabled = true; + try { + localStorage.setItem(RACK_LAYOUT_ENABLED_KEY, "1"); + } catch { + // ignore + } + return; + } rackLayoutEnabled = Boolean(enabled); try { localStorage.setItem(RACK_LAYOUT_ENABLED_KEY, rackLayoutEnabled ? "1" : "0"); @@ -680,6 +885,7 @@ function loadRackLayoutState() { presetId: "discordLike", docked: { bottom: [] }, racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, + lastRackByPanelId: {}, }; const parsed = JSON.parse(raw); if (!parsed || parsed.version !== 2) @@ -688,6 +894,7 @@ function loadRackLayoutState() { presetId: "discordLike", docked: { bottom: [] }, racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, + lastRackByPanelId: {}, }; const bottom = Array.isArray(parsed?.docked?.bottom) ? parsed.docked.bottom.map((x) => String(x || "")).filter(Boolean) : []; const presetId = typeof parsed?.presetId === "string" ? parsed.presetId : "discordLike"; @@ -695,9 +902,23 @@ function loadRackLayoutState() { const workspaceRight = Array.isArray(parsed?.racks?.workspaceRight) ? parsed.racks.workspaceRight.map((x) => String(x || "")).filter(Boolean) : []; const side = Array.isArray(parsed?.racks?.side) ? parsed.racks.side.map((x) => String(x || "")).filter(Boolean) : []; const right = Array.isArray(parsed?.racks?.right) ? parsed.racks.right.map((x) => String(x || "")).filter(Boolean) : []; - return { version: 2, presetId, docked: { bottom }, racks: { workspaceLeft, workspaceRight, side, right } }; + const lastRackByPanelIdRaw = parsed?.lastRackByPanelId && typeof parsed.lastRackByPanelId === "object" ? parsed.lastRackByPanelId : {}; + const lastRackByPanelId = {}; + for (const [k, v] of Object.entries(lastRackByPanelIdRaw)) { + const id = String(k || "").trim(); + const rackId = typeof v === "string" ? v.trim() : ""; + if (!id || !rackId) continue; + lastRackByPanelId[id] = rackId; + } + return { version: 2, presetId, docked: { bottom }, racks: { workspaceLeft, workspaceRight, side, right }, lastRackByPanelId }; } catch { - return { version: 2, presetId: "discordLike", docked: { bottom: [] }, racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] } }; + return { + version: 2, + presetId: "discordLike", + docked: { bottom: [] }, + racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, + lastRackByPanelId: {}, + }; } } @@ -743,6 +964,23 @@ function panelTitle(panelId) { return String(panelId || ""); } +function chatRailClass({ fromUser, isModMessage }) { + const from = String(fromUser || "").trim(); + const isSystem = !from || from.toLowerCase() === "system"; + const isModMsg = Boolean(isModMessage); + const isYou = Boolean(loggedInUser && from && from === loggedInUser); + if (isSystem || isModMsg) return "railLeft"; + if (isYou) return "railRight"; + return "railCenter"; +} + +function updateChatModToggleVisibility() { + if (!chatModToggleWrapEl) return; + const canUse = Boolean(canModerate && activeChatPostId && !activeDmThreadId && !isMapChatActive()); + chatModToggleWrapEl.classList.toggle("hidden", !canUse); + if (!canUse && chatModToggleEl) chatModToggleEl.checked = false; +} + function panelIcon(panelId) { const entry = panelRegistry.get(panelId); if (entry?.icon) return entry.icon; @@ -759,11 +997,23 @@ function panelRole(panelId) { function panelCanExpand(panelId) { const id = String(panelId || "").trim(); if (!id) return false; + 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"; } +// 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"]); + +function panelIsSkinnyCapable(panelId) { + const id = String(panelId || "").trim(); + if (!id) return false; + if (id.startsWith("chat:")) return true; + return SKINNY_CAPABLE_PANELS.has(id); +} + function isDocked(panelId) { return rackLayoutState.docked.bottom.includes(panelId); } @@ -776,9 +1026,29 @@ function getPanelElement(panelId) { return el instanceof HTMLElement ? el : null; } +function rackIdForPanelElement(panelEl) { + const el = panelEl instanceof HTMLElement ? panelEl : null; + if (!el) return ""; + const parent = el.parentElement; + const id = parent && typeof parent.id === "string" ? parent.id : ""; + if (id === "workspaceLeftSlot" || id === "workspaceRightSlot" || id === "mainSideRack" || id === "rightRack") return id; + return ""; +} + +function rememberPanelLastRack(panelId, rackId) { + const id = String(panelId || "").trim(); + const rack = String(rackId || "").trim(); + if (!id || !rack) return; + if (!rackLayoutState.lastRackByPanelId || typeof rackLayoutState.lastRackByPanelId !== "object") rackLayoutState.lastRackByPanelId = {}; + rackLayoutState.lastRackByPanelId[id] = rack; +} + function dockPanel(panelId) { const id = String(panelId || "").trim(); if (!id) return; + const el = getPanelElement(id); + const lastRack = rackIdForPanelElement(el); + if (lastRack) rememberPanelLastRack(id, lastRack); if (!isDocked(id)) rackLayoutState.docked.bottom.push(id); saveRackLayoutState(); applyDockState(); @@ -792,6 +1062,80 @@ function undockPanel(panelId) { applyDockState(); } +function restorePanelFromHotbar(panelId) { + const id = String(panelId || "").trim(); + if (!id) return; + if (!rackLayoutEnabled) return; + + const panelEl = getPanelElement(id); + if (!panelEl) return; + + // Decide where to restore the panel. + const lastRackId = + rackLayoutState?.lastRackByPanelId && typeof rackLayoutState.lastRackByPanelId === "object" + ? String(rackLayoutState.lastRackByPanelId[id] || "") + : ""; + const lastRack = lastRackId ? document.getElementById(lastRackId) : null; + + const leftSlot = ensureWorkspaceLeftRack(); + const rightSlot = ensureWorkspaceRightRack(); + const sideRack = ensureMainSideRack(); + const rightRack = ensureRightRack(); + + const pickWorkspaceSlot = () => { + const leftEmpty = leftSlot ? leftSlot.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; + const rightEmpty = rightSlot ? rightSlot.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; + return leftEmpty ? leftSlot : rightEmpty ? rightSlot : leftSlot; + }; + + let targetRack = null; + if (lastRack instanceof HTMLElement) { + targetRack = lastRack; + } else if (panelIsSkinnyCapable(id)) { + // Heuristic: aux-like panels default to side rack; "right" defaults to the right rack. + const defRack = String(panelRegistry.get(id)?.defaultRack || ""); + targetRack = defRack === "right" ? rightRack : sideRack; + } else { + targetRack = pickWorkspaceSlot(); + } + + // If restoring into a collapsed rack, uncollapse it (hotbar acts like a summonable launcher). + if (targetRack && targetRack.id === "mainSideRack") setSideCollapsed(false); + if (targetRack && targetRack.id === "rightRack") setRightCollapsed(false); + + // If the panel already lives in a rack, keep its place and just reveal it. + const currentRackId = rackIdForPanelElement(panelEl); + const currentRack = currentRackId ? document.getElementById(currentRackId) : null; + + undockPanel(id); + + if (!(currentRack instanceof HTMLElement)) { + const rack = targetRack instanceof HTMLElement ? targetRack : null; + if (rack) { + // Right rack + workspace slots are single-slot: docking the existing occupant is the least surprising behavior. + const isWorkspaceSlot = rack.id === "workspaceLeftSlot" || rack.id === "workspaceRightSlot"; + const isRightRackSlot = rack.id === "rightRack"; + if (isWorkspaceSlot || isRightRackSlot) { + const existing = rack.querySelector?.(":scope > .rackPanel:not(.hidden)"); + if (existing instanceof HTMLElement && existing !== panelEl) { + const existingId = String(existing.dataset.panelId || "").trim(); + if (existingId) dockPanel(existingId); + } + } + rack.appendChild(panelEl); + rememberPanelLastRack(id, rack.id); + saveRackLayoutState(); + } + } else { + // Ensure the rack is visible if we restored into it. + if (currentRack.id === "mainSideRack") setSideCollapsed(false); + if (currentRack.id === "rightRack") setRightCollapsed(false); + } + + syncRackStateFromDom(); + enforceWorkspaceRules(); +} + function showHotbar(show) { if (!dockHotbarEl) return; if (!show && dockHotbarEl.dataset.lockVisible === "1") return; @@ -802,13 +1146,15 @@ function showHotbar(show) { function renderHotbar() { if (!dockHotbarEl) return; const items = rackLayoutState.docked.bottom.slice().filter((id) => getPanelElement(id)); - if (!items.length) { + const includePlus = Boolean(rackLayoutEnabled); + if (!items.length && !includePlus) { dockHotbarEl.classList.add("hidden"); dockHotbarEl.classList.remove("show"); dockHotbarEl.innerHTML = ""; return; } - dockHotbarEl.innerHTML = items + + const orbsHtml = items .map( (id) => ` <button type="button" class="dockOrb" data-undock="${escapeHtml(id)}" title="Restore ${escapeHtml(panelTitle(id))}"> @@ -818,10 +1164,81 @@ function renderHotbar() { ` ) .join(""); + + const plusHtml = includePlus + ? ` + <button type="button" class="dockOrb dockOrbPlus" data-hotbarplus="1" title="Add panel"> + <span class="dockOrbIcon" aria-hidden="true">οΌ‹</span> + <span>Add</span> + </button> + ` + : ""; + + dockHotbarEl.innerHTML = `${orbsHtml}${plusHtml}`; dockHotbarEl.classList.remove("hidden"); requestAnimationFrame(() => showHotbar(true)); } +let hotbarPlusMenuEl = null; + +function closeHotbarPlusMenu() { + if (!hotbarPlusMenuEl) return; + try { + hotbarPlusMenuEl.remove(); + } catch { + // ignore + } + hotbarPlusMenuEl = null; +} + +function openHotbarPlusMenu(anchorEl) { + closeHotbarPlusMenu(); + if (!dockHotbarEl) return; + if (!(anchorEl instanceof HTMLElement)) return; + + const list = sortPosts(Array.from(posts.values())).slice(0, 8); + const items = list + .map((p) => { + const id = String(p?.id || "").trim(); + if (!id) return ""; + const title = postTitle(p); + return `<button type="button" class="ghost smallBtn" data-addchatpost="${escapeHtml(id)}">${escapeHtml(title)}</button>`; + }) + .filter(Boolean) + .join(""); + + const menu = document.createElement("div"); + menu.className = "hotbarAddMenu"; + menu.innerHTML = ` + <div class="small muted" style="padding:6px 8px 4px;">New chat panel for…</div> + <div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No hives yet.</div>`}</div> + `; + + const rect = anchorEl.getBoundingClientRect(); + const left = Math.max(12, Math.min(window.innerWidth - 260, rect.left - 200)); + const top = Math.max(12, rect.top - 260); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + + menu.addEventListener("click", (e) => { + const btn = e.target.closest?.("[data-addchatpost]"); + if (!btn) return; + const postId = String(btn.getAttribute("data-addchatpost") || "").trim(); + if (!postId) return; + ensureChatPostPanelInstance(postId, { docked: true }); + try { + ws.send(JSON.stringify({ type: "getChat", postId })); + } catch { + // ignore + } + closeHotbarPlusMenu(); + renderHotbar(); + }); + + document.body.appendChild(menu); + hotbarPlusMenuEl = menu; +} + function applyDockState() { // For the first implementation phase, we support docking any registered panel that has a DOM element. for (const [id, p] of panelRegistry.entries()) { @@ -905,13 +1322,25 @@ function enforceWorkspaceRules() { // Primary panels: allow up to 2 visible (one per workspace slot). Enforce max 1 per slot. const cleanupSlot = (slotEl) => { - const kids = Array.from(slotEl.querySelectorAll(":scope > .rackPanel")); + const kids = Array.from(slotEl.querySelectorAll(":scope > .rackPanel:not(.hidden)")); if (kids.length <= 1) return; for (const extra of kids.slice(1)) side.appendChild(extra); }; cleanupSlot(left); cleanupSlot(rightWorkspace); + // Side rack and right rack are "skinny columns": only allow skinny-capable panels. + const enforceSkinny = (rackEl) => { + const kids = Array.from(rackEl.querySelectorAll(":scope > .rackPanel:not(.hidden)")); + for (const kid of kids) { + const id = String(kid?.dataset?.panelId || "").trim(); + if (!id) continue; + if (!panelIsSkinnyCapable(id)) dockPanel(id); + } + }; + enforceSkinny(side); + enforceSkinny(rightRack); + // Right rack is single-slot: keep at most one visible panel. const rightKids = Array.from(rightRack.querySelectorAll(":scope > .rackPanel:not(.hidden)")); if (rightKids.length > 1) { @@ -923,7 +1352,7 @@ function enforceWorkspaceRules() { // Panels that live in the workspace slots should be "full" by default (especially primaries). for (const slot of [left, rightWorkspace]) { - const panel = slot.querySelector?.(":scope > .rackPanel"); + const panel = slot.querySelector?.(":scope > .rackPanel:not(.hidden)"); if (!(panel instanceof HTMLElement)) continue; const id = String(panel.dataset.panelId || "").trim(); if (!id) continue; @@ -933,8 +1362,8 @@ function enforceWorkspaceRules() { // If only one workspace slot is occupied, allow it to expand to full width to avoid blank space. // (We temporarily disable this during drag so the empty slot remains a visible drop target.) - const leftPanel = left.querySelector?.(":scope > .rackPanel"); - const rightPanel = rightWorkspace.querySelector?.(":scope > .rackPanel"); + const leftPanel = left.querySelector?.(":scope > .rackPanel:not(.hidden)"); + const rightPanel = rightWorkspace.querySelector?.(":scope > .rackPanel:not(.hidden)"); const leftId = String(leftPanel?.dataset?.panelId || "").trim(); const rightId = String(rightPanel?.dataset?.panelId || "").trim(); @@ -970,17 +1399,6 @@ function enforceWorkspaceRules() { appRoot.classList.remove("workspaceSingleLeft", "workspaceSingleRight"); } - // If a primary ends up outside the workspace slots, dock it (no half-width primaries). - const primariesToDock = []; - for (const el of Array.from(appRoot.querySelectorAll(".rackPanel"))) { - const id = String(el?.dataset?.panelId || "").trim(); - if (!id) continue; - if (panelRole(id) !== "primary") continue; - if (el.parentElement === left || el.parentElement === rightWorkspace) continue; - primariesToDock.push(id); - } - for (const id of primariesToDock) dockPanel(id); - // Transient panels should live in the side column and be collapsed by default. for (const el of Array.from(appRoot.querySelectorAll("#mainWorkspaceRack .rackPanel, #mainSideRack .rackPanel"))) { const id = String(el?.dataset?.panelId || "").trim(); @@ -1286,6 +1704,17 @@ function installPanelMinimizeButtons() { row.appendChild(drag); } + if (panelIsSkinnyCapable(panelId) && !headerEl.querySelector(`[data-skinny="${panelId}"]`)) { + const skinny = document.createElement("button"); + skinny.type = "button"; + skinny.className = "ghost smallBtn"; + skinny.textContent = "]["; + skinny.title = "Toggle skinny/full"; + skinny.setAttribute("data-skinny", panelId); + skinny.onclick = () => togglePanelSkinny(panelId); + row.appendChild(skinny); + } + if (panelCanExpand(panelId) && !headerEl.querySelector(`[data-expand="${panelId}"]`)) { const expand = document.createElement("button"); expand.type = "button"; @@ -1378,6 +1807,320 @@ function ensurePluginPanelShell(panelId, title, icon, defaultRack, role) { return shell; } +function ensureChatPostPanelInstance(postId, opts) { + if (!rackLayoutEnabled) return ""; + const pid = String(postId || "").trim(); + if (!pid) return ""; + const post = posts.get(pid) || null; + const panelId = chatInstancePanelIdForPost(pid); + if (!panelId) return ""; + + if (panelRegistry.has(panelId)) return panelId; + + const title = post?.title ? `Chat: ${String(post.title).slice(0, 32)}` : "Chat"; + const shell = document.createElement("section"); + shell.className = "panel panelFill rackPanel chat chatInstance"; + shell.dataset.panelId = panelId; + shell.innerHTML = ` + <div class="panelHeader"> + <div> + <div class="panelTitle">${escapeHtml(title)}</div> + <div class="small muted chatMeta"></div> + </div> + <div class="row"> + <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">☰</button> + <button type="button" class="ghost smallBtn" data-skinny="${escapeHtml(panelId)}" title="Toggle skinny/full">][</button> + <button type="button" class="ghost smallBtn" data-expand="${escapeHtml(panelId)}" title="Expand workspace">[]</button> + <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">β€”</button> + </div> + </div> + <div class="chatMessages"></div> + <div class="typingIndicator small muted"></div> + <form class="chatForm"> + <div class="chatComposer"> + <div class="toolbar" role="toolbar" aria-label="Chat formatting"> + <button type="button" data-chatcmd="bold"><b>B</b></button> + <button type="button" data-chatcmd="italic"><i>I</i></button> + <button type="button" data-chatcmd="underline"><u>U</u></button> + <button type="button" data-chatcmd="strikeThrough"><s>S</s></button> + <span class="sep"></span> + <button type="button" data-chatcmd="insertUnorderedList">List</button> + <button type="button" data-chatcmd="insertOrderedList">1. List</button> + <button type="button" data-chatlink="1">Link</button> + <button type="button" data-chatimg="1">GIF/Image</button> + <button type="button" data-chataudio="1">Audio</button> + <button type="button" data-chatemoji="1">Emoji</button> + <button type="button" data-chatcmd="removeFormat">Clear</button> + </div> + <div class="chatInstanceTools"> + <label class="checkRow chatModToggle chatInstModToggle hidden" title="Send as moderator/system message (left rail)"> + <span>Mod</span> + <input class="chatInstModToggleInput" type="checkbox" /> + </label> + </div> + <div class="editor chatEditor" contenteditable="true" aria-label="Chat editor"></div> + </div> + <button class="primary" type="submit">Send</button> + </form> + `; + + const metaEl = shell.querySelector(".chatMeta"); + const messagesEl = shell.querySelector(".chatMessages"); + const typingEl = shell.querySelector(".typingIndicator"); + const formEl = shell.querySelector("form.chatForm"); + const editorEl = shell.querySelector(".chatEditor"); + const modToggleWrapEl = shell.querySelector(".chatInstModToggle"); + const modToggleEl = shell.querySelector(".chatInstModToggleInput"); + + shell.querySelector(`[data-minimize="${cssEscape(panelId)}"]`)?.addEventListener("click", () => dockPanel(panelId)); + shell.querySelector(`[data-expand="${cssEscape(panelId)}"]`)?.addEventListener("click", () => togglePrimaryExpand(panelId)); + shell.querySelector(`[data-skinny="${cssEscape(panelId)}"]`)?.addEventListener("click", () => togglePanelSkinny(panelId)); + + if (formEl && editorEl) { + formEl.addEventListener("submit", (e) => { + e.preventDefault(); + const html = String(editorEl.innerHTML || "").trim(); + const text = String(editorEl.innerText || "").trim(); + const hasImg = Boolean(editorEl.querySelector("img")); + const hasAudio = Boolean(editorEl.querySelector("audio")); + if (!text && !hasImg && !hasAudio) return; + if (!loggedInUser) { + toast("Sign in required", "Sign in to chat."); + return; + } + const currentPost = posts.get(pid) || null; + if (currentPost && String(currentPost.mode || currentPost.chatMode || "").toLowerCase() === "walkie") { + toast("Walkie Talkie", "This hive is walkie-only. Hold ~ to talk."); + return; + } + if (currentPost?.readOnly && !(loggedInRole === "owner" || loggedInRole === "moderator")) { + toast("Read-only", "This hive is read-only."); + return; + } + if (currentPost?.deleted) { + toast("Unavailable", "This post was deleted."); + return; + } + const wantsMod = Boolean(canModerate && modToggleEl instanceof HTMLInputElement && modToggleEl.checked); + ws.send(JSON.stringify({ type: "typing", postId: pid, isTyping: false })); + ws.send(JSON.stringify({ type: "chatMessage", postId: pid, text, html, replyToId: "", asMod: wantsMod })); + editorEl.innerHTML = ""; + // Leave global reply-to state alone; this instance panel is independent (MVP). + }); + + editorEl.addEventListener("focus", () => { + chatUploadTargetEditor = editorEl; + }); + + editorEl.addEventListener("keydown", (e) => { + if (e.key !== "Enter") return; + if (!(e.ctrlKey || e.metaKey)) return; + e.preventDefault(); + formEl.requestSubmit(); + }); + + // Allow drag/drop uploads in instance chats too. + try { + installDropUpload(editorEl, { allowImages: true, allowAudio: true }); + } catch { + // ignore + } + } + + if (modToggleWrapEl) modToggleWrapEl.classList.toggle("hidden", !canModerate); + + // Register + insert. + panelRegistry.set(panelId, { + id: panelId, + title, + icon: "πŸ’¬", + source: "core", + role: "aux", + defaultRack: "main", + element: shell, + }); + chatPanelInstances.set(panelId, { postId: pid }); + + const options = opts && typeof opts === "object" ? opts : {}; + const docked = Boolean(options.docked); + const sideRack = ensureMainSideRack(); + if (docked) { + // Keep it out of layout; show as orb. + if (sideRack) sideRack.appendChild(shell); + dockPanel(panelId); + } else { + setSideCollapsed(false); + if (sideRack) sideRack.prepend(shell); + rememberPanelLastRack(panelId, "mainSideRack"); + saveRackLayoutState(); + applyDockState(); + syncRackStateFromDom(); + enforceWorkspaceRules(); + } + + renderChatPostPanelInstance(panelId, true); + return panelId; +} + +function renderTypingIndicatorForPost(postId, targetEl) { + if (!(targetEl instanceof HTMLElement)) return; + const id = String(postId || "").trim(); + if (!id) { + targetEl.textContent = ""; + return; + } + const set = typingUsersByPostId.get(id); + if (!set || set.size === 0) { + targetEl.textContent = ""; + return; + } + const names = Array.from(set.values()).slice(0, 3); + const more = set.size > names.length ? ` +${set.size - names.length}` : ""; + targetEl.textContent = `${names.map((u) => `@${u}`).join(", ")}${more} typing…`; +} + +function renderChatPostPanelInstance(panelId, forceScroll) { + const id = String(panelId || "").trim(); + if (!id) return; + const inst = chatPanelInstances.get(id); + if (!inst) return; + const postId = String(inst.postId || "").trim(); + const post = postId ? posts.get(postId) : null; + const root = getPanelElement(id); + if (!(root instanceof HTMLElement)) return; + const metaEl = root.querySelector(".chatMeta"); + const messagesEl = root.querySelector(".chatMessages"); + const typingEl = root.querySelector(".typingIndicator"); + const editorEl = root.querySelector(".chatEditor"); + const sendBtn = root.querySelector("form.chatForm button[type='submit']"); + + if (metaEl) { + if (!post) metaEl.textContent = "Hive not found."; + else { + const tags = (post.keywords || []).map((k) => `#${k}`).join(" "); + const author = post.author ? `by @${post.author}` : ""; + const exp = formatCountdown(post.expiresAt); + const ro = post.readOnly ? " | read-only" : ""; + metaEl.textContent = `${author}${ro} | ${exp === "permanent" ? "permanent" : `expires in ${exp}`} | ${tags}`.trim(); + } + } + + if (!(messagesEl instanceof HTMLElement)) return; + const atBottomBefore = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 24; + + if (!post) { + messagesEl.innerHTML = `<div class="small muted">Hive not found.</div>`; + if (typingEl) typingEl.textContent = ""; + return; + } + if (post.deleted) { + messagesEl.innerHTML = `<div class="small muted">Post was deleted.</div>`; + if (typingEl) typingEl.textContent = ""; + return; + } + + const isWalkie = String(post.mode || post.chatMode || "").toLowerCase() === "walkie"; + const canChatWrite = Boolean(loggedInRole === "owner" || loggedInRole === "moderator" || !post.readOnly); + if (editorEl) editorEl.contentEditable = String(Boolean(canChatWrite && !isWalkie)); + if (sendBtn instanceof HTMLButtonElement) sendBtn.disabled = !(loggedInUser && canChatWrite && !isWalkie); + + const modToggleWrapEl = root.querySelector(".chatInstModToggle"); + const modToggleEl = root.querySelector(".chatInstModToggleInput"); + if (modToggleWrapEl) modToggleWrapEl.classList.toggle("hidden", !canModerate); + if (!canModerate && modToggleEl instanceof HTMLInputElement) modToggleEl.checked = false; + + const messages = chatByPost.get(post.id) || []; + const ignoreUserSet = new Set( + [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase()) + ); + const selfLower = String(loggedInUser || "").toLowerCase(); + const visibleMessages = messages.filter((m) => { + const fromLower = String(m?.fromUser || "").toLowerCase(); + if (!fromLower || fromLower === selfLower) return true; + return !ignoreUserSet.has(fromLower); + }); + + messagesEl.innerHTML = visibleMessages + .map((m, index) => { + const isModMsg = Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod"; + const from = isModMsg ? "MOD" : m.fromUser || ""; + const isYou = loggedInUser && from && from === loggedInUser; + const rail = chatRailClass({ fromUser: from, isModMessage: isModMsg }); + const prev = index > 0 ? visibleMessages[index - 1] : null; + const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); + const mentions = Array.isArray(m.mentions) ? m.mentions.map((u) => String(u || "").toLowerCase()) : []; + const mentionMe = Boolean(loggedInUser && mentions.includes(loggedInUser)); + const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || ""); + const youTag = !isModMsg && isYou ? `<span class="muted">(you)</span>` : ""; + const time = new Date(m.createdAt).toLocaleTimeString(); + const tint = isModMsg ? "" : tintStylesFromHex(getProfile(from).color); + const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; + const content = html ? html : highlightMentionsInText(m.text || ""); + const replyMeta = m.replyTo && typeof m.replyTo === "object" ? m.replyTo : null; + const replyBlock = replyMeta + ? `<div class="chatReplyRef"><span class="small muted">@${escapeHtml(replyMeta.fromUser || "unknown")}</span><div class="small">${escapeHtml( + String(replyMeta.text || "[media]").slice(0, 120) + )}</div></div>` + : ""; + const reacts = renderReactionButtons({ kind: "chat", id: m.id, reactions: m.reactions || {}, postId: post.id }); + const deletedLine = m.deleted + ? `<div class="small muted">message deleted${ + m.deletedBy ? ` by @${escapeHtml(m.deletedBy)}` : "" + } at ${escapeHtml(new Date(Number(m.deletedAt || m.createdAt || Date.now())).toLocaleString())}</div>` + : ""; + const editedLine = + !m.deleted && Number(m.editCount || 0) > 0 + ? `<div class="small muted">edited (${Number(m.editCount || 0)}) at ${escapeHtml( + new Date(Number(m.editedAt || m.createdAt || Date.now())).toLocaleTimeString() + )}</div>` + : ""; + const reportAction = + loggedInUser && !m.deleted + ? `<button type="button" class="ghost smallBtn" data-reportchat="${escapeHtml(m.id)}" data-postid="${escapeHtml( + post.id + )}">Report</button>` + : ""; + const deleteAction = + loggedInUser && !m.deleted && (loggedInRole === "owner" || loggedInRole === "moderator" || from === loggedInUser) + ? `<button type="button" class="ghost smallBtn" data-delchat="${escapeHtml(m.id)}" data-postid="${escapeHtml( + post.id + )}">Delete</button>` + : ""; + const actions = + reportAction || deleteAction + ? `<div class="chatTools">${reportAction}${deleteAction}</div>` + : ""; + return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""} ${rail} ${isModMsg ? "isModMsg" : ""}" data-msgid="${escapeHtml( + m.id + )}" ${tint}> + <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> + ${replyBlock} + <div class="content">${content}</div> + ${deletedLine}${editedLine} + <div class="chatActionsRow">${reacts}${actions}</div> + </div>`; + }) + .join(""); + + for (const contentEl of messagesEl.querySelectorAll(".chatMsg .content")) { + decorateMentionNodesInElement(contentEl); + decorateYouTubeEmbedsInElement(contentEl); + } + + renderTypingIndicatorForPost(post.id, typingEl); + + if (forceScroll || atBottomBefore) messagesEl.scrollTop = messagesEl.scrollHeight; +} + +function renderChatInstancesForPost(postId) { + const pid = String(postId || "").trim(); + if (!pid) return; + for (const [panelId, inst] of chatPanelInstances.entries()) { + if (String(inst?.postId || "") !== pid) continue; + renderChatPostPanelInstance(panelId); + } +} + function applyPluginPresetHint(panelDef) { if (!rackLayoutEnabled) return; const id = String(panelDef?.id || "").trim(); @@ -1501,10 +2244,13 @@ function enableRackDnD() { if (targetRack && placeholderEl && placeholderEl.parentElement === targetRack) { const isWorkspaceSlot = targetRack.id === "workspaceLeftSlot" || targetRack.id === "workspaceRightSlot"; const isRightRackSlot = targetRack.id === "rightRack"; - const isPrimary = panelRole(draggingPanelId) === "primary"; + const isSideRackSlot = targetRack.id === "mainSideRack"; + const isSkinnyRackSlot = isRightRackSlot || isSideRackSlot; + const skinnyOk = panelIsSkinnyCapable(draggingPanelId); - // Primaries are only allowed in the workspace slots. Dropping elsewhere snaps them back. - if (isPrimary && !isWorkspaceSlot) { + // 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.`); if (originRack) { if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(draggingEl, originBefore); else originRack.appendChild(draggingEl); @@ -1605,11 +2351,21 @@ function initRackLayout() { if (toggleRackLayoutEl) { toggleRackLayoutEl.checked = rackLayoutEnabled; - toggleRackLayoutEl.onchange = () => { - writeRackLayoutEnabled(Boolean(toggleRackLayoutEl.checked)); - // Reload is the simplest safe path while the feature is in flux. - location.reload(); - }; + // Hide/disable the toggle while rack mode is forced on. + if (FORCE_RACK_MODE) { + toggleRackLayoutEl.checked = true; + toggleRackLayoutEl.disabled = true; + const row = toggleRackLayoutEl.closest?.("label"); + if (row) row.classList.add("hidden"); + const toggleBtn = document.getElementById("toggleRackLayoutBtn"); + if (toggleBtn) toggleBtn.classList.add("hidden"); + } else { + toggleRackLayoutEl.onchange = () => { + writeRackLayoutEnabled(Boolean(toggleRackLayoutEl.checked)); + // Reload is the simplest safe path while the feature is in flux. + location.reload(); + }; + } } if (layoutPresetEl) { @@ -1677,22 +2433,35 @@ function initRackLayout() { if (dockHotbarEl) { dockHotbarEl.onmouseenter = () => showHotbar(true); dockHotbarEl.onmouseleave = () => showHotbar(false); + // Docked items must be restored via drag-and-drop (click does nothing), but the "+" orb is clickable. dockHotbarEl.onclick = (e) => { if (dockHotbarEl.dataset.dragging === "1") return; - const btn = e.target.closest?.("[data-undock]"); - if (!btn) return; - const id = String(btn.getAttribute("data-undock") || ""); - if (!id) return; - undockPanel(id); + const plus = e.target.closest?.("[data-hotbarplus]"); + if (!plus) return; + if (hotbarPlusMenuEl) closeHotbarPlusMenu(); + else openHotbarPlusMenu(plus); }; } + // Close the "+" menu when clicking elsewhere. + if (appRoot && appRoot.dataset.hotbarPlusClose !== "1") { + appRoot.dataset.hotbarPlusClose = "1"; + document.addEventListener("pointerdown", (e) => { + if (!hotbarPlusMenuEl) return; + const t = e.target; + if (t && (hotbarPlusMenuEl.contains(t) || dockHotbarEl?.contains(t))) return; + closeHotbarPlusMenu(); + }); + } + // Drag orbs back into the rack to restore (MVP: restore to end of rack). if (dockHotbarEl) { let orbDragId = ""; let orbPointer = null; let orbStart = null; let orbMoved = false; + let orbPlaceholder = null; + let orbActiveRack = null; const lockHotbarVisible = (lock) => { dockHotbarEl.dataset.lockVisible = lock ? "1" : "0"; @@ -1713,6 +2482,15 @@ function initRackLayout() { const resolveOrbDropRack = (panelId, rackEl) => { const id = String(panelId || "").trim(); if (!id) return rackEl; + // Skinny racks (side/right) only allow skinny-capable panels. + if (rackEl && (rackEl.id === "mainSideRack" || rackEl.id === "rightRack")) { + if (panelIsSkinnyCapable(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; + } if (panelRole(id) !== "primary") return rackEl; const isWorkspaceSlot = rackEl && (rackEl.id === "workspaceLeftSlot" || rackEl.id === "workspaceRightSlot"); if (isWorkspaceSlot) return rackEl; @@ -1723,15 +2501,50 @@ function initRackLayout() { return leftEmpty ? left : rightEmpty ? right : left; }; - const dropOrbIntoRack = (panelId, targetRack) => { + const insertOrbPlaceholderAt = (rack, y) => { + if (!(rack instanceof HTMLElement) || !(orbPlaceholder instanceof HTMLElement)) return; + const items = Array.from(rack.querySelectorAll(":scope > .rackPanel")).filter((el) => el !== orbPlaceholder); + for (const el of items) { + const r = el.getBoundingClientRect(); + const mid = r.top + r.height / 2; + if (y < mid) { + rack.insertBefore(orbPlaceholder, el); + return; + } + } + rack.appendChild(orbPlaceholder); + }; + + const orbRacks = () => { + const leftRack = ensureWorkspaceLeftRack(); + const rightWorkspaceRack = ensureWorkspaceRightRack(); + const sideRack = ensureMainSideRack(); + const rightRack = ensureRightRack(); + return [leftRack, rightWorkspaceRack, sideRack, rightRack].filter((x) => x instanceof HTMLElement); + }; + + const rackAtPoint = (x, y) => { + for (const r of orbRacks()) { + const rect = r.getBoundingClientRect(); + if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return r; + } + return null; + }; + + const dropOrbIntoRack = (panelId, targetRack, beforeEl) => { const id = String(panelId || "").trim(); if (!id) return; const rack = resolveOrbDropRack(id, targetRack); if (!(rack instanceof HTMLElement)) return; - undockPanel(id); const panelEl = getPanelElement(id); if (!panelEl) return; + // Restoring into a collapsed rack should uncollapse it (hotbar is a summonable launcher). + if (rack.id === "mainSideRack") setSideCollapsed(false); + if (rack.id === "rightRack") setRightCollapsed(false); + + undockPanel(id); + const isWorkspaceSlot = rack.id === "workspaceLeftSlot" || rack.id === "workspaceRightSlot"; const isRightRackSlot = rack.id === "rightRack"; if (isWorkspaceSlot) { @@ -1749,7 +2562,16 @@ function initRackLayout() { } } - if (panelEl.parentElement !== rack) rack.appendChild(panelEl); + const insertBefore = + beforeEl instanceof HTMLElement && beforeEl.parentElement === rack && beforeEl.classList.contains("rackPanel") + ? beforeEl + : null; + if (panelEl.parentElement !== rack) { + if (insertBefore) rack.insertBefore(panelEl, insertBefore); + else rack.appendChild(panelEl); + } + rememberPanelLastRack(id, rack.id); + saveRackLayoutState(); syncRackStateFromDom(); enforceWorkspaceRules(); }; @@ -1762,10 +2584,16 @@ function initRackLayout() { orbPointer = e.pointerId; orbStart = { x: e.clientX, y: e.clientY }; orbMoved = false; + orbActiveRack = null; orb.classList.add("dragging"); orb.setPointerCapture?.(orbPointer); lockHotbarVisible(true); e.preventDefault(); + + // Placeholder shows drop position while dragging. + orbPlaceholder = document.createElement("div"); + orbPlaceholder.className = "rackPlaceholder"; + orbPlaceholder.style.height = "52px"; }); window.addEventListener("pointermove", (e) => { if (!orbDragId || e.pointerId !== orbPointer) return; @@ -1773,29 +2601,33 @@ function initRackLayout() { const dx = Math.abs(e.clientX - orbStart.x); const dy = Math.abs(e.clientY - orbStart.y); if (dx + dy > 6) orbMoved = true; + + if (orbMoved && orbPlaceholder) { + const r = rackAtPoint(e.clientX, e.clientY) || orbActiveRack; + if (r && orbPlaceholder.parentElement !== r) r.appendChild(orbPlaceholder); + if (r) { + orbActiveRack = r; + insertOrbPlaceholderAt(r, e.clientY); + } + } }); dockHotbarEl.addEventListener("pointerup", (e) => { if (!orbDragId || e.pointerId !== orbPointer) return; const orb = dockHotbarEl.querySelector(`[data-undock="${CSS.escape(orbDragId)}"]`); if (orb) orb.classList.remove("dragging"); - const leftRack = ensureWorkspaceLeftRack(); - const rightWorkspaceRack = ensureWorkspaceRightRack(); - const sideRack = ensureMainSideRack(); - const rightRack = ensureRightRack(); - const racks = [leftRack, rightWorkspaceRack, sideRack, rightRack].filter((x) => x instanceof HTMLElement); - let targetRack = null; - for (const r of racks) { - const rect = r.getBoundingClientRect(); - if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) { - targetRack = r; - break; - } - } - if (orbMoved && targetRack) dropOrbIntoRack(orbDragId, targetRack); + const targetRack = orbMoved ? (rackAtPoint(e.clientX, e.clientY) || orbActiveRack) : null; + const beforeEl = + orbMoved && orbPlaceholder && targetRack instanceof HTMLElement && orbPlaceholder.parentElement === targetRack + ? orbPlaceholder.nextSibling + : null; + if (orbMoved && targetRack) dropOrbIntoRack(orbDragId, targetRack, beforeEl); orbDragId = ""; orbPointer = null; orbStart = null; orbMoved = false; + orbActiveRack = null; + if (orbPlaceholder && orbPlaceholder.parentElement) orbPlaceholder.parentElement.removeChild(orbPlaceholder); + orbPlaceholder = null; lockHotbarVisible(false); }); dockHotbarEl.addEventListener("pointercancel", () => { @@ -1803,6 +2635,9 @@ function initRackLayout() { orbPointer = null; orbStart = null; orbMoved = false; + orbActiveRack = null; + if (orbPlaceholder && orbPlaceholder.parentElement) orbPlaceholder.parentElement.removeChild(orbPlaceholder); + orbPlaceholder = null; lockHotbarVisible(false); dockHotbarEl.querySelectorAll(".dockOrb.dragging").forEach((x) => x.classList.remove("dragging")); }); @@ -1812,7 +2647,6 @@ function initRackLayout() { window.addEventListener("mousemove", (e) => { if (!dockHotbarEl) return; if (!rackLayoutEnabled) return; - if (!rackLayoutState.docked.bottom.length) return; const nearBottom = e.clientY > window.innerHeight - 80; showHotbar(Boolean(nearBottom)); }); @@ -5016,7 +5850,38 @@ function renderModPanel() { .join(""); } +function isMapChatActive() { + return Boolean(!activeDmThreadId && !activeChatPostId && activeMapsRoomId); +} + +function normalizeMapChatScope(scope) { + const s = String(scope || "").trim().toLowerCase(); + return s === "global" ? "global" : "local"; +} + +function mapChatListFor(mapId, scope) { + const mid = String(mapId || "").trim().toLowerCase(); + if (!mid) return []; + const sc = normalizeMapChatScope(scope); + const store = sc === "global" ? mapsChatGlobalByMapId : mapsChatLocalByMapId; + const arr = store.get(mid); + return Array.isArray(arr) ? arr : []; +} + +function pushMapChatMessage(mapId, scope, message) { + const mid = String(mapId || "").trim().toLowerCase(); + if (!mid) return; + const sc = normalizeMapChatScope(scope); + const store = sc === "global" ? mapsChatGlobalByMapId : mapsChatLocalByMapId; + const prev = store.get(mid); + const arr = Array.isArray(prev) ? prev.slice() : []; + arr.push(message); + if (arr.length > 240) arr.splice(0, arr.length - 240); + store.set(mid, arr); +} + function renderChatPanel(forceScroll = false) { + updateChatModToggleVisibility(); const mediaState = captureMediaState(chatMessagesEl); if (activeDmThreadId) { const thread = dmThreadsById.get(activeDmThreadId) || null; @@ -5058,15 +5923,20 @@ function renderChatPanel(forceScroll = false) { .map((m, index) => { const from = m.fromUser || ""; const isYou = loggedInUser && from && from === loggedInUser; + const rail = chatRailClass({ + fromUser: from, + isModMessage: Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod" + }); const prev = index > 0 ? messages[index - 1] : null; const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); - const who = isYou ? `<span>you</span>` : renderUserPill(from || ""); + const who = renderUserPill(from || ""); + const youTag = isYou ? `<span class="muted">(you)</span>` : ""; const time = new Date(m.createdAt).toLocaleTimeString(); const tint = tintStylesFromHex(getProfile(from).color); const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; const content = html ? html : highlightMentionsInText(m.text || ""); - return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""}" data-msgid="${escapeHtml(m.id)}" ${tint}> - <div class="meta"><span class="chatHeaderInline">${who}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> + return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${rail}" data-msgid="${escapeHtml(m.id)}" ${tint}> + <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> <div class="content">${content}</div> </div>`; }) @@ -5083,6 +5953,68 @@ function renderChatPanel(forceScroll = false) { const post = activeChatPostId ? posts.get(activeChatPostId) : null; if (!post) { + if (isMapChatActive()) { + const mapId = String(activeMapsRoomId || "").trim().toLowerCase(); + const scope = normalizeMapChatScope(activeMapsChatScope); + const atBottomBefore = + chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24; + + const title = activeMapsRoomTitle ? `Map: ${activeMapsRoomTitle}` : `Map: ${mapId}`; + chatTitle.textContent = "Chat"; + chatMeta.innerHTML = ` + <span class="muted">${escapeHtml(title)}</span> + <span class="muted">|</span> + <span class="mapChatToggle"> + <button type="button" class="${scope === "local" ? "primary" : "ghost"} smallBtn" data-mapchatscope="local" title="Local chat (nearby)">Local</button> + <button type="button" class="${scope === "global" ? "primary" : "ghost"} smallBtn" data-mapchatscope="global" title="Global chat (entire map)">Global</button> + </span> + `; + + if (chatPanelEl) chatPanelEl.classList.remove("walkie"); + if (walkieBarEl) walkieBarEl.classList.add("hidden"); + if (chatForm) chatForm.classList.remove("hidden"); + + const messages = mapChatListFor(mapId, scope); + if (!messages.length) { + chatMessagesEl.innerHTML = `<div class="small muted">${ + scope === "local" ? "Local chat is proximity-based. Say something nearby." : "No messages yet. Say hello!" + }</div>`; + restoreMediaState(chatMessagesEl, mediaState); + setReplyToMessage(null); + return; + } + + chatMessagesEl.innerHTML = messages + .map((m, index) => { + const from = String(m.fromUser || ""); + const isYou = loggedInUser && from && from === loggedInUser; + const rail = chatRailClass({ + fromUser: from, + isModMessage: Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod" + }); + const prev = index > 0 ? messages[index - 1] : null; + const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); + const who = renderUserPill(from || ""); + const youTag = isYou ? `<span class="muted">(you)</span>` : ""; + const time = new Date(Number(m.createdAt || 0) || Date.now()).toLocaleTimeString(); + const tint = tintStylesFromHex(getProfile(from).color); + const content = highlightMentionsInText(String(m.text || "")); + return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${rail}" data-msgid="${escapeHtml(String(m.id || ""))}" ${tint}> + <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> + <div class="content">${content}</div> + </div>`; + }) + .join(""); + for (const contentEl of chatMessagesEl.querySelectorAll(".chatMsg .content")) { + decorateMentionNodesInElement(contentEl); + decorateYouTubeEmbedsInElement(contentEl); + } + restoreMediaState(chatMessagesEl, mediaState); + if (forceScroll || atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; + setReplyToMessage(null); + return; + } + chatTitle.textContent = "Chat"; chatMeta.textContent = "Select a post to chat."; if (chatPanelEl) chatPanelEl.classList.remove("walkie"); @@ -5094,6 +6026,7 @@ function renderChatPanel(forceScroll = false) { return; } + updateChatModToggleVisibility(); const isWalkie = String(post.mode || post.chatMode || "").toLowerCase() === "walkie"; if (chatPanelEl) chatPanelEl.classList.toggle("walkie", isWalkie); if (walkieBarEl) walkieBarEl.classList.toggle("hidden", !isWalkie); @@ -5134,15 +6067,17 @@ function renderChatPanel(forceScroll = false) { chatMessagesEl.innerHTML = visibleMessages .map((m, index) => { - const from = m.fromUser || ""; - const isYou = loggedInUser && from && from === loggedInUser; + const isModMsg = Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod"; + const from = isModMsg ? "MOD" : m.fromUser || ""; + const rail = chatRailClass({ fromUser: from, isModMessage: isModMsg }); const prev = index > 0 ? visibleMessages[index - 1] : null; const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); const mentions = Array.isArray(m.mentions) ? m.mentions.map((u) => String(u || "").toLowerCase()) : []; const mentionMe = Boolean(loggedInUser && mentions.includes(loggedInUser)); - const who = isYou ? `<span>you</span>` : renderUserPill(from || ""); + const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || ""); + const youTag = !isModMsg && loggedInUser && from && from === loggedInUser ? `<span class="muted">(you)</span>` : ""; const time = new Date(m.createdAt).toLocaleTimeString(); - const tint = tintStylesFromHex(getProfile(from).color); + const tint = isModMsg ? "" : tintStylesFromHex(getProfile(from).color); const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; const content = html ? html : highlightMentionsInText(m.text || ""); const replyMeta = m.replyTo && typeof m.replyTo === "object" ? m.replyTo : null; @@ -5178,8 +6113,8 @@ function renderChatPanel(forceScroll = false) { const ownDeleteAction = canManageOwnMessage ? `<button type="button" class="ghost smallBtn" data-deletemsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(post.id)}">Delete</button>` : ""; - return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""}" data-msgid="${escapeHtml(m.id)}" ${tint}> - <div class="meta"><span class="chatHeaderInline">${who}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> + return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""} ${rail} ${isModMsg ? "isModMsg" : ""}" data-msgid="${escapeHtml(m.id)}" ${tint}> + <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> ${replyBlock} ${deletedLine} ${editedLine} @@ -5282,14 +6217,17 @@ function appendPostChatMessageToDom(postId, message) { } const m = message; - const from = m?.fromUser || ""; + const isModMsg = Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod"; + const from = isModMsg ? "MOD" : m?.fromUser || ""; const isYou = loggedInUser && from && from === loggedInUser; + const rail = chatRailClass({ fromUser: from, isModMessage: isModMsg }); const sameAuthorAsPrev = Boolean(prevVisible && String(prevVisible.fromUser || "") === from); const mentions = Array.isArray(m?.mentions) ? m.mentions.map((u) => String(u || "").toLowerCase()) : []; const mentionMe = Boolean(loggedInUser && mentions.includes(loggedInUser)); - const who = isYou ? `<span>you</span>` : renderUserPill(from || ""); + const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || ""); + const youTag = !isModMsg && isYou ? `<span class="muted">(you)</span>` : ""; const time = new Date(m.createdAt).toLocaleTimeString(); - const tint = tintStylesFromHex(getProfile(from).color); + const tint = isModMsg ? "" : tintStylesFromHex(getProfile(from).color); const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; const content = html ? html : highlightMentionsInText(m.text || ""); const replyMeta = m.replyTo && typeof m.replyTo === "object" ? m.replyTo : null; @@ -5326,10 +6264,10 @@ function appendPostChatMessageToDom(postId, message) { ? `<button type="button" class="ghost smallBtn" data-deletemsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(postId)}">Delete</button>` : ""; - const msgHtml = `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""}" data-msgid="${escapeHtml( + const msgHtml = `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""} ${rail} ${isModMsg ? "isModMsg" : ""}" data-msgid="${escapeHtml( m.id )}" ${tint}> - <div class="meta"><span class="chatHeaderInline">${who}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> + <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> ${replyBlock} ${deletedLine} ${editedLine} @@ -5360,17 +6298,19 @@ function appendDmMessageToDom(threadId, message) { const m = message; const from = m.fromUser || ""; const isYou = loggedInUser && from && from === loggedInUser; + const rail = chatRailClass({ fromUser: from, isModMessage: false }); const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); - const who = isYou ? `<span>you</span>` : renderUserPill(from || ""); + const who = renderUserPill(from || ""); + const youTag = isYou ? `<span class="muted">(you)</span>` : ""; const time = new Date(m.createdAt).toLocaleTimeString(); const tint = tintStylesFromHex(getProfile(from).color); const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; const content = html ? html : highlightMentionsInText(m.text || ""); - const msgHtml = `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""}" data-msgid="${escapeHtml(m.id)}" ${tint}> - <div class="meta"><span class="chatHeaderInline">${who}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> - <div class="content">${content}</div> - </div>`; + const msgHtml = `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${rail}" data-msgid="${escapeHtml(m.id)}" ${tint}> + <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> + <div class="content">${content}</div> + </div>`; appendChatHtmlAndDecorate(msgHtml, atBottomBefore); return true; @@ -5429,6 +6369,15 @@ function openChat(postId) { unlockPostFlow(postId, true); return; } + + // Rack mode: hive chats live in dedicated chat panels (instances). Don't also open the legacy main chat panel. + if (rackLayoutEnabled) { + markRead(postId); + renderFeed(); + ws.send(JSON.stringify({ type: "getChat", postId })); + ensureChatPostPanelInstance(postId, { docked: false }); + return; + } if (activeChatPostId && activeChatPostId !== postId) { ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); setReplyToMessage(null); @@ -5441,6 +6390,7 @@ function openChat(postId) { renderTypingIndicator(); if (isMobileSwipeMode()) setMobilePanel("chat"); chatEditor.focus(); + } let pendingOpenChatAfterUnlock = null; @@ -5938,27 +6888,35 @@ document.querySelector(".editorShell .toolbar")?.addEventListener("click", (e) = if (btn.getAttribute("data-postemoji")) runEmoji(editor); }); -document.querySelector(".chatComposer .toolbar")?.addEventListener("click", (e) => { - const btn = e.target.closest("button"); +document.addEventListener("click", (e) => { + const btn = e.target.closest?.("button"); if (!btn) return; + const toolbar = btn.closest?.(".chatComposer .toolbar"); + if (!toolbar) return; + const composer = toolbar.closest?.(".chatComposer"); + if (!composer) return; + const targetEditor = composer.querySelector?.(".chatEditor") || chatEditor; + if (!(targetEditor instanceof HTMLElement)) return; + chatUploadTargetEditor = targetEditor; + const cmd = btn.getAttribute("data-chatcmd"); if (cmd) { - runCmd(chatEditor, cmd); + runCmd(targetEditor, cmd); return; } if (btn.getAttribute("data-chatlink")) { - runLink(chatEditor); + runLink(targetEditor); return; } if (btn.getAttribute("data-chatimg")) { - chatImageInput.click(); + chatImageInput?.click(); return; } if (btn.getAttribute("data-chataudio")) { chatAudioInput?.click(); return; } - if (btn.getAttribute("data-chatemoji")) runEmoji(chatEditor); + if (btn.getAttribute("data-chatemoji")) runEmoji(targetEditor); }); profileBioToolbar?.addEventListener("click", (e) => { @@ -6286,6 +7244,34 @@ function submitChat() { return; } + if (isMapChatActive()) { + if (!text && !hasImg && !hasAudio) return; + if (hasImg || hasAudio) { + toast("Maps chat", "Maps chat is text-only for now."); + return; + } + if (!loggedInUser) { + toast("Sign in required", "Sign in to chat in maps."); + return; + } + try { + ws.send(JSON.stringify({ type: "plugin:maps:chatSend", mapId: activeMapsRoomId, scope: normalizeMapChatScope(activeMapsChatScope), text })); + // Optimistic add so it feels instant (server will also echo back). + pushMapChatMessage(activeMapsRoomId, activeMapsChatScope, { + id: `local_${Date.now()}_${Math.random().toString(16).slice(2)}`, + fromUser: loggedInUser, + text, + createdAt: Date.now(), + }); + } catch { + // ignore + } + chatEditor.innerHTML = ""; + setReplyToMessage(null); + renderChatPanel(true); + return; + } + if (!activeChatPostId || (!text && !hasImg && !hasAudio)) return; const post = posts.get(activeChatPostId); if (post && String(post.mode || post.chatMode || "").toLowerCase() === "walkie") { @@ -6301,8 +7287,9 @@ function submitChat() { return; } const replyToId = replyToMessage?.id ? String(replyToMessage.id) : ""; + const wantsMod = Boolean(canModerate && chatModToggleEl instanceof HTMLInputElement && chatModToggleEl.checked); ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); - ws.send(JSON.stringify({ type: "chatMessage", postId: activeChatPostId, text, html, replyToId })); + ws.send(JSON.stringify({ type: "chatMessage", postId: activeChatPostId, text, html, replyToId, asMod: wantsMod })); chatEditor.innerHTML = ""; setReplyToMessage(null); } @@ -7105,6 +8092,25 @@ chatForm.addEventListener("submit", (e) => { submitChat(); }); +chatMeta?.addEventListener("click", (e) => { + const btn = e.target?.closest?.("button[data-mapchatscope]"); + if (!btn) return; + const scope = normalizeMapChatScope(btn.getAttribute("data-mapchatscope") || "local"); + activeMapsChatScope = scope; + // Fetch global history on-demand when switching to global. + if (scope === "global" && activeMapsRoomId) { + try { + const wsRef = window.__bzlWs; + if (wsRef && wsRef.readyState === WebSocket.OPEN) { + wsRef.send(JSON.stringify({ type: "plugin:maps:chatHistoryReq", mapId: activeMapsRoomId })); + } + } catch { + // ignore + } + } + renderChatPanel(true); +}); + chatEditor.addEventListener("keydown", (e) => { if (mentionState.open) { if (e.key === "ArrowDown") { @@ -7166,6 +8172,10 @@ chatEditor.addEventListener("input", () => { }, 1800); }); +chatEditor.addEventListener("focus", () => { + chatUploadTargetEditor = chatEditor; +}); + chatEditor.addEventListener("blur", () => { if (!activeChatPostId || !loggedInUser) return; ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); @@ -7186,7 +8196,8 @@ chatImageInput.addEventListener("change", async () => { try { const url = await uploadMediaFile(file, "image"); if (!url) return; - chatEditor.focus(); + const target = chatUploadTargetEditor instanceof HTMLElement ? chatUploadTargetEditor : chatEditor; + target.focus(); document.execCommand("insertImage", false, url); } catch { // ignore @@ -7214,7 +8225,8 @@ chatAudioInput?.addEventListener("change", async () => { try { const url = await uploadMediaFile(file, "audio"); if (!url) return; - insertAudioTag(chatEditor, url); + const target = chatUploadTargetEditor instanceof HTMLElement ? chatUploadTargetEditor : chatEditor; + insertAudioTag(target, url); } catch { // ignore } @@ -7330,6 +8342,72 @@ ws.addEventListener("message", (evt) => { return; } + if (msg.type === "plugin:maps:joinOk") { + const map = msg.map && typeof msg.map === "object" ? msg.map : null; + const mapId = map && typeof map.id === "string" ? map.id.trim().toLowerCase() : ""; + if (mapId) { + activeMapsRoomId = mapId; + activeMapsRoomTitle = map && typeof map.title === "string" ? map.title.trim().slice(0, 64) : mapId; + activeMapsChatScope = "local"; + try { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "plugin:maps:chatHistoryReq", mapId })); + } + } catch { + // ignore + } + if (isMapChatActive()) renderChatPanel(true); + } + return; + } + + if (msg.type === "plugin:maps:left") { + const wasActive = Boolean(activeMapsRoomId); + activeMapsRoomId = ""; + activeMapsRoomTitle = ""; + activeMapsChatScope = "local"; + if (wasActive && !activeDmThreadId && !activeChatPostId) renderChatPanel(true); + return; + } + + if (msg.type === "plugin:maps:chatHistory") { + const mapId = typeof msg.mapId === "string" ? msg.mapId.trim().toLowerCase() : ""; + const scope = normalizeMapChatScope(msg.scope || "global"); + const messages = Array.isArray(msg.messages) ? msg.messages : []; + if (mapId && scope === "global") { + mapsChatGlobalByMapId.set( + mapId, + messages + .map((m) => ({ + id: String(m?.id || ""), + fromUser: String(m?.fromUser || m?.username || ""), + text: String(m?.text || ""), + createdAt: Number(m?.createdAt || 0) || Date.now(), + })) + .filter((m) => m.id && m.fromUser && m.text) + .slice(-240) + ); + if (isMapChatActive()) renderChatPanel(false); + } + return; + } + + if (msg.type === "plugin:maps:chatMessage") { + const mapId = typeof msg.mapId === "string" ? msg.mapId.trim().toLowerCase() : ""; + const scope = normalizeMapChatScope(msg.scope || "local"); + const m = msg.message && typeof msg.message === "object" ? msg.message : null; + if (mapId && m) { + pushMapChatMessage(mapId, scope, { + id: String(m.id || ""), + fromUser: String(m.fromUser || m.username || ""), + text: String(m.text || ""), + createdAt: Number(m.createdAt || 0) || Date.now(), + }); + if (isMapChatActive()) renderChatPanel(false); + } + return; + } + if (msg.type === "collectionsUpdated") { const prevView = activeHiveView; collections = normalizeCollections(msg.collections); @@ -7752,6 +8830,7 @@ ws.addEventListener("message", (evt) => { markRead(msg.postId); renderChatPanel(true); renderTypingIndicator(); + renderChatInstancesForPost(msg.postId); return; } @@ -7829,6 +8908,7 @@ ws.addEventListener("message", (evt) => { const m = arr.find((x) => x && x.id === messageId); if (m) m.reactions = reactions; if (activeChatPostId === postId) renderChatPanel(); + renderChatInstancesForPost(postId); return; } @@ -7850,6 +8930,7 @@ ws.addEventListener("message", (evt) => { if (set.size === 0) typingUsersByPostId.delete(postId); else typingUsersByPostId.set(postId, set); if (activeChatPostId === postId) renderTypingIndicator(); + renderChatInstancesForPost(postId); return; } @@ -7874,6 +8955,7 @@ ws.addEventListener("message", (evt) => { ); if (!isFromYou && senderLower && senderLower !== selfLower && ignoreUserSet.has(senderLower)) { if (activeChatPostId === msg.postId) renderChatPanel(); + renderChatInstancesForPost(msg.postId); return; } const mentions = Array.isArray(msg.message?.mentions) ? msg.message.mentions.map((u) => String(u || "").toLowerCase()) : []; @@ -7916,10 +8998,12 @@ ws.addEventListener("message", (evt) => { } } } + renderChatInstancesForPost(msg.postId); } }); renderLanHint(); +initDisplayPrefsUi(); 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=88" /> + <link rel="stylesheet" href="/styles.css?v=103" /> </head> <body> <div class="app"> @@ -28,22 +28,10 @@ <section class="panel"> <div class="panelTitle">View</div> - <label class="checkRow"> - <span>Show reactions bar</span> - <input id="toggleReactions" type="checkbox" /> - </label> <label class="checkRow" style="margin-top:8px;"> <span>Rack layout (experimental)</span> <input id="toggleRackLayout" type="checkbox" /> </label> - <label class="checkRow" style="margin-top:8px;"> - <span>Side panels</span> - <input id="toggleSideRack" type="checkbox" checked /> - </label> - <label class="checkRow" style="margin-top:8px;"> - <span>Right rack</span> - <input id="toggleRightRack" type="checkbox" checked /> - </label> <label style="margin-top:10px;"> <span>Layout preset</span> <select id="layoutPreset"> @@ -57,6 +45,45 @@ <option value="ops">Ops</option> </select> </label> + <label class="checkRow" style="margin-top:8px;"> + <span>Side panels</span> + <input id="toggleSideRack" type="checkbox" checked /> + </label> + <label class="checkRow" style="margin-top:8px;"> + <span>Right rack</span> + <input id="toggleRightRack" type="checkbox" checked /> + </label> + <label class="checkRow" style="margin-top:8px;"> + <span>Show reactions bar</span> + <input id="toggleReactions" type="checkbox" /> + </label> + + <details style="margin-top:10px;"> + <summary class="small muted" style="cursor:pointer;user-select:none;">Advanced display</summary> + <div style="margin-top:10px;"> + <label> + <span>Text size</span> + <select id="uiScale"> + <option value="auto" selected>Auto</option> + <option value="xs">Compact</option> + <option value="sm">Small</option> + <option value="md">Default</option> + <option value="lg">Large</option> + </select> + </label> + <label style="margin-top:10px;"> + <span>Device layout</span> + <select id="deviceLayout"> + <option value="auto" selected>Auto</option> + <option value="widescreen">16:9 / 16:10</option> + <option value="fourThree">4:3</option> + <option value="threeTwo">3:2</option> + <option value="ultrawide">Ultrawide</option> + <option value="portrait">Portrait</option> + </select> + </label> + </div> + </details> </section> <section class="panel"> @@ -309,7 +336,7 @@ </div> <button id="chatReplyCancel" class="ghost smallBtn" type="button">Cancel</button> </div> - <div class="chatComposer"> + <div class="chatComposer"> <div class="toolbar" role="toolbar" aria-label="Chat formatting"> <button type="button" data-chatcmd="bold"><b>B</b></button> <button type="button" data-chatcmd="italic"><i>I</i></button> @@ -323,6 +350,10 @@ <button type="button" data-chataudio="1">Audio</button> <button type="button" data-chatemoji="1">Emoji</button> <button type="button" data-chatcmd="removeFormat">Clear</button> + <label id="chatModToggleWrap" class="checkRow chatModToggle hidden" title="Send as moderator/system message (left rail)"> + <span>Mod</span> + <input id="chatModToggle" type="checkbox" /> + </label> </div> <div id="chatEditor" class="editor chatEditor" contenteditable="true" aria-label="Chat editor"></div> <div id="mentionMenu" class="mentionMenu hidden" role="listbox" aria-label="Mention suggestions"></div> @@ -499,6 +530,6 @@ </div> <div id="dockHotbar" class="dockHotbar hidden" aria-label="Docked panels"></div> - <script src="/app.js?v=99"></script> + <script src="/app.js?v=116"></script> </body> </html> diff --git a/public/styles.css b/public/styles.css @@ -10,14 +10,20 @@ --panel2: color-mix(in srgb, var(--text) calc(var(--panel2-pct) * 1%), transparent); --accent: #ff3ea5; --accent2: #b84bff; + --warn: #ffb84d; + --warn2: #ff7a18; --font-body: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --good: #3ddc97; --bad: #ff4d8a; --sidebar-width: 320px; + --sidebar-min: 240px; --chat-width: 640px; + --chat-min: 380px; --mod-width: 360px; + --mod-min: 280px; --people-width: 360px; + --people-min: 320px; --dur-fast: 110ms; --dur-med: 180ms; --dur-slow: 260ms; @@ -25,6 +31,158 @@ --ease-soft: cubic-bezier(0.2, 0.8, 0.2, 1); --shadow-panel: 0 20px 70px rgba(0, 0, 0, 0.45); --shadow-soft: 0 12px 34px rgba(0, 0, 0, 0.35); + --font-size-base: 15px; + --font-size-small: 11px; + --app-gap: 12px; + --app-pad: 12px; + --panel-pad: 14px; + --panel-header-pad-y: 10px; + --panel-header-pad-x: 12px; + --label-font-size: 12px; + --label-gap: 6px; + --control-pad-y: 10px; + --control-pad-x: 10px; + --btn-pad-y: 10px; + --btn-pad-x: 12px; + --chat-rail-inset: 12px; + --chat-rail-side-max: 66%; + --chat-rail-center-max: 70%; +} + +:root[data-ui-scale="xs"] { + --font-size-base: 12px; + --font-size-small: 9px; + --app-gap: 7px; + --app-pad: 7px; + --panel-pad: 10px; + --panel-header-pad-y: 8px; + --panel-header-pad-x: 10px; + --label-font-size: 11px; + --label-gap: 5px; + --control-pad-y: 8px; + --control-pad-x: 9px; + --btn-pad-y: 8px; + --btn-pad-x: 10px; +} + +:root[data-ui-scale="sm"] { + --font-size-base: 13px; + --font-size-small: 10px; + --app-gap: 9px; + --app-pad: 9px; + --panel-pad: 12px; + --panel-header-pad-y: 9px; + --panel-header-pad-x: 11px; + --label-font-size: 12px; + --label-gap: 6px; + --control-pad-y: 9px; + --control-pad-x: 10px; + --btn-pad-y: 9px; + --btn-pad-x: 11px; +} + +:root[data-ui-scale="md"] { + --font-size-base: 15px; + --font-size-small: 11px; + --app-gap: 12px; + --app-pad: 12px; +} + +:root[data-ui-scale="lg"] { + --font-size-base: 16px; + --font-size-small: 12px; + --app-gap: 14px; + --app-pad: 14px; +} + +:root[data-aspect="threeTwo"] { + --sidebar-width: 300px; + --sidebar-min: 230px; + --chat-width: 600px; + --chat-min: 360px; + --people-width: 340px; + --people-min: 300px; + --mod-width: 340px; + --mod-min: 280px; + --app-gap: 10px; + --app-pad: 10px; + --chat-rail-side-max: 70%; + --chat-rail-center-max: 74%; +} + +:root[data-aspect="fourThree"] { + --sidebar-width: 280px; + --sidebar-min: 220px; + --chat-width: 560px; + --chat-min: 340px; + --people-width: 320px; + --people-min: 280px; + --mod-width: 320px; + --mod-min: 260px; + --app-gap: 10px; + --app-pad: 10px; + --chat-rail-side-max: 74%; + --chat-rail-center-max: 78%; +} + +:root[data-aspect="portrait"] { + --sidebar-width: 300px; + --sidebar-min: 230px; + --chat-width: 520px; + --chat-min: 320px; + --people-width: 320px; + --people-min: 260px; + --mod-width: 320px; + --mod-min: 260px; + --app-gap: 10px; + --app-pad: 10px; + --chat-rail-inset: 10px; + --chat-rail-side-max: 88%; + --chat-rail-center-max: 88%; +} + +:root[data-aspect="ultrawide"] { + --sidebar-width: 340px; + --sidebar-min: 260px; + --chat-width: 720px; + --chat-min: 420px; + --people-width: 400px; + --people-min: 340px; + --mod-width: 400px; + --mod-min: 320px; + --chat-rail-side-max: 62%; + --chat-rail-center-max: 66%; +} + +:root[data-viewport="xs"] { + --sidebar-width: 230px; + --sidebar-min: 210px; + --chat-width: 480px; + --chat-min: 300px; + --people-width: 260px; + --people-min: 250px; + --mod-width: 260px; + --mod-min: 250px; + --app-gap: 8px; + --app-pad: 8px; + --chat-rail-inset: 10px; + --chat-rail-side-max: 92%; + --chat-rail-center-max: 92%; +} + +:root[data-viewport="sm"] { + --sidebar-width: 290px; + --sidebar-min: 220px; + --chat-width: 600px; + --chat-min: 340px; + --people-width: 320px; + --people-min: 280px; + --mod-width: 320px; + --mod-min: 260px; + --app-gap: 11px; + --app-pad: 11px; + --chat-rail-side-max: 78%; + --chat-rail-center-max: 82%; } * { @@ -35,6 +193,7 @@ body { margin: 0; height: 100vh; font-family: var(--font-body); + font-size: var(--font-size-base); background: radial-gradient(1200px 800px at 10% 0%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 55%), radial-gradient(900px 700px at 90% 10%, color-mix(in srgb, var(--accent2) 14%, transparent), transparent 50%), var(--bg); color: var(--text); @@ -113,7 +272,7 @@ body { } .small { - font-size: 12px; + font-size: var(--font-size-small); } .muted { @@ -122,17 +281,17 @@ body { .app { display: grid; - grid-template-columns: minmax(240px, var(--sidebar-width)) 10px minmax(380px, var(--chat-width)) 10px 1fr; + grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px minmax(var(--chat-min), var(--chat-width)) 10px 1fr; grid-template-areas: "sidebar sidebarResize chat chatResize main"; - gap: 12px; - padding: 12px; + gap: 0; + padding: var(--app-pad); height: 100vh; overflow: hidden; position: relative; } .app.rackMode { - grid-template-columns: minmax(240px, var(--sidebar-width)) 10px 1fr 10px minmax(320px, var(--people-width)); + grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px 1fr 10px minmax(var(--people-min), var(--people-width)); grid-template-areas: "sidebar sidebarResize main mainResize rightRack"; } @@ -142,7 +301,7 @@ body { .app.rackMode.rightCollapsed, .app.rackMode.hasMod.rightCollapsed { - grid-template-columns: minmax(240px, var(--sidebar-width)) 10px 1fr; + grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px 1fr; grid-template-areas: "sidebar sidebarResize main"; } @@ -204,28 +363,28 @@ body { .app.rackMode.hasMod { /* In rack mode, mod is just another panel inside the right rack. */ - grid-template-columns: minmax(240px, var(--sidebar-width)) 10px 1fr 10px minmax(320px, var(--people-width)); + grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px 1fr 10px minmax(var(--people-min), var(--people-width)); grid-template-areas: "sidebar sidebarResize main mainResize rightRack"; } .app.hasMod { - grid-template-columns: minmax(240px, var(--sidebar-width)) 10px minmax(380px, var(--chat-width)) 10px 1fr 10px minmax(280px, var(--mod-width)); + grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px minmax(var(--chat-min), var(--chat-width)) 10px 1fr 10px minmax(var(--mod-min), var(--mod-width)); grid-template-areas: "sidebar sidebarResize chat chatResize main mainResize moderation"; } .app.chatRight { - grid-template-columns: minmax(240px, var(--sidebar-width)) 10px 1fr 10px minmax(380px, var(--chat-width)); + grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px 1fr 10px minmax(var(--chat-min), var(--chat-width)); grid-template-areas: "sidebar sidebarResize main chatResize chat"; } .app.hasMod.chatRight { - grid-template-columns: minmax(240px, var(--sidebar-width)) 10px 1fr 10px minmax(380px, var(--chat-width)) 10px minmax(280px, var(--mod-width)); + grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px 1fr 10px minmax(var(--chat-min), var(--chat-width)) 10px minmax(var(--mod-min), var(--mod-width)); grid-template-areas: "sidebar sidebarResize main chatResize chat mainResize moderation"; } .app.rackMode.chatRight, .app.rackMode.hasMod.chatRight { - grid-template-columns: minmax(240px, var(--sidebar-width)) 10px 1fr 10px minmax(320px, var(--people-width)); + grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px 1fr 10px minmax(var(--people-min), var(--people-width)); grid-template-areas: "sidebar sidebarResize main mainResize rightRack"; } @@ -236,6 +395,7 @@ body { grid-template-areas: "sidebar main" "chat chat"; + gap: var(--app-gap); } .app.hasMod { grid-template-columns: 300px 1fr; @@ -298,11 +458,31 @@ body { min-height: 0; display: flex; flex-direction: column; - gap: 12px; + gap: var(--app-gap); overflow: hidden; } .rightRack .rackPanel { + flex: 1; + min-height: 0; +} + +.sideRack .rackPanel { + flex: 1; + min-height: 0; +} + +.rightRack .rackPanel.panelCollapsed, +.sideRack .rackPanel.panelCollapsed { + flex: 0 0 auto; +} + +.rightRack .rackPanel > .panelBody, +.rightRack .rackPanel > .panelFill, +.rightRack .rackPanel .panelBody, +.sideRack .rackPanel > .panelBody, +.sideRack .rackPanel > .panelFill, +.sideRack .rackPanel .panelBody { min-height: 0; } @@ -374,6 +554,9 @@ body { display: flex; gap: 10px; padding: 8px 10px; + max-width: calc(100vw - 24px); + overflow-x: auto; + overscroll-behavior: contain; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--text) 12%, transparent); background: color-mix(in srgb, var(--panel) 92%, transparent); @@ -400,6 +583,7 @@ body { display: inline-flex; align-items: center; gap: 8px; + flex: 0 0 auto; max-width: 220px; padding: 7px 12px; border-radius: 999px; @@ -427,6 +611,32 @@ body { background: color-mix(in srgb, var(--accent) 22%, transparent); } +.hotbarAddMenu { + position: fixed; + z-index: 90; + width: 260px; + max-height: min(320px, 70vh); + overflow: hidden; + border: 1px solid rgba(246, 240, 255, 0.16); + border-radius: 14px; + background: rgba(8, 8, 16, 0.92); + backdrop-filter: blur(12px); + box-shadow: 0 16px 60px rgba(0, 0, 0, 0.45); + animation: popFloat var(--dur-med) var(--ease-out) both; +} + +.hotbarAddMenuList { + overflow: auto; + padding: 6px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.dockOrbPlus .dockOrbIcon { + background: color-mix(in srgb, var(--accent) 30%, transparent); +} + .sidebarScroll { flex: 1; min-height: 0; @@ -495,7 +705,7 @@ body { grid-area: main; display: flex; flex-direction: column; - gap: 12px; + gap: var(--app-gap); overflow: hidden; } @@ -504,7 +714,7 @@ body { min-height: 0; display: flex; flex-direction: row; - gap: 12px; + gap: var(--app-gap); overflow: hidden; } @@ -514,7 +724,7 @@ body { min-height: 0; display: flex; flex-direction: column; - gap: 12px; + gap: var(--app-gap); overflow: hidden; } @@ -523,7 +733,7 @@ body { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr)); - gap: 12px; + gap: var(--app-gap); } .workspaceSlot { @@ -592,7 +802,7 @@ body { min-height: 0; display: flex; flex-direction: column; - gap: 12px; + gap: var(--app-gap); overflow: hidden; } @@ -699,6 +909,13 @@ body { border-radius: 999px; } +.mapChatToggle { + display: inline-flex; + gap: 6px; + margin-left: 6px; + vertical-align: middle; +} + .sidebarToggle { position: absolute; top: 12px; @@ -714,12 +931,12 @@ body { } .app.sidebarHidden { - grid-template-columns: minmax(380px, var(--chat-width)) 10px 1fr; + grid-template-columns: minmax(var(--chat-min), var(--chat-width)) 10px 1fr; grid-template-areas: "chat chatResize main"; } .app.sidebarHidden.hasMod { - grid-template-columns: minmax(380px, var(--chat-width)) 10px 1fr 10px minmax(280px, var(--mod-width)); + grid-template-columns: minmax(var(--chat-min), var(--chat-width)) 10px 1fr 10px minmax(var(--mod-min), var(--mod-width)); grid-template-areas: "chat chatResize main mainResize moderation"; } @@ -1208,7 +1425,7 @@ body { background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 60%), var(--panel); border: 1px solid var(--line); border-radius: 16px; - padding: 14px; + padding: var(--panel-pad); box-shadow: var(--shadow-soft); } @@ -1224,7 +1441,7 @@ body { justify-content: space-between; gap: 10px; align-items: center; - padding: 10px 12px 8px; + padding: var(--panel-header-pad-y) var(--panel-header-pad-x) calc(var(--panel-header-pad-y) - 2px); border-bottom: 1px solid var(--line); } @@ -1322,8 +1539,8 @@ body { label span { display: block; color: var(--muted); - font-size: 12px; - margin-bottom: 6px; + font-size: var(--label-font-size); + margin-bottom: var(--label-gap); } input, @@ -1334,7 +1551,7 @@ select { background: color-mix(in srgb, var(--text) 3%, transparent); border: 1px solid color-mix(in srgb, var(--text) 14%, transparent); border-radius: 12px; - padding: 10px 10px; + padding: var(--control-pad-y) var(--control-pad-x); outline: none; } @@ -1351,7 +1568,7 @@ button { background: color-mix(in srgb, var(--text) 4%, transparent); color: var(--text); border-radius: 12px; - padding: 10px 12px; + padding: var(--btn-pad-y) var(--btn-pad-x); cursor: pointer; transition: transform var(--dur-fast) var(--ease-soft), border-color var(--dur-fast) var(--ease-soft), background var(--dur-fast) var(--ease-soft), box-shadow var(--dur-fast) var(--ease-soft), filter var(--dur-fast) var(--ease-soft); @@ -2108,6 +2325,7 @@ button:disabled { gap: 0; flex: 1; min-height: 0; + container-type: inline-size; } .typingIndicator { @@ -2150,10 +2368,34 @@ button:disabled { border-radius: 14px; padding: 8px 9px; margin-top: 8px; + width: fit-content; + margin-left: auto; + margin-right: auto; + max-width: min(var(--chat-rail-center-max, 70%), calc(100% - (var(--chat-rail-inset, 12px) * 2))); transition: border-color var(--dur-med) var(--ease-out), background var(--dur-med) var(--ease-out), box-shadow var(--dur-med) var(--ease-out), transform var(--dur-med) var(--ease-out); } +.chatMsg.railLeft { + margin-left: var(--chat-rail-inset, 12px); + margin-right: auto; + max-width: min(var(--chat-rail-side-max, 66%), calc(100% - (var(--chat-rail-inset, 12px) * 2))); +} + +.chatMsg.railCenter { + margin-left: auto; + margin-right: auto; + max-width: min(var(--chat-rail-center-max, 70%), calc(100% - (var(--chat-rail-inset, 12px) * 2))); +} + +.chatMsg.railRight { + margin-left: auto; + margin-right: var(--chat-rail-inset, 12px); + max-width: min(var(--chat-rail-side-max, 66%), calc(100% - (var(--chat-rail-inset, 12px) * 2))); + background: color-mix(in srgb, var(--accent) 10%, rgba(255, 255, 255, 0.02)); + border-color: color-mix(in srgb, var(--accent) 30%, rgba(246, 240, 255, 0.12)); +} + .chatMsg:first-child { margin-top: 0; } @@ -2162,15 +2404,69 @@ button:disabled { margin-top: 4px; } +.chatMsg.railRight .meta { + justify-content: flex-end; +} + .chatMsg.mentionMe { border-color: rgba(255, 62, 165, 0.45); box-shadow: 0 0 0 2px rgba(255, 62, 165, 0.14); } +.chatMsg.isModMsg { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--warn) 22%, transparent), + rgba(255, 255, 255, 0.02) + ), + rgba(255, 255, 255, 0.02); + border-color: color-mix(in srgb, var(--warn2) 38%, rgba(246, 240, 255, 0.12)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--warn2) 10%, transparent); +} + +.chatMsg.isModMsg .meta { + color: color-mix(in srgb, var(--warn) 60%, var(--muted)); +} + +.modPill { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 900; + letter-spacing: 0.3px; + color: color-mix(in srgb, var(--warn) 88%, var(--text)); + border: 1px solid color-mix(in srgb, var(--warn2) 42%, transparent); + background: color-mix(in srgb, var(--warn2) 10%, transparent); + padding: 2px 8px; + border-radius: 999px; +} + .chatMsg.isNewMsg { animation: msgIn 520ms var(--ease-out) both; } +/* When the chat container gets narrow, collapse center-lane messages to the left lane. + Self stays right-aligned; mod/system keep their visual treatment. */ +@container (max-width: 520px) { + .chatMessages { + --chat-rail-side-max: 92%; + --chat-rail-center-max: 92%; + } + + .chatMsg.railCenter { + margin-left: var(--chat-rail-inset, 12px); + margin-right: auto; + } + + .chatMsg.railCenter .meta { + justify-content: flex-start; + } + + .chatMsg.isModMsg { + margin-left: calc(var(--chat-rail-inset, 12px) + 8px); + } +} + .chatMsg:hover { border-color: rgba(246, 240, 255, 0.18); background: rgba(255, 255, 255, 0.028); diff --git a/server.js b/server.js @@ -873,11 +873,13 @@ function verifyPostPassword(post, password) { function serializeChatMessageForWs(message) { if (!message || typeof message !== "object") return null; + const asMod = Boolean(message.asMod) || String(message.fromUser || "").trim().toLowerCase() === "mod"; return { id: typeof message.id === "string" ? message.id : "", postId: typeof message.postId === "string" ? message.postId : "", text: typeof message.text === "string" ? message.text : "", html: typeof message.html === "string" ? message.html : "", + asMod, mentions: Array.isArray(message.mentions) ? message.mentions : [], replyTo: message.replyTo || null, deleted: Boolean(message.deleted), @@ -888,8 +890,8 @@ function serializeChatMessageForWs(message) { editedAt: Number(message.editedAt || 0) || 0, reactions: message.reactions || {}, createdAt: Number(message.createdAt || 0) || 0, - fromClientId: typeof message.fromClientId === "string" ? message.fromClientId : "", - fromUser: normalizeUsername(message.fromUser || "") + fromClientId: !asMod && typeof message.fromClientId === "string" ? message.fromClientId : "", + fromUser: asMod ? "MOD" : normalizeUsername(message.fromUser || "") }; } @@ -2264,6 +2266,7 @@ function loadPostsFromDisk() { const html = htmlRaw ? sanitizeRichHtml(htmlRaw) : ""; const createdAtMsg = Number(m.createdAt || 0) || createdAt; const fromUser = normalizeUsername(m.fromUser || ""); + const asMod = Boolean(m.asMod) || String(fromUser || "").toLowerCase() === "mod"; const mentions = Array.isArray(m.mentions) ? m.mentions.map((x) => normalizeUsername(x)).filter(Boolean).slice(0, 16) : []; @@ -2290,8 +2293,9 @@ function loadPostsFromDisk() { editedAt, reactions: {}, createdAt: createdAtMsg, - fromClientId: typeof m.fromClientId === "string" ? m.fromClientId : "", - fromUser: fromUser || "" + asMod, + fromClientId: !asMod && typeof m.fromClientId === "string" ? m.fromClientId : "", + fromUser: asMod ? "MOD" : fromUser || "" }); } if (snapChat.length > CHAT_MAX_PER_POST) snapChat.splice(0, snapChat.length - CHAT_MAX_PER_POST); @@ -2683,6 +2687,7 @@ function markChatDeleted(messageRef, actor, roleOverride = "") { const message = messageRef.message; if (message.deleted) return { ok: false, message: "Message is already deleted." }; if (!message.deletedSnapshot) { + const asMod = Boolean(message.asMod) || String(message.fromUser || "").trim().toLowerCase() === "mod"; message.deletedSnapshot = { savedAt: now(), message: { @@ -2690,11 +2695,12 @@ function markChatDeleted(messageRef, actor, roleOverride = "") { postId: message.postId, text: typeof message.text === "string" ? message.text : "", html: typeof message.html === "string" ? message.html : "", + asMod, mentions: Array.isArray(message.mentions) ? [...message.mentions] : [], replyTo: message.replyTo || null, createdAt: Number(message.createdAt || 0) || 0, - fromClientId: typeof message.fromClientId === "string" ? message.fromClientId : "", - fromUser: normalizeUsername(message.fromUser || "") + fromClientId: !asMod && typeof message.fromClientId === "string" ? message.fromClientId : "", + fromUser: asMod ? "MOD" : normalizeUsername(message.fromUser || "") }, reactions: mapSetsToObj(chatReactionsByMessageId.get(message.id)) }; @@ -4806,18 +4812,21 @@ wss.on("connection", (ws, req) => { } : null; const mentions = extractMentionUsernames(safeText); + const wantsMod = Boolean(msg.asMod); + const asMod = wantsMod && hasRole(ws.user.username, ROLE_MODERATOR); const message = { id: toId(), postId, text: safeText || "[media]", html: safeHtml, + asMod, mentions: sanitizePostMode(entry.post?.mode) === "walkie" ? [] : mentions, replyTo, reactions: {}, createdAt: now(), - fromClientId: ws.clientId, - fromUser: ws.user.username + fromClientId: asMod ? "" : ws.clientId, + fromUser: asMod ? "MOD" : ws.user.username }; appendChatMessage(postId, message); const t = message.createdAt;