Websim menu

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل 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);
})();