Files
mc-bot-town-2/nodejs/controller/storage/web.js
T
2026-02-22 20:27:09 -05:00

883 lines
35 KiB
JavaScript

'use strict';
const express = require('express');
const database = require('./database');
function createRouter(getActiveInstance) {
const router = express.Router();
function dbAvailable() {
return database && database.db;
}
// ========================================
// Read-only routes (query DB singleton directly)
// ========================================
router.get('/api/inventory', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Storage database not initialized' });
try {
const items = await database.searchItems(req.query.q);
res.json({ items });
} catch (error) {
console.error('API Error /api/inventory:', error);
res.status(500).json({ error: error.message });
}
});
router.get('/api/inventory/:itemId', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Storage database not initialized' });
try {
const itemId = parseInt(req.params.itemId);
const item = await database.getItemDetails(itemId);
if (!item) return res.status(404).json({ error: 'Item not found' });
res.json({ item });
} catch (error) {
console.error('API Error /api/inventory/:itemId:', error);
res.status(500).json({ error: error.message });
}
});
router.get('/api/chests', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Storage database not initialized' });
try {
const chests = await database.getChests();
res.json({ chests });
} catch (error) {
console.error('API Error /api/chests:', error);
res.status(500).json({ error: error.message });
}
});
router.get('/api/chests/:id', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Storage database not initialized' });
try {
const chestId = parseInt(req.params.id);
const chest = await database.getChestById(chestId);
if (!chest) return res.status(404).json({ error: 'Chest not found' });
const shulkers = await database.getShulkersByChest(chestId);
res.json({ chest, shulkers });
} catch (error) {
console.error('API Error /api/chests/:id:', error);
res.status(500).json({ error: error.message });
}
});
router.get('/api/stats', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Storage database not initialized' });
try {
const stats = await database.getStats();
res.json(stats);
} catch (error) {
console.error('API Error /api/stats:', error);
res.status(500).json({ error: error.message });
}
});
router.get('/api/trades', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Storage database not initialized' });
try {
const limit = parseInt(req.query.limit) || 50;
const trades = await database.getRecentTrades(limit);
res.json({ trades });
} catch (error) {
console.error('API Error /api/trades:', error);
res.status(500).json({ error: error.message });
}
});
router.get('/api/pending/:playerName', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Storage database not initialized' });
try {
const pending = await database.getPendingWithdrawals(req.params.playerName);
res.json({ pending });
} catch (error) {
console.error('API Error /api/pending/:playerName:', error);
res.status(500).json({ error: error.message });
}
});
router.get('/api/map', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Storage database not initialized' });
try {
const chests = await database.getChestsWithSummary();
res.json({ chests });
} catch (error) {
console.error('API Error /api/map:', error);
res.status(500).json({ error: error.message });
}
});
router.get('/api/chests/:id/contents', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Storage database not initialized' });
try {
const chestId = parseInt(req.params.id);
const contents = await database.getChestContents(chestId);
if (!contents) return res.status(404).json({ error: 'Chest not found' });
res.json(contents);
} catch (error) {
console.error('API Error /api/chests/:id/contents:', error);
res.status(500).json({ error: error.message });
}
});
router.get('/api/shulkers/:id', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Storage database not initialized' });
try {
const shulkerId = parseInt(req.params.id);
const shulker = await database.getShulkerWithItems(shulkerId);
if (!shulker) return res.status(404).json({ error: 'Shulker not found' });
res.json({ shulker });
} catch (error) {
console.error('API Error /api/shulkers/:id:', error);
res.status(500).json({ error: error.message });
}
});
router.get('/api/players', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Storage database not initialized' });
try {
const players = await database.getAllPlayers();
res.json({ players });
} catch (error) {
console.error('API Error /api/players:', error);
res.status(500).json({ error: error.message });
}
});
router.get('/api/special-items', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Storage database not initialized' });
try {
const items = await database.getSpecialItems();
const parsed = items.map(item => {
let nbt = null;
try { nbt = JSON.parse(item.nbt_data); } catch (e) {}
return { ...item, nbt_parsed: nbt };
});
res.json({ items: parsed });
} catch (error) {
console.error('API Error /api/special-items:', error);
res.status(500).json({ error: error.message });
}
});
// ========================================
// Storage status (for command polling)
// ========================================
router.get('/api/storage/status', (req, res) => {
try {
const { plugin } = getActiveInstance(req.query.bot);
if (!plugin) {
return res.json({ busy: false, command: null });
}
res.json({
busy: !!plugin._busy,
command: plugin._currentCommand || null,
});
} catch (error) {
res.json({ busy: false, command: null });
}
});
// ========================================
// Action routes (need live plugin instance)
// ========================================
router.post('/api/withdraw-special', async (req, res) => {
try {
const { playerName, shulkerItemId } = req.body;
if (!playerName || !shulkerItemId) {
return res.status(400).json({ error: 'Missing playerName or shulkerItemId' });
}
if (!dbAvailable()) return res.status(503).json({ error: 'Storage database not initialized' });
const hasPermission = await database.checkPermission(playerName, 'team');
if (!hasPermission) {
return res.status(403).json({ error: `Player ${playerName} does not have permission` });
}
const { plugin, bot } = getActiveInstance(req.query.bot);
if (!plugin && !bot) {
return res.status(503).json({ error: 'Storage plugin not available' });
}
const parsedId = parseInt(shulkerItemId);
const connecting = !plugin;
if (plugin) {
plugin.handleWithdrawSpecialItem(playerName, parsedId)
.catch(err => console.error('Web special withdraw error:', err));
} else {
bot.ensureConnected(async () => {
const p = bot.plunginsLoaded['Storage'];
if (!p) throw new Error('Storage plugin not loaded after connect');
await p.handleWithdrawSpecialItem(playerName, parsedId);
}).catch(err => console.error('On-demand special withdraw error:', err));
}
res.json({
status: 'queued',
connecting,
message: connecting
? `Bot connecting, special item withdrawal queued for ${playerName}...`
: `Special item withdrawal queued for ${playerName}`
});
} catch (error) {
console.error('API Error /api/withdraw-special:', error);
res.status(500).json({ error: error.message });
}
});
router.post('/api/withdraw', async (req, res) => {
try {
const { playerName, itemName, count, mode } = req.body;
if (!playerName || !itemName || !count) {
return res.status(400).json({ error: 'Missing playerName, itemName, or count' });
}
if (!dbAvailable()) return res.status(503).json({ error: 'Storage database not initialized' });
const hasPermission = await database.checkPermission(playerName, 'team');
if (!hasPermission) {
return res.status(403).json({ error: `Player ${playerName} does not have permission` });
}
const { plugin, bot } = getActiveInstance(req.query.bot);
if (!plugin && !bot) {
return res.status(503).json({ error: 'Storage plugin not available' });
}
const parsedCount = parseInt(count);
const connecting = !plugin;
const runTask = async (p) => {
if (mode === 'shulkers') {
await p.handleWithdrawShulkers(playerName, itemName, parsedCount);
} else {
await p.handleWithdrawRequest(playerName, itemName, parsedCount);
}
};
if (plugin) {
runTask(plugin).catch(err => console.error('Web withdraw error:', err));
} else {
bot.ensureConnected(async () => {
const p = bot.plunginsLoaded['Storage'];
if (!p) throw new Error('Storage plugin not loaded after connect');
await runTask(p);
}).catch(err => console.error('On-demand withdraw error:', err));
}
const modeLabel = mode === 'shulkers'
? `${parsedCount} shulker(s) of ${itemName}`
: `${parsedCount}x ${itemName}`;
res.json({
status: 'queued',
connecting,
message: connecting
? `Bot connecting, withdrawal of ${modeLabel} queued for ${playerName}...`
: `Withdrawal of ${modeLabel} queued for ${playerName}`
});
} catch (error) {
console.error('API Error /api/withdraw:', error);
res.status(500).json({ error: error.message });
}
});
return router;
}
const webUI = {
tabId: 'storage',
tabLabel: 'Storage',
tabOrder: 10,
sidebarHtml: `
<div class="search-wrap">
<div class="ac-wrap">
<input type="text" id="search" placeholder="Search items..." autocomplete="off">
<div class="ac-list" id="ac-search"></div>
</div>
</div>
<div class="inv-list" id="invList">
<div style="padding:20px;color:#6b7280;text-align:center">Loading...</div>
</div>
`,
sidebarJs: `
// Sidebar search autocomplete
setupAC('search','ac-search',
q=>{
const lower=q.toLowerCase();
return allItems
.filter(i=>!lower||i.item_name.includes(lower))
.slice(0,15)
.map(i=>({label:i.item_name,value:i.item_name,extra:fmt(i.total_count)}));
},
val=>{if(val!==null){filterItems()}else{filterItems()}}
);
`,
onTabActive: 'onStorageTabActive',
html: `
<div style="margin-bottom:8px"><span class="last-updated" id="ts-storage"></span></div>
<div class="stats-row" id="stats"></div>
<div class="tabs storage-sub-tabs">
<div class="tab active" onclick="switchStorageSubTab('inventory')">Inventory</div>
<div class="tab" onclick="switchStorageSubTab('map')">Storage Map</div>
<div class="tab" onclick="switchStorageSubTab('special')">Special Items</div>
<div class="tab" onclick="switchStorageSubTab('withdraw')">Withdraw</div>
</div>
<div id="stab-inventory" class="stab-content active" style="margin-top:16px">
<table>
<thead>
<tr>
<th onclick="sortItems('item_name')">Item <span class="arrow" id="sort-item_name"></span></th>
<th onclick="sortItems('total_count')">Count <span class="arrow" id="sort-total_count"></span></th>
</tr>
</thead>
<tbody id="invTable"></tbody>
</table>
</div>
<div id="stab-map" class="stab-content" style="margin-top:16px">
<div class="map-legend">
<span><span class="dot" style="background:#6b7280"></span> Empty</span>
<span><span class="dot" style="background:#f59e0b"></span> Partial</span>
<span><span class="dot" style="background:#10b981"></span> Full</span>
<span><span class="dot" style="background:#ef4444"></span> Loose Items</span>
</div>
<div id="mapArea"></div>
</div>
<div id="stab-special" class="stab-content" style="margin-top:16px">
<div class="panel">
<h3>Named & Custom Items</h3>
<p style="font-size:.85em;color:#9ca3af;margin-bottom:12px">Items with custom names, lore, or special properties stored separately from regular items.</p>
<input type="text" id="specialSearch" placeholder="Search special items..." oninput="filterSpecialItems()" style="width:100%;padding:10px 12px;border:1px solid #374151;border-radius:6px;background:#111827;color:#e5e7eb;font-size:.9em;margin-bottom:12px;box-sizing:border-box">
<div id="specialItems"><div style="padding:20px;color:#6b7280;text-align:center">Click tab to load...</div></div>
</div>
</div>
<div id="stab-withdraw" class="stab-content" style="margin-top:16px">
<div class="panel">
<h3>Request Withdrawal</h3>
<form class="withdraw-form" id="withdrawForm">
<div class="ac-wrap" style="flex:1;min-width:120px">
<input type="text" id="wPlayer" placeholder="Player name" autocomplete="off" style="width:100%">
<div class="ac-list" id="ac-wPlayer"></div>
</div>
<div class="ac-wrap" style="flex:1;min-width:150px">
<input type="text" id="wItem" placeholder="Item name (e.g. diamond)" autocomplete="off" style="width:100%">
<div class="ac-list" id="ac-wItem"></div>
</div>
<select id="wMode" style="padding:8px;border:1px solid #374151;border-radius:6px;background:#111827;color:#e5e7eb;font-size:.85em" onchange="updateWithdrawMode()">
<option value="items">Items</option>
<option value="shulkers">Shulkers</option>
</select>
<input type="number" id="wCount" placeholder="Count" min="1" value="1" style="width:80px">
<button type="submit">Request</button>
</form>
<div id="withdrawStatus"></div>
</div>
</div>
<div class="detail-panel" id="detailPanel">
<button class="close" onclick="closeDetail()">&times;</button>
<div id="detailContent"></div>
</div>
<div class="tooltip" id="tooltip" style="display:none"></div>
`,
css: `
.stats-row{display:grid;grid-template-columns:repeat(7,1fr);gap:12px;margin-bottom:20px}
.stat{background:#1f2937;border:1px solid #374151;border-radius:8px;padding:14px;text-align:center}
.stat .val{font-size:1.6em;font-weight:700;color:#60a5fa}
.stat .lbl{font-size:.75em;color:#9ca3af;margin-top:2px}
.search-wrap{padding:12px;border-bottom:1px solid #374151}
.search-wrap input{width:100%;padding:10px 12px;border:1px solid #374151;border-radius:6px;background:#111827;color:#e5e7eb;font-size:.9em}
.search-wrap input:focus{outline:none;border-color:#2563eb}
.inv-list{flex:1;overflow:auto;padding:4px 0}
.inv-item{display:flex;justify-content:space-between;align-items:center;padding:8px 14px;cursor:pointer;border-bottom:1px solid #1f2937}
.inv-item:hover{background:#374151}
.inv-item .name{font-size:.85em}
.inv-item .count{background:#2563eb;color:#fff;padding:2px 8px;border-radius:10px;font-size:.8em;font-weight:600;min-width:40px;text-align:center}
.inv-item .count.large{background:#059669}
table{width:100%;border-collapse:collapse}
th{text-align:left;padding:10px 12px;background:#1f2937;border-bottom:1px solid #374151;color:#9ca3af;font-size:.8em;cursor:pointer;user-select:none;position:sticky;top:0}
th:hover{color:#e5e7eb}
th .arrow{margin-left:4px;font-size:.7em}
td{padding:8px 12px;border-bottom:1px solid #1f2937;font-size:.85em}
tr:hover td{background:#1f2937}
.map-container{position:relative;background:#0f172a;border:1px solid #374151;border-radius:8px;overflow:auto}
.map-level{margin-bottom:16px}
.map-level h3{color:#60a5fa;font-size:.9em;margin-bottom:8px;padding:8px 12px;background:#1f2937;border-radius:6px 6px 0 0}
.map-grid{position:relative;margin:0 auto}
.map-chest{position:absolute;border-radius:3px;cursor:pointer;font-size:7px;display:flex;align-items:center;justify-content:center;color:#000;font-weight:700;transition:transform .1s;border:1px solid rgba(0,0,0,.3)}
.map-chest:hover{transform:scale(1.5);z-index:10}
.map-chest.empty{background:#6b7280}
.map-chest.partial{background:#f59e0b}
.map-chest.full{background:#10b981}
.map-chest.loose{background:#ef4444}
.map-chest.unscanned{background:#8b5cf6}
.map-legend{display:flex;gap:16px;padding:12px;font-size:.8em;color:#9ca3af}
.map-legend span{display:flex;align-items:center;gap:4px}
.map-legend .dot{width:10px;height:10px;border-radius:2px}
.panel{background:#1f2937;border:1px solid #374151;border-radius:8px;padding:16px;margin-bottom:16px}
.panel h3{color:#60a5fa;font-size:1em;margin-bottom:12px}
.shulker-grid{display:grid;grid-template-columns:repeat(9,1fr);gap:2px;background:#374151;padding:2px;border-radius:4px;margin-bottom:8px}
.shulker-slot{background:#111827;aspect-ratio:1;display:flex;align-items:center;justify-content:center;font-size:.65em;color:#9ca3af;border-radius:2px;position:relative}
.shulker-slot.filled{background:#1e3a5f;color:#60a5fa}
.shulker-slot .slot-count{position:absolute;bottom:1px;right:2px;font-size:.6em;color:#f59e0b}
.withdraw-form{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:8px}
.withdraw-form input{padding:8px;border:1px solid #374151;border-radius:6px;background:#111827;color:#e5e7eb;font-size:.85em}
.withdraw-form button{background:#2563eb;color:#fff;border:none;padding:8px 14px;border-radius:6px;cursor:pointer;font-size:.85em}
#withdrawStatus{font-size:.85em;min-height:20px}
.detail-panel{position:fixed;right:0;top:57px;width:400px;height:calc(100vh - 57px);background:#1f2937;border-left:1px solid #374151;overflow:auto;padding:16px;z-index:50;transform:translateX(100%);transition:transform .2s}
.detail-panel.open{transform:translateX(0)}
.detail-panel .close{position:absolute;top:12px;right:12px;background:none;border:none;color:#9ca3af;font-size:1.2em;cursor:pointer}
.detail-panel .close:hover{color:#e5e7eb}
.chest-info{margin-bottom:16px}
.chest-info p{font-size:.85em;color:#9ca3af;margin:4px 0}
.shulker-card{background:#111827;border:1px solid #374151;border-radius:6px;padding:10px;margin-bottom:8px}
.shulker-card h4{font-size:.85em;color:#f59e0b;margin-bottom:6px}
.shulker-card .items{font-size:.8em;color:#9ca3af}
.shulker-card .items span{display:inline-block;background:#1f2937;padding:2px 6px;border-radius:4px;margin:2px}
.ac-wrap{position:relative}
.ac-list{position:absolute;top:100%;left:0;right:0;background:#1f2937;border:1px solid #374151;border-top:none;border-radius:0 0 6px 6px;max-height:220px;overflow:auto;z-index:60;display:none}
.ac-list.open{display:block}
.ac-opt{padding:8px 12px;cursor:pointer;font-size:.85em;display:flex;justify-content:space-between;align-items:center}
.ac-opt:hover,.ac-opt.active{background:#374151}
.ac-opt .ac-count{color:#9ca3af;font-size:.75em}
.ac-opt .ac-match{color:#60a5fa;font-weight:600}
.special-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
.special-card{background:#111827;border:1px solid #374151;border-radius:8px;padding:14px;transition:border-color .2s}
.special-card:hover{border-color:#60a5fa}
.special-card .sp-name{font-size:1em;font-weight:700;color:#f59e0b;margin-bottom:4px}
.special-card .sp-base{font-size:.8em;color:#9ca3af;margin-bottom:8px}
.special-card .sp-enchants{font-size:.8em;color:#a78bfa;margin-bottom:4px}
.special-card .sp-lore{font-size:.8em;color:#6ee7b7;font-style:italic;margin-bottom:4px}
.special-card .sp-count{font-size:.85em;color:#60a5fa;margin-bottom:8px}
.special-card .sp-withdraw{display:flex;gap:6px;align-items:center;margin-top:8px}
.special-card .sp-withdraw input{padding:6px;border:1px solid #374151;border-radius:4px;background:#1f2937;color:#e5e7eb;font-size:.8em;width:120px}
.special-card .sp-withdraw button{background:#2563eb;color:#fff;border:none;padding:6px 12px;border-radius:4px;cursor:pointer;font-size:.8em}
.special-card .sp-withdraw button:hover{background:#1d4ed8}
.special-card .sp-status{font-size:.8em;margin-top:4px;min-height:16px}
.stab-content{display:none}
.stab-content.active{display:block}
.storage-sub-tabs{display:flex;border-bottom:1px solid #374151}
.db-unavailable{padding:20px;color:#f59e0b;text-align:center;font-size:.9em}
`,
js: `
let allItems=[], mapData=[], sortKey='total_count', sortDir=-1;
let specialLoaded=false, storageSubTab='inventory';
function onStorageTabActive() {
if (allItems.length === 0) { loadStats(); loadInventory(); loadPlayers(); }
}
function switchStorageSubTab(name) {
storageSubTab = name;
document.querySelectorAll('.storage-sub-tabs .tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.stab-content').forEach(t => t.classList.remove('active'));
const el = document.getElementById('stab-'+name);
if (el) el.classList.add('active');
const subNames=['inventory','map','special','withdraw'];
const tabs = document.querySelectorAll('.storage-sub-tabs .tab');
const idx = subNames.indexOf(name);
if (idx >= 0 && tabs[idx]) tabs[idx].classList.add('active');
if (name === 'map' && mapData.length === 0) loadMap();
if (name === 'special' && !specialLoaded) loadSpecialItems();
}
async function loadStats() {
try {
const r = await fetch('/api/stats');
if (!r.ok) { document.getElementById('stats').innerHTML=''; return; }
const s = await r.json();
document.getElementById('stats').innerHTML =
stat(fmt(s.totalItems||0),'Total Items')+
stat(fmt(s.totalShulkers||0),'Shulkers')+
stat(fmt(s.totalChests||0),'Chests')+
stat(fmt(s.emptyShulkers||0),'Empty')+
stat(fmt(s.recentTrades||0),'Trades (24h)')+
stat(fmt(s.looseItemCount||0),'Loose Items')+
stat((s.totalChestSlots ? Math.round((s.totalShulkers||0)/(s.totalChestSlots)*100) : 0)+'%','Storage Full');
} catch(e) {
document.getElementById('stats').innerHTML='';
}
}
function stat(v,l){return '<div class="stat"><div class="val">'+v+'</div><div class="lbl">'+l+'</div></div>'}
async function loadInventory() {
try {
const r = await fetch('/api/inventory');
if (!r.ok) {
document.getElementById('invList').innerHTML='<div class="db-unavailable">Storage database not available</div>';
document.getElementById('invTable').innerHTML='';
return;
}
const d = await r.json();
allItems = d.items || [];
renderSidebar(allItems);
renderTable(allItems);
updateTimestamp('ts-storage');
} catch(e) {
document.getElementById('invList').innerHTML='<div style="padding:20px;color:#ef4444">Error loading</div>';
}
}
function renderSidebar(items) {
if (!items.length) {
document.getElementById('invList').innerHTML='<div style="padding:20px;color:#6b7280">No items found</div>';
return;
}
document.getElementById('invList').innerHTML = items.map(i =>
'<div class="inv-item" onclick="highlightItem(\\''+i.item_name+'\\')">' +
'<span class="name">'+fmtName(i.item_name)+'</span>' +
'<span class="count'+(i.total_count>=1000?' large':'')+'">'+fmt(i.total_count)+'</span></div>'
).join('');
}
function renderTable(items) {
const sorted = [...items].sort((a,b) => {
const av=a[sortKey], bv=b[sortKey];
if (typeof av==='string') return sortDir*av.localeCompare(bv);
return sortDir*(av-bv);
});
document.querySelectorAll('th .arrow').forEach(a=>a.textContent='');
const el=document.getElementById('sort-'+sortKey);
if(el)el.textContent=sortDir>0?'\\u25B2':'\\u25BC';
document.getElementById('invTable').innerHTML = sorted.map(i =>
'<tr><td>'+fmtName(i.item_name)+'</td><td>'+fmt(i.total_count)+'</td></tr>'
).join('');
}
function sortItems(key) {
if (sortKey===key) sortDir*=-1;
else { sortKey=key; sortDir=key==='total_count'?-1:1; }
const q=document.getElementById('search').value.toLowerCase();
const filtered=q?allItems.filter(i=>i.item_name.includes(q)):allItems;
renderTable(filtered);
}
function filterItems() {
const q=document.getElementById('search').value.toLowerCase();
const filtered=q?allItems.filter(i=>i.item_name.includes(q)):allItems;
renderSidebar(filtered);
renderTable(filtered);
}
function highlightItem(name) {
document.getElementById('search').value=name;
filterItems();
}
// === MAP ===
async function loadMap() {
try {
const r = await fetch('/api/map');
if (!r.ok) { document.getElementById('mapArea').innerHTML='<div class="db-unavailable">Storage database not available</div>'; return; }
const d = await r.json();
mapData = d.chests || [];
renderMap(mapData);
} catch(e) {
document.getElementById('mapArea').innerHTML='<div style="padding:20px;color:#ef4444">Failed to load map</div>';
}
}
function renderMap(chests) {
if (!chests.length) {
document.getElementById('mapArea').innerHTML='<div style="padding:20px;color:#6b7280">No chests found</div>';
return;
}
const levels = {};
let minX=Infinity,maxX=-Infinity,minZ=Infinity,maxZ=-Infinity;
for (const c of chests) {
if(!levels[c.pos_y]) levels[c.pos_y]=[];
levels[c.pos_y].push(c);
minX=Math.min(minX,c.pos_x); maxX=Math.max(maxX,c.pos_x);
minZ=Math.min(minZ,c.pos_z); maxZ=Math.max(maxZ,c.pos_z);
}
const scale=18, pad=20;
const w=(maxX-minX+2)*scale+pad*2;
const h=(maxZ-minZ+2)*scale+pad*2;
const sortedYs = Object.keys(levels).sort((a,b)=>Number(b)-Number(a));
let html='';
for (const y of sortedYs) {
html+='<div class="map-level"><h3>Level Y='+y+' ('+levels[y].length+' chests)</h3>';
html+='<div class="map-grid" style="width:'+w+'px;height:'+h+'px;position:relative">';
for (let x=minX; x<=maxX; x++) {
const px=(x-minX)*scale+pad;
html+='<div style="position:absolute;left:'+px+'px;top:0;width:1px;height:100%;background:#1e293b"></div>';
}
for (let z=minZ; z<=maxZ; z++) {
const py=(z-minZ)*scale+pad;
html+='<div style="position:absolute;top:'+py+'px;left:0;height:1px;width:100%;background:#1e293b"></div>';
}
for (const c of levels[y]) {
const px=(c.pos_x-minX)*scale+pad;
const py=(c.pos_z-minZ)*scale+pad;
const cw=c.chest_type==='double'?scale*2-2:scale-2;
const cls=c.loose_item_count>0?'loose':c.shulker_count===0?'empty':c.total_items===0?'empty':c.shulker_count>20?'full':'partial';
const focuses=(c.item_focuses||'').split(',').filter(Boolean).slice(0,3).map(fmtName).join(', ')||'Empty';
const looseLabel=c.loose_item_count>0?'\\\\n'+c.loose_item_count+' loose item(s)':'';
html+='<div class="map-chest '+cls+'" style="left:'+px+'px;top:'+py+'px;width:'+cw+'px;height:'+(scale-2)+'px" '+
'onclick="showChestDetail('+c.id+')" '+
'onmouseenter="showTooltip(event,\\''+c.chest_type+' chest ('+c.pos_x+','+c.pos_y+','+c.pos_z+')\\\\n'+
c.shulker_count+' shulkers, '+fmt(c.total_items)+' items\\\\n'+focuses.replace(/'/g,"\\\\'")+looseLabel+'\\')" '+
'onmouseleave="hideTooltip()">'+
c.shulker_count+'</div>';
}
html+='</div></div>';
}
document.getElementById('mapArea').innerHTML=html;
}
function showTooltip(e, text) {
const t=document.getElementById('tooltip');
t.innerHTML=text.replace(/\\\\n/g,'<br>');
t.style.display='block';
t.style.left=(e.clientX+12)+'px';
t.style.top=(e.clientY+12)+'px';
}
function hideTooltip(){document.getElementById('tooltip').style.display='none'}
// === CHEST DETAIL ===
async function showChestDetail(chestId) {
const panel=document.getElementById('detailPanel');
const content=document.getElementById('detailContent');
content.innerHTML='<p style="color:#6b7280">Loading...</p>';
panel.classList.add('open');
try {
const r=await fetch('/api/chests/'+chestId+'/contents');
const d=await r.json();
const c=d.chest;
let html='<div class="chest-info"><h3>Chest #'+c.id+'</h3>';
html+='<p>Position: ('+c.pos_x+', '+c.pos_y+', '+c.pos_z+')</p>';
html+='<p>Type: '+c.chest_type+'</p>';
html+='<p>Category: '+(c.category||'none')+'</p></div>';
const shulkers=d.shulkers||[];
html+='<h3 style="color:#60a5fa;margin-bottom:8px">'+shulkers.length+' Shulkers</h3>';
for (const s of shulkers) {
html+='<div class="shulker-card">';
html+='<h4>Slot '+s.slot+' - '+(s.item_focus?fmtName(s.item_focus):'Empty')+' ('+s.slot_count+'/27 slots, '+fmt(s.total_items)+' items)</h4>';
if (s.item_summary) {
const items=s.item_summary.split(',').reduce((acc,pair)=>{
const[name,cnt]=pair.split(':');
if(!acc[name])acc[name]=0;
acc[name]+=parseInt(cnt)||0;
return acc;
},{});
html+='<div class="items">';
for(const[name,cnt] of Object.entries(items).sort((a,b)=>b[1]-a[1])) {
html+='<span>'+fmtName(name)+': '+cnt+'</span>';
}
html+='</div>';
} else {
html+='<div class="items"><span style="color:#6b7280">Empty</span></div>';
}
html+='</div>';
}
content.innerHTML=html;
} catch(e) {
content.innerHTML='<p style="color:#ef4444">Failed to load chest</p>';
}
}
function closeDetail(){document.getElementById('detailPanel').classList.remove('open')}
// === WITHDRAW ===
function updateWithdrawMode(){
const mode=document.getElementById('wMode').value;
const countInput=document.getElementById('wCount');
if(mode==='shulkers'){countInput.placeholder='Shulkers';countInput.max=12;if(parseInt(countInput.value)>12)countInput.value=12}
else{countInput.placeholder='Count';countInput.removeAttribute('max')}
}
document.getElementById('withdrawForm').addEventListener('submit', async(e)=>{
e.preventDefault();
const p=document.getElementById('wPlayer').value.trim();
const i=document.getElementById('wItem').value.trim();
const c=parseInt(document.getElementById('wCount').value);
const mode=document.getElementById('wMode').value;
const st=document.getElementById('withdrawStatus');
if(!p||!i||!c){st.textContent='Fill all fields';st.style.color='#ef4444';return}
try{
const r=await fetch('/api/withdraw',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({playerName:p,itemName:i,count:c,mode:mode})});
const d=await r.json();
st.textContent=r.ok?(d.message||'Queued'):(d.error||'Failed');
st.style.color=r.ok?'#60a5fa':'#ef4444';
}catch(e){st.textContent='Network error';st.style.color='#ef4444'}
});
// Withdraw player autocomplete
setupAC('wPlayer','ac-wPlayer',
q=>{
const lower=q.toLowerCase();
return playerNames.filter(p=>!lower||p.toLowerCase().includes(lower)).map(p=>({label:p,value:p}));
}
);
// Withdraw item autocomplete
setupAC('wItem','ac-wItem',
q=>{
const lower=q.toLowerCase();
return allItems
.filter(i=>!lower||i.item_name.includes(lower))
.slice(0,15)
.map(i=>({label:i.item_name,value:i.item_name,extra:fmt(i.total_count)}));
},
val=>{
if(val){
const item=allItems.find(i=>i.item_name===val);
if(item){document.getElementById('wCount').max=item.total_count}
}
}
);
// Parse Minecraft text: JSON chat components or section-sign formatted strings
function parseMcText(raw) {
if (!raw) return '';
if (typeof raw !== 'string') return String(raw);
// Strip surrounding quotes if present
let s = raw;
if (s.startsWith('"') && s.endsWith('"')) s = s.slice(1, -1);
// Try parsing as JSON chat component
try {
const obj = JSON.parse(s);
if (typeof obj === 'object' && obj !== null) return extractChatText(obj);
} catch(e) {}
// Try parsing the original (unstripped) as JSON too
try {
const obj = JSON.parse(raw);
if (typeof obj === 'object' && obj !== null) return extractChatText(obj);
} catch(e) {}
// Fall back to stripping section-sign codes
return stripMcCodes(s);
}
function extractChatText(obj) {
if (typeof obj === 'string') return stripMcCodes(obj);
let text = '';
if (obj.text) text += obj.text;
if (Array.isArray(obj.extra)) text += obj.extra.map(extractChatText).join('');
if (Array.isArray(obj)) text += obj.map(extractChatText).join('');
return stripMcCodes(text);
}
function stripMcCodes(s) {
return s.replace(/\\u00a7[0-9a-fk-or]/gi, '').replace(/§[0-9a-fk-or]/gi, '');
}
let allSpecialItems = [];
function renderSpecialCard(item) {
const nbt = item.nbt_parsed || {};
const displayName = parseMcText(nbt.displayName || '');
const enchants = (nbt.enchantments || []).map(e => fmtName(String(e.id).replace('minecraft:','')) + ' ' + toRoman(e.level)).join(', ');
const loreLines = (nbt.lore || []).map(l => parseMcText(l)).filter(Boolean);
return '<div class="special-card">' +
(displayName ? '<div class="sp-name">' + escHtml(displayName) + '</div>' : '') +
'<div class="sp-base">' + fmtName(item.item_name) + '</div>' +
(enchants ? '<div class="sp-enchants">' + escHtml(enchants) + '</div>' : '') +
(loreLines.length ? '<div class="sp-lore">' + loreLines.map(l => escHtml(l)).join('<br>') + '</div>' : '') +
'<div class="sp-count">x' + item.count + '</div>' +
'<div class="sp-withdraw">' +
'<input type="text" placeholder="Player name" id="sp-player-' + item.id + '">' +
'<button onclick="withdrawSpecial(' + item.id + ')">Withdraw</button>' +
'</div>' +
'<div class="sp-status" id="sp-status-' + item.id + '"></div>' +
'</div>';
}
function renderSpecialItems(items) {
const container = document.getElementById('specialItems');
if (items.length === 0) {
container.innerHTML='<div style="padding:20px;color:#6b7280;text-align:center">No special items found</div>';
return;
}
container.innerHTML = '<div class="special-grid">' + items.map(renderSpecialCard).join('') + '</div>';
}
function filterSpecialItems() {
const q = (document.getElementById('specialSearch').value || '').toLowerCase();
if (!q) { renderSpecialItems(allSpecialItems); return; }
const filtered = allSpecialItems.filter(item => {
const nbt = item.nbt_parsed || {};
const name = parseMcText(nbt.displayName || '').toLowerCase();
const base = (item.item_name || '').toLowerCase();
const lore = (nbt.lore || []).map(l => parseMcText(l).toLowerCase()).join(' ');
const enchants = (nbt.enchantments || []).map(e => String(e.id).replace('minecraft:','')).join(' ').toLowerCase();
return name.includes(q) || base.includes(q) || lore.includes(q) || enchants.includes(q);
});
renderSpecialItems(filtered);
}
async function loadSpecialItems() {
const container = document.getElementById('specialItems');
container.innerHTML='<div style="padding:20px;color:#6b7280;text-align:center">Loading...</div>';
try {
const r = await fetch('/api/special-items');
if (!r.ok) { container.innerHTML='<div class="db-unavailable">Storage database not available</div>'; return; }
const d = await r.json();
allSpecialItems = d.items || [];
specialLoaded = true;
renderSpecialItems(allSpecialItems);
} catch(e) {
container.innerHTML='<div style="padding:20px;color:#ef4444">Failed to load special items</div>';
}
}
async function withdrawSpecial(itemId) {
const playerInput = document.getElementById('sp-player-' + itemId);
const statusEl = document.getElementById('sp-status-' + itemId);
const playerName = playerInput.value.trim();
if (!playerName) {
statusEl.textContent = 'Enter player name';
statusEl.style.color = '#ef4444';
return;
}
try {
const r = await fetch('/api/withdraw-special', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ playerName, shulkerItemId: itemId })
});
const d = await r.json();
statusEl.textContent = r.ok ? (d.message || 'Queued') : (d.error || 'Failed');
statusEl.style.color = r.ok ? '#60a5fa' : '#ef4444';
if (r.ok) { specialLoaded = false; setTimeout(loadSpecialItems, 3000); }
} catch(e) {
statusEl.textContent = 'Network error';
statusEl.style.color = '#ef4444';
}
}
function toRoman(n) {
if (!n || n <= 0) return '';
const vals = [10,9,5,4,1];
const syms = ['X','IX','V','IV','I'];
let result = '';
for (let i = 0; i < vals.length; i++) {
while (n >= vals[i]) { result += syms[i]; n -= vals[i]; }
}
return result;
}
let playerNames=[];
async function loadPlayers(){
try{const r=await fetch('/api/players');if(!r.ok)return;const d=await r.json();playerNames=(d.players||[]).map(p=>p.player_name)}catch(e){}
}
function storageLoadAll(){loadStats();loadInventory();loadPlayers();if(storageSubTab==='map')loadMap();if(storageSubTab==='special')loadSpecialItems()}
`,
};
module.exports = { createRouter, webUI };