Add inventory controls and enhance dashboard

This commit is contained in:
Tanner
2026-11-05 03:00:13 +00:00
parent efa2140a91
commit 659fa49ea1
13 changed files with 979 additions and 36 deletions
+15 -3
View File
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#10130f" />
<meta name="description" content="Live Minecraft bot dashboard" />
<title>Mosfet · Minecraft Bot</title>
<link rel="stylesheet" href="/static/css/dashboard.css?v=4" />
<script defer src="/static/js/dashboard.js?v=7"></script>
<link rel="stylesheet" href="/static/css/dashboard.css?v=16" />
<script defer src="/static/js/dashboard.js?v=14"></script>
</head>
<body>
<div class="shell">
@@ -68,7 +68,7 @@
Some item components are not decoded yet; displayed contents may be partial.
</div>
<div id="inventory-grid" class="inventory-grid" aria-label="Minecraft inventory"></div>
<div class="inventory-legend"><span class="legend-swatch"></span> Empty slot <span class="legend-note">Hover an item for its name and stack size</span></div>
<div class="inventory-legend"><span class="legend-swatch"></span> Empty slot <span class="legend-selected"></span> Selected hotbar slot <span class="legend-note">Hover for details · click an item for actions</span></div>
</section>
<aside class="side-column">
@@ -103,6 +103,18 @@
</footer>
</main>
</div>
<div id="item-tooltip" class="item-tooltip" role="tooltip" hidden></div>
<div id="inventory-menu" class="inventory-menu" role="menu" aria-label="Item actions" hidden>
<div class="inventory-menu-heading">
<strong id="inventory-menu-title">Item</strong>
<span id="inventory-menu-meta"></span>
</div>
<button type="button" data-inventory-action="one" role="menuitem">Drop one</button>
<button type="button" data-inventory-action="stack" role="menuitem">Drop whole stack</button>
<button type="button" data-inventory-action="matching" role="menuitem">Drop all matching</button>
<div id="inventory-menu-status" class="inventory-menu-status" aria-live="polite" hidden></div>
<button type="button" class="menu-cancel" data-inventory-action="cancel" role="menuitem">Cancel</button>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
</body>
</html>
+15 -3
View File
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#10130f" />
<meta name="description" content="Live Minecraft bot dashboard" />
<title>Mosfet · Minecraft Bot</title>
<link rel="stylesheet" href="/static/css/dashboard.css?v=4" />
<script defer src="/static/js/dashboard.js?v=7"></script>
<link rel="stylesheet" href="/static/css/dashboard.css?v=16" />
<script defer src="/static/js/dashboard.js?v=14"></script>
</head>
<body>
<div class="shell">
@@ -68,7 +68,7 @@
Some item components are not decoded yet; displayed contents may be partial.
</div>
<div id="inventory-grid" class="inventory-grid" aria-label="Minecraft inventory"></div>
<div class="inventory-legend"><span class="legend-swatch"></span> Empty slot <span class="legend-note">Hover an item for its name and stack size</span></div>
<div class="inventory-legend"><span class="legend-swatch"></span> Empty slot <span class="legend-selected"></span> Selected hotbar slot <span class="legend-note">Hover for details · click an item for actions</span></div>
</section>
<aside class="side-column">
@@ -103,6 +103,18 @@
</footer>
</main>
</div>
<div id="item-tooltip" class="item-tooltip" role="tooltip" hidden></div>
<div id="inventory-menu" class="inventory-menu" role="menu" aria-label="Item actions" hidden>
<div class="inventory-menu-heading">
<strong id="inventory-menu-title">Item</strong>
<span id="inventory-menu-meta"></span>
</div>
<button type="button" data-inventory-action="one" role="menuitem">Drop one</button>
<button type="button" data-inventory-action="stack" role="menuitem">Drop whole stack</button>
<button type="button" data-inventory-action="matching" role="menuitem">Drop all matching</button>
<div id="inventory-menu-status" class="inventory-menu-status" aria-live="polite" hidden></div>
<button type="button" class="menu-cancel" data-inventory-action="cancel" role="menuitem">Cancel</button>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
</body>
</html>
+97 -2
View File
@@ -83,10 +83,45 @@ h1 { margin: 5px 0 3px; font-size: 34px; line-height: 1.05; letter-spacing: -.02
h2 { margin: 3px 0 0; font-size: 20px; letter-spacing: -.01em; font-weight: 600; }
.inventory-summary { display: flex; gap: 8px; align-items: center; color: var(--muted); font: 11px var(--mono); }
.heading-divider { width: 1px; height: 12px; background: var(--border); }
.inventory-grid { display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); gap: 4px; padding: 8px; background: #111411; border: 1px solid var(--border-soft); border-radius: 3px; }
.inventory-grid { --slot-gap: 4px; display: grid; gap: 8px; padding: 8px; background: #111411; border: 1px solid var(--border-soft); border-radius: 3px; }
.inventory-upper { display: grid; grid-template-columns: repeat(10, minmax(0, 1fr)); gap: var(--slot-gap); align-items: start; }
.inventory-lower-headings, .inventory-lower-grid { display: grid; grid-template-columns: repeat(10, minmax(0, 1fr)); column-gap: 10px; row-gap: var(--slot-gap); align-items: start; }
.inventory-lower-headings .inventory-group-label { margin-bottom: 0; }
.inventory-lower-grid { align-items: stretch; }
.inventory-group { min-width: 0; }
.inventory-group-label { min-height: 14px; margin-bottom: 4px; color: var(--subtle); font: 9px var(--mono); letter-spacing: .05em; }
.upper-spacer { grid-column: 1; }
.crafting-group { grid-column: 2 / span 8; }
.crafting-labels { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: var(--slot-gap); }
.crafting-label-offhand { grid-column: 1; }
.crafting-label-grid { grid-column: 3 / span 4; }
.crafting-label-output { grid-column: 8; }
.crafting-layout { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: var(--slot-gap); align-items: center; }
.crafting-input-1 { grid-column: 3; }
.crafting-input-2 { grid-column: 4; }
.crafting-input-3 { grid-column: 5; }
.crafting-input-4 { grid-column: 6; }
.crafting-offhand { grid-column: 1; }
.crafting-arrow { grid-column: 7; display: grid; place-items: center; color: var(--muted); font: 22px var(--sans); }
.crafting-result { grid-column: 8; }
.inventory-lower-headings > :first-child, .armor-slots { grid-column: 1; }
.inventory-lower-headings > :last-child { grid-column: 2 / span 9; }
.armor-slots { display: flex; height: 100%; flex-direction: column; justify-content: flex-start; gap: var(--slot-gap); }
.inventory-body { grid-column: 2 / span 9; display: grid; row-gap: 6px; }
.hotbar-label { min-height: 12px; color: var(--subtle); font: 9px/12px var(--mono); letter-spacing: .05em; }
.main-inventory { display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); gap: var(--slot-gap); }
.hotbar-inventory { display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); gap: var(--slot-gap); }
.inventory-slot { aspect-ratio: 1; min-width: 0; position: relative; display: grid; place-items: center; border: 1px solid #3d433d; border-radius: 2px; background: var(--slot-empty); }
.inventory-slot.occupied { border-color: #596259; background: var(--slot); }
.inventory-slot.interactive { cursor: pointer; }
.inventory-slot.interactive:hover { border-color: #d7e8cf; background: #343b33; }
.inventory-slot.interactive:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; z-index: 2; }
.inventory-slot.hotbar-slot { border-bottom: 2px solid #66834e; }
.inventory-slot.selected-slot {
z-index: 2;
border-color: #f5f6e8;
box-shadow: inset 0 0 0 2px #e9ead8, 0 0 0 1px #121411;
}
.slot-number { position: absolute; top: 2px; left: 3px; z-index: 1; color: #c3cbc1; font: 10px var(--mono); }
.item-art { width: 67%; height: 67%; display: grid; place-items: center; }
.item-art img { display: block; width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated; filter: drop-shadow(1px 1px 1px #000); }
@@ -95,6 +130,7 @@ h2 { margin: 3px 0 0; font-size: 20px; letter-spacing: -.01em; font-weight: 600;
.stack-count { position: absolute; z-index: 2; right: 2px; bottom: 0; color: #fff; line-height: .9; font: 700 24px var(--mono); -webkit-text-stroke: 1px #000; text-shadow: 2px 2px 2px #000, -2px -2px 2px #000; }
.inventory-legend { display: flex; align-items: center; gap: 6px; margin-top: 8px; color: var(--muted); font-size: 11px; }
.legend-swatch { width: 8px; height: 8px; border: 1px solid var(--border); border-radius: 1px; }
.legend-selected { width: 10px; height: 10px; margin-left: 12px; border: 2px solid #f5f6e8; border-radius: 1px; }
.legend-note { margin-left: auto; color: var(--subtle); }
.inventory-warning { margin: 0 0 9px; padding: 7px 9px; border: 1px solid #927542; border-radius: 3px; background: #332b1c; color: #f0c36c; font-size: 12px; }
@@ -119,6 +155,65 @@ h2 { margin: 3px 0 0; font-size: 20px; letter-spacing: -.01em; font-weight: 600;
.footer-mark { color: var(--accent); font-size: 7px; }
.toast { position: fixed; right: 12px; bottom: 12px; max-width: 340px; padding: 9px 12px; border: 1px solid var(--border); border-radius: 3px; color: var(--text); background: var(--panel); opacity: 0; transform: translateY(5px); transition: .15s; pointer-events: none; }
.toast.visible { opacity: 1; transform: translateY(0); }
.toast.is-error { border-color: #8f4848; color: #ff9999; }
.item-tooltip {
position: fixed;
z-index: 1000;
width: max-content;
max-width: min(320px, calc(100vw - 16px));
max-height: calc(100vh - 16px);
overflow-y: auto;
padding: 7px 9px;
border: 2px solid #2b1744;
outline: 1px solid #09070d;
color: #f5f5f5;
background: linear-gradient(135deg, rgba(16, 0, 32, .98), rgba(24, 14, 38, .98));
box-shadow: 3px 3px 0 rgba(0, 0, 0, .75);
font-size: 12px;
line-height: 1.35;
pointer-events: none;
}
.item-tooltip[hidden], .inventory-menu[hidden] { display: none; }
.tooltip-name { margin-bottom: 2px; color: #fff; font-size: 13px; font-weight: 700; }
.tooltip-name.rarity-uncommon { color: #ffff55; }
.tooltip-name.rarity-rare { color: #55ffff; }
.tooltip-name.rarity-epic { color: #ff55ff; }
.tooltip-meta { color: #aaa; font: 10px var(--mono); }
.tooltip-divider { height: 1px; margin: 5px 0; background: #746181; }
.tooltip-property { overflow-wrap: anywhere; color: #ddd; }
.tooltip-enchantment { color: #b9a5ff; }
.tooltip-lore { color: #aaaaff; font-style: italic; }
.tooltip-muted { color: #999; }
.tooltip-durability { display: flex; justify-content: space-between; gap: 10px; color: #ddd; }
.tooltip-durability b { font-weight: 400; }
.tooltip-durability i { height: 3px; flex: 0 0 48px; align-self: center; overflow: hidden; background: #4b2525; }
.tooltip-durability em { display: block; height: 100%; background: #62c752; }
.inventory-menu {
position: fixed;
z-index: 1001;
display: grid;
width: 224px;
max-height: calc(100vh - 16px);
overflow-y: auto;
gap: 3px;
padding: 6px;
border: 1px solid #596259;
border-radius: 3px;
background: #171a17;
box-shadow: 0 8px 24px #000b;
}
.inventory-menu-heading { display: grid; gap: 2px; padding: 5px 6px 7px; border-bottom: 1px solid var(--border); }
.inventory-menu-heading strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.inventory-menu-heading span { color: var(--muted); font: 10px var(--mono); }
.inventory-menu button { min-height: 32px; padding: 6px 8px; border: 1px solid transparent; border-radius: 2px; color: var(--text); background: transparent; text-align: left; font: 12px var(--sans); cursor: pointer; }
.inventory-menu button:hover:not(:disabled) { border-color: #506044; background: #2a3227; }
.inventory-menu button:focus-visible { outline: 2px solid var(--accent); }
.inventory-menu button:disabled { color: #747a73; cursor: not-allowed; }
.inventory-menu .menu-cancel { margin-top: 2px; border-top: 1px solid var(--border-soft); border-radius: 0; color: var(--muted); }
.inventory-menu-status { padding: 4px 6px; color: var(--amber); font-size: 11px; overflow-wrap: anywhere; }
.inventory-menu-status.is-error { color: var(--red); }
@media (max-width: 850px) {
.shell { width: min(100% - 24px, 700px); }
@@ -139,7 +234,7 @@ h2 { margin: 3px 0 0; font-size: 20px; letter-spacing: -.01em; font-weight: 600;
.content-grid { gap: 5px; margin-top: 5px; }
.inventory-panel { padding: 10px 7px; }
.inventory-summary { font-size: 10px; gap: 5px; }
.inventory-grid { gap: 3px; padding: 5px; }
.inventory-grid { --slot-gap: 3px; gap: 6px; padding: 5px; }
.slot-number { top: 2px; left: 2px; font-size: 9px; }
.stack-count { right: 1px; bottom: 0; font-size: 20px; }
.side-column { grid-template-columns: 1fr; gap: 5px; }
+358 -9
View File
@@ -6,6 +6,9 @@
let itemNames = {};
let lastInventorySignature = '';
let lastState = null;
let selectedInventoryItem = null;
let menuAnchor = null;
let toastTimer = null;
const escapeHTML = (value) => String(value ?? '').replace(/[&<>"']/g, (ch) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
@@ -16,6 +19,26 @@
.replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function componentText(value) {
if (value == null) return '';
if (typeof value === 'string' || typeof value === 'number') return String(value);
if (Array.isArray(value)) return value.map(componentText).filter(Boolean).join('');
if (typeof value !== 'object') return '';
if ('raw' in value) return componentText(value.raw);
if ('text' in value) return componentText(value.text) + componentText(value.extra);
if ('translate' in value) {
const args = Array.isArray(value.with) ? value.with.map(componentText).join(' ') : '';
return `${prettyName(value.translate)}${args ? ` ${args}` : ''}${componentText(value.extra)}`;
}
if ('value' in value) return componentText(value.value);
return '';
}
function itemDisplayName(item, itemName) {
const components = item?.components || {};
return componentText(components.custom_name || components.item_name) || prettyName(itemName);
}
function vectorText(pos) {
if (!pos) return '—';
if (typeof pos === 'object' && ['x', 'y', 'z'].every((key) => key in pos)) {
@@ -67,15 +90,43 @@
function inventorySignature(inv, partial) {
return JSON.stringify([Boolean(partial), Object.entries(inv || {}).map(([slot, item]) => [
slot, item && item.present, item && item.item_id, item && item.item_count
slot, item && item.present, item && item.item_id, item && item.item_count,
item && item.components
])]);
}
function selectedHotbarSlot(state) {
if (!state.connected) return null;
const selected = Number(state.holding_slot);
return Number.isInteger(selected) && selected >= 0 && selected <= 8
? selected + 36 : null;
}
function updateSelectedSlot(slot) {
const grid = $('inventory-grid');
grid.querySelectorAll('.selected-slot').forEach((element) => {
element.classList.remove('selected-slot');
element.removeAttribute('aria-current');
});
if (slot == null) return;
const selected = grid.querySelector(`.inventory-slot[data-slot="${slot}"]`);
if (selected) {
selected.classList.add('selected-slot');
selected.setAttribute('aria-current', 'true');
}
}
function renderInventory(state) {
const inv = state.inv || {};
const signature = inventorySignature(inv, state.inventory_partial);
if (signature === lastInventorySignature) return;
const selectedSlot = selectedHotbarSlot(state);
if (signature === lastInventorySignature) {
updateSelectedSlot(selectedSlot);
return;
}
lastInventorySignature = signature;
hideItemTooltip();
closeInventoryMenu();
const grid = $('inventory-grid');
const items = [];
@@ -90,32 +141,266 @@
items.push({slot, item});
}
grid.innerHTML = items.map(({slot, item}) => {
const bySlot = new Map(items.map((entry) => [entry.slot, entry.item]));
const slotHTML = (slot, layoutClass = '') => {
const item = bySlot.get(slot);
const present = Boolean(item && item.present);
const itemId = present ? item.item_id : null;
const name = present ? (itemNames[String(itemId)] || `item_${itemId}`) : '';
const count = present ? Number(item.item_count || 0) : 0;
const title = present ? `${prettyName(name)} × ${count} (id ${itemId}, slot ${slot})` : `Empty slot ${slot}`;
return `<div class="inventory-slot${present ? ' occupied' : ''}${slot >= 36 && slot <= 44 ? ' hotbar-slot' : ''}"
title="${escapeHTML(title)}" aria-label="${escapeHTML(title)}">
const displayName = present ? itemDisplayName(item, name) : `Empty slot ${slot}`;
const label = present ? `${displayName}, ${count} item${count === 1 ? '' : 's'}, slot ${slot}` : displayName;
return `<div class="inventory-slot${layoutClass ? ` ${layoutClass}` : ''}${present ? ' occupied interactive' : ''}${slot >= 36 && slot <= 44 ? ' hotbar-slot' : ''}${slot === selectedSlot ? ' selected-slot' : ''}"
${present ? `data-slot="${slot}" role="button" tabindex="0" aria-haspopup="menu" aria-expanded="false"` : ''}
aria-label="${escapeHTML(label)}"${slot === selectedSlot ? ' aria-current="true"' : ''}>
<span class="slot-number">${slot}</span>
${present ? `<span class="item-art"><img alt="" loading="lazy"><span class="icon-fallback" hidden></span></span>
<span class="stack-count">${count > 1 ? count : ''}</span>` : ''}
</div>`;
}).join('');
};
const slotsHTML = (first, last) => {
let html = '';
for (let slot = first; slot <= last; slot++) html += slotHTML(slot);
return html;
};
grid.innerHTML = `<div class="inventory-upper">
<div class="upper-spacer" aria-hidden="true"></div>
<section class="inventory-group crafting-group" aria-label="Crafting">
<div class="crafting-labels">
<div class="inventory-group-label crafting-label-offhand">OFFHAND</div>
<div class="inventory-group-label crafting-label-grid">CRAFTING</div>
<div class="inventory-group-label crafting-label-output">OUTPUT</div>
</div>
<div id="crafting-grid" class="crafting-layout">
${slotHTML(45, 'crafting-offhand')}
${slotHTML(1, 'crafting-input crafting-input-1')}
${slotHTML(2, 'crafting-input crafting-input-2')}
${slotHTML(3, 'crafting-input crafting-input-3')}
${slotHTML(4, 'crafting-input crafting-input-4')}
<span class="crafting-arrow" aria-label="Crafting result">→</span>
${slotHTML(0, 'crafting-result')}
</div>
</section>
</div>
<div class="inventory-lower-headings">
<div class="inventory-group-label">ARMOUR</div>
<div class="inventory-group-label">INVENTORY</div>
</div>
<div class="inventory-lower-grid">
<div id="armor-slots" class="armor-slots">${slotsHTML(5, 8)}</div>
<div class="inventory-body">
<div id="main-inventory" class="main-inventory">${slotsHTML(9, 35)}</div>
<div class="hotbar-label">HOTBAR</div>
<div id="hotbar-inventory" class="hotbar-inventory">${slotsHTML(36, 44)}</div>
</div>
</div>`;
items.forEach(({slot, item}) => {
if (!item || !item.present) return;
const icon = grid.children[slot]?.querySelector('img');
const fallback = grid.children[slot]?.querySelector('.icon-fallback');
const element = grid.querySelector(`.inventory-slot[data-slot="${slot}"]`);
const icon = element?.querySelector('img');
const fallback = element?.querySelector('.icon-fallback');
if (icon && fallback) renderItemIcon(icon, fallback, itemNames[String(item.item_id)] || `item_${item.item_id}`);
});
updateSelectedSlot(selectedSlot);
$('inventory-count').textContent = `${totalCount} items`;
$('inventory-slots').textContent = `${stackCount} / 46 slots`;
$('inventory-warning').hidden = !state.inventory_partial;
}
function romanNumeral(number) {
let value = Math.max(0, Math.floor(Number(number) || 0));
const numerals = [[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'],
[100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'], [10, 'X'],
[9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I']];
let result = '';
for (const [amount, glyph] of numerals) {
while (value >= amount) { result += glyph; value -= amount; }
}
return result;
}
function summarizeProperty(value) {
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
if (typeof value === 'string' || typeof value === 'number') return String(value);
if (Array.isArray(value)) return `${value.length} entries`;
if (value && typeof value === 'object') {
const text = componentText(value);
return text || `${Object.keys(value).length} fields`;
}
return '';
}
function tooltipHTML(item, itemName, slot) {
const components = item.components || {};
const displayName = itemDisplayName(item, itemName);
const rarity = String(components.rarity || 'common').toLowerCase();
const titleClass = /^[a-z]+$/.test(rarity) ? ` rarity-${rarity}` : '';
const rows = [];
const add = (text, className = '') => {
if (text) rows.push(`<div class="tooltip-property${className ? ` ${className}` : ''}">${escapeHTML(text)}</div>`);
};
for (const key of ['enchantments', 'stored_enchantments']) {
const enchantments = components[key];
if (!enchantments || typeof enchantments !== 'object') continue;
for (const [name, level] of Object.entries(enchantments)) {
add(`${prettyName(name)} ${romanNumeral(level)}`, 'tooltip-enchantment');
}
}
const damage = Number(components.damage);
const maxDamage = Number(components.max_damage);
if (Number.isFinite(damage) && Number.isFinite(maxDamage) && maxDamage > 0) {
const remaining = Math.max(0, maxDamage - damage);
const percent = Math.max(0, Math.min(100, remaining / maxDamage * 100));
rows.push(`<div class="tooltip-durability"><span>Durability</span><b>${remaining} / ${maxDamage}</b><i><em style="width:${percent}%"></em></i></div>`);
} else if (damage > 0) {
add(`Damage: ${damage}`);
}
if (components.unbreakable) add('Unbreakable', 'tooltip-enchantment');
if (components.max_stack_size != null) add(`Maximum stack size: ${components.max_stack_size}`);
const lore = Array.isArray(components.lore) ? components.lore : [];
for (const line of lore) add(componentText(line), 'tooltip-lore');
const container = components.container;
if (Array.isArray(container)) {
const contents = container.filter((entry) => entry && entry.present).length;
add(`${contents} item${contents === 1 ? '' : 's'} inside`);
}
const book = components.written_book_content;
if (book) {
const title = componentText(book.title) || 'Untitled';
add(`Book: ${title} (${(book.pages || []).length} pages)`);
}
if (components.dyed_color != null) {
add(`Dyed color: #${(Number(components.dyed_color) & 0xffffff).toString(16).padStart(6, '0').toUpperCase()}`);
}
const shown = new Set([
'custom_name', 'item_name', 'rarity', 'enchantments', 'stored_enchantments',
'damage', 'max_damage', 'unbreakable', 'max_stack_size', 'lore',
'container', 'written_book_content', 'writable_book_content', 'dyed_color',
'custom_data', 'custom_model_data', 'tooltip_display',
]);
for (const [key, value] of Object.entries(components)) {
if (shown.has(key) || value == null || value === false) continue;
add(`${prettyName(key)}: ${summarizeProperty(value)}`);
}
if (item.components_unknown) add('Some item properties could not be decoded.', 'tooltip-muted');
if (!rows.length) add('No additional properties', 'tooltip-muted');
return `<div class="tooltip-name${titleClass}">${escapeHTML(displayName)}</div>
<div class="tooltip-meta">Stack ${Number(item.item_count || 0)} · Slot ${slot} · ID ${item.item_id}</div>
<div class="tooltip-divider"></div>${rows.join('')}`;
}
function positionFloating(element, x, y) {
element.style.left = '0px';
element.style.top = '0px';
const bounds = element.getBoundingClientRect();
const margin = 8;
const left = Math.max(margin, Math.min(x, window.innerWidth - bounds.width - margin));
const top = y + bounds.height + margin <= window.innerHeight
? y : Math.max(margin, y - bounds.height - margin);
element.style.left = `${left}px`;
element.style.top = `${top}px`;
}
function hideItemTooltip() {
const tooltip = $('item-tooltip');
if (tooltip) tooltip.hidden = true;
}
function showItemTooltip(slotElement, point) {
if (selectedInventoryItem) return;
const slot = Number(slotElement.dataset.slot);
const item = lastState?.inv?.[String(slot)] || lastState?.inv?.[slot];
if (!item?.present) return hideItemTooltip();
const name = itemNames[String(item.item_id)] || `item_${item.item_id}`;
const tooltip = $('item-tooltip');
tooltip.innerHTML = tooltipHTML(item, name, slot);
tooltip.hidden = false;
positionFloating(tooltip, point.x + 14, point.y + 14);
}
function closeInventoryMenu() {
const menu = $('inventory-menu');
if (menu) menu.hidden = true;
if (menuAnchor) menuAnchor.setAttribute('aria-expanded', 'false');
menuAnchor = null;
selectedInventoryItem = null;
}
function openInventoryMenu(slotElement) {
const slot = Number(slotElement.dataset.slot);
const item = lastState?.inv?.[String(slot)] || lastState?.inv?.[slot];
if (!item?.present) return closeInventoryMenu();
hideItemTooltip();
closeInventoryMenu();
const name = itemNames[String(item.item_id)] || `item_${item.item_id}`;
selectedInventoryItem = {slot, itemId: item.item_id, count: Number(item.item_count || 0)};
menuAnchor = slotElement;
menuAnchor.setAttribute('aria-expanded', 'true');
$('inventory-menu-title').textContent = itemDisplayName(item, name);
$('inventory-menu-meta').textContent = `Slot ${slot} · ${selectedInventoryItem.count} item${selectedInventoryItem.count === 1 ? '' : 's'}`;
$('inventory-menu-status').classList.remove('is-error');
$('inventory-menu-status').textContent = slot < 5
? 'Crafting slots cannot be dropped from the dashboard.' : '';
$('inventory-menu-status').hidden = slot >= 5;
const unavailable = !lastState?.connected || slot < 5;
$('inventory-menu').querySelectorAll('[data-inventory-action]:not([data-inventory-action="cancel"])')
.forEach((button) => { button.disabled = unavailable; });
const rect = slotElement.getBoundingClientRect();
const menu = $('inventory-menu');
menu.hidden = false;
positionFloating(menu, rect.left, rect.bottom + 4);
}
function showToast(message, isError = false) {
const toast = $('toast');
toast.textContent = message;
toast.classList.toggle('is-error', isError);
toast.classList.add('visible');
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => toast.classList.remove('visible'), 2400);
}
async function runInventoryAction(action) {
if (!selectedInventoryItem) return;
const selected = {...selectedInventoryItem};
const buttons = $('inventory-menu').querySelectorAll('button');
buttons.forEach((button) => { button.disabled = true; });
$('inventory-menu-status').hidden = false;
$('inventory-menu-status').textContent = 'Sending action…';
try {
const response = await fetch('/api/inventory/drop', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({slot: selected.slot, action}),
});
const result = await response.json();
if (!response.ok || !result.ok) throw new Error(result.error || `API ${response.status}`);
closeInventoryMenu();
const message = action === 'matching'
? `Dropping ${result.stacks} matching stack${result.stacks === 1 ? '' : 's'}`
: action === 'one' ? 'Dropped one item' : 'Dropped the stack';
showToast(message);
lastInventorySignature = '';
await poll();
} catch (error) {
$('inventory-menu-status').textContent = error.message;
$('inventory-menu-status').classList.add('is-error');
buttons.forEach((button) => {
const cancel = button.dataset.inventoryAction === 'cancel';
button.disabled = !cancel && (!lastState?.connected || selected.slot < 5);
});
}
}
function renderPlayers(state) {
const players = Object.values(state.players || {});
$('player-count').textContent = String(players.length);
@@ -206,6 +491,70 @@
}
}
const inventoryGrid = $('inventory-grid');
inventoryGrid.addEventListener('pointerover', (event) => {
const slot = event.target.closest('.inventory-slot.interactive');
if (!slot || selectedInventoryItem) return;
showItemTooltip(slot, {x: event.clientX, y: event.clientY});
});
inventoryGrid.addEventListener('pointermove', (event) => {
const tooltip = $('item-tooltip');
if (!tooltip.hidden && !selectedInventoryItem) {
positionFloating(tooltip, event.clientX + 14, event.clientY + 14);
}
});
inventoryGrid.addEventListener('pointerout', (event) => {
const slot = event.target.closest('.inventory-slot.interactive');
if (slot && !slot.contains(event.relatedTarget)) hideItemTooltip();
});
inventoryGrid.addEventListener('focusin', (event) => {
const slot = event.target.closest('.inventory-slot.interactive');
if (!slot) return;
const rect = slot.getBoundingClientRect();
showItemTooltip(slot, {x: rect.right, y: rect.top});
});
inventoryGrid.addEventListener('focusout', (event) => {
if (!event.relatedTarget?.closest('.inventory-slot.interactive')) hideItemTooltip();
});
inventoryGrid.addEventListener('click', (event) => {
const slot = event.target.closest('.inventory-slot');
if (!slot) return;
if (slot.classList.contains('interactive')) openInventoryMenu(slot);
else closeInventoryMenu();
});
inventoryGrid.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
const slot = event.target.closest('.inventory-slot.interactive');
if (!slot) return;
event.preventDefault();
openInventoryMenu(slot);
});
$('inventory-menu').addEventListener('click', (event) => {
const button = event.target.closest('button[data-inventory-action]');
if (!button || button.disabled) return;
const action = button.dataset.inventoryAction;
if (action === 'cancel') closeInventoryMenu();
else runInventoryAction(action);
});
document.addEventListener('click', (event) => {
if ($('inventory-menu').hidden) return;
if (event.target.closest('#inventory-menu') ||
event.target.closest('.inventory-slot.interactive')) return;
closeInventoryMenu();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
hideItemTooltip();
closeInventoryMenu();
}
});
window.addEventListener('resize', () => {
if (!selectedInventoryItem || !menuAnchor?.isConnected) return;
const rect = menuAnchor.getBoundingClientRect();
positionFloating($('inventory-menu'), rect.left, rect.bottom + 4);
});
loadItems();
poll();
window.setInterval(poll, POLL_MS);
+15 -3
View File
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#10130f" />
<meta name="description" content="Live Minecraft bot dashboard" />
<title>Mosfet · Minecraft Bot</title>
<link rel="stylesheet" href="/static/css/dashboard.css?v=4" />
<script defer src="/static/js/dashboard.js?v=7"></script>
<link rel="stylesheet" href="/static/css/dashboard.css?v=16" />
<script defer src="/static/js/dashboard.js?v=14"></script>
</head>
<body>
<div class="shell">
@@ -68,7 +68,7 @@
Some item components are not decoded yet; displayed contents may be partial.
</div>
<div id="inventory-grid" class="inventory-grid" aria-label="Minecraft inventory"></div>
<div class="inventory-legend"><span class="legend-swatch"></span> Empty slot <span class="legend-note">Hover an item for its name and stack size</span></div>
<div class="inventory-legend"><span class="legend-swatch"></span> Empty slot <span class="legend-selected"></span> Selected hotbar slot <span class="legend-note">Hover for details · click an item for actions</span></div>
</section>
<aside class="side-column">
@@ -103,6 +103,18 @@
</footer>
</main>
</div>
<div id="item-tooltip" class="item-tooltip" role="tooltip" hidden></div>
<div id="inventory-menu" class="inventory-menu" role="menu" aria-label="Item actions" hidden>
<div class="inventory-menu-heading">
<strong id="inventory-menu-title">Item</strong>
<span id="inventory-menu-meta"></span>
</div>
<button type="button" data-inventory-action="one" role="menuitem">Drop one</button>
<button type="button" data-inventory-action="stack" role="menuitem">Drop whole stack</button>
<button type="button" data-inventory-action="matching" role="menuitem">Drop all matching</button>
<div id="inventory-menu-status" class="inventory-menu-status" aria-live="polite" hidden></div>
<button type="button" class="menu-cancel" data-inventory-action="cancel" role="menuitem">Cancel</button>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
</body>
</html>