Create-CC-Shop/public/items.html
2025-12-26 13:37:00 +01:00

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 {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[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>