Better Inventory

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

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         Better Inventory
// @namespace    https://gazellegames.net/
// @version      1.50
// @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 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  },
    };

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

    const { grid, infscr, gold, category, compact, showactions } =
        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';

    let activeFilter     = localStorage.getItem(FILTER_KEY)        ?? 'all';
    let hideNonTradeable = localStorage.getItem(HIDE_NONTRADE_KEY) === '1';

    // ── 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;
                }
            }

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

    // ── 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:not([type])')].filter(b => {
            if (b.classList.contains('submit_feature')) return false;
            return window.getComputedStyle(b).display !== 'none';
        });
    }

    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(); origBtn.click(); });
                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(); origBtn.click(); });
                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;

            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;

            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;

            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;
            }
        `;
        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; }
            .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);
        });
    }

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

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

        const isSelf = detectIsSelf();

        const header = document.createElement('h3');
        header.textContent = 'Better Inventory';

        const ul = document.createElement('ul');
        ul.id = 'ggn-bi-options';

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

        options.forEach(([label, cfgKey, val]) => {
            const li  = document.createElement('li');
            const lbl = document.createElement('label');
            lbl.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;color:rgb(180,150,41);font-size:14.3px';
            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);
        });

        // ── Tradeable filter (always visible) ────────────────────────────────
        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);

        // ── Have/missing filter (shop or viewing others' inventory) ──────────
        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 radioOptions = [
                ['all',     'All'],
                ['have',    'Only items I have'],
                ['missing', "Only items I'm missing"],
            ];

            radioOptions.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', () => {
                    activeFilter = value;
                    localStorage.setItem(FILTER_KEY, value);
                    applyFilter();
                });
                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');
    }

    // ── 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();
        } else {
            injectItemInfo();
            initClearSearch();
            if (grid)   initGrid();
            if (infscr) initInfiniteScroll();
            initDialogActionHook();
        }
    }

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