586 lines
23 KiB
HTML
586 lines
23 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>Latest Items</title>
|
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
<style>
|
|
:root {
|
|
--bg-main: #000;
|
|
--bg-secondary: #111;
|
|
--bg-tertiary: #23272a;
|
|
--text-main: #fff;
|
|
--text-muted: #b9bbbe;
|
|
--accent: #5865f2;
|
|
--accent-hover: #4752c4;
|
|
--border: #222;
|
|
--badge-bg: #5865f2;
|
|
--badge-text: #fff;
|
|
--table-header: #23272a;
|
|
--monospace:
|
|
ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono",
|
|
"Courier New", monospace;
|
|
}
|
|
html,
|
|
body {
|
|
height: 100%;
|
|
background: var(--bg-main);
|
|
color: var(--text-main);
|
|
}
|
|
body {
|
|
font-family:
|
|
Inter,
|
|
system-ui,
|
|
-apple-system,
|
|
"Segoe UI",
|
|
Roboto,
|
|
"Helvetica Neue",
|
|
Arial;
|
|
margin: 20px;
|
|
color: var(--text-main);
|
|
background: var(--bg-main);
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
h1 {
|
|
margin: 0 0 8px 0;
|
|
font-weight: 600;
|
|
color: var(--accent);
|
|
}
|
|
p.lead {
|
|
margin: 0 0 16px 0;
|
|
color: var(--text-muted);
|
|
}
|
|
.controls {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
flex-wrap: wrap;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
padding: 12px;
|
|
border-radius: 8px;
|
|
}
|
|
.controls > * {
|
|
margin: 0;
|
|
}
|
|
input[type="search"],
|
|
select {
|
|
padding: 6px 8px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
background: var(--bg-tertiary);
|
|
color: var(--text-main);
|
|
}
|
|
button {
|
|
padding: 6px 10px;
|
|
border-radius: 4px;
|
|
border: none;
|
|
background: var(--accent);
|
|
color: var(--badge-text);
|
|
cursor: pointer;
|
|
}
|
|
button.primary {
|
|
background: var(--accent-hover);
|
|
color: var(--badge-text);
|
|
}
|
|
button:hover {
|
|
background: var(--accent-hover);
|
|
}
|
|
label {
|
|
font-size: 14px;
|
|
color: var(--text-muted);
|
|
display: flex;
|
|
gap: 6px;
|
|
align-items: center;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-top: 8px;
|
|
background: var(--bg-secondary);
|
|
color: var(--text-main);
|
|
}
|
|
thead th {
|
|
text-align: left;
|
|
padding: 8px;
|
|
border-bottom: 1px solid var(--border);
|
|
background: var(--table-header);
|
|
font-weight: 600;
|
|
color: var(--text-main);
|
|
}
|
|
tbody td {
|
|
padding: 8px;
|
|
border-bottom: 1px solid var(--border);
|
|
vertical-align: middle;
|
|
}
|
|
tbody tr:hover {
|
|
background: #18191c;
|
|
}
|
|
.small {
|
|
font-size: 13px;
|
|
color: var(--text-muted);
|
|
}
|
|
.badge {
|
|
display: inline-block;
|
|
padding: 2px 6px;
|
|
background: var(--badge-bg);
|
|
border-radius: 12px;
|
|
font-size: 12px;
|
|
color: var(--badge-text);
|
|
}
|
|
.meta {
|
|
margin-left: auto;
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
color: var(--text-muted);
|
|
}
|
|
.empty {
|
|
color: var(--text-muted);
|
|
text-align: center;
|
|
padding: 20px 0;
|
|
}
|
|
|
|
.id-muted {
|
|
color: var(--muted);
|
|
font-family: var(--monospace);
|
|
font-size: 12px;
|
|
margin-left: 8px;
|
|
}
|
|
|
|
/* Responsive: stack columns under 640px */
|
|
@media (max-width: 640px) {
|
|
table,
|
|
thead,
|
|
tbody,
|
|
th,
|
|
td,
|
|
tr {
|
|
display: block;
|
|
}
|
|
thead {
|
|
display: none;
|
|
}
|
|
tbody td {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 10px 8px;
|
|
}
|
|
tbody tr {
|
|
margin-bottom: 8px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
padding: 0;
|
|
overflow: hidden;
|
|
}
|
|
.row-left {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
.row-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
margin-left: 12px;
|
|
}
|
|
.id-muted {
|
|
display: block;
|
|
margin-left: 0;
|
|
font-size: 11px;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Latest Items</h1>
|
|
<p class="lead">
|
|
Shows the most recently received item lists from connected clients.
|
|
Data comes from <code>/api/items</code>.
|
|
</p>
|
|
|
|
<div class="controls">
|
|
<input
|
|
id="search"
|
|
type="search"
|
|
placeholder="Filter item or source (case-insensitive)"
|
|
/>
|
|
<select id="groupBy" title="Group table">
|
|
<option value="source">Group by source (rows per item)</option>
|
|
<option value="item">
|
|
Group by item (aggregate across sources)
|
|
</option>
|
|
</select>
|
|
|
|
<label
|
|
><input id="autoRefresh" type="checkbox" /> Auto-refresh</label
|
|
>
|
|
<button id="refreshBtn" class="primary">Refresh</button>
|
|
|
|
<div class="meta">
|
|
<div class="small">
|
|
Last update: <span id="lastUpdate">—</span>
|
|
</div>
|
|
<div class="small">
|
|
API: <span id="apiStatus" class="badge">unknown</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="tableWrap">
|
|
<table id="itemsTable" aria-describedby="tableDesc">
|
|
<thead>
|
|
<tr>
|
|
<th id="thSource">Source</th>
|
|
<th id="thItem">Item</th>
|
|
<th id="thCount">Count</th>
|
|
<th id="thUpdated">Last updated</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="tbody">
|
|
<tr>
|
|
<td colspan="4" class="empty">Loading…</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<p id="tableDesc" class="small">
|
|
Live view of item lists reported by connected clients.
|
|
</p>
|
|
|
|
<script>
|
|
(function () {
|
|
const api = "/api/items";
|
|
const refreshBtn = document.getElementById("refreshBtn");
|
|
const autoRefresh = document.getElementById("autoRefresh");
|
|
const searchEl = document.getElementById("search");
|
|
const groupByEl = document.getElementById("groupBy");
|
|
const apiStatus = document.getElementById("apiStatus");
|
|
const lastUpdateEl = document.getElementById("lastUpdate");
|
|
const tbody = document.getElementById("tbody");
|
|
|
|
let autoTimer = null;
|
|
let lastFetched = 0;
|
|
let latestData = {}; // raw latestItems object
|
|
|
|
// Helper: format timestamp ms -> human friendly
|
|
function formatTs(ms) {
|
|
// get time from epoch
|
|
const n = Number(ms) || 0;
|
|
const d = new Date(n);
|
|
if (isNaN(d.getTime())) return String(ms);
|
|
return d.toLocaleString();
|
|
}
|
|
|
|
// Heuristic formatter for Minecraft item IDs -> human friendly text
|
|
// Examples:
|
|
// minecraft:sugar_cane -> Sugar cane
|
|
// modid:custom_block -> Custom block
|
|
// oak_log -> Oak log
|
|
function formatItemId(id) {
|
|
if (!id && id !== 0) return "(unknown)";
|
|
id = String(id);
|
|
|
|
// Remove any NBT/variant suffixes that might follow a space or bracket e.g. "minecraft:stone{...}"
|
|
// Keep it simple: strip anything starting with '{' or '[' or ' ' after id body.
|
|
id = id.split("{")[0].split("[")[0].trim();
|
|
|
|
// If there is a namespace, take the part after colon
|
|
const parts = id.split(":");
|
|
let name = parts.length > 1 ? parts[1] : parts[0];
|
|
|
|
// Normalize common separators
|
|
name = name.replace(/[-.]/g, "_");
|
|
|
|
// Replace multiple underscores with single space
|
|
name = name.replace(/_+/g, " ").trim();
|
|
|
|
// If name contains uppercase letters (already pretty), keep spacing but fix underscores
|
|
if (/[A-Z]/.test(name)) {
|
|
// Replace underscores and keep casing; ensure single spaces
|
|
name = name.replace(/_+/g, " ").trim();
|
|
return name;
|
|
}
|
|
|
|
// Lowercase everything then title-case just the first word for a natural look:
|
|
// "sugar cane" -> "Sugar cane", "redstone_dust" -> "Redstone dust"
|
|
name = name.toLowerCase().split(" ").filter(Boolean);
|
|
|
|
if (name.length === 0) return id;
|
|
|
|
// Title-case only first word, leave rest lowercase (matches examples like "Sugar cane")
|
|
const first = name[0];
|
|
const rest = name.slice(1);
|
|
const firstCap =
|
|
first.charAt(0).toUpperCase() + first.slice(1);
|
|
const result = [firstCap].concat(rest).join(" ");
|
|
|
|
return result;
|
|
}
|
|
|
|
// Escape HTML in text nodes
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, function (c) {
|
|
return {
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'",
|
|
}[c];
|
|
});
|
|
}
|
|
|
|
// Flatten latestItems object into rows: { source, item, itemDisplay, count, updated }
|
|
function buildRows(data) {
|
|
const rows = [];
|
|
for (const key of Object.keys(data)) {
|
|
const entry = data[key];
|
|
const when = formatTs(entry.ts);
|
|
const items = Array.isArray(entry.items)
|
|
? entry.items
|
|
: [];
|
|
if (items.length === 0) {
|
|
rows.push({
|
|
source: entry.from,
|
|
item: "(no items)",
|
|
itemDisplay: "(no items)",
|
|
count: 0,
|
|
updated: when,
|
|
});
|
|
} else {
|
|
for (const it of items) {
|
|
const original =
|
|
it.name || it.id || it.item || "(unknown)";
|
|
const display = formatItemId(original);
|
|
rows.push({
|
|
source: entry.from,
|
|
item: original,
|
|
itemDisplay: display,
|
|
count: it.count || 0,
|
|
updated: when,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
// Build aggregated rows by item (across sources)
|
|
function buildItemAggregates(rows) {
|
|
const map = {};
|
|
for (const r of rows) {
|
|
const key = r.item; // aggregate by original id
|
|
if (!map[key])
|
|
map[key] = {
|
|
item: key,
|
|
itemDisplay: r.itemDisplay,
|
|
count: 0,
|
|
sources: {},
|
|
};
|
|
map[key].count += Number(r.count) || 0;
|
|
map[key].sources[r.source] =
|
|
(map[key].sources[r.source] || 0) +
|
|
(Number(r.count) || 0);
|
|
}
|
|
const out = [];
|
|
for (const k of Object.keys(map)) {
|
|
out.push({
|
|
item: map[k].item,
|
|
itemDisplay: map[k].itemDisplay,
|
|
count: map[k].count,
|
|
sources: map[k].sources,
|
|
});
|
|
}
|
|
out.sort((a, b) =>
|
|
a.itemDisplay.localeCompare(b.itemDisplay),
|
|
);
|
|
return out;
|
|
}
|
|
|
|
function renderTable() {
|
|
const s = (searchEl.value || "").trim().toLowerCase();
|
|
const groupBy = groupByEl.value;
|
|
|
|
const rows = buildRows(latestData);
|
|
if (groupBy === "item") {
|
|
const aggs = buildItemAggregates(rows);
|
|
if (aggs.length === 0) {
|
|
tbody.innerHTML =
|
|
'<tr><td colspan="4" class="empty">No items</td></tr>';
|
|
return;
|
|
}
|
|
// Render one row per item, with sources shown in Last updated column
|
|
tbody.innerHTML = "";
|
|
for (const a of aggs) {
|
|
const matchSearch =
|
|
a.itemDisplay.toLowerCase().includes(s) ||
|
|
Object.keys(a.sources).some((src) =>
|
|
src.toLowerCase().includes(s),
|
|
);
|
|
if (s && !matchSearch) continue;
|
|
const tr = document.createElement("tr");
|
|
|
|
const tdSource = document.createElement("td");
|
|
tdSource.innerHTML = escapeHtml(
|
|
Object.keys(a.sources).join(", "),
|
|
);
|
|
|
|
const tdItem = document.createElement("td");
|
|
// show display name and original id smaller and muted
|
|
tdItem.innerHTML =
|
|
"<strong>" +
|
|
escapeHtml(a.itemDisplay) +
|
|
"</strong>" +
|
|
'<div class="id-muted">' +
|
|
escapeHtml(a.item) +
|
|
"</div>";
|
|
|
|
const tdCount = document.createElement("td");
|
|
tdCount.textContent = a.count;
|
|
|
|
const tdUpdated = document.createElement("td");
|
|
// show per-source counts in updated column (compact)
|
|
const parts = [];
|
|
for (const src of Object.keys(a.sources).sort()) {
|
|
parts.push(`${src}: ${a.sources[src]}`);
|
|
}
|
|
tdUpdated.textContent = parts.join(" • ");
|
|
|
|
tr.appendChild(tdSource);
|
|
tr.appendChild(tdItem);
|
|
tr.appendChild(tdCount);
|
|
tr.appendChild(tdUpdated);
|
|
tbody.appendChild(tr);
|
|
}
|
|
} else {
|
|
// group by source -> item rows
|
|
if (rows.length === 0) {
|
|
tbody.innerHTML =
|
|
'<tr><td colspan="4" class="empty">No items</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = "";
|
|
// sort rows by source then itemDisplay
|
|
rows.sort((a, b) => {
|
|
if (a.source === b.source)
|
|
return a.itemDisplay.localeCompare(
|
|
b.itemDisplay,
|
|
);
|
|
return a.source.localeCompare(b.source);
|
|
});
|
|
for (const r of rows) {
|
|
const matchSearch =
|
|
r.source.toLowerCase().includes(s) ||
|
|
r.itemDisplay.toLowerCase().includes(s);
|
|
if (s && !matchSearch) continue;
|
|
const tr = document.createElement("tr");
|
|
|
|
const tdSource = document.createElement("td");
|
|
tdSource.textContent = r.source;
|
|
|
|
const tdItem = document.createElement("td");
|
|
tdItem.innerHTML =
|
|
"<strong>" +
|
|
escapeHtml(r.itemDisplay) +
|
|
"</strong>" +
|
|
'<div class="id-muted">' +
|
|
escapeHtml(r.item) +
|
|
"</div>";
|
|
|
|
const tdCount = document.createElement("td");
|
|
tdCount.textContent = r.count;
|
|
|
|
const tdUpdated = document.createElement("td");
|
|
tdUpdated.textContent = r.updated;
|
|
|
|
tr.appendChild(tdSource);
|
|
tr.appendChild(tdItem);
|
|
tr.appendChild(tdCount);
|
|
tr.appendChild(tdUpdated);
|
|
tbody.appendChild(tr);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function fetchAndRender() {
|
|
try {
|
|
const res = await fetch(api, { cache: "no-store" });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const json = await res.json();
|
|
if (!json || typeof json !== "object")
|
|
throw new Error("Invalid JSON");
|
|
latestData = json.data || {};
|
|
lastFetched = Date.now();
|
|
lastUpdateEl.textContent = new Date(
|
|
lastFetched,
|
|
).toLocaleString();
|
|
apiStatus.textContent = "ok";
|
|
apiStatus.style.background = "#e6ffed";
|
|
apiStatus.style.color = "#06612a";
|
|
renderTable();
|
|
} catch (err) {
|
|
apiStatus.textContent = "error";
|
|
apiStatus.style.background = "#ffecec";
|
|
apiStatus.style.color = "#7a1a1a";
|
|
console.error("Failed to fetch /api/items", err);
|
|
tbody.innerHTML =
|
|
'<tr><td colspan="4" class="empty">Error fetching items</td></tr>';
|
|
}
|
|
}
|
|
|
|
// Event handlers
|
|
refreshBtn.addEventListener("click", fetchAndRender);
|
|
searchEl.addEventListener("input", renderTable);
|
|
groupByEl.addEventListener("change", renderTable);
|
|
autoRefresh.addEventListener("change", () => {
|
|
if (autoRefresh.checked) {
|
|
if (autoTimer) clearInterval(autoTimer);
|
|
autoTimer = setInterval(fetchAndRender, 3000);
|
|
} else {
|
|
if (autoTimer) {
|
|
clearInterval(autoTimer);
|
|
autoTimer = null;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Optionally open a WebSocket to receive broadcast messages and refresh on activity
|
|
(function tryOpenWs() {
|
|
try {
|
|
const proto =
|
|
location.protocol === "https:" ? "wss:" : "ws:";
|
|
const wsUrl = proto + "//" + location.host;
|
|
const ws = new WebSocket(wsUrl);
|
|
ws.addEventListener("open", () => {
|
|
console.debug("WS open", wsUrl);
|
|
});
|
|
ws.addEventListener("message", (ev) => {
|
|
// For simplicity, refresh the items list on any message
|
|
// You could optionally parse and only refresh on messages with items
|
|
fetchAndRender();
|
|
});
|
|
ws.addEventListener("close", () => {
|
|
console.debug(
|
|
"WS closed; falling back to polling only",
|
|
);
|
|
});
|
|
ws.addEventListener("error", () => {
|
|
// ignore; polling will continue
|
|
});
|
|
} catch (e) {
|
|
// WebSocket not supported or blocked; rely on polling
|
|
}
|
|
})();
|
|
|
|
// Initial load
|
|
fetchAndRender();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|