Better Inventory

Grid layout, infinite scroll, gold values, owned counts for GGn inventory + shop

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!)

Advertisement:

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.

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name         Better Inventory
// @namespace    https://gazellegames.net/
// @version      1.65
// @description  Grid layout, infinite scroll, gold values, owned counts for GGn inventory + shop
// @author       waiter7
// @match        https://gazellegames.net/user.php?*action=inventory*
// @match        https://gazellegames.net/shop.php*
// @grant        GM_getValue
// @grant        GM_setValue
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const beta = false;
    const IS_SHOP      = location.pathname === '/shop.php';
    const IS_INVENTORY = !IS_SHOP;

    const CFG = {
        grid:        { key: 'ggn_inv_grid',       default: true  },
        infscr:      { key: 'ggn_inv_infscroll',  default: true  },
        gold:        { key: 'ggn_inv_gold',        default: true  },
        category:    { key: IS_SHOP ? 'ggn_shop_category'   : 'ggn_inv_category',   default: true  },
        compact:     { key: IS_SHOP ? 'ggn_shop_compact'    : 'ggn_inv_compact',    default: true  },
        showactions: { key: 'ggn_inv_showactions', default: true  },
        filtersinsidebar: { key: IS_SHOP ? 'ggn_shop_filtersinsidebar' : 'ggn_inv_filtersinsidebar', default: false },
    };

    for (const c of Object.values(CFG)) c.value = GM_getValue(c.key, c.default);

    const { grid, infscr, gold, category, compact, showactions, filtersinsidebar } =
        Object.fromEntries(Object.entries(CFG).map(([k, c]) => [k, c.value]));

    const FILTER_KEY          = IS_SHOP ? 'ggn_bi_filter_shop'     : 'ggn_bi_filter_inv';
    const HIDE_NONTRADE_KEY   = IS_SHOP ? 'ggn_bi_hidenontrade_shop': 'ggn_bi_hidenontrade_inv';
    const GROUP_SUBCAT_KEY    = IS_SHOP ? 'ggn_bi_groupsubcat_shop' : 'ggn_bi_groupsubcat_inv';

    const _cat = new URL(location.href).searchParams.get('category') ?? '';
    const hasCategoryFilter = !!_cat && _cat.toLowerCase() !== 'all';

    let activeFilter     = localStorage.getItem(FILTER_KEY)        ?? 'all';
    let hideNonTradeable = localStorage.getItem(HIDE_NONTRADE_KEY) === '1';
    let groupSubcat      = hasCategoryFilter && localStorage.getItem(GROUP_SUBCAT_KEY) === '1';
    let activeSearch     = '';

    // ── helpers ──────────────────────────────────────────────────────────────

    function getCollapsed(key, def) {
        const v = localStorage.getItem('ggn_nav_' + key);
        return v === null ? def : v === '1';
    }
    function setCollapsed(key, val) {
        localStorage.setItem('ggn_nav_' + key, val ? '1' : '0');
    }
    function fmtGold(n)   { return n.toLocaleString('en-US'); }
    function parseGold(s) { return parseInt(s.replace(/,/g, '').trim(), 10); }

    function detectIsSelf() {
        const uid = new URL(window.location.href).searchParams.get('userid');
        if (!uid) return true;
        const myId = document.querySelector('a[href*="user.php?id="]')
            ?.href.match(/user\.php\?id=(\d+)/)?.[1];
        return myId ? uid === myId : false;
    }

    function getOwnedFromLi(li) {
        const stat = Array.from(li.querySelectorAll('.gg-stat'))
            .find(s => s.title.startsWith('You own:'));
        if (stat) return parseInt(stat.title.replace('You own:', '').trim(), 10) || 0;
        const info = li.querySelector('.gg-owned-info');
        if (info) {
            const m = info.textContent.match(/👤\s*(\d+)/) ?? info.textContent.match(/You own:\s*(\d+)/);
            if (m) return parseInt(m[1], 10);
        }
        return 0;
    }

    /**
     * Parse the datetime string from the .time span title inside p#life_info.
     * Returns a Date or null.
     */
    function parseLifeDate(lifeP) {
        if (!lifeP) return null;
        const timeSpan = lifeP.querySelector('span.time');
        if (!timeSpan) return null;
        const d = new Date(timeSpan.title);
        return isNaN(d.getTime()) ? null : d;
    }

    /**
     * Given a future Date, return total days remaining (fractional).
     */
    function daysUntil(date) {
        return (date - Date.now()) / (1000 * 60 * 60 * 24);
    }

    /**
     * Compact life for display:
     *   >= 1 day  → "32d"
     *   < 1 day   → "13h"
     * Returns null if ∞ or no parseable date.
     */
    function compactLifeDays(lifeP) {
        const date = parseLifeDate(lifeP);
        if (!date) return null;
        const days = daysUntil(date);
        if (days < 0) return null;
        if (days < 1) {
            const hours = Math.ceil(days * 24);
            return `${hours}h`;
        }
        return `${Math.ceil(days)}d`;
    }

    /**
     * Full abbreviated life string e.g. "2mo 3w" — used in non-compact mode and hover.
     */
    function compactLife(lifeText) {
        if (!lifeText) return null;
        let s = lifeText.replace(/^Life:\s*/i, '').replace(/\s*left\s*$/i, '').trim();
        if (s === '∞' || s === '') return null;
        s = s
            .replace(/\byears?\b/gi,   'yr')
            .replace(/\bmonths?\b/gi,  'mo')
            .replace(/\bweeks?\b/gi,   'w')
            .replace(/\bdays?\b/gi,    'd')
            .replace(/\bhours?\b/gi,   'h')
            .replace(/\bminutes?\b/gi, 'm')
            .replace(/\bseconds?\b/gi, 's');
        s = s.replace(/\s*,\s*/g, ' ').replace(/\s+/g, ' ').trim();
        return s;
    }

    /**
     * Build the hover tooltip string: "1 month, 3 weeks left\nExpires: Jun 01 2026, 04:26:17"
     */
    function buildLifeTooltip(lifeP) {
        if (!lifeP) return null;
        const timeSpan = lifeP.querySelector('span.time');
        const naturalText = lifeP.textContent.trim().replace(/^Life:\s*/i, '');
        const dateStr = timeSpan?.title ?? null;
        if (dateStr) return `${naturalText}\nExpires: ${dateStr}`;
        return naturalText || null;
    }

    // ── filter ───────────────────────────────────────────────────────────────

    function applyFilter() {
        const list = document.getElementById('items_list');
        if (!list) return;

        const isSelf = detectIsSelf();

        for (const li of list.querySelectorAll(':scope > li')) {
            if (!li.querySelector('.gg-owned-info')) continue;

            let visible = true;

            if (isSelf) {
                // On own inventory: only apply the tradeable filter
                if (hideNonTradeable) {
                    const tradeable = li.dataset.tradeable;
                    if (tradeable === 'no') visible = false;
                }
            } else {
                const owned = getOwnedFromLi(li);
                if (activeFilter === 'have')    visible = owned > 0;
                if (activeFilter === 'missing') visible = owned === 0;
                if (visible && hideNonTradeable) {
                    const tradeable = li.dataset.tradeable;
                    if (tradeable === 'no') visible = false;
                }
            }

            // Text search
            if (visible && activeSearch) {
                const name = li.querySelector('h3')?.textContent.trim().toLowerCase() ?? '';
                if (!name.includes(activeSearch.toLowerCase())) visible = false;
            }

            li.classList.toggle('gg-hidden', !visible);
        }
        applyGrouping();
    }

    function applyGrouping() {
        const list = document.getElementById('items_list');
        if (!list) return;
        list.querySelectorAll(':scope > li.ggn-subcat-divider').forEach(li => li.remove());

        // Stamp original page order on any items that don't have it yet (before first sort)
        let nextOrder = Math.max(0, ...[...list.querySelectorAll(':scope > li:not(.ggn-subcat-divider)')]
            .map(li => li.dataset.biOrder != null ? parseInt(li.dataset.biOrder) + 1 : 0));
        list.querySelectorAll(':scope > li:not(.ggn-subcat-divider)').forEach(li => {
            if (li.dataset.biOrder == null) li.dataset.biOrder = nextOrder++;
        });

        if (!groupSubcat) {
            // Restore original page order
            [...list.querySelectorAll(':scope > li:not(.ggn-subcat-divider)')]
                .sort((a, b) => parseInt(a.dataset.biOrder) - parseInt(b.dataset.biOrder))
                .forEach(li => list.appendChild(li));
            return;
        }

        const clean = s => (s ?? '').replace(/\s*\(.*?\)\s*/g, '').trim();

        // Sort visible items by subcat (stable — name order preserved within group)
        const visible = [...list.querySelectorAll(':scope > li:not(.gg-hidden):not(.ggn-subcat-divider)')];
        visible.sort((a, b) => {
            const sa = clean(a.dataset.subcat) || '\uFFFF'; // blank sorts last
            const sb = clean(b.dataset.subcat) || '\uFFFF';
            return sa.localeCompare(sb);
        });
        visible.forEach(li => list.appendChild(li));

        // Insert dividers at group boundaries
        let lastSubcat = null;
        for (const li of visible) {
            const subcat = clean(li.dataset.subcat);
            if (subcat !== lastSubcat) {
                const divider = document.createElement('li');
                divider.className   = 'ggn-subcat-divider';
                divider.textContent = subcat || 'No Subcategory';
                list.insertBefore(divider, li);
                lastSubcat = subcat;
            }
        }
    }

    // ── stat block ────────────────────────────────────────────────────────────

    function buildStatParts({ amount, goldPrice, owned, isSelf }) {
        const parts = [];
        if (grid && !isSelf && amount !== null)
            parts.push({ emoji: '📦', title: `Amount: ${amount}` });
        if (gold && goldPrice > 0)
            parts.push({ emoji: '💰', title: `Gold: ${fmtGold(goldPrice)}` });
        parts.push({ emoji: '👤', title: `You own: ${owned}` });
        return parts;
    }

    function renderStatBlock({ amount, goldPrice, owned, isSelf, level, lifeCompact, lifeDays, lifeTooltip }) {
        const parts = buildStatParts({ amount, goldPrice, owned, isSelf });

        const el = document.createElement('div');
        el.className = 'gg-owned-info';

        if (compact) {
            el.classList.add('gg-compact');

            // Level — compact: ⚔️ + number
            if (level != null) {
                const span = document.createElement('span');
                span.className   = 'gg-stat gg-level';
                span.title       = `Level: ${level}`;
                span.textContent = `⚔️${level}`;
                el.appendChild(span);
            }

            // Life — compact: ⏳ + days (or hours if < 1 day)
            if (lifeDays) {
                if (el.childNodes.length > 0) el.appendChild(document.createTextNode(' '));
                const span = document.createElement('span');
                span.className   = 'gg-stat gg-life';
                span.title       = lifeTooltip ?? lifeDays;
                span.textContent = `⏳${lifeDays}`;
                el.appendChild(span);
            }

            // Main stats
            parts.forEach((p, i) => {
                if (el.childNodes.length > 0) el.appendChild(document.createTextNode(' '));
                const span = document.createElement('span');
                span.className   = 'gg-stat';
                span.title       = p.title;
                span.textContent = `${p.emoji}${p.title.replace(/^[^:]+:\s*/, '')}`;
                el.appendChild(span);
            });

        } else {
            // Level row
            if (level != null) {
                const row = document.createElement('div');
                row.textContent = `Level: ${level}`;
                row.className   = 'gg-level-row';
                el.appendChild(row);
            }

            // Life row
            if (lifeCompact) {
                const row = document.createElement('div');
                row.title       = lifeTooltip ?? lifeCompact;
                row.textContent = `Life: ${lifeCompact}`;
                row.className   = 'gg-life-row';
                el.appendChild(row);
            }

            // Main stats
            parts.forEach(p => {
                const row = document.createElement('div');
                row.textContent = p.title;
                if (p.emoji === '👤' && owned === 0) {
                    row.style.fontStyle = 'italic';
                    row.style.opacity   = '0.6';
                }
                el.appendChild(row);
            });
        }

        return el;
    }

    // ── category ─────────────────────────────────────────────────────────────

    function buildCategoryEl(categoryText, subcategoryText, subcatIsCategory = false, parentCategoryText = null) {
        const uid  = new URL(window.location.href).searchParams.get('userid') ?? '';
        const base = IS_SHOP
            ? '/shop.php'
            : `/user.php?action=inventory${uid ? `&userid=${uid}` : ''}`;

        const el     = document.createElement('div');
        el.className = 'gg-category';

        const mainLink       = document.createElement('a');
        mainLink.href        = `${base}&category=${encodeURIComponent(categoryText)}`;
        mainLink.textContent = categoryText;
        mainLink.title       = categoryText;
        mainLink.addEventListener('click', e => e.stopPropagation());
        el.appendChild(mainLink);

        if (subcategoryText) {
            const displaySubcat = subcategoryText.replace(/\s*\(.*?\)\s*/g, '').trim();
            el.appendChild(document.createTextNode(' / '));
            const subLink = document.createElement('a');
            if (subcatIsCategory) {
                subLink.href = `${base}&category=${encodeURIComponent(displaySubcat)}`;
            } else {
                const cat = parentCategoryText ?? categoryText;
                subLink.href = `${base}&category=${encodeURIComponent(cat)}&search=${encodeURIComponent(displaySubcat)}&search_more=searchmore`;
            }
            subLink.textContent = displaySubcat;
            subLink.title       = subcategoryText;
            subLink.addEventListener('click', e => e.stopPropagation());
            el.appendChild(subLink);
        }

        return el;
    }

    // ── dialog meta ──────────────────────────────────────────────────────────

    function extractDialogMeta(dialogDiv) {
        if (!dialogDiv) return {};

        const dlgPs = Array.from(dialogDiv.querySelectorAll('p'));

        const categoryText = dlgPs.find(p => /Category:\s*\S/.test(p.textContent))
            ?.textContent.match(/Category:\s*(.+)/)?.[1].trim() ?? null;

        const equipmentType = dlgPs.find(p => /Equipment Type:\s*\S/.test(p.textContent))
            ?.textContent.match(/Equipment Type:\s*(.+)/)?.[1].trim() ?? null;

        let subcategoryText = null;
        for (const p of dlgPs) {
            const catSpan = Array.from(p.querySelectorAll('span'))
                .find(s => s.textContent.trim() === 'Category');
            if (catSpan) {
                const after = catSpan.nextSibling;
                if (after?.nodeType === Node.TEXT_NODE) {
                    const raw = after.textContent.replace(/^:\s*/, '').trim();
                    if (raw) { subcategoryText = raw; break; }
                    const afterNext = after.nextSibling;
                    if (afterNext?.nodeType === Node.ELEMENT_NODE && afterNext.textContent.trim()) {
                        subcategoryText = afterNext.textContent.trim();
                        break;
                    }
                }
            }
        }

        const ownedMatch = dlgPs.find(p => /You own:\s*\d+/.test(p.textContent))
            ?.textContent.match(/You own:\s*(\d+)/)?.[1];
        const ownedFromDlg = ownedMatch != null ? +ownedMatch : 0;

        const firstCostNode = Array.from(dialogDiv.querySelector('#cost')?.childNodes ?? [])
            .find(n => n.nodeType === Node.TEXT_NODE && n.textContent.trim());
        const goldPrice = firstCostNode ? parseGold(firstCostNode.textContent) : NaN;

        // Tradeable: parse "Tradeable: Yes" / "Tradeable: No"
        const tradeableRaw = dlgPs.find(p => /^Tradeable:\s*(Yes|No)/i.test(p.textContent.trim()))
            ?.textContent.trim();
        let tradeable = null; // null = unknown
        if (tradeableRaw) {
            tradeable = /No/i.test(tradeableRaw) ? false : true;
        }

        return { categoryText, equipmentType, subcategoryText, ownedFromDlg, goldPrice, tradeable };
    }

    // ── action buttons ───────────────────────────────────────────────────────

    function getActionableBtns(li) {
        const submitSection = li.querySelector('.submit_section');
        if (!submitSection) return [];
        return [...submitSection.querySelectorAll('input[type=submit], button[type=submit], button[type=button], button:not([type])')].filter(b => {
            if (b.classList.contains('submit_feature')) return false;
            if (b.classList.contains('hidden')) return false;
            if (b.style.display === 'none') return false;
            return true;
        });
    }

    function buildActionsEl(li, forModal = false) {
        const actionBtns = getActionableBtns(li);
        if (actionBtns.length === 0) return null;

        const wrapper = document.createElement('div');
        wrapper.className = forModal ? 'gg-actions gg-actions-modal' : 'gg-actions';

        const useMultiInput = li.querySelector('input[name=use_multi]');
        if (useMultiInput) {
            const row = document.createElement('div');
            row.className = 'gg-actions-qty';
            const inp = document.createElement('input');
            inp.type  = 'number';
            inp.min   = useMultiInput.min || '1';
            inp.max   = useMultiInput.max || '1';
            inp.value = useMultiInput.value || '1';
            inp.addEventListener('input', () => { useMultiInput.value = inp.value; });
            inp.addEventListener('click', e => e.stopPropagation());
            row.appendChild(inp);
            if (forModal) wrapper.appendChild(row);
            else wrapper._qtyRow = row;
        }

        if (!forModal && wrapper._qtyRow) {
            const inlineRow = wrapper._qtyRow;
            for (const origBtn of actionBtns) {
                const btn = document.createElement('button');
                btn.type        = 'button';
                btn.textContent = origBtn.value || origBtn.textContent.trim();
                btn.className   = 'gg-action-delegate';
                btn.addEventListener('click', e => {
                    e.stopPropagation();
                    const section = origBtn.closest('.submit_section');
                    if (section) section.style.setProperty('display', 'block', 'important');
                    origBtn.click();
                    if (section) section.style.removeProperty('display');
                });
                inlineRow.appendChild(btn);
            }
            wrapper.appendChild(inlineRow);
            delete wrapper._qtyRow;
        } else {
            const inlineRow = document.createElement('div');
            inlineRow.className = forModal ? '' : 'gg-actions-qty';
            for (const origBtn of actionBtns) {
                const btn = document.createElement('button');
                btn.type        = 'button';
                btn.textContent = origBtn.value || origBtn.textContent.trim();
                btn.className   = 'gg-action-delegate';
                btn.addEventListener('click', e => {
                    e.stopPropagation();
                    const section = origBtn.closest('.submit_section');
                    if (section) section.style.setProperty('display', 'block', 'important');
                    origBtn.click();
                    if (section) section.style.removeProperty('display');
                });
                inlineRow.appendChild(btn);
            }
            if (forModal) {
                for (const btn of [...inlineRow.children]) wrapper.appendChild(btn);
            } else {
                wrapper.appendChild(inlineRow);
            }
        }

        return wrapper;
    }

    // ── trash overlay ─────────────────────────────────────────────────────────

    function injectTrashOverlay(li) {
        if (li.querySelector('.gg-trash-overlay')) return;
        const trashImg = li.querySelector('img.trash_icon');
        if (!trashImg) return;

        const overlay = document.createElement('div');
        overlay.className = 'gg-trash-overlay';
        overlay.title     = trashImg.title || 'Remove Item Permanently';
        const img = document.createElement('img');
        img.src = trashImg.src;
        img.alt = trashImg.alt || 'Trash';
        overlay.appendChild(img);
        overlay.addEventListener('click', e => {
            e.stopPropagation();
            e.preventDefault();
            if (trashImg.onclick) trashImg.onclick.call(trashImg, e);
        });
        li.insertBefore(overlay, li.firstChild);
    }

    // ── dialog action hook ────────────────────────────────────────────────────

    function initDialogActionHook() {
        if (IS_SHOP) return;
        if (!detectIsSelf()) return;

        $(document).on('dialogopen', '.ui-dialog-content', function () {
            const dlgContent = this;
            if (dlgContent.querySelector('.gg-actions-modal')) return;
            const dlgId = dlgContent.id;
            if (!dlgId) return;
            const list = document.getElementById('items_list');
            if (!list) return;

            let matchedLi = null;
            for (const li of list.querySelectorAll(':scope > li')) {
                const clickable  = li.querySelector('#clickable, .item_info');
                const onclickStr = clickable?.onclick?.toString() ?? '';
                const refId      = onclickStr.match(/ItemInfo\("?(\d+)"?/)?.[1];
                if (refId === dlgId) { matchedLi = li; break; }
            }
            if (!matchedLi) return;

            const actionsEl = buildActionsEl(matchedLi, true);
            if (!actionsEl) return;

            const youOwnEl = dlgContent.querySelector('.info_cost p:last-of-type');
            const moreInfo = dlgContent.querySelector('.more_info');
            if (youOwnEl && youOwnEl.textContent.match(/You own:/)) {
                youOwnEl.insertAdjacentElement('afterend', actionsEl);
            } else if (moreInfo) {
                moreInfo.insertAdjacentElement('afterend', actionsEl);
            } else {
                dlgContent.appendChild(actionsEl);
            }
        });
    }

    // ── main inject ───────────────────────────────────────────────────────────

    function injectItemInfo(root) {
        const list = root || document.getElementById('items_list');
        if (!list) return;

        const isSelf = detectIsSelf();

        for (const li of list.querySelectorAll(':scope > li')) {
            if (li.querySelector('.gg-owned-info')) continue;

            const form      = li.querySelector('form');
            const clickable = li.querySelector('#clickable');
            if (!form) continue;

            const onclickId = clickable?.onclick
                ?.toString().match(/ItemInfo\("?(\d+)"?/)?.[1];
            const itemid    = form.dataset.itemid;
            const dialogDiv = onclickId
                ? document.getElementById(onclickId)
                : (itemid ? document.querySelector(`div[data-itemid="${itemid}"]`) : null);

            if (!dialogDiv) continue;

            const { categoryText, equipmentType, subcategoryText, ownedFromDlg, goldPrice, tradeable }
                = extractDialogMeta(dialogDiv);

            // Stamp tradeable on the li for filtering
            if (tradeable !== null) {
                li.dataset.tradeable = tradeable ? 'yes' : 'no';
            }

            const amountText = li.querySelector('p[id^="amount_"]')?.textContent;
            let amount = amountText ? parseInt(amountText.match(/Amount:\s*(\d+)/)?.[1], 10) : null;
            if (amount === null && li.querySelector('p#life_info')) amount = 1;
            // Cloned items from own inventory: suppress amount — they don't own any
            if (li.dataset.myInv) amount = null;

            const owned = isSelf ? (amount ?? ownedFromDlg) : ownedFromDlg;

            // Level — from p#equipment_level on the li
            const levelText = li.querySelector('#equipment_level')?.textContent.trim();
            const level = levelText ? (parseInt(levelText.match(/Level:\s*(\d+)/)?.[1], 10) || null) : null;

            // Life — from p#life_info on the li
            const lifeP       = li.querySelector('#life_info') ?? null;
            const lifeRaw     = lifeP?.textContent.trim() ?? null;
            const lifeCompact = compactLife(lifeRaw);
            const lifeDays    = compactLifeDays(lifeP);
            const lifeTooltip = buildLifeTooltip(lifeP);

            const subcat           = equipmentType ?? subcategoryText;
            const subcatIsCategory = !!equipmentType;
            const subcatForGrouping = subcat ?? categoryText;

            if (subcatForGrouping) li.dataset.subcat = subcatForGrouping;

            if (category && categoryText && !li.querySelector('.gg-category')) {
                const catEl = buildCategoryEl(categoryText, subcat, subcatIsCategory, categoryText);
                if (grid) {
                    if (clickable) {
                        const imageDiv = clickable.querySelector('.item_image_div');
                        clickable.insertBefore(catEl, imageDiv ?? null);
                    }
                } else {
                    const amountP = form.querySelector('p[id^="amount_"]');
                    if (amountP) {
                        form.insertBefore(catEl, amountP);
                    } else {
                        clickable?.insertAdjacentElement('afterend', catEl);
                    }
                }
            }

            // Move p#not_tradeable into #clickable, after .gg-category
            if (grid && clickable) {
                const notTradeP = li.querySelector('#not_tradeable');
                if (notTradeP && !clickable.querySelector('#not_tradeable')) {
                    const catEl = clickable.querySelector('.gg-category');
                    (catEl ?? clickable.querySelector('h3'))
                        ?.insertAdjacentElement('afterend', notTradeP);
                }
            }

            const imageDiv = li.querySelector('.item_image_div');
            if (grid && imageDiv && !imageDiv.querySelector('.gg-img-area')) {
                const img     = imageDiv.querySelector('.item_center.item_image');
                const imgArea = document.createElement('div');
                imgArea.className = 'gg-img-area';
                imageDiv.insertBefore(imgArea, img ?? null);
                if (img) imgArea.appendChild(img);
            }

            if (grid && isSelf) {
                const actionsEl = buildActionsEl(li, false);
                if (actionsEl) (imageDiv ?? form).appendChild(actionsEl);
                injectTrashOverlay(li);
            }

            (imageDiv ?? form).appendChild(
                renderStatBlock({ amount, goldPrice, owned, isSelf, level, lifeCompact, lifeDays, lifeTooltip })
            );
        }

        applyFilter();
    }

    function injectShopItemInfo() {
        const list = document.getElementById('items_list');
        if (!list) return;

        for (const li of list.querySelectorAll(':scope > li')) {
            if (li.querySelector('.gg-owned-info')) continue;

            const form      = li.querySelector('form');
            const clickable = li.querySelector('.item_info');
            if (!form) continue;

            const itemid    = form.dataset.itemid;
            const dialogDiv = itemid ? document.getElementById(itemid) : null;
            if (!dialogDiv) continue;

            const { categoryText, equipmentType, subcategoryText, ownedFromDlg, tradeable }
                = extractDialogMeta(dialogDiv);

            // Stamp tradeable on the li for filtering
            if (tradeable !== null) {
                li.dataset.tradeable = tradeable ? 'yes' : 'no';
            }

            const subcat           = equipmentType ?? subcategoryText;
            const subcatIsCategory = !!equipmentType;
            const subcatForGrouping = subcat ?? categoryText;

            if (subcatForGrouping) li.dataset.subcat = subcatForGrouping;

            if (category && categoryText && clickable && !clickable.querySelector('.gg-category')) {
                const h3 = clickable.querySelector('h3');
                h3.insertAdjacentElement('afterend',
                    buildCategoryEl(categoryText, subcat, subcatIsCategory, categoryText)
                );
            }

            const submitSection = li.querySelector('.submit_section');
            if (submitSection && !li.querySelector('.gg-owned-info')) {
                const statEl = renderStatBlock({
                    amount: null, goldPrice: NaN, owned: ownedFromDlg, isSelf: true,
                    level: null, lifeCompact: null, lifeRaw: null
                });
                submitSection.insertAdjacentElement('afterend', statEl);
            }
        }

        applyFilter();
    }

    // ── click-outside close ───────────────────────────────────────────────────

    function initClickOutside() {
        document.addEventListener('mousedown', e => {
            const open = document.querySelectorAll('.ui-dialog');
            if (!open.length) return;
            if ([...open].some(d => d.contains(e.target))) return;
            document.querySelectorAll('.ui-dialog-content').forEach(c => {
                try { $(c).dialog('close'); } catch (_) {}
            });
        });
    }

    // ── styles ────────────────────────────────────────────────────────────────

    function injectBaseStyles() {
        const s = document.createElement('style');
        s.id = 'ggn-bi-base';
        s.textContent = `
            #items_navigation > a { display: block !important; }
            #items_list > li.gg-hidden,
            .inventory #items_list.ggn-grid > li.gg-hidden { display: none !important; }
            .gg-owned-info {
                font-size: 0.85em;
                text-align: center;
                color: #ccc;
                line-height: 1.5;
                padding-top: 4px;
                font-weight: normal;
            }
            .gg-owned-info.gg-compact {
                white-space: nowrap;
                overflow: visible;
                padding-top: 5px;
                letter-spacing: 0.02em;
            }
            .gg-stat {
                cursor: default;
                border-bottom: 1px dotted rgba(200,200,200,0);
                padding-bottom: 1px;
            }
            .gg-stat:hover { border-bottom-color: rgba(200,200,200,1); }
            .gg-level-row { color: #a8d8a8; font-size: 0.92em; }
            .gg-life-row  { color: #d8c08a; font-size: 0.92em; cursor: default; }
            .gg-level { color: #a8d8a8; }
            .gg-life  { color: #d8c08a; }

            .inventory #items_list.ggn-grid #not_tradeable {
                display: block !important;
                font-size: 0.7em;
                color: #c0392b;
                text-align: center;
                line-height: 1.4;
                margin: 0;
                padding: 0;
            }

            .gg-category a {
                color: inherit;
                text-decoration: none;
            }
            .gg-category a:hover { text-decoration: underline; }
            .inventory #items_list:not(.ggn-grid) .gg-category {
                display: block;
                font-size: 1em;
                color: #fff;
                margin-bottom: 2px;
                margin-left: 120px;
                line-height: 1.4;
                white-space: nowrap;
                overflow: hidden;
                text-overflow: ellipsis;
            }
            .inventory #items_list:not(.ggn-grid) .gg-category a { color: #b49629; }
            .inventory #items_list:not(.ggn-grid) .gg-category a:hover { text-decoration: underline; }
            .items_shop .gg-category {
                font-size: 0.78em;
                color: #aaa;
                text-align: center;
                margin-top: -15px;
                margin-bottom: 4px;
                white-space: nowrap;
                overflow: hidden;
                text-overflow: ellipsis;
            }
            .items_shop .gg-owned-info {
                margin-top: 6px;
                text-align: center;
                display: block;
                width: 100%;
            }
            #ggn-clear-search {
                margin-left: 6px;
                font-size: 0.85em;
                cursor: pointer;
                color: #b49629;
                text-decoration: none;
                border: 1px solid #b49629;
                border-radius: 3px;
                padding: 1px 6px;
                vertical-align: middle;
                white-space: nowrap;
            }
            #ggn-clear-search:hover { background: rgba(180,150,41,0.15); }
            #ggn-bi-filter {
                margin-top: 6px;
                display: flex;
                flex-direction: column;
                gap: 3px;
            }
            #ggn-bi-filter label {
                display: flex;
                align-items: center;
                gap: 6px;
                cursor: pointer;
                color: rgb(180,150,41);
                font-size: 14.3px;
            }
            #ggn-bi-filter input[type=radio] { cursor: pointer; }
            /* ── Tradeable filter checkbox ── */
            .gg-trade-filter {
                display: flex;
                align-items: center;
                gap: 6px;
                cursor: pointer;
                color: rgb(180,150,41);
                font-size: 14.3px;
                margin-top: 6px;
            }
            .gg-trade-filter input[type=checkbox] { cursor: pointer; }
            /* ── Action controls ── */
            .gg-actions {
                display: flex;
                flex-direction: column;
                align-items: center;
                gap: 4px;
                padding-top: 5px;
                width: 100%;
                box-sizing: border-box;
            }
            .gg-actions-qty {
                font-size: 0.72em;
                color: #ccc;
                display: flex;
                align-items: center;
                gap: 2px;
            }
            .gg-actions-qty input[type=number] {
                background: #1a2535;
                color: #eee;
                border: 1px solid #555;
                border-radius: 2px;
                padding: 0 2px;
                font-size: 1em;
                width: 2.8em;
            }
            .gg-action-delegate {
                background: #4a3e10;
                color: #e8c84a;
                border: 1px solid #b49629;
                border-radius: 2px;
                padding: 1px 4px;
                font-size: 1.2em;
                cursor: pointer;
                width: auto;
                display: inline-block;
                box-sizing: border-box;
                font-weight: bold;
                letter-spacing: 0.02em;
                line-height: 1.3;
            }
            .gg-action-delegate:hover {
                background: #5e4e14;
                border-color: #e8c84a;
            }
            .gg-actions-modal {
                margin-top: 10px;
                padding: 8px;
                border-top: 1px solid #444;
                flex-direction: row;
                flex-wrap: wrap;
                justify-content: center;
                gap: 6px;
            }
            .gg-actions-modal .gg-action-delegate {
                width: auto;
                padding: 0px 16px;
                font-size: 0.9em;
            }
            .gg-actions-modal .gg-actions-qty {
                width: 100%;
                justify-content: center;
                font-size: 0.88em;
                margin-bottom: 2px;
            }
            /* ── Trash overlay ── */
            .gg-trash-overlay {
                position: absolute;
                top: 3px;
                right: 3px;
                z-index: 10;
                opacity: 0;
                transition: opacity 0.15s;
                cursor: pointer;
                pointer-events: none;
                line-height: 0;
            }
            .gg-trash-overlay img {
                width: 16px;
                height: 16px;
                display: block;
                filter: drop-shadow(0 0 2px #000);
            }
            .inventory #items_list.ggn-grid > li:hover .gg-trash-overlay {
                opacity: 1;
                pointer-events: auto;
            }
            /* ── Text search input (sidebar) ── */
            .gg-search-wrap { margin-top: 6px; }
            .gg-search-input {
                width: 100%;
                box-sizing: border-box;
                background: #1a2535;
                color: #eee;
                border: 1px solid #555;
                border-radius: 3px;
                padding: 3px 6px;
                font-size: 13px;
            }
            .gg-search-input:focus { outline: none; border-color: #b49629; }
            .gg-search-input::placeholder { color: #888; }
            /* ── Filter bar above grid ── */
            #ggn-bi-filterbar {
                display: flex;
                flex-wrap: wrap;
                align-items: center;
                gap: 3px 10px;
                padding: 5px 8px 6px;
                margin-top: 40px;
                margin-bottom: 4px;
                border: 1px solid #2a3a50;
                border-radius: 4px;
                background: rgba(10,20,35,0.65);
                font-size: 12.5px;
                color: rgb(180,150,41);
            }
            #ggn-bi-filterbar .gg-search-wrap { margin: 0; }
            #ggn-bi-filterbar .gg-search-input {
                width: 150px; padding: 2px 5px; font-size: 12.5px;
            }
            #ggn-bi-filterbar .gg-filterbar-radios {
                display: flex; align-items: center; gap: 6px;
            }
            #ggn-bi-filterbar .gg-filterbar-radios label {
                display: flex; align-items: center; gap: 3px;
                cursor: pointer; color: rgb(180,150,41); white-space: nowrap;
            }
            #ggn-bi-filterbar .gg-filterbar-radios input[type=radio] { cursor: pointer; margin: 0; }
            #ggn-bi-filterbar .gg-filterbar-trade {
                display: flex; align-items: center; gap: 4px;
                cursor: pointer; color: rgb(180,150,41); white-space: nowrap;
            }
            #ggn-bi-filterbar .gg-filterbar-trade input[type=checkbox] { cursor: pointer; margin: 0; }
            /* ── Inventory overlay states ── */
            .gg-inv-both-have {
                outline: 1px solid rgba(100,200,100,0.55) !important;
                background: rgba(60,140,60,0.13) !important;
            }
            .gg-inv-mine-only {
                outline: 1px solid rgba(210,130,40,0.65) !important;
                background: rgba(170,90,20,0.15) !important;
            }
            .gg-inv-theirs-only { }
            .gg-inv-mine-only .gg-owned-info::after,
            .gg-inv-both-have .gg-owned-info::after,
            .gg-inv-theirs-only .gg-owned-info::after {
                display: block; font-size: 0.78em; margin-top: 3px;
                letter-spacing: 0.01em; opacity: 0.85;
            }
            .gg-inv-mine-only   .gg-owned-info::after { content: "You have, they don't"; color: rgb(210,140,60); }
            .gg-inv-both-have   .gg-owned-info::after { content: "You both have";         color: rgb(90,190,90);  }
            .gg-inv-theirs-only .gg-owned-info::after { content: "They have, you don't";  color: rgb(170,170,170); }
            /* ── Combined loading indicator ── */
            .ggn-loading-combined::after { content: " ⌛"; font-style: normal; }
            /* ── Subcategory group dividers ── */
            li.ggn-subcat-divider {
                width: 100% !important; list-style: none !important;
                font-size: 11px; font-weight: bold; letter-spacing: 0.6px;
                text-transform: uppercase; color: #7a8fa8;
                padding: 8px 2px 2px; margin: 0 !important;
                border-top: 1px solid #2a3a50;
            }
            li.ggn-subcat-divider:first-child { border-top: none; padding-top: 2px; }
            .inventory #items_list.ggn-grid li.ggn-subcat-divider {
                flex-basis: 100%; padding: 10px 4px 2px;
            }
        `;
        document.head.appendChild(s);
    }

    function initGrid() {
        const s = document.createElement('style');
        s.id = 'ggn-grid-style';
        s.textContent = `
            .inventory #items_list.ggn-grid { align-items: stretch !important; margin-top: 0 !important; }
            .inventory #items_list.ggn-grid > li {
                position: relative;
                width: 140px !important; min-height: unset !important;
                padding: 5px !important;
                display: flex !important; flex-direction: column !important;
                align-items: stretch !important;
                overflow: hidden !important; box-sizing: border-box !important;
            }
            .inventory #items_list.ggn-grid > li form {
                flex: 1 !important; display: flex !important;
                flex-direction: column !important;
                width: 100% !important; box-sizing: border-box !important;
            }
            .inventory #items_list.ggn-grid > li .item_info {
                flex: 1 !important; display: flex !important;
                flex-direction: column !important;
                width: 100% !important; box-sizing: border-box !important;
            }
            .inventory #items_list.ggn-grid > li h3.center {
                flex-shrink: 0 !important;
                font-size: 1em !important; font-weight: bold !important;
                height: auto !important;
                margin: 0 0 2px !important; text-align: center !important;
                white-space: normal !important;
                overflow: visible !important;
                text-overflow: unset !important;
                word-break: break-word !important;
                width: 100% !important; box-sizing: border-box !important;
            }
            .inventory #items_list.ggn-grid .gg-category {
                flex-shrink: 0 !important;
                font-size: 0.9em !important; color: #ccc !important;
                text-align: center !important;
                white-space: nowrap !important; overflow: hidden !important;
                text-overflow: ellipsis !important;
                width: 100% !important; margin-bottom: 3px !important;
                box-sizing: border-box !important;
            }
            .inventory #items_list.ggn-grid > li .item_image_div {
                flex: 1 !important; display: flex !important;
                flex-direction: column !important;
                float: none !important; width: 100% !important;
                margin: 0 !important; box-sizing: border-box !important;
            }
            .inventory #items_list.ggn-grid .gg-img-area {
                flex: 1 !important; display: flex !important;
                align-items: center !important; justify-content: center !important;
                min-height: 40px !important; box-sizing: border-box !important;
            }
            .inventory #items_list.ggn-grid > li .item_center.item_image {
                display: block !important; width: auto !important; height: auto !important;
                max-width: 100% !important; max-height: 115px !important;
                object-fit: contain !important; margin-top:0;
            }
            .inventory #items_list.ggn-grid .gg-owned-info {
                flex-shrink: 0 !important; width: 100% !important;
                box-sizing: border-box !important;
            }
            .inventory #items_list.ggn-grid .gg-actions {
                flex-shrink: 0 !important; width: 100% !important;
                box-sizing: border-box !important;
            }
            .inventory #items_list.ggn-grid > li form > p:not(#not_tradeable),
            .inventory #items_list.ggn-grid > li form > br,
            .inventory #items_list.ggn-grid > li form > div:not(#clickable),
            .inventory #items_list.ggn-grid > li .submit_section,
            .inventory #items_list.ggn-grid > li .admin_hide,
            .inventory #items_list.ggn-grid > li #one_time_inputs,
            .inventory #items_list.ggn-grid > li #item_use_amount,
            .inventory #items_list.ggn-grid > li .item_description,
            .inventory #items_list.ggn-grid > li .trash_icon,
            .inventory #items_list.ggn-grid > li .item_info > p {
                display: none !important;
            }
            /* Hide .gg-actions in grid when "Show actions" setting is off */
            .inventory #items_list.ggn-grid.gg-hide-actions > li .gg-actions {
                display: none !important;
            }
        `;
        document.head.appendChild(s);

        const itemsList = document.getElementById('items_list');
        itemsList.classList.add('ggn-grid');
        if (!showactions) {
            itemsList.classList.add('gg-hide-actions');
        }
    }

    // ── infinite scroll ───────────────────────────────────────────────────────

    function initInfiniteScroll() {
        let loading = false, exhausted = false;
        const PAGE_SIZE = 30;
        let pagesLoaded = 0;

        const getNextUrl = () => {
            const a = [...document.querySelectorAll('a')].find(a => a.textContent.trim() === 'Next >');
            return a ? new URL(a.href) : null;
        };

        const hidePagination = () =>
            document.querySelectorAll('.top_linkbox, .bottom_linkbox')
                .forEach(el => { el.style.display = 'none'; });

        async function loadNext() {
            if (loading || exhausted) return;
            loading = true;
            const url = getNextUrl();
            if (!url) { exhausted = true; loading = false; return; }

            try {
                const doc = new DOMParser().parseFromString(
                    await (await fetch(url.toString())).text(), 'text/html');

                pagesLoaded++;
                const idOffset = pagesLoaded * PAGE_SIZE;
                const currentList = document.getElementById('items_list');

                doc.getElementById('items_list')?.querySelectorAll('li').forEach(li => {
                    const clone     = li.cloneNode(true);
                    const clickable = clone.querySelector('#clickable, .item_info');
                    if (clickable) {
                        const existing = clickable.getAttribute('onclick') ?? '';
                        clickable.setAttribute('onclick',
                            existing.replace(
                                /ItemInfo\("?(\d+)"?/,
                                (_, n) => `ItemInfo("${parseInt(n, 10) + idOffset}"`
                            )
                        );
                    }
                    currentList.appendChild(clone);
                });

                const dialogContainer = document.getElementById('1')?.parentElement ?? document.body;
                doc.querySelectorAll('div[id]').forEach(d => {
                    if (!/^\d+$/.test(d.id)) return;
                    const newId = String(parseInt(d.id, 10) + idOffset);
                    if (document.getElementById(newId)) return;
                    const clone = d.cloneNode(true);
                    clone.id    = newId;
                    dialogContainer.appendChild(clone);
                });

                ['top_linkbox', 'bottom_linkbox'].forEach(cls => {
                    const fresh = doc.querySelector('.' + cls);
                    if (fresh) document.querySelector('.' + cls)?.replaceWith(fresh.cloneNode(true));
                });

                hidePagination();
                injectItemInfo(currentList);
                if (!getNextUrl()) exhausted = true;

            } catch (e) {
                console.error('[Better Inventory] Infinite scroll error:', e);
            }
            loading = false;
        }

        hidePagination();
        window.addEventListener('scroll', () => {
            if (!exhausted && !loading &&
                document.documentElement.scrollHeight - scrollY - innerHeight < 400)
                loadNext();
        }, { passive: true });
    }

    // ── collapsible nav ───────────────────────────────────────────────────────

    function makeCollapsible(headerEl, bodyEl, storageKey) {
        const collapsed = getCollapsed(storageKey, storageKey === 'betterinv');
        const arrowTarget = headerEl.tagName === 'A'
            ? (headerEl.querySelector('h3') ?? headerEl)
            : headerEl;

        headerEl.style.cssText += ';margin-top:10px;cursor:pointer;user-select:none';
        const arrow = document.createElement('span');
        arrow.className = 'ggn-nav-arrow';
        arrow.style.cssText = 'float:right;font-size:0.55em;font-family:sans-serif;opacity:0.65;line-height:2.4;pointer-events:none';
        arrow.textContent = collapsed ? '▶' : '▼';
        arrowTarget.appendChild(arrow);

        if (collapsed) bodyEl.style.display = 'none';

        headerEl.addEventListener('click', e => {
            e.preventDefault();
            const nowCollapsed = bodyEl.style.display !== 'none';
            bodyEl.style.display = nowCollapsed ? 'none' : '';
            arrow.textContent    = nowCollapsed ? '▶' : '▼';
            setCollapsed(storageKey, nowCollapsed);
        });
    }

    function initCollapsibleNav() {
        [
            ['categories',    'categories'],
            ['shop_links',    'shops'     ],
            ['account_links', 'mylinks'   ],
        ].forEach(([ulId, key]) => {
            const bodyEl   = document.getElementById(ulId);
            const headerEl = bodyEl?.previousElementSibling;
            if (headerEl && bodyEl) makeCollapsible(headerEl, bodyEl, key);
        });
    }

    // ── search input builder ──────────────────────────────────────────────────

    function buildSearchInput(id) {
        const wrap = document.createElement('div');
        wrap.className = 'gg-search-wrap';
        const inp = document.createElement('input');
        inp.type        = 'text';
        inp.id          = id;
        inp.className   = 'gg-search-input';
        inp.placeholder = 'Filter items…';
        inp.value       = activeSearch;
        inp.addEventListener('input', () => {
            activeSearch = inp.value;
            document.querySelectorAll('.gg-search-input').forEach(el => {
                if (el !== inp) el.value = activeSearch;
            });
            applyFilter();
        });
        wrap.appendChild(inp);
        return wrap;
    }

    // ── sidebar settings ──────────────────────────────────────────────────────

    function initSidebarSettings() {
        const nav = document.getElementById('items_navigation');
        if (!nav) return;

        const isSelf  = detectIsSelf();
        const LBL_CSS = 'display:flex;align-items:center;gap:6px;cursor:pointer;color:rgb(180,150,41);font-size:14.3px';

        const header = document.createElement('h3');
        header.textContent = 'Better Inventory';
        const ul = document.createElement('ul');
        ul.id = 'ggn-bi-options';

        // ── View toggles ─────────────────────────────────────────────────────
        const options = IS_SHOP
            ? [['Compact stats', 'compact', compact]]
            : [
                ['Grid view',          'grid',        grid       ],
                ['Infinite scroll',    'infscr',      infscr     ],
                ['Show Gold',          'gold',        gold       ],
                ['Compact stats',      'compact',     compact    ],
                ['Show Categories',    'category',    category   ],
                ...(isSelf && grid ? [['Show actions (grid)', 'showactions', showactions]] : []),
              ];

        options.forEach(([label, cfgKey, val]) => {
            const li  = document.createElement('li');
            const lbl = document.createElement('label');
            lbl.style.cssText = LBL_CSS;
            const chk = document.createElement('input');
            chk.type    = 'checkbox';
            chk.checked = val;
            chk.style.cursor = 'pointer';
            chk.addEventListener('change', () => {
                GM_setValue(CFG[cfgKey].key, chk.checked);
                window.location.reload();
            });
            lbl.append(chk, document.createTextNode(label));
            li.appendChild(lbl);
            ul.appendChild(li);
        });

        // ── "Filters in sidebar" toggle ──────────────────────────────────────
        const filterLocLi  = document.createElement('li');
        const filterLocLbl = document.createElement('label');
        filterLocLbl.style.cssText = LBL_CSS;
        const filterLocChk = document.createElement('input');
        filterLocChk.type    = 'checkbox';
        filterLocChk.checked = filtersinsidebar;
        filterLocChk.style.cursor = 'pointer';
        filterLocChk.addEventListener('change', () => {
            GM_setValue(CFG.filtersinsidebar.key, filterLocChk.checked);
            window.location.reload();
        });
        filterLocLbl.append(filterLocChk, document.createTextNode('Filters in sidebar'));
        filterLocLi.appendChild(filterLocLbl);
        ul.appendChild(filterLocLi);

        // ── Filter controls (only when filtersinsidebar is on) ───────────────
        if (filtersinsidebar) {
            const searchLi = document.createElement('li');
            searchLi.appendChild(buildSearchInput('ggn-sidebar-search'));
            ul.appendChild(searchLi);

            const tradeLi  = document.createElement('li');
            const tradeLbl = document.createElement('label');
            tradeLbl.className = 'gg-trade-filter';
            const tradeChk = document.createElement('input');
            tradeChk.type    = 'checkbox';
            tradeChk.checked = hideNonTradeable;
            tradeChk.addEventListener('change', () => {
                hideNonTradeable = tradeChk.checked;
                localStorage.setItem(HIDE_NONTRADE_KEY, hideNonTradeable ? '1' : '0');
                applyFilter();
            });
            tradeLbl.append(tradeChk, document.createTextNode('Hide non-tradeable'));
            tradeLi.appendChild(tradeLbl);
            ul.appendChild(tradeLi);

            if (hasCategoryFilter) {
                const groupSbLi  = document.createElement('li');
                const groupSbLbl = document.createElement('label');
                groupSbLbl.className = 'gg-trade-filter';
                const groupSbChk = document.createElement('input');
                groupSbChk.type    = 'checkbox';
                groupSbChk.checked = groupSubcat;
                groupSbChk.addEventListener('change', () => {
                    groupSubcat = groupSbChk.checked;
                    localStorage.setItem(GROUP_SUBCAT_KEY, groupSubcat ? '1' : '0');
                    applyFilter();
                });
                groupSbLbl.append(groupSbChk, document.createTextNode('Group subcategories'));
                groupSbLi.appendChild(groupSbLbl);
                ul.appendChild(groupSbLi);
            }

            if (IS_SHOP || !isSelf) {
                const filterLi = document.createElement('li');
                filterLi.style.marginTop = '6px';
                const filterTitle = document.createElement('div');
                filterTitle.textContent   = 'Show items:';
                filterTitle.style.cssText = 'color:rgb(180,150,41);font-size:14.3px;margin-bottom:3px';
                filterLi.appendChild(filterTitle);
                const radioGroup = document.createElement('div');
                radioGroup.id = 'ggn-bi-filter';
                const sidebarOpts = [
                    ['all',     'All'],
                    ['have',    'Only items I have'],
                    ['missing', "Only items I'm missing"],
                ];
                if (beta && !IS_SHOP && !isSelf) sidebarOpts.push(['combined', 'Combined (+ my inventory)']);
                sidebarOpts.forEach(([value, label]) => {
                    const lbl   = document.createElement('label');
                    const radio = document.createElement('input');
                    radio.type    = 'radio';
                    radio.name    = 'ggn-bi-filter-radio';
                    radio.value   = value;
                    radio.checked = activeFilter === value;
                    radio.addEventListener('change', () => {
                        const prev = activeFilter;
                        activeFilter = value;
                        localStorage.setItem(FILTER_KEY, value);
                        if (prev === 'combined' && value !== 'combined') deactivateMyInvOverlay();
                        applyFilter();
                        if (value === 'combined') activateMyInvOverlay();
                    });
                    lbl.append(radio, document.createTextNode(label));
                    radioGroup.appendChild(lbl);
                });
                filterLi.appendChild(radioGroup);
                ul.appendChild(filterLi);
            }
        }

        nav.appendChild(header);
        nav.appendChild(ul);
        makeCollapsible(header, ul, IS_SHOP ? 'bettershop' : 'betterinv');
    }

    // ── inventory overlay (Combined mode) ────────────────────────────────────

    const MY_INV_ID_OFFSET = 1_000_000;
    let   myInvActive      = false;
    let   myInvLoading     = false;

    function deactivateMyInvOverlay() {
        if (!myInvActive) return;
        const list = document.getElementById('items_list');
        if (list) {
            list.querySelectorAll(':scope > li[data-my-inv]').forEach(li => li.remove());
            list.querySelectorAll(':scope > li').forEach(li =>
                li.classList.remove('gg-inv-both-have', 'gg-inv-mine-only', 'gg-inv-theirs-only')
            );
        }
        myInvActive = false;
    }

    async function activateMyInvOverlay() {
        if (myInvActive || myInvLoading) return;
        const list = document.getElementById('items_list');
        if (!list) return;

        myInvLoading = true;
        document.querySelectorAll('input[value="combined"]').forEach(r =>
            r.closest('label')?.classList.add('ggn-loading-combined')
        );
        try {
            const params = new URL(location.href).searchParams;
            const base   = new URL('/user.php', location.origin);
            base.searchParams.set('action', 'inventory');
            if (params.get('category')) base.searchParams.set('category', params.get('category'));
            if (params.get('search'))   base.searchParams.set('search',   params.get('search'));

            const myMap     = new Map(); // name → clonedLi
            const myDialogs = [];
            let   fetchUrl  = base.toString();
            let   pageIdx   = 0;

            while (fetchUrl) {
                const doc    = new DOMParser().parseFromString(
                    await (await fetch(fetchUrl)).text(), 'text/html'
                );
                const offset = MY_INV_ID_OFFSET + pageIdx * 30;

                doc.querySelectorAll('#items_list > li').forEach(li => {
                    const name = li.querySelector('h3')?.textContent.trim();
                    if (!name || myMap.has(name)) return;
                    const clone    = li.cloneNode(true);
                    const clickable = clone.querySelector('#clickable, .item_info');
                    if (clickable) {
                        const oc = clickable.getAttribute('onclick') ?? '';
                        clickable.setAttribute('onclick',
                            oc.replace(/ItemInfo\("?(\d+)"?/, (_, n) =>
                                `ItemInfo("${parseInt(n, 10) + offset}"`)
                        );
                    }
                    clone.dataset.myInv = '1';
                    myMap.set(name, clone);
                });

                doc.querySelectorAll('div[id]').forEach(d => {
                    if (!/^\d+$/.test(d.id)) return;
                    const newId = String(parseInt(d.id, 10) + offset);
                    if (document.getElementById(newId)) return;
                    const clone = d.cloneNode(true);
                    clone.id    = newId;
                    myDialogs.push(clone);
                });

                const nextA = [...doc.querySelectorAll('a')]
                    .find(a => a.textContent.trim() === 'Next >');
                fetchUrl = nextA
                    ? new URL(nextA.getAttribute('href'), location.origin).href
                    : null;
                pageIdx++;
            }

            const theirNames = new Set(
                [...list.querySelectorAll(':scope > li h3')]
                    .map(h => h.textContent.trim())
            );

            list.querySelectorAll(':scope > li').forEach(li => {
                const name = li.querySelector('h3')?.textContent.trim();
                if (!name) return;
                li.classList.add(myMap.has(name) ? 'gg-inv-both-have' : 'gg-inv-theirs-only');
            });

            const dialogContainer = document.getElementById('1')?.parentElement ?? document.body;
            myDialogs.forEach(d => dialogContainer.appendChild(d));

            for (const [name, clone] of myMap) {
                if (!theirNames.has(name)) {
                    clone.classList.add('gg-inv-mine-only');
                    list.appendChild(clone);
                }
            }

            injectItemInfo(list);
            myInvActive = true;
            applyFilter();

        } catch (err) {
            console.error('[Better Inventory] overlay error:', err);
            // revert radio to 'all' on failure
            activeFilter = 'all';
            document.querySelectorAll('input[name="ggn-filterbar-radio"], input[name="ggn-bi-filter-radio"]')
                .forEach(r => { r.checked = r.value === 'all'; });
            applyFilter();
        } finally {
            document.querySelectorAll('input[value="combined"]').forEach(r =>
                r.closest('label')?.classList.remove('ggn-loading-combined')
            );
            myInvLoading = false;
        }
    }

    // ── filter bar above grid ─────────────────────────────────────────────────

    function initFilterBar() {
        if (filtersinsidebar) return;
        const list = document.getElementById('items_list');
        if (!list) return;

        const isSelf = detectIsSelf();
        const bar    = document.createElement('div');
        bar.id = 'ggn-bi-filterbar';

        bar.appendChild(buildSearchInput('ggn-filterbar-search'));

        if (IS_SHOP || !isSelf) {
            const radios = document.createElement('div');
            radios.className = 'gg-filterbar-radios';
            const opts = [['all', 'All'], ['have', 'Have'], ['missing', 'Missing']];
            if (beta && !IS_SHOP && !isSelf) opts.push(['combined', 'Combined']);
            opts.forEach(([value, label]) => {
                const lbl   = document.createElement('label');
                const radio = document.createElement('input');
                radio.type    = 'radio';
                radio.name    = 'ggn-filterbar-radio';
                radio.value   = value;
                radio.checked = activeFilter === value;
                radio.addEventListener('change', () => {
                    const prev = activeFilter;
                    activeFilter = value;
                    localStorage.setItem(FILTER_KEY, value);
                    if (prev === 'combined' && value !== 'combined') deactivateMyInvOverlay();
                    applyFilter();
                    if (value === 'combined') activateMyInvOverlay();
                });
                lbl.append(radio, document.createTextNode(label));
                radios.appendChild(lbl);
            });
            bar.appendChild(radios);
        }

        const tradeLbl = document.createElement('label');
        tradeLbl.className = 'gg-filterbar-trade';
        const tradeChk = document.createElement('input');
        tradeChk.type    = 'checkbox';
        tradeChk.checked = hideNonTradeable;
        tradeChk.addEventListener('change', () => {
            hideNonTradeable = tradeChk.checked;
            localStorage.setItem(HIDE_NONTRADE_KEY, hideNonTradeable ? '1' : '0');
            applyFilter();
        });
        tradeLbl.append(tradeChk, document.createTextNode('Hide non-tradeable'));
        bar.appendChild(tradeLbl);

        if (hasCategoryFilter) {
            const groupLbl = document.createElement('label');
            groupLbl.className = 'gg-filterbar-trade';
            const groupChk = document.createElement('input');
            groupChk.type    = 'checkbox';
            groupChk.checked = groupSubcat;
            groupChk.addEventListener('change', () => {
                groupSubcat = groupChk.checked;
                localStorage.setItem(GROUP_SUBCAT_KEY, groupSubcat ? '1' : '0');
                applyFilter();
            });
            groupLbl.append(groupChk, document.createTextNode('Group subcategories'));
            bar.appendChild(groupLbl);
        }

        list.insertAdjacentElement('beforebegin', bar);
    }

    // ── clear search ──────────────────────────────────────────────────────────

    function initClearSearch() {
        const searchInput = document.getElementById('search_query');
        if (!searchInput) return;
        const params    = new URL(window.location.href).searchParams;
        const hasSearch = params.get('search')?.trim();
        if (!hasSearch) return;

        const btn = document.createElement('a');
        btn.id          = 'ggn-clear-search';
        btn.textContent = '✕ Clear';
        btn.href        = '#';
        btn.title       = 'Clear search filter';
        btn.addEventListener('click', e => {
            e.preventDefault();
            const url = new URL(window.location.href);
            url.searchParams.delete('search');
            url.searchParams.delete('search_more');
            url.searchParams.delete('page');
            window.location.href = url.toString();
        });
        searchInput.insertAdjacentElement('afterend', btn);
    }

    // ── init ──────────────────────────────────────────────────────────────────

    function init() {
        injectBaseStyles();
        initClickOutside();
        initCollapsibleNav();
        initSidebarSettings();

        if (IS_SHOP) {
            injectShopItemInfo();
            initFilterBar();
        } else {
            injectItemInfo();
            initClearSearch();
            if (grid) initGrid();
            initFilterBar();
            if (infscr) initInfiniteScroll();
            initDialogActionHook();
        }
        if (beta && activeFilter === 'combined' && !IS_SHOP && !detectIsSelf()) {
            activateMyInvOverlay();
        }
    }

    document.readyState === 'loading'
        ? document.addEventListener('DOMContentLoaded', init)
        : init();
})();