Better Inventory

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();
})();