LOLZ: drag, hide, add custom menu items

Редактирование пунктов меню

// ==UserScript==
// @name         LOLZ: drag, hide, add custom menu items
// @namespace    https://lolz.live/
// @version      1.0
// @description  Редактирование пунктов меню
// @author       MisterLis
// @match        https://lolz.live/*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    const STORAGE_KEY = 'manageItemsData_vFinal';

    function qs(sel, ctx = document) { return ctx.querySelector(sel); }
    function qsa(sel, ctx = document) { return [...ctx.querySelectorAll(sel)]; }

    function normalizeHref(href) {
        try {
            const url = new URL(href, location.origin);
            url.searchParams.delete('_xfToken');
            return url.href;
        } catch { return href; }
    }

    function loadData() {
        try {
            return JSON.parse(localStorage.getItem(STORAGE_KEY)) || { order: [], hidden: [], custom: [] };
        } catch { return { order: [], hidden: [], custom: [] }; }
    }
    function saveData(data) {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
    }

    function rebuildContainer() {
        const container = qs('.manageItems');
        if (!container) return;
        const data = loadData();

        const native = qsa('.manageItem', container)
            .filter(el => !el.dataset.custom)
            .map(el => ({ el, href: normalizeHref(el.href), isCustom: false }))
            .filter(it => !data.hidden.includes(it.href));

        const customs = data.custom.map(c => {
            const a = document.createElement('a');
            a.className = 'manageItem';
            a.href = c.href;
            a.dataset.custom = '1';
            a.innerHTML = `
                <div class="SvgIcon duotone">
                  <svg width="20" height="20" fill="currentColor"><path d="${c.icon}"/></svg>
                </div>
                <span>${c.text}</span>`;
            return { el: a, href: c.href, isCustom: true };
        });

        const all = [...native, ...customs];
        const orderMap = {};
        data.order.forEach((h, i) => orderMap[h] = i);
        all.sort((a, b) => (orderMap[a.href] ?? 999) - (orderMap[b.href] ?? 999));

        container.innerHTML = '';
        all.forEach(it => container.appendChild(it.el));

        initDragAndDrop(container);
    }

    function initDragAndDrop(container) {
        const items = qsa('.manageItem', container);
        items.forEach(it => {
            it.draggable = true;
            it.style.cursor = 'grab';
        });

        let dragged = null;

        function onStart(e) {
            dragged = this;
            e.dataTransfer.effectAllowed = 'move';
            e.dataTransfer.setData('text/plain', normalizeHref(this.href));
            this.classList.add('dragging');
        }
        function onEnd() { this.classList.remove('dragging'); }
        function onOver(e) {
            e.preventDefault();
            e.dataTransfer.dropEffect = 'move';
            const after = getDragAfterElement(container, e.clientY);
            if (after == null) container.appendChild(dragged);
            else container.insertBefore(dragged, after);
        }
        function onDrop(e) {
            e.preventDefault();
            saveOrder();
        }
        items.forEach(it => {
            it.addEventListener('dragstart', onStart);
            it.addEventListener('dragend', onEnd);
            it.addEventListener('dragover', onOver);
            it.addEventListener('drop', onDrop);
        });
    }
    function getDragAfterElement(container, y) {
        const els = [...qsa('.manageItem', container).filter(it => !it.classList.contains('dragging'))];
        return els.reduce((closest, child) => {
            const box = child.getBoundingClientRect();
            const offset = y - box.top - box.height / 2;
            return (offset < 0 && offset > closest.offset) ? { offset, element: child } : closest;
        }, { offset: Number.NEGATIVE_INFINITY }).element;
    }
    function saveOrder() {
        const data = loadData();
        data.order = qsa('.manageItem').map(el => normalizeHref(el.href));
        saveData(data);
    }

    function createEditTrigger() {
        const cont = qs('.manageItems');
        if (!cont) return;

        const bar = document.createElement('div');
        bar.className = 'editTriggerBar';
        bar.innerHTML = `<svg width="24" height="24" fill="#888"><path d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97 0-.33-.03-.65-.07-.97l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65A.488.488 0 0 0 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.32-.07.65-.07.97 0 .33.03.65.07.97L2.46 14.6c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.31.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.66Z"/></svg>`;
        bar.title = 'Редактировать пункты';
        bar.style.cssText = 'text-align:center;padding:6px 0;cursor:pointer;opacity:.6;transition:opacity .2s';
        bar.onmouseenter = () => bar.style.opacity = 1;
        bar.onmouseleave = () => bar.style.opacity = .6;
        bar.onclick = () => toggleEditMode();
        cont.parentElement.insertBefore(bar, cont.nextSibling);

        const plus = document.createElement('a');
        plus.className = 'manageItem addCustomItem';
        plus.id = 'addCustomItemBtn';
        plus.href = 'javascript:;';
        plus.innerHTML = `
        <div class="SvgIcon duotone">
            <svg width="24" height="24" fill="currentColor">
                <path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2Z"/>
            </svg>
        </div>
        <span>Добавить свой пункт</span>`;
        plus.style.display = 'none';
        cont.parentElement.insertBefore(plus, cont.nextSibling);
        plus.addEventListener('click', showAddDialog);
    }

    function toggleEditMode() {
        const cont = qs('.manageItems');
        const isEdit = cont.classList.toggle('editMode');
        isEdit ? enterEditMode(cont) : exitEditMode(cont);
    }

    function enterEditMode(container) {
        qsa('.manageItem', container).forEach(a => {
            if (a.dataset.custom) return;
            const close = document.createElement('span');
            close.innerHTML = '×';
            close.className = 'itemCloser';
            close.onclick = e => { e.preventDefault(); removeItem(a.href); };
            a.style.position = 'relative';
            a.appendChild(close);
        });
        qsa('.manageItem[data-custom]', container).forEach(a => {
            const close = document.createElement('span');
            close.innerHTML = '×';
            close.className = 'itemCloser';
            close.onclick = e => { e.preventDefault(); removeCustomItem(a.href); };
            a.appendChild(close);
        });
        qs('#addCustomItemBtn').style.display = 'flex';
    }

    function exitEditMode(container) {
        qsa('.itemCloser').forEach(x => x.remove());
        qs('#addCustomItemBtn').style.display = 'none';
    }

    function rebuildAndRestoreEdit() {
        rebuildContainer();
        const cont = qs('.manageItems');
        if (cont && cont.classList.contains('editMode')) {
            cont.classList.remove('editMode');
            cont.classList.add('editMode');
            enterEditMode(cont);
        }
    }

    function removeItem(href) {
        const key = normalizeHref(href);
        const data = loadData();
        if (!data.hidden.includes(key)) data.hidden.push(key);
        saveData(data);
        rebuildAndRestoreEdit();
    }

    function removeCustomItem(href) {
        const key = normalizeHref(href);
        const data = loadData();
        data.custom = data.custom.filter(c => c.href !== key);
        saveData(data);
        rebuildAndRestoreEdit();
    }

    function showAddDialog() {
        if (qs('#customItemOverlay')) return;

        const overlay = document.createElement('div');
        overlay.id = 'customItemOverlay';
        overlay.className = 'xenOverlay formOverlay';
        overlay.style.display = 'block';

        const form = document.createElement('form');
        form.className = 'xenForm';
        form.id = 'customItemForm';

        const fieldset = document.createElement('fieldset');

        function createInputRow(labelText, id, type = 'text', placeholder = '') {
            const dl = document.createElement('dl');
            dl.className = 'ctrlUnit';

            const dt = document.createElement('dt');
            const label = document.createElement('label');
            label.setAttribute('for', id);
            label.textContent = labelText;
            dt.appendChild(label);

            const dd = document.createElement('dd');
            const input = document.createElement('input');
            input.type = type;
            input.id = id;
            input.className = 'textCtrl OptOut';
            input.placeholder = placeholder;
            dd.appendChild(input);

            dl.appendChild(dt);
            dl.appendChild(dd);
            return dl;
        }

        fieldset.appendChild(createInputRow('Адрес:', 'ctrl_custom_url', 'text', 'forums/585/'));
        fieldset.appendChild(createInputRow('Название:', 'ctrl_custom_text', 'text', 'Мой пункт'));
        fieldset.appendChild(createInputRow('SVG-иконка:', 'ctrl_custom_icon', 'text', 'M4 6h16M4 12h16M4 18h16'));

        form.appendChild(fieldset);

        const footer = document.createElement('div');
        footer.className = 'sectionFooter';

        const saveBtn = document.createElement('input');
        saveBtn.type = 'submit';
        saveBtn.value = 'Сохранить';
        saveBtn.className = 'button primary';

        const cancelBtn = document.createElement('input');
        cancelBtn.type = 'button';
        cancelBtn.value = 'Отмена';
        cancelBtn.className = 'button';
        cancelBtn.id = 'cancelCustomItem';

        footer.appendChild(saveBtn);
        footer.appendChild(cancelBtn);

        form.appendChild(footer);
        overlay.appendChild(form);
        document.body.appendChild(overlay);

        function closeOverlay() { overlay.remove(); }
        cancelBtn.onclick = closeOverlay;

        form.onsubmit = e => {
            e.preventDefault();
            const url = qs('#ctrl_custom_url').value.trim();
            const text = qs('#ctrl_custom_text').value.trim();
            const icon = qs('#ctrl_custom_icon').value.trim();
            if (!url || !text) return alert('Заполни адрес и название!');

            const absHref = normalizeHref(
                url.startsWith('http') ? url : location.origin + '/' + url.replace(/^\/+/, '')
            );

            const data = loadData();
            const exist = data.custom.findIndex(c => c.href === absHref);
            if (exist !== -1) {
                data.custom[exist].text = text;
                data.custom[exist].icon = icon;
            } else {
                data.custom.push({ href: absHref, text, icon });
            }
            saveData(data);
            rebuildAndRestoreEdit();
            closeOverlay();
        };
    }

    function init() {
        if (!qs('.manageItems')) return;
        rebuildContainer();
        createEditTrigger();
    }

    new MutationObserver((_, ob) => {
        if (qs('.manageItems')) { init(); ob.disconnect(); }
    }).observe(document, { childList: true, subtree: true });

    const style = document.createElement('style');
    style.textContent = `
    .manageItems.editMode .manageItem{position:relative}
    .itemCloser{position:absolute;top:2px;right:6px;font-size:18px;color:#e00;cursor:pointer;line-height:1}
    #addCustomItemBtn.manageItem{display:none;align-items:center;padding:8px 12px;gap:12px;
        height:52px;box-sizing:border-box;border-radius:8px;
        background-color:#2d2d2d;color:#aaa;text-decoration:none;
        transition:background-color .2s,color .2s}
    #addCustomItemBtn.manageItem:hover{background-color:#303030;text-decoration:none}
    #addCustomItemBtn.manageItem:hover span{color:#37D38D}
    #addCustomItemBtn.manageItem .SvgIcon svg{fill:#888;transition:fill .2s}
    #addCustomItemBtn.manageItem:hover .SvgIcon svg{fill:#37D38D}

    #customItemOverlay {
        position: fixed;
        top: 50%; left: 50%;
        transform: translate(-50%,-50%);
        background: #2d2d2d;
        color: #ccc;
        padding: 20px;
        border-radius: 8px;
        z-index: 9999;
        min-width: 460px;
        box-shadow: 0 0 15px rgba(0,0,0,.6);
    }
    #customItemOverlay fieldset { border: none; margin: 0; padding: 0; }
    #customItemOverlay .ctrlUnit { display: flex; align-items: center; margin-bottom: 12px; }
    #customItemOverlay .ctrlUnit dt { width: 120px; margin: 0; font-weight: 500; color: #aaa; }
    #customItemOverlay .ctrlUnit dd { flex: 1; margin: 0; }
    #customItemOverlay .textCtrl {
        width: 100%;
        padding: 6px 8px;
        border: 1px solid #444;
        border-radius: 4px;
        background: #1f1f1f;
        color: #ddd;
    }
    #customItemOverlay .textCtrl:focus { border-color: #37D38D; outline: none; }
    #customItemOverlay .sectionFooter { margin-top: 15px; text-align: right; }
    #customItemOverlay .button { margin-left: 8px; }
`;
    document.head.appendChild(style);
})();