init test commit

This commit is contained in:
2026-11-10 20:36:01 -07:00
commit 8693b3bd8d
42 changed files with 311150 additions and 0 deletions

View File

@@ -0,0 +1,306 @@
/* Star Citizen Inventory — vanilla JS frontend.
* All API calls use RELATIVE paths (/api/v1/..., /healthz) served by the
* nginx reverse proxy, so the app works behind any host/origin.
*/
"use strict";
const PAGE_SIZE = 25;
const state = {
category: "",
search: "",
manufacturer: "",
type: "",
size: "",
page: 1,
totalPages: 1,
};
const els = {};
function cacheElements() {
const ids = [
"api-status", "stats-line", "category-tabs", "search-input",
"manufacturer-select", "type-input", "size-input", "reset-filters",
"error-banner", "loading", "empty-state", "results-grid",
"pagination", "prev-page", "next-page", "page-info",
"modal-backdrop", "modal-close", "modal-title", "modal-body",
];
for (const id of ids) els[id] = document.getElementById(id);
}
function escapeHtml(value) {
if (value === null || value === undefined) return "";
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
async function fetchJson(url) {
const resp = await fetch(url);
if (!resp.ok) {
const detail = await resp.json().catch(() => ({}));
throw new Error(detail.detail || `Request failed: ${resp.status}`);
}
return resp.json();
}
/* ---------- header: status, stats, manufacturers ---------- */
async function initHeader() {
try {
const health = await fetchJson("/healthz");
els["api-status"].textContent = "API ONLINE";
els["api-status"].className = "status-pill online";
const c = health.counts || {};
els["stats-line"].textContent =
`${c.ships ?? 0} ships · ${c["ship-items"] ?? 0} ship items · ` +
`${c["fps-items"] ?? 0} FPS items`;
} catch {
els["api-status"].textContent = "API OFFLINE";
els["api-status"].className = "status-pill offline";
showError("Cannot reach the API. Is the backend container running?");
}
try {
const manufacturers = await fetchJson("/api/v1/manufacturers");
for (const m of manufacturers) {
const option = document.createElement("option");
option.value = m.code;
option.textContent = m.name;
els["manufacturer-select"].appendChild(option);
}
} catch {
/* header error already shown; dropdown simply stays empty */
}
}
/* ---------- item list ---------- */
function buildQuery() {
const params = new URLSearchParams();
if (state.category) params.set("category", state.category);
if (state.search) params.set("search", state.search);
if (state.manufacturer) params.set("manufacturer", state.manufacturer);
if (state.type) params.set("type", state.type);
if (state.size !== "") params.set("size", state.size);
params.set("page", String(state.page));
params.set("page_size", String(PAGE_SIZE));
return params.toString();
}
function categoryBadge(category) {
const labels = { ships: "Ship", "ship-items": "Ship Item", "fps-items": "FPS Item" };
return `<span class="badge cat-${category}">${labels[category] || category}</span>`;
}
function renderItems(items) {
const grid = els["results-grid"];
grid.innerHTML = "";
for (const item of items) {
const card = document.createElement("article");
card.className = "item-card";
card.dataset.class_name = item.class_name;
const manufacturer = item.manufacturer?.name || item.manufacturer?.code || "—";
const size = item.size ? `<span class="badge size">S${escapeHtml(item.size)}</span>` : "";
const grade = item.grade ? `<span class="badge size">G${escapeHtml(item.grade)}</span>` : "";
card.innerHTML = `
<div class="item-name">${escapeHtml(item.name)}</div>
<div class="item-manufacturer">${escapeHtml(manufacturer)}</div>
<div class="item-classification">${escapeHtml(item.classification || item.type || "")}</div>
<div class="item-meta">${categoryBadge(item.category)}${size}${grade}</div>
`;
card.addEventListener("click", () => openModal(item.class_name));
grid.appendChild(card);
}
}
function renderPagination(body) {
state.totalPages = body.total_pages;
els["page-info"].textContent = `Page ${body.page} of ${body.total_pages} — ${body.total} items`;
els["prev-page"].disabled = body.page <= 1;
els["next-page"].disabled = body.page >= body.total_pages;
}
async function loadItems() {
els["loading"].classList.remove("hidden");
els["empty-state"].classList.add("hidden");
els["results-grid"].innerHTML = "";
els["error-banner"].classList.add("hidden");
try {
const body = await fetchJson(`/api/v1/items?${buildQuery()}`);
renderItems(body.items);
renderPagination(body);
els["empty-state"].classList.toggle("hidden", body.total !== 0);
} catch (err) {
showError(`Failed to load items: ${err.message}`);
} finally {
els["loading"].classList.add("hidden");
}
}
function showError(message) {
els["error-banner"].textContent = message;
els["error-banner"].classList.remove("hidden");
}
/* ---------- detail modal ---------- */
const SHIP_DETAIL_KEYS = [
["career", "Career"], ["role", "Role"], ["length", "Length (m)"],
["width", "Width (m)"], ["height", "Height (m)"], ["crew", "Crew"],
["mass", "Mass (kg)"], ["is_spaceship", "Spaceship"],
["is_vehicle", "Vehicle"], ["is_gravlev", "Gravlev"],
];
const ITEM_DETAIL_KEYS = [
["mass", "Mass (kg)"], ["width", "Width (m)"], ["height", "Height (m)"],
["length", "Length (m)"],
];
function formatValue(value) {
if (value === null || value === undefined || value === "") return "—";
if (typeof value === "boolean") return value ? "Yes" : "No";
if (typeof value === "number") {
return Number.isInteger(value) ? String(value) : value.toFixed(2);
}
return escapeHtml(value);
}
function renderPorts(ports) {
if (!Array.isArray(ports) || ports.length === 0) return "";
const rows = ports
.map(
(p) => `<tr><th>${escapeHtml(p.DisplayName || p.PortName || "Port")}</th>` +
`<td>size ${escapeHtml(p.Size ?? "—")}</td></tr>`
)
.join("");
return `<h3>Ports</h3><table class="spec-table">${rows}</table>`;
}
async function openModal(class_name) {
els["modal-body"].innerHTML = '<p class="hint">Loading…</p>';
els["modal-backdrop"].classList.remove("hidden");
try {
const item = await fetchJson(`/api/v1/items/${encodeURIComponent(class_name)}`);
els["modal-title"].textContent = item.name;
const manufacturer = item.manufacturer?.name || "—";
const tags = (item.tags || [])
.map((t) => `<span class="badge size">${escapeHtml(t)}</span>`)
.join("");
const keys = item.category === "ships" ? SHIP_DETAIL_KEYS : ITEM_DETAIL_KEYS;
const rows = keys
.map(([key, label]) => `<tr><th>${label}</th><td>${formatValue(item.details?.[key])}</td></tr>`)
.join("");
els["modal-body"].innerHTML = `
<p class="modal-sub">${escapeHtml(manufacturer)} · ${escapeHtml(item.class_name)}</p>
${item.description ? `<p class="modal-desc">${escapeHtml(item.description)}</p>` : ""}
${tags ? `<div class="tag-row">${tags}</div>` : ""}
<h3>Specifications</h3>
<table class="spec-table">${rows}</table>
${item.category === "ships" ? "" : renderPorts(item.details?.ports)}
`;
} catch (err) {
els["modal-body"].innerHTML = `<p class="modal-desc">Failed to load item: ${escapeHtml(err.message)}</p>`;
}
}
function closeModal() {
els["modal-backdrop"].classList.add("hidden");
}
/* ---------- events ---------- */
function debounce(fn, wait) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
function resetPage() {
state.page = 1;
}
function bindEvents() {
els["category-tabs"].addEventListener("click", (event) => {
const tab = event.target.closest(".tab");
if (!tab) return;
document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
tab.classList.add("active");
state.category = tab.dataset.category;
resetPage();
loadItems();
});
const debouncedSearch = debounce(() => {
state.search = els["search-input"].value.trim();
resetPage();
loadItems();
}, 300);
els["search-input"].addEventListener("input", debouncedSearch);
els["manufacturer-select"].addEventListener("change", () => {
state.manufacturer = els["manufacturer-select"].value;
resetPage();
loadItems();
});
els["type-input"].addEventListener("change", () => {
state.type = els["type-input"].value.trim();
resetPage();
loadItems();
});
els["size-input"].addEventListener("change", () => {
state.size = els["size-input"].value;
resetPage();
loadItems();
});
els["reset-filters"].addEventListener("click", () => {
state.search = "";
state.manufacturer = "";
state.type = "";
state.size = "";
els["search-input"].value = "";
els["manufacturer-select"].value = "";
els["type-input"].value = "";
els["size-input"].value = "";
resetPage();
loadItems();
});
els["prev-page"].addEventListener("click", () => {
if (state.page > 1) {
state.page -= 1;
loadItems();
}
});
els["next-page"].addEventListener("click", () => {
if (state.page < state.totalPages) {
state.page += 1;
loadItems();
}
});
els["modal-close"].addEventListener("click", closeModal);
els["modal-backdrop"].addEventListener("click", (event) => {
if (event.target === els["modal-backdrop"]) closeModal();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeModal();
});
}
/* ---------- boot ---------- */
document.addEventListener("DOMContentLoaded", () => {
cacheElements();
bindEvents();
initHeader();
loadItems();
});

View File

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Star Citizen Inventory</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="site-header">
<div class="header-inner">
<div class="brand">
<h1>STAR CITIZEN <span>INVENTORY</span></h1>
<p class="subtitle">Item catalog from scunpacked game data</p>
</div>
<div class="header-status">
<div id="api-status" class="status-pill checking">CHECKING API…</div>
<div id="stats-line" class="stats-line">Loading counts…</div>
</div>
</div>
</header>
<nav class="tabs" id="category-tabs">
<button class="tab active" data-category="">All Items</button>
<button class="tab" data-category="ships">Ships</button>
<button class="tab" data-category="ship-items">Ship Equipment</button>
<button class="tab" data-category="fps-items">FPS Items</button>
</nav>
<section class="controls">
<input type="search" id="search-input" placeholder="Search name or class name…" autocomplete="off">
<select id="manufacturer-select">
<option value="">All manufacturers</option>
</select>
<input type="text" id="type-input" placeholder="Type (e.g. WeaponPersonal)">
<input type="number" id="size-input" placeholder="Size" min="0" max="12">
<button id="reset-filters" class="btn-secondary">Reset</button>
</section>
<main>
<div id="error-banner" class="error-banner hidden"></div>
<div id="loading" class="loading">Loading inventory…</div>
<div id="empty-state" class="empty-state hidden">
<p>No items match your filters.</p>
<p class="hint">Try clearing the search or resetting the filters.</p>
</div>
<div id="results-grid" class="results-grid"></div>
<div class="pagination" id="pagination">
<button id="prev-page" class="btn-secondary">&larr; Prev</button>
<span id="page-info">Page 1 of 1</span>
<button id="next-page" class="btn-secondary">Next &rarr;</button>
</div>
</main>
<footer class="site-footer">
<p>Data source:
<a href="https://github.com/richardthombs/scunpacked" target="_blank" rel="noopener noreferrer">scunpacked</a>
— unofficial Star Citizen fansite content, not affiliated with Cloud Imperium Games.
</p>
</footer>
<div id="modal-backdrop" class="modal-backdrop hidden">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<button class="modal-close" id="modal-close" aria-label="Close">&times;</button>
<h2 id="modal-title">Item</h2>
<div id="modal-body"></div>
</div>
</div>
<script src="app.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,276 @@
/* Star Citizen Inventory — dark sci-fi theme */
:root {
--bg: #0b0e14;
--bg-panel: #11151f;
--bg-card: #151a26;
--border: #232b3d;
--text: #d7dce8;
--text-dim: #8b93a7;
--accent: #38bdf8;
--accent-warm: #f59e0b;
--danger: #ef4444;
--success: #22c55e;
--cat-ships: #38bdf8;
--cat-ship-items: #a78bfa;
--cat-fps-items: #f59e0b;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", "Inter", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
/* Header */
.site-header {
background: linear-gradient(180deg, #0d1220 0%, var(--bg) 100%);
border-bottom: 1px solid var(--border);
}
.header-inner {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem 1.25rem 1rem;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 1rem;
flex-wrap: wrap;
}
.brand h1 {
margin: 0;
font-size: 1.6rem;
letter-spacing: 0.12em;
font-weight: 800;
}
.brand h1 span { color: var(--accent); }
.subtitle { margin: 0.25rem 0 0; color: var(--text-dim); font-size: 0.85rem; }
.header-status { text-align: right; }
.status-pill {
display: inline-block;
padding: 0.3rem 0.9rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
border: 1px solid transparent;
}
.status-pill.online { color: var(--success); border-color: var(--success); background: rgba(34,197,94,0.08); }
.status-pill.offline { color: var(--danger); border-color: var(--danger); background: rgba(239,68,68,0.08); }
.status-pill.checking { color: var(--text-dim); border-color: var(--border); }
.stats-line { margin-top: 0.4rem; font-size: 0.8rem; color: var(--text-dim); }
/* Tabs */
.tabs {
max-width: 1200px;
margin: 1rem auto 0;
padding: 0 1.25rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.tab {
background: var(--bg-panel);
color: var(--text-dim);
border: 1px solid var(--border);
border-radius: 8px 8px 0 0;
padding: 0.6rem 1.1rem;
font-size: 0.9rem;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.tab:hover { color: var(--text); }
.tab.active {
color: var(--accent);
border-bottom: 2px solid var(--accent);
background: var(--bg-card);
}
/* Controls */
.controls {
max-width: 1200px;
margin: 0 auto;
padding: 1rem 1.25rem;
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
background: var(--bg-card);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 8px 8px;
}
.controls input, .controls select {
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.55rem 0.75rem;
font-size: 0.9rem;
}
#search-input { flex: 2 1 240px; }
#manufacturer-select { flex: 1 1 180px; }
#type-input { flex: 1 1 160px; }
#size-input { flex: 0 1 90px; }
.controls input:focus, .controls select:focus {
outline: none;
border-color: var(--accent);
}
.btn-secondary {
background: var(--bg-panel);
color: var(--text-dim);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
cursor: pointer;
}
.btn-secondary:hover:not(:disabled) { color: var(--text); border-color: var(--accent); }
.btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; }
/* Main */
main {
max-width: 1200px;
margin: 0 auto;
padding: 1.25rem;
}
.error-banner {
background: rgba(239,68,68,0.1);
border: 1px solid var(--danger);
color: #fca5a5;
border-radius: 8px;
padding: 0.8rem 1rem;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.loading, .empty-state {
text-align: center;
color: var(--text-dim);
padding: 3rem 1rem;
font-size: 1rem;
}
.empty-state .hint { font-size: 0.85rem; opacity: 0.7; }
.hidden { display: none !important; }
/* Results grid */
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 0.9rem;
}
.item-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.9rem 1rem;
cursor: pointer;
transition: border-color 0.15s, transform 0.1s;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.item-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.item-card .item-name { font-weight: 700; font-size: 0.98rem; line-height: 1.3; }
.item-card .item-manufacturer { color: var(--text-dim); font-size: 0.8rem; }
.item-card .item-meta {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
align-items: center;
margin-top: auto;
}
.badge {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0.15rem 0.55rem;
border-radius: 999px;
border: 1px solid;
}
.badge.cat-ships { color: var(--cat-ships); border-color: var(--cat-ships); }
.badge.cat-ship-items { color: var(--cat-ship-items); border-color: var(--cat-ship-items); }
.badge.cat-fps-items { color: var(--cat-fps-items); border-color: var(--cat-fps-items); }
.badge.size { color: var(--text-dim); border-color: var(--border); }
.item-classification { color: var(--text-dim); font-size: 0.78rem; font-family: ui-monospace, monospace; }
/* Pagination */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
}
#page-info { color: var(--text-dim); font-size: 0.9rem; }
/* Footer */
.site-footer {
border-top: 1px solid var(--border);
margin-top: 2rem;
padding: 1.25rem;
text-align: center;
color: var(--text-dim);
font-size: 0.8rem;
}
.site-footer a { color: var(--accent); text-decoration: none; }
.site-footer a:hover { text-decoration: underline; }
/* Modal */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(5,8,14,0.75);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 50;
}
.modal {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 12px;
max-width: 640px;
width: 100%;
max-height: 85vh;
overflow-y: auto;
padding: 1.5rem;
position: relative;
}
.modal-close {
position: absolute;
top: 0.75rem;
right: 0.9rem;
background: none;
border: none;
color: var(--text-dim);
font-size: 1.6rem;
cursor: pointer;
line-height: 1;
}
.modal-close:hover { color: var(--text); }
.modal h2 { margin: 0 2rem 0.25rem 0; font-size: 1.25rem; }
.modal .modal-sub { color: var(--text-dim); font-size: 0.85rem; margin-bottom: 1rem; }
.modal .modal-desc { font-size: 0.92rem; line-height: 1.55; margin-bottom: 1rem; }
.modal .tag-row { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-bottom: 1rem; }
.spec-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
.spec-table th, .spec-table td {
text-align: left;
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--border);
}
.spec-table th { color: var(--text-dim); font-weight: 600; width: 40%; }
.spec-table td { font-family: ui-monospace, monospace; }
@media (max-width: 640px) {
.header-inner { flex-direction: column; align-items: flex-start; }
.header-status { text-align: left; }
.controls input, .controls select { flex: 1 1 100%; }
}