/* 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 INVENTORY_PAGE_SIZE = 100; const state = { category: "", search: "", manufacturer: "", type: "", size: "", page: 1, totalPages: 1, view: "catalog", user: null, inventory: { search: "", category: "", sort: "name" }, }; let authMode = "login"; let pendingView = null; const els = {}; function cacheElements() { const ids = [ "api-status", "stats-line", "account-bar", "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", "catalog-view", "inventory-view", "inv-search", "inv-category", "inv-sort", "inv-summary", "inv-empty", "inv-table-wrap", "inv-tbody", "tab-mine", "auth-backdrop", "auth-close", "auth-title", "auth-form", "auth-username", "auth-password", "auth-error", "auth-submit", "auth-toggle", "toast", ]; for (const id of ids) els[id] = document.getElementById(id); } function escapeHtml(value) { if (value === null || value === undefined) return ""; return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } async function apiFetch(url, options = {}) { const opts = { credentials: "same-origin", ...options }; if (opts.body !== undefined && typeof opts.body !== "string") { opts.body = JSON.stringify(opts.body); opts.headers = { "Content-Type": "application/json", ...(opts.headers || {}) }; } const resp = await fetch(url, opts); let data = null; if (resp.status !== 204) { data = await resp.json().catch(() => null); } if (!resp.ok) { const err = new Error((data && data.detail) || `Request failed: ${resp.status}`); err.status = resp.status; throw err; } return data; } function fetchJson(url) { return apiFetch(url); } function showToast(message, isError = false) { const toast = els["toast"]; toast.textContent = message; toast.classList.toggle("error", isError); toast.classList.remove("hidden"); clearTimeout(showToast._timer); showToast._timer = setTimeout(() => toast.classList.add("hidden"), 2600); } function handleUnauthorized(err) { if (err.status !== 401) return false; state.user = null; resetInventoryView(); renderAccount(); openAuth("login", "Please log in to continue."); return true; } /* ---------- 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 */ } } /* ---------- auth ---------- */ async function initAuth() { try { const data = await apiFetch("/api/v1/auth/me"); state.user = data.user; } catch { state.user = null; } renderAccount(); } function renderAccount() { const bar = els["account-bar"]; bar.innerHTML = ""; if (state.user) { const who = document.createElement("span"); who.className = "who"; who.textContent = state.user.username; const out = document.createElement("button"); out.type = "button"; out.className = "btn-secondary"; out.textContent = "Log out"; out.addEventListener("click", logout); bar.append(who, out); } else { const btn = document.createElement("button"); btn.type = "button"; btn.className = "btn-secondary"; btn.textContent = "Log in / Register"; btn.addEventListener("click", () => openAuth("login")); bar.appendChild(btn); } } function openAuth(mode = "login", message = "") { authMode = mode; els["auth-error"].textContent = message; els["auth-title"].textContent = mode === "register" ? "Create account" : "Welcome back"; els["auth-submit"].textContent = mode === "register" ? "Create account" : "Log in"; els["auth-toggle"].textContent = mode === "register" ? "Already have an account? Log in" : "Need an account? Register"; els["auth-password"].autocomplete = mode === "register" ? "new-password" : "current-password"; els["auth-backdrop"].classList.remove("hidden"); els["auth-username"].focus(); } function closeAuth() { els["auth-backdrop"].classList.add("hidden"); } async function submitAuth(event) { event.preventDefault(); const username = els["auth-username"].value.trim(); const password = els["auth-password"].value; const url = authMode === "register" ? "/api/v1/auth/register" : "/api/v1/auth/login"; els["auth-submit"].disabled = true; els["auth-error"].textContent = ""; try { const data = await apiFetch(url, { method: "POST", body: { username, password } }); state.user = data.user; els["auth-password"].value = ""; closeAuth(); resetInventoryView(); renderAccount(); showToast(`Signed in as ${state.user.username}`); if (pendingView === "mine") { pendingView = null; switchToMine(); } } catch (err) { els["auth-error"].textContent = err.message; } finally { els["auth-submit"].disabled = false; } } async function logout() { try { await apiFetch("/api/v1/auth/logout", { method: "POST" }); } catch { /* clearing local state below is what matters */ } state.user = null; resetInventoryView(); renderAccount(); showToast("Logged out"); if (state.view === "mine") { showCatalogView(); const allTab = document.querySelector('.tab[data-category=""]'); setActiveTab(allTab); state.category = ""; loadItems(); } } /* ---------- catalog 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 `${labels[category] || category}`; } 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 ? `S${escapeHtml(item.size)}` : ""; const grade = item.grade ? `G${escapeHtml(item.grade)}` : ""; card.innerHTML = `
Loading…
'; 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) => `${escapeHtml(t)}`) .join(""); const keys = item.category === "ships" ? SHIP_DETAIL_KEYS : ITEM_DETAIL_KEYS; const rows = keys .map(([key, label]) => `${escapeHtml(manufacturer)} · ${escapeHtml(item.class_name)}
${item.description ? `${escapeHtml(item.description)}
` : ""} ${tags ? `Failed to load item: ${escapeHtml(err.message)}
`; } } function closeModal() { els["modal-backdrop"].classList.add("hidden"); } /* ---------- personal inventory ---------- */ async function switchToMine() { if (!state.user) { pendingView = "mine"; openAuth("login", "Log in to see your inventory."); return; } setActiveTab(els["tab-mine"]); state.view = "mine"; els["catalog-view"].classList.add("hidden"); els["inventory-view"].classList.remove("hidden"); await loadInventory(); } function showCatalogView() { state.view = "catalog"; els["inventory-view"].classList.add("hidden"); els["catalog-view"].classList.remove("hidden"); } /* A new session must never show the previous account's rows or filters. */ function resetInventoryView() { state.inventory.search = ""; state.inventory.category = ""; state.inventory.sort = "name"; els["inv-search"].value = ""; els["inv-category"].value = ""; els["inv-sort"].value = "name"; els["inv-summary"].textContent = ""; renderInventory({ total: 0, items: [] }); } async function loadInventory() { const params = new URLSearchParams(); if (state.inventory.search) params.set("search", state.inventory.search); if (state.inventory.category) params.set("category", state.inventory.category); params.set("sort", state.inventory.sort); params.set("page", "1"); params.set("page_size", String(INVENTORY_PAGE_SIZE)); els["inv-summary"].textContent = "Loading…"; try { const listing = await apiFetch(`/api/v1/me/inventory?${params}`); renderInventory(listing); await refreshSummary(); } catch (err) { if (!handleUnauthorized(err)) showToast(err.message, true); } } async function refreshSummary() { try { const summary = await apiFetch("/api/v1/me/inventory/summary"); renderInventorySummary(summary); } catch { /* summary is decorative; the table already reflects the data */ } } function renderInventorySummary(summary) { const labels = { ships: "ships", "ship-items": "ship items", "fps-items": "FPS items" }; const parts = Object.entries(summary.categories || {}) .map(([key, qty]) => `${qty} ${labels[key] || key}`) .join(" · "); const unavailable = summary.unavailable ? ` · ${summary.unavailable} unavailable catalog item(s)` : ""; els["inv-summary"].textContent = `${summary.items} item${summary.items === 1 ? "" : "s"} · ` + `${summary.quantity} unit${summary.quantity === 1 ? "" : "s"}` + (parts ? ` · ${parts}` : "") + unavailable; } function renderInventory(listing) { const tbody = els["inv-tbody"]; tbody.innerHTML = ""; els["inv-empty"].classList.toggle("hidden", listing.total !== 0); els["inv-table-wrap"].classList.toggle("hidden", listing.total === 0); for (const entry of listing.items) { const row = document.createElement("tr"); const nameCell = document.createElement("td"); nameCell.innerHTML = `