Websim menu

A tool for modifying websim project info (alt+m)

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Websim menu
// @namespace    http://tampermonkey.net/
// @version      7.HTMLrenderfix
// @description  A tool for modifying websim project info (alt+m)
// @match        *://*.websim.com/*
// @match        *://websim.com/*
// @run-at       document-start
// @license      MIT
// @grant        none
// ==/UserScript==

(function () {
    'use strict';
    if (window === window.top) return;
    console.log("Websim Manager + RoomState loaded");

    let currentUsername = null;
    let allPosts = [];
    let displayedPosts = [];
    let currentType = "";
    let currentPage = 1;
    const PAGE_SIZE = 50;

    let roomState = null;
    let selectedRoomKey = null;     // which top-level key in roomState we're viewing
    let isRoomMode = false;

    let allCollectionTypes = new Set();

    function escapeHTML(str) {
        str=String(str);
        if (!str) return '';
        return str
            .replace(/&/g, "&")   // must be first
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;")
            .replace(/'/g, "&#39;")
            .replace(/=/g, "&#61;")
            .replace(/\\/g, "&#92;");
    }


    function prettyPrintJson(value, indentSize = 2, maxPrettyDepth = 80) {
        if (typeof value !== 'object' || value === null) {
            return JSON.stringify(value);
        }

        const indent = ' '.repeat(indentSize);

        function helper(obj, depth, currentIndent) {
            if (depth > maxPrettyDepth || typeof obj !== 'object' || obj === null) {
                return JSON.stringify(obj);
            }

            const isArray = Array.isArray(obj);
            const keys = isArray ? null : Object.keys(obj);
            const opening = isArray ? '[' : '{';
            const closing = isArray ? ']' : '}';
            let result = opening;

            const length = isArray ? obj.length : keys.length;

            for (let i = 0; i < length; i++) {
                const key = isArray ? null : keys[i];
                const child = isArray ? obj[i] : obj[key];

                result += '\n' + currentIndent + indent;

                if (!isArray) {
                    result += JSON.stringify(key) + ': ';
                }

                if (typeof child === 'object' && child !== null && depth + 1 <= maxPrettyDepth) {
                    result += helper(child, depth + 1, currentIndent + indent);
                } else {
                    result += JSON.stringify(child);
                }

                if (i < length - 1) {
                    result += ','; // comma only between siblings
                }
            }

            result += '\n' + currentIndent + closing;
            return result;
        }

        return helper(value, 0, '');
    }


    async function startUI() {
        if (document.__websim_ui_initialized) return;
        document.__websim_ui_initialized = true;
        window.room=new WebsimSocket();

        try {
            const user = await window.websim?.getCurrentUser?.();
            currentUsername = user?.username || null;
        } catch {}

        // ── Container ────────────────────────────────────────
        const container = document.createElement("div");
        Object.assign(container.style, {
            position: "fixed", top: "10px", right: "10px",
            width: "720px", height: "850px",
            background: "#1e1e1e", color: "#ddd",
            overflowY: "auto",
            border: "2px solid #555", borderRadius: "12px",
            zIndex: "999999", padding: "14px", fontFamily: "monospace",
            fontSize: "13px", display: "none", flexDirection: "column",
            boxShadow: "0 10px 40px #000c", boxSizing: "border-box"
        });
        document.body.appendChild(container);

        const toggleBtn = document.createElement("button");
        toggleBtn.textContent = "Manager (Alt+M)";
        toggleBtn.style.cssText = "width:100%;padding:12px;background:#0066cc;color:white;font-weight:bold;border:none;border-radius:8px;cursor:pointer;font-size:14px;margin-bottom:12px";
        container.appendChild(toggleBtn);

        const panel = document.createElement("div");
        panel.style.flex = "1"; panel.style.minHeight = "0";
        panel.style.display = "flex"; panel.style.flexDirection = "column";
        panel.style.gap = "10px";
        container.appendChild(panel);

        let visible = false;
        toggleBtn.onclick = () => {
            visible = !visible;
            container.style.display = visible ? "flex" : "none";
        };
        document.addEventListener("keydown", e => {
            if (e.altKey && e.code === "KeyM") {
                e.preventDefault();
                toggleBtn.click();
            }
        });

        // ── Mode switch + Collection selector ────────────────
        const modeRow = document.createElement("div");
        modeRow.style.display = "flex"; modeRow.style.gap = "12px";
        modeRow.style.alignItems = "center"; modeRow.style.padding = "6px 0";

        const modeToggle = document.createElement("input");
        modeToggle.type = "checkbox";
        modeToggle.id = "room-mode-toggle";

        const modeLabel = document.createElement("label");
        modeLabel.htmlFor = "room-mode-toggle";
        modeLabel.textContent = "Room State Mode";
        modeLabel.style.color = "#ffcc00"; modeLabel.style.fontWeight = "bold";

        const collSelect = document.createElement("select");
        collSelect.style.flex = "1"; collSelect.style.padding = "6px";
        collSelect.style.background = "#2a2a2a"; collSelect.style.color = "white";
        collSelect.style.border = "1px solid #555"; collSelect.style.borderRadius = "4px";

        const defaultOpt = document.createElement("option");
        defaultOpt.value = ""; defaultOpt.text = "— all collections —";
        collSelect.appendChild(defaultOpt);

        modeRow.append(modeToggle, modeLabel, collSelect);
        panel.appendChild(modeRow);

        // ── Type / Filter input row ──────────────────────────
        const filterRow = document.createElement("div");
        filterRow.style.display = "flex"; filterRow.style.gap = "8px";

        const typeInput = document.createElement("input");
        typeInput.placeholder = "Type filter (required for create/nuke)";
        typeInput.style.flex = "1"; typeInput.style.padding = "8px";
        typeInput.style.background = "#333"; typeInput.style.color = "white";
        typeInput.style.border = "1px solid #555"; typeInput.style.borderRadius = "4px";

        const reloadBtn = document.createElement("button");
        reloadBtn.textContent = "↻ Reload";
        reloadBtn.style.padding = "8px 16px"; reloadBtn.style.background = "#28a745";
        reloadBtn.style.color = "white"; reloadBtn.style.border = "none";
        reloadBtn.style.borderRadius = "4px";

        const status = document.createElement("div");
        status.style.padding = "8px 12px"; status.style.background = "#333";
        status.style.borderRadius = "4px"; status.style.minWidth = "140px";
        status.textContent = "Not loaded";

        filterRow.append(typeInput, reloadBtn, status);
        panel.appendChild(filterRow);

        // Custom property filter (still applies in post mode)
        const customFilterBox = document.createElement("textarea");
        customFilterBox.placeholder = '{"username":"fish", "score":9001}  — exact match';
        customFilterBox.style.width = "100%"; customFilterBox.style.height = "48px";
        customFilterBox.style.background = "#2a2a2a"; customFilterBox.style.color = "#eee";
        customFilterBox.style.border = "1px solid #555"; customFilterBox.style.borderRadius = "4px";
        customFilterBox.style.fontSize = "12px"; customFilterBox.style.padding = "6px";
        panel.appendChild(customFilterBox);

        const filterActions = document.createElement("div");
        filterActions.style.display = "flex"; filterActions.style.gap = "8px";
        const applyF = document.createElement("button");
        applyF.textContent = "Apply Filter"; applyF.style.background = "#7b1fa2";
        applyF.style.color = "white"; applyF.style.padding = "6px 14px";
        const clearF = document.createElement("button");
        clearF.textContent = "Clear"; clearF.style.background = "#444"; clearF.style.color = "white";
        clearF.style.padding = "6px 14px";
        filterActions.append(applyF, clearF);
        panel.appendChild(filterActions);

        // ── Main content area ────────────────────────────────
        const contentArea = document.createElement("div");
        contentArea.style.flex = "1"; contentArea.style.overflowY = "auto";
        contentArea.style.background = "#252525"; contentArea.style.border = "1px solid #444";
        contentArea.style.borderRadius = "8px"; contentArea.style.padding = "10px";
        contentArea.style.height = "auto";
        contentArea.style.minHeight = "700px";
        contentArea.style.maxHeight = "85vh";
        contentArea.style.overflowY = "auto";
        panel.appendChild(contentArea);

        // ── Pagination ───────────────────────────────────────
        const pagi = document.createElement("div");
        pagi.style.display = "flex"; pagi.style.justifyContent = "center";
        pagi.style.gap = "16px"; pagi.style.padding = "8px";
        const prev = document.createElement("button"); prev.textContent = "← Prev";
        const pageTxt = document.createElement("span"); pageTxt.style.color = "#aaa";
        const next = document.createElement("button"); next.textContent = "Next →";
        pagi.append(prev, pageTxt, next);
        panel.appendChild(pagi);

        // ── Create / Nuke (only visible in post mode) ────────
        const createNukeArea = document.createElement("div");
        createNukeArea.style.display = "flex"; createNukeArea.style.flexDirection = "column";
        createNukeArea.style.gap = "12px"; createNukeArea.style.marginTop = "auto";
        createNukeArea.style.paddingTop = "12px"; createNukeArea.style.borderTop = "1px solid #444";

        const createSection = document.createElement("div");
        createSection.style.background = "#1b3a1b"; createSection.style.padding = "10px";
        createSection.style.borderRadius = "6px";
        const createTA = document.createElement("textarea");
        createTA.placeholder = "JSON data for new entry";
        createTA.style.width = "97%"; createTA.style.height = "64px";
        const createB = document.createElement("button");
        createB.textContent = "Create"; createB.style.background = "#2e7d32";
        createSection.append(createTA, createB);

        const nukeSection = document.createElement("div");
        nukeSection.style.background = "#3a1b1b"; nukeSection.style.padding = "10px";
        nukeSection.style.borderRadius = "6px";
        nukeSection.innerHTML = `
            <div style="color:#ff9999;margin-bottom:6px">Depth Bomb</div>
            <div style="display:flex;gap:8px">
                <input id="bomb-depth" type="number" value="15000" min="1000" style="width:110px;padding:6px;background:#300;color:#fee">
                <input id="bomb-key"   type="text"   value="data" style="flex:1;padding:6px;background:#300;color:#fee">
                <button id="bomb-go" style="background:#900;color:#fee;padding:6px 12px">NUKE</button>
            </div>
        `;
        createNukeArea.append(createSection, nukeSection);
        panel.appendChild(createNukeArea);

        // ── Helpers ──────────────────────────────────────────
        function getCollection(t) {
            return new window.WebsimSocket().collection(t);
        }

        function flattenEntry(p) {
            return {
                // first the user-controlled data
                ...p.data,

                // then the real metadata → these overwrite any shadowing keys
                id: p.id,
                type: p.type,
                created_at: p.created_at,
                updated_at: p.updated_at || null,
                username: p.author?.username || "?"
            };
        }

        function matchesCustomFilter(entry, filterObj) {
            if (!filterObj || typeof filterObj !== "object") return true;
            return Object.entries(filterObj).every(([k, v]) => entry[k] === v);
        }

        function updateCollectionList() {
            collSelect.innerHTML = "";
            const allOpt = document.createElement("option");
            allOpt.value = ""; allOpt.text = "— all collections —";
            collSelect.appendChild(allOpt);

            [...allCollectionTypes].sort().forEach(t => {
                const opt = document.createElement("option");
                opt.value = t; opt.text = t;
                collSelect.appendChild(opt);
            });
        }

        async function loadPosts() {
            status.textContent = "Loading posts...";
            try {
                const proj = await window.websim.getCurrentProject();
                const res = await fetch(`https://websim.com/api/v1/projects/${proj.id}/posts`);
                if (!res.ok) throw new Error(res.status);
                const { posts } = await res.json();
                allPosts = posts || [];
                allCollectionTypes.clear();
                allPosts.forEach(p => allCollectionTypes.add(p.type));
                updateCollectionList();
                status.textContent = `Posts: ${allPosts.length}`;
                refreshView();
            } catch (err) {
                status.textContent = "Error";
                contentArea.innerHTML = `<div style="color:#f66;padding:20px">Failed to load: ${err}</div>`;
            }
        }

        async function loadRoomState() {
            status.textContent = "Loading room state...";
            try {
                roomState = await window.room.roomState;
                status.textContent = `Room keys: ${Object.keys(roomState).length}`;
                refreshView();
            } catch (err) {
                status.textContent = "Room error";
                contentArea.innerHTML = `<div style="color:#f66;padding:20px">Cannot read room state: ${err}</div>`;
            }
        }

        function refreshView() {
            contentArea.innerHTML = "";
            pagi.style.display = isRoomMode ? "none" : "flex";
            createNukeArea.style.display = isRoomMode ? "none" : "flex";

            if (isRoomMode) {
                renderRoomStateView();
            } else {
                renderPostsView();
            }
        }

        function renderPostsView() {
            let filtered = allPosts;
            if (currentType) {
                filtered = filtered.filter(p => p.type === currentType);
            }
            const custom = (() => {
                try { return customFilterBox.value.trim() ? JSON.parse(customFilterBox.value) : {}; }
                catch { return {}; }
            })();
            if (Object.keys(custom).length > 0) {
                filtered = filtered.filter(p => matchesCustomFilter(flattenEntry(p), custom));
            }
            displayedPosts = filtered;

            const total = displayedPosts.length;
            const pages = Math.ceil(total / PAGE_SIZE) || 1;
            currentPage = Math.max(1, Math.min(currentPage, pages));
            pageTxt.textContent = `Page ${currentPage} / ${pages}  (${total})`;

            prev.disabled = currentPage <= 1;
            next.disabled = currentPage >= pages;

            const start = (currentPage - 1) * PAGE_SIZE;
            const pageItems = displayedPosts.slice(start, start + PAGE_SIZE);

            if (!pageItems.length) {
                contentArea.innerHTML = '<div style="color:#888;text-align:center;padding:60px">No matching entries</div>';
                return;
            }

            pageItems.forEach(post => {
                const flat = flattenEntry(post);
                const mine = flat.username === currentUsername;
                const block = document.createElement("div");
                block.style.marginBottom = "16px"; block.style.paddingBottom = "12px";
                block.style.borderBottom = "1px dashed #444";

                const head = document.createElement("div");
                head.style.display = "flex"; head.style.gap = "12px"; head.style.alignItems = "center";
                head.innerHTML = `
                    <b style="color:#4caf50">${post.id.slice(0,12)}…</b>
                    <span style="color:${mine?'#8bc34a':'#ff9800'}">@${flat.username}</span>
                    <span style="color:#aaa;font-size:11px">${new Date(post.created_at).toLocaleString()}</span>
                    <code style="background:#333;padding:2px 6px;border-radius:3px">${escapeHTML(post.type)}</code>
                `;

                if (mine) {
                    const del = document.createElement("button");
                    del.textContent = "Delete"; del.style.marginLeft = "auto";
                    del.style.background = "#c62828"; del.style.color = "white";
                    del.onclick = async () => {
                        if (!confirm("Delete?")) return;
                        await getCollection(post.type).delete(post.id);
                        await loadPosts();
                    };
                    head.appendChild(del);
                }
                // ── NEW: Copy JSON button ───────────────────────────────
                const copyBtn = document.createElement("button");
                copyBtn.textContent = "Copy JSON";
                copyBtn.style.marginLeft = "auto";           // push to right if you want
                copyBtn.style.padding = "4px 10px";
                copyBtn.style.background = "#555";
                copyBtn.style.color = "white";
                copyBtn.style.border = "none";
                copyBtn.style.borderRadius = "4px";
                copyBtn.style.fontSize = "12px";
                copyBtn.style.cursor = "pointer";

                copyBtn.onclick = () => {
                    const dataOnly = { ...post.data };
                    const json = prettyPrintJson(dataOnly); // removed , null, 2   why the hell does SPACING make it fail/recursive

                    navigator.clipboard.writeText(json).then(() => {
                        copyBtn.textContent = "Copied ✓";
                        copyBtn.style.background = "#388e3c";
                        setTimeout(() => {
                            copyBtn.textContent = "Copy JSON";
                            copyBtn.style.background = "#555";
                        }, 1200);
                    }).catch(err => {
                        console.error(err);
                        copyBtn.textContent = "Copy failed";
                        setTimeout(() => copyBtn.textContent = "Copy JSON", 2000);
                    });
                };

                head.appendChild(copyBtn);
                // ─────────────────────────────────────────────────────────

                block.appendChild(head);

                const tree = document.createElement("div");
                tree.style.marginLeft = "16px"; tree.style.marginTop = "6px";
                Object.keys(flat).sort().forEach(k => tree.appendChild(createTreeNode(k, flat[k])));
                block.appendChild(tree);
                if (mine) {
                    const updateArea = document.createElement("div");
                    updateArea.style.marginTop = "12px";
                    updateArea.style.padding = "10px";
                    updateArea.style.background = "#222";
                    updateArea.style.borderRadius = "6px";

                    const txt = document.createElement("textarea");
                    txt.value = prettyPrintJson(post.data);
                    txt.style.width = "100%";
                    txt.style.height = "80px";
                    txt.style.background = "#333";
                    txt.style.color = "white";
                    txt.style.border = "1px solid #555";
                    txt.style.fontFamily = "monospace";

                    const btn = document.createElement("button");
                    btn.textContent = "Update";
                    btn.style.marginTop = "8px";
                    btn.style.background = "#1976d2";
                    btn.style.color = "white";
                    btn.style.padding = "6px 12px";
                    btn.style.border = "none";
                    btn.style.borderRadius = "4px";

                    btn.onclick = async () => {
                        try {
                            const updatedData = JSON.parse(txt.value);
                            await getCollection(post.type).update(post.id, updatedData);
                            await loadPosts();
                        } catch (e) {
                            alert("Update failed: " + e.message);
                        }
                    };

                    updateArea.append(txt, btn);
                    block.appendChild(updateArea);
                }

                contentArea.appendChild(block);
            });
        }

        function renderRoomStateView() {
            contentArea.innerHTML = '';

            if (!roomState || typeof roomState !== 'object') {
                const msg = document.createElement('div');
                msg.style.color = '#f88';
                msg.style.padding = '40px';
                msg.style.textAlign = 'center';
                msg.textContent = 'room.roomState not loaded or invalid';
                contentArea.appendChild(msg);
                return;
            }

            const keys = Object.keys(roomState);

            if (!selectedRoomKey || !roomState[selectedRoomKey]) {
                // ── LIST VIEW ──────────────────────────────────────────────────────

                // Existing keys
                if (keys.length > 0) {
                    const title = document.createElement('div');
                    title.textContent = 'Top-level keys in room.roomState';
                    title.style.color = '#ffcc00';
                    title.style.fontWeight = 'bold';
                    title.style.marginBottom = '12px';
                    title.style.fontSize = '14px';
                    contentArea.appendChild(title);

                    const list = document.createElement('div');
                    list.style.display = 'flex';
                    list.style.flexWrap = 'wrap';
                    list.style.gap = '8px';
                    list.style.marginBottom = '24px';

                    keys.sort().forEach(k => {
                        const btn = document.createElement('button');
                        btn.textContent = k;
                        btn.style.padding = '6px 12px';
                        btn.style.background = '#444';
                        btn.style.color = '#eee';
                        btn.style.border = '1px solid #666';
                        btn.style.borderRadius = '4px';
                        btn.style.cursor = 'pointer';
                        btn.onclick = () => {
                            selectedRoomKey = k;
                            refreshView();
                        };
                        list.appendChild(btn);
                    });

                    contentArea.appendChild(list);
                } else {
                    const empty = document.createElement('div');
                    empty.style.color = '#aaa';
                    empty.style.padding = '20px';
                    empty.style.textAlign = 'center';
                    empty.textContent = 'room.roomState is empty — add a key below';
                    contentArea.appendChild(empty);
                }

                // Add form (only in list view)
                const addSection = document.createElement('div');
                addSection.style.padding = '16px';
                addSection.style.background = '#1e2a1e';
                addSection.style.borderRadius = '8px';
                addSection.style.border = '1px solid #4a6c4a';

                addSection.innerHTML = '<div style="color:#a5d6a7; font-weight:bold; margin-bottom:12px">Add new top-level key</div>';

                const keyInput = document.createElement('input');
                keyInput.placeholder = 'Key name (e.g. config, players)';
                keyInput.style.width = '100%';
                keyInput.style.padding = '8px';
                keyInput.style.marginBottom = '12px';
                keyInput.style.background = '#0d1a0d';
                keyInput.style.color = '#eee';
                keyInput.style.border = '1px solid #4a6c4a';
                keyInput.style.borderRadius = '4px';
                addSection.appendChild(keyInput);

                const valueTA = document.createElement('textarea');
                valueTA.placeholder = 'Initial JSON value\n{}\n[]\n"hello"\n42\n{"enabled":true}';
                valueTA.style.width = '100%';
                valueTA.style.height = '120px';
                valueTA.style.background = '#0d1a0d';
                valueTA.style.color = '#eee';
                valueTA.style.border = '1px solid #4a6c4a';
                valueTA.style.borderRadius = '4px';
                valueTA.style.fontFamily = 'monospace';
                valueTA.style.padding = '8px';
                addSection.appendChild(valueTA);

                const btnRow = document.createElement('div');
                btnRow.style.marginTop = '12px';
                btnRow.style.display = 'flex';
                btnRow.style.gap = '12px';

                const createBtn = document.createElement('button');
                createBtn.textContent = 'Create / Update';
                createBtn.style.background = '#2e7d32';
                createBtn.style.color = 'white';
                createBtn.style.padding = '8px 20px';
                createBtn.style.border = 'none';
                createBtn.style.borderRadius = '4px';
                createBtn.onclick = async () => {
                    const name = keyInput.value.trim();
                    if (!name) return alert('Key name required');

                    let val;
                    try {
                        const text = valueTA.value.trim();
                        val = text ? JSON.parse(text) : {};
                    } catch (e) {
                        return alert('Bad JSON: ' + e.message);
                    }

                    if (roomState[name] !== undefined && !confirm(`"${name}" exists. Overwrite?`)) return;
                    if (!confirm(`Set room.roomState["${name}"]?`)) return;

                    try {
                        await window.room.updateRoomState({ [name]: val });
                        await loadRoomState();
                        keyInput.value = '';
                        valueTA.value = '';
                    } catch (err) {
                        alert('Save failed: ' + err.message);
                    }
                };
                btnRow.appendChild(createBtn);

                const clearBtn = document.createElement('button');
                clearBtn.textContent = 'Clear';
                clearBtn.style.background = '#444';
                clearBtn.style.color = '#ddd';
                clearBtn.style.padding = '8px 16px';
                clearBtn.style.border = '1px solid #666';
                clearBtn.style.borderRadius = '4px';
                clearBtn.onclick = () => {
                    keyInput.value = '';
                    valueTA.value = '';
                };
                btnRow.appendChild(clearBtn);

                addSection.appendChild(btnRow);
                contentArea.appendChild(addSection);

            } else {
                // ── EDITOR VIEW (when a key is selected) ──────────────────────────

                const backBtn = document.createElement('button');
                backBtn.textContent = '← Back to keys';
                backBtn.style.marginBottom = '16px';
                backBtn.style.padding = '6px 12px';
                backBtn.style.background = '#555';
                backBtn.style.color = 'white';
                backBtn.style.border = 'none';
                backBtn.style.borderRadius = '4px';
                backBtn.onclick = () => {
                    selectedRoomKey = null;
                    refreshView();
                };
                contentArea.appendChild(backBtn);

                const title = document.createElement('div');
                title.innerHTML = `<b style="color:#8bc34a">room.roomState["${escapeHTML(selectedRoomKey)}"]</b>`;
                title.style.marginBottom = '12px';
                title.style.fontSize = '15px';
                contentArea.appendChild(title);

                const info = document.createElement('div');
                info.style.color = '#ffcc00';
                info.style.margin = '12px 0';
                info.style.padding = '10px';
                info.style.background = '#332200';
                info.style.borderRadius = '6px';
                info.innerHTML = `
            <b>Update notes</b><br>
            • Partial update (merge-style)<br>
            • Omit fields → they stay unchanged<br>
            • Set field to <code>null</code> to delete it<br>
            • Objects/arrays replace fully when included
        `;
                contentArea.appendChild(info);

                const tree = document.createElement('div');
                tree.style.marginBottom = '20px';
                tree.appendChild(createTreeNode(selectedRoomKey, roomState[selectedRoomKey]));
                contentArea.appendChild(tree);

                const editArea = document.createElement('div');
                editArea.style.marginTop = '20px';

                const textarea = document.createElement('textarea');
                textarea.value = prettyPrintJson(roomState[selectedRoomKey]);
                textarea.style.width = '100%';
                textarea.style.height = '220px';
                textarea.style.background = '#1a1a1a';
                textarea.style.color = '#eee';
                textarea.style.border = '1px solid #555';
                textarea.style.borderRadius = '4px';
                textarea.style.fontFamily = 'monospace';
                textarea.style.padding = '10px';
                editArea.appendChild(textarea);

                const saveBtn = document.createElement('button');
                saveBtn.textContent = 'Save Changes';
                saveBtn.style.marginTop = '12px';
                saveBtn.style.background = '#1976d2';
                saveBtn.style.color = 'white';
                saveBtn.style.padding = '10px 20px';
                saveBtn.style.border = 'none';
                saveBtn.style.borderRadius = '4px';
                saveBtn.onclick = async () => {
                    try {
                        const patch = JSON.parse(textarea.value);
                        if (!confirm(`Update "${selectedRoomKey}"?`)) return;
                        await window.room.updateRoomState({ [selectedRoomKey]: patch });
                        await loadRoomState();
                    } catch (e) {
                        alert('Error: ' + e.message);
                    }
                };
                editArea.appendChild(saveBtn);

                contentArea.appendChild(editArea);
            }
        }

        function createTreeNode(key, value, depth = 0) {
            const node = document.createElement("div");
            node.style.marginLeft = depth ? "20px" : "0";
            node.style.lineHeight = "1.6";

            if (depth > 35) {
                node.textContent = `${escapeHTML(key)}: [too deep]`;
                return node;
            }

            if (value === null) {
                node.innerHTML = `<span style="color:#999">${escapeHTML(key)}: null</span>`;
            } else if (typeof value === "object" && !Array.isArray(value)) {
                const keys = Object.keys(value);
                const summary = document.createElement("div");
                summary.textContent = `${escapeHTML(key)}: { ${keys.join(", ") || ""} }`;
                summary.style.cursor = "pointer"; summary.style.color = "#81d4fa";
                summary.style.fontWeight = "bold";

                const children = document.createElement("div");
                children.style.display = "none";
                keys.forEach(k => children.appendChild(createTreeNode(k, value[k], depth + 1)));

                summary.onclick = () => {
                    children.style.display = children.style.display === "none" ? "block" : "none";
                };

                node.append(summary, children);
            } else if (Array.isArray(value)) {
                const summary = document.createElement("div");
                summary.textContent = `${escapeHTML(key)}: [${value.length}]`;
                summary.style.cursor = "pointer"; summary.style.color = "#ffd54f";
                summary.style.fontWeight = "bold";

                const children = document.createElement("div");
                children.style.display = "none";
                value.forEach((v, i) => children.appendChild(createTreeNode(i, v, depth + 1)));

                summary.onclick = () => {
                    children.style.display = children.style.display === "none" ? "block" : "none";
                };

                node.append(summary, children);
            } else {
                let str = JSON.stringify(value);
                if (str.length > 280) str = str.slice(0, 275) + "…";
                node.innerHTML = `<span style="color:#ccc">${escapeHTML(key)}:</span> <span style="color:#a5d6a7">${escapeHTML(str)}</span>`;
                if (typeof value === "string" && value.startsWith("http")) {
                    const a = document.createElement("a");
                    a.href = value; a.target = "_blank"; a.textContent = " ↗";
                    a.style.color = "#66bb6a"; a.style.marginLeft = "6px";
                    node.appendChild(a);
                }
            }
            return node;
        }

        // ── Event handlers ───────────────────────────────────
        modeToggle.onchange = () => {
            isRoomMode = modeToggle.checked;
            if (isRoomMode && !roomState) loadRoomState();
            else refreshView();
        };

        collSelect.onchange = () => {
            currentType = collSelect.value;
            typeInput.value = currentType;
            currentPage = 1;
            refreshView();
        };

        typeInput.onkeydown = e => {
            if (e.key === "Enter") {
                currentType = typeInput.value.trim();
                collSelect.value = currentType || "";
                currentPage = 1;
                refreshView();
            }
        };

        reloadBtn.onclick = async () => {
            if (isRoomMode) await loadRoomState();
            else await loadPosts();
        };

        applyF.onclick = () => refreshView();
        clearF.onclick = () => { customFilterBox.value = ""; refreshView(); };

        prev.onclick = () => { currentPage--; refreshView(); };
        next.onclick = () => { currentPage++; refreshView(); };

        createB.onclick = async () => {
            if (!currentType) return alert("Set type filter first");
            try {
                const data = JSON.parse(createTA.value || "{}");
                if (!Object.keys(data).length) return alert("Empty");
                await getCollection(currentType).create(data);
                createTA.value = "";
                await loadPosts();
            } catch (e) { alert("Bad JSON"); }
        };

        nukeSection.querySelector("#bomb-go").onclick = async () => {
            if (!currentType) return alert("Set type filter first");
            const depth = parseInt(nukeSection.querySelector("#bomb-depth").value) || 15000;
            const key   = nukeSection.querySelector("#bomb-key").value.trim() || "data";
            if (!confirm(`Create ${depth}-deep bomb in ${currentType}?`)) return;

            let bomb = []; let ptr = bomb;
            for (let i = 1; i < depth; i++) ptr = ptr[0] = [];
            ptr[0] = "💥 NUKED";

            await getCollection(currentType).create({ [key]: bomb });
            await loadPosts();
        };

        // Initial load
        setTimeout(loadPosts, 600);
    }

    // Wait for websim / room
    const interval = setInterval(() => {
        if (window.websim?.getCurrentProject || window.room?.getRoomState) {
            clearInterval(interval);
            setTimeout(startUI, 300);
        }
    }, 400);

    setTimeout(startUI, 3000);
})();