forked from wmantly/mc-bot-town
883 lines
35 KiB
JavaScript
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()">×</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 };
|