/* 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 = `
${escapeHtml(item.name)}
${escapeHtml(manufacturer)}
${escapeHtml(item.classification || item.type || "")}
${categoryBadge(item.category)}${size}${grade}
`; card.addEventListener("click", () => openModal(item.class_name)); card.querySelector(".card-add").addEventListener("click", (event) => { event.stopPropagation(); addToInventory(item.class_name, 1); }); 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) => `${escapeHtml(p.DisplayName || p.PortName || "Port")}` + `size ${escapeHtml(p.Size ?? "—")}` ) .join(""); return `

Ports

${rows}
`; } async function openModal(class_name) { els["modal-body"].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]) => `${label}${formatValue(item.details?.[key])}`) .join(""); els["modal-body"].innerHTML = ` ${item.description ? `` : ""} ${tags ? `
${tags}
` : ""}

Specifications

${rows}
${item.category === "ships" ? "" : renderPorts(item.details?.ports)}
`; document.getElementById("add-to-inventory").addEventListener("click", () => { const qty = Math.max(1, Number(document.getElementById("add-qty").value) || 1); addToInventory(item.class_name, qty); }); } catch (err) { els["modal-body"].innerHTML = ``; } } 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 = `
${escapeHtml(entry.name)}
` + `
${escapeHtml(entry.class_name)}
` + (entry.available ? "" : '
Not in current catalog
'); const categoryCell = document.createElement("td"); categoryCell.innerHTML = entry.category ? categoryBadge(entry.category) : '—'; const qtyCell = document.createElement("td"); const qty = document.createElement("input"); qty.type = "number"; qty.className = "inv-qty"; qty.min = "1"; qty.max = "1000000"; qty.value = String(entry.quantity); qty.setAttribute("aria-label", `Quantity of ${entry.name}`); qty.addEventListener("change", () => { const value = Number(qty.value); if (!Number.isInteger(value) || value < 1) { qty.value = String(entry.quantity); showToast("Quantity must be at least 1", true); return; } patchEntry(entry.class_name, { quantity: value }); }); qtyCell.appendChild(qty); const notesCell = document.createElement("td"); const notes = document.createElement("input"); notes.type = "text"; notes.className = "inv-notes"; notes.maxLength = 1000; notes.value = entry.notes || ""; notes.placeholder = "Notes…"; notes.setAttribute("aria-label", `Notes for ${entry.name}`); notes.addEventListener("change", () => { patchEntry(entry.class_name, { notes: notes.value }); }); notesCell.appendChild(notes); const actionCell = document.createElement("td"); const remove = document.createElement("button"); remove.type = "button"; remove.className = "inv-remove"; remove.textContent = "Remove"; remove.addEventListener("click", () => deleteEntry(entry.class_name, entry.name)); actionCell.appendChild(remove); row.append(nameCell, categoryCell, qtyCell, notesCell, actionCell); tbody.appendChild(row); } } async function patchEntry(class_name, payload) { try { await apiFetch(`/api/v1/me/inventory/${encodeURIComponent(class_name)}`, { method: "PATCH", body: payload, }); showToast("Saved"); refreshSummary(); } catch (err) { if (!handleUnauthorized(err)) showToast(err.message, true); loadInventory(); } } async function deleteEntry(class_name, name) { if (!window.confirm(`Remove "${name}" from your inventory?`)) return; try { await apiFetch(`/api/v1/me/inventory/${encodeURIComponent(class_name)}`, { method: "DELETE", }); showToast("Removed from your inventory"); loadInventory(); } catch (err) { if (!handleUnauthorized(err)) showToast(err.message, true); } } async function addToInventory(class_name, quantity = 1) { if (!state.user) { openAuth("login", "Log in to add items to your inventory."); return; } try { const data = await apiFetch("/api/v1/me/inventory", { method: "POST", body: { class_name, quantity }, }); showToast( data.created ? "Added to your inventory" : `Quantity updated (${data.entry.quantity})` ); if (state.view === "mine") loadInventory(); else refreshSummary(); } catch (err) { if (!handleUnauthorized(err)) showToast(err.message, true); } } /* ---------- events ---------- */ function debounce(fn, wait) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), wait); }; } function resetPage() { state.page = 1; } function setActiveTab(tab) { if (!tab) return; document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active")); tab.classList.add("active"); } function bindEvents() { els["category-tabs"].addEventListener("click", (event) => { const tab = event.target.closest(".tab"); if (!tab) return; if (tab.dataset.view === "mine") { switchToMine(); return; } setActiveTab(tab); showCatalogView(); 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") return; closeModal(); closeAuth(); }); } function bindAuthEvents() { els["auth-form"].addEventListener("submit", submitAuth); els["auth-close"].addEventListener("click", closeAuth); els["auth-backdrop"].addEventListener("click", (event) => { if (event.target === els["auth-backdrop"]) closeAuth(); }); els["auth-toggle"].addEventListener("click", () => { openAuth(authMode === "login" ? "register" : "login"); }); } function bindInventoryEvents() { els["inv-search"].addEventListener( "input", debounce(() => { state.inventory.search = els["inv-search"].value.trim(); loadInventory(); }, 300) ); els["inv-category"].addEventListener("change", () => { state.inventory.category = els["inv-category"].value; loadInventory(); }); els["inv-sort"].addEventListener("change", () => { state.inventory.sort = els["inv-sort"].value; loadInventory(); }); } /* ---------- boot ---------- */ document.addEventListener("DOMContentLoaded", async () => { cacheElements(); bindEvents(); bindAuthEvents(); bindInventoryEvents(); initHeader(); await initAuth(); loadItems(); });