Grid layout, infinite scroll, gold values, owned counts for GGn inventory + shop
// ==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();
})();