/* 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" },
available: { search: "", category: "", sort: "updated" },
orders: { search: "", category: "", status: "", sort: "updated" },
};
let authMode = "login";
let pendingView = null;
let currentDetailClass = 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",
"available-view", "avail-search", "avail-category", "avail-sort",
"avail-summary", "avail-empty", "avail-table-wrap", "avail-tbody",
"tab-available",
"orders-view", "order-search", "order-category", "order-status", "order-sort",
"order-summary", "order-empty", "order-table-wrap", "order-tbody", "tab-orders",
"auth-backdrop", "auth-close", "auth-title", "auth-form",
"auth-username", "auth-password", "auth-error", "auth-submit",
"auth-toggle", "toast",
"confirm-backdrop", "confirm-message", "confirm-ok", "confirm-cancel",
];
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 · ${c.commodities ?? 0} commodities`;
} 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();
}
// The detail view was opened while logged out: re-render it so the
// order form becomes available in place.
if (currentDetailClass && !els["modal-backdrop"].classList.contains("hidden")) {
openModal(currentDetailClass);
}
} 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();
}
// Orders stay public, but the owner-only action buttons must disappear.
if (state.view === "orders") loadOrders();
}
/* ---------- 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",
commodities: "Commodity",
};
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
`;
}
async function openModal(class_name) {
currentDetailClass = 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 = `
${escapeHtml(manufacturer)} · ${escapeHtml(item.class_name)}
${item.description ? `${escapeHtml(item.description)}
` : ""}
${tags ? `${tags}
` : ""}
Specifications
${item.category === "ships" ? "" : renderPorts(item.details?.ports)}
${item.quality_applicable ? `
` : ""}
Request this item
${state.user ? `
Set up an order so every player can see what you are looking for.
${item.quality_applicable ? `
` : ""}
` : `
Log in to set up an order other players can see.
`}
`;
document.getElementById("add-to-inventory").addEventListener("click", () => {
const qty = Math.max(1, Number(document.getElementById("add-qty").value) || 1);
let quality = null;
const qualityInput = document.getElementById("add-quality");
if (qualityInput && qualityInput.value.trim() !== "") {
quality = Number(qualityInput.value);
if (!Number.isInteger(quality) || quality < 1 || quality > 1000) {
showToast("Quality must be between 1 and 1000", true);
return;
}
}
addToInventory(item.class_name, qty, quality);
});
const createOrderButton = document.getElementById("create-order");
if (createOrderButton) {
createOrderButton.addEventListener("click", () =>
submitDetailOrder(item, createOrderButton)
);
} else {
const loginButton = document.getElementById("order-login");
if (loginButton) {
loginButton.addEventListener("click", () =>
openAuth("login", "Log in to set up an order.")
);
}
}
} catch (err) {
els["modal-body"].innerHTML = `Failed to load item: ${escapeHtml(err.message)}
`;
}
}
function closeModal() {
currentDetailClass = null;
els["modal-backdrop"].classList.add("hidden");
}
let confirmResolve = null;
function confirmAction(message, label = "Remove") {
if (confirmResolve) closeConfirm(false);
els["confirm-message"].textContent = message;
els["confirm-ok"].textContent = label;
els["confirm-backdrop"].classList.remove("hidden");
els["confirm-ok"].focus();
return new Promise((resolve) => {
confirmResolve = resolve;
});
}
function closeConfirm(result) {
els["confirm-backdrop"].classList.add("hidden");
const resolve = confirmResolve;
confirmResolve = null;
if (resolve) resolve(result);
}
/* ---------- 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["available-view"].classList.add("hidden");
els["orders-view"].classList.add("hidden");
els["inventory-view"].classList.remove("hidden");
await loadInventory();
}
function showCatalogView() {
state.view = "catalog";
els["inventory-view"].classList.add("hidden");
els["available-view"].classList.add("hidden");
els["orders-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",
commodities: "commodities",
};
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 itemNameCell(entry) {
const cell = document.createElement("td");
const button = document.createElement("button");
button.type = "button";
button.className = "inv-item-link";
button.innerHTML =
`${escapeHtml(entry.name)}
` +
`${escapeHtml(entry.class_name)}
`;
if (entry.available === false) {
button.disabled = true;
button.classList.add("is-static");
button.insertAdjacentHTML(
"beforeend",
'Not in current catalog
'
);
} else {
button.title = "View item details";
button.addEventListener("click", () => openModal(entry.class_name));
}
cell.appendChild(button);
return cell;
}
function qualityCell(entry, editable) {
const cell = document.createElement("td");
if (!entry.quality_applicable) {
// Keep a recorded quality visible even if the catalog item disappeared.
cell.innerHTML =
entry.quality == null
? '—'
: escapeHtml(String(entry.quality));
return cell;
}
if (!editable) {
cell.textContent = entry.quality == null ? "—" : String(entry.quality);
return cell;
}
const input = document.createElement("input");
input.type = "number";
input.className = "inv-quality";
input.min = "1";
input.max = "1000";
input.placeholder = "—";
input.value = entry.quality == null ? "" : String(entry.quality);
input.setAttribute("aria-label", `Quality of ${entry.name}`);
input.addEventListener("change", () => {
const raw = input.value.trim();
if (raw === "") {
entry.quality = null;
patchEntry(entry.class_name, { quality: null });
return;
}
const value = Number(raw);
if (!Number.isInteger(value) || value < 1 || value > 1000) {
input.value = entry.quality == null ? "" : String(entry.quality);
showToast("Quality must be between 1 and 1000", true);
return;
}
entry.quality = value;
patchEntry(entry.class_name, { quality: value });
});
cell.appendChild(input);
return cell;
}
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 = itemNameCell(entry);
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);
const visibilityCell = document.createElement("td");
const visibility = document.createElement("button");
visibility.type = "button";
visibility.className = "visibility-toggle";
visibility.addEventListener("click", () => toggleVisibility(entry, visibility));
visibilityCell.appendChild(visibility);
applyVisibility(visibility, entry.visibility);
row.append(
nameCell,
categoryCell,
qtyCell,
qualityCell(entry, true),
notesCell,
visibilityCell,
actionCell
);
tbody.appendChild(row);
}
}
function applyVisibility(button, value) {
const isAvailable = value === "available";
button.textContent = isAvailable ? "Available" : "Private";
button.classList.toggle("is-available", isAvailable);
button.setAttribute("aria-pressed", String(isAvailable));
button.title = isAvailable
? "Marked available — click to make private"
: "Private to you — click to mark available";
}
async function toggleVisibility(entry, button) {
const next = entry.visibility === "available" ? "private" : "available";
button.disabled = true;
try {
const updated = await apiFetch(
`/api/v1/me/inventory/${encodeURIComponent(entry.class_name)}`,
{ method: "PATCH", body: { visibility: next } }
);
entry.visibility = updated.visibility;
applyVisibility(button, updated.visibility);
showToast(updated.visibility === "available" ? "Marked available" : "Marked private");
} catch (err) {
if (!handleUnauthorized(err)) showToast(err.message, true);
loadInventory();
} finally {
button.disabled = false;
}
}
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) {
const confirmed = await confirmAction(
`Remove "${name}" from your inventory? If it is marked available it will ` +
"disappear from everyone else's view.",
"Remove"
);
if (!confirmed) return;
try {
await apiFetch(`/api/v1/me/inventory/${encodeURIComponent(class_name)}`, {
method: "DELETE",
});
showToast("Removed from your inventory");
await loadInventory();
} catch (err) {
if (!handleUnauthorized(err)) showToast(err.message, true);
}
}
async function addToInventory(class_name, quantity = 1, quality = null) {
if (!state.user) {
openAuth("login", "Log in to add items to your inventory.");
return;
}
const body = { class_name, quantity };
if (quality !== null) body.quality = quality;
try {
const data = await apiFetch("/api/v1/me/inventory", {
method: "POST",
body,
});
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);
}
}
/* ---------- available items (public) ---------- */
async function switchToAvailable() {
setActiveTab(els["tab-available"]);
state.view = "available";
els["catalog-view"].classList.add("hidden");
els["inventory-view"].classList.add("hidden");
els["orders-view"].classList.add("hidden");
els["available-view"].classList.remove("hidden");
await loadAvailable();
}
function formatDate(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
}
async function loadAvailable() {
const params = new URLSearchParams();
if (state.available.search) params.set("search", state.available.search);
if (state.available.category) params.set("category", state.available.category);
params.set("sort", state.available.sort);
params.set("page_size", String(INVENTORY_PAGE_SIZE));
els["avail-summary"].textContent = "Loading…";
try {
const listing = await apiFetch(`/api/v1/available?${params}`);
renderAvailable(listing);
} catch (err) {
els["avail-summary"].textContent = "";
showToast(err.message, true);
}
}
function renderAvailable(listing) {
const tbody = els["avail-tbody"];
tbody.innerHTML = "";
els["avail-summary"].textContent =
`${listing.total} item${listing.total === 1 ? "" : "s"} offered`;
els["avail-empty"].classList.toggle("hidden", listing.total !== 0);
els["avail-table-wrap"].classList.toggle("hidden", listing.total === 0);
for (const entry of listing.items) {
const row = document.createElement("tr");
const nameCell = itemNameCell(entry);
const categoryCell = document.createElement("td");
categoryCell.innerHTML = entry.category
? categoryBadge(entry.category)
: '—';
const qtyCell = document.createElement("td");
qtyCell.textContent = String(entry.quantity);
const userCell = document.createElement("td");
userCell.className = "avail-user";
userCell.textContent = entry.username;
const updatedCell = document.createElement("td");
updatedCell.className = "avail-updated";
updatedCell.textContent = formatDate(entry.updated_at);
row.append(
nameCell,
categoryCell,
qtyCell,
qualityCell(entry, false),
userCell,
updatedCell
);
tbody.appendChild(row);
}
}
/* ---------- orders (public board) ---------- */
async function switchToOrders() {
setActiveTab(els["tab-orders"]);
state.view = "orders";
els["catalog-view"].classList.add("hidden");
els["inventory-view"].classList.add("hidden");
els["available-view"].classList.add("hidden");
els["orders-view"].classList.remove("hidden");
await loadOrders();
}
async function submitDetailOrder(item, button) {
const qtyInput = document.getElementById("order-qty");
const qualityInput = document.getElementById("order-quality");
const notesInput = document.getElementById("order-notes");
const error = document.getElementById("order-error");
const quantity = Number(qtyInput.value);
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 1000000) {
error.textContent = "Quantity must be between 1 and 1,000,000";
return;
}
let quality = null;
if (item.quality_applicable && qualityInput) {
const raw = qualityInput.value.trim();
if (raw !== "") {
quality = Number(raw);
if (!Number.isInteger(quality) || quality < 1 || quality > 1000) {
error.textContent = "Quality must be between 1 and 1000";
return;
}
}
}
const body = {
class_name: item.class_name,
quantity,
notes: notesInput.value.trim(),
};
if (quality !== null) body.quality = quality;
button.disabled = true;
error.textContent = "";
try {
await apiFetch("/api/v1/orders", { method: "POST", body });
showToast("Order set up — visible to all pilots");
if (state.view === "orders") loadOrders();
} catch (err) {
if (!handleUnauthorized(err)) error.textContent = err.message;
} finally {
button.disabled = false;
}
}
async function loadOrders() {
const params = new URLSearchParams();
if (state.orders.search) params.set("search", state.orders.search);
if (state.orders.category) params.set("category", state.orders.category);
if (state.orders.status) params.set("status", state.orders.status);
params.set("sort", state.orders.sort);
params.set("page_size", String(INVENTORY_PAGE_SIZE));
els["order-summary"].textContent = "Loading…";
try {
const listing = await apiFetch(`/api/v1/orders?${params}`);
renderOrders(listing);
} catch (err) {
els["order-summary"].textContent = "";
showToast(err.message, true);
}
}
function renderOrders(listing) {
const tbody = els["order-tbody"];
tbody.innerHTML = "";
els["order-summary"].textContent =
`${listing.total} order${listing.total === 1 ? "" : "s"}`;
els["order-empty"].classList.toggle("hidden", listing.total !== 0);
els["order-table-wrap"].classList.toggle("hidden", listing.total === 0);
for (const entry of listing.items) {
const row = document.createElement("tr");
const nameCell = itemNameCell(entry);
const categoryCell = document.createElement("td");
categoryCell.innerHTML = entry.category
? categoryBadge(entry.category)
: '—';
const qtyCell = document.createElement("td");
qtyCell.textContent = String(entry.quantity);
const notesCell = document.createElement("td");
notesCell.className = "order-notes";
notesCell.textContent = entry.notes || "—";
const userCell = document.createElement("td");
userCell.className = "avail-user";
userCell.textContent = entry.username;
const statusCell = document.createElement("td");
const status = document.createElement("span");
status.className = `order-status ${entry.status}`;
status.textContent = entry.status === "fulfilled" ? "Fulfilled" : "Open";
statusCell.appendChild(status);
const updatedCell = document.createElement("td");
updatedCell.className = "avail-updated";
updatedCell.textContent = formatDate(entry.updated_at);
const actionCell = document.createElement("td");
actionCell.className = "order-actions";
if (state.user && entry.username === state.user.username) {
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "visibility-toggle";
const next = entry.status === "fulfilled" ? "open" : "fulfilled";
toggle.textContent = next === "fulfilled" ? "Fulfil" : "Reopen";
toggle.dataset.status = next;
toggle.addEventListener("click", () =>
patchOrder(entry, { status: next }, toggle)
);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "inv-remove";
remove.textContent = "Remove";
remove.addEventListener("click", () => deleteOrder(entry));
actionCell.append(toggle, remove);
}
row.append(
nameCell,
categoryCell,
qtyCell,
qualityCell(entry, false),
notesCell,
userCell,
statusCell,
updatedCell,
actionCell
);
tbody.appendChild(row);
}
}
async function patchOrder(entry, payload, button = null) {
if (button) button.disabled = true;
try {
const updated = await apiFetch(`/api/v1/orders/${entry.id}`, {
method: "PATCH",
body: payload,
});
showToast(
updated.status === "fulfilled" ? "Order marked fulfilled" : "Order reopened"
);
await loadOrders();
} catch (err) {
if (!handleUnauthorized(err)) showToast(err.message, true);
await loadOrders();
} finally {
if (button) button.disabled = false;
}
}
async function deleteOrder(entry) {
const confirmed = await confirmAction(
`Remove your order for "${entry.name}"? It will disappear for everyone.`,
"Remove"
);
if (!confirmed) return;
try {
await apiFetch(`/api/v1/orders/${entry.id}`, { method: "DELETE" });
showToast("Order removed");
await loadOrders();
} 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;
}
if (tab.dataset.view === "available") {
switchToAvailable();
return;
}
if (tab.dataset.view === "orders") {
switchToOrders();
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();
});
els["confirm-ok"].addEventListener("click", () => closeConfirm(true));
els["confirm-cancel"].addEventListener("click", () => closeConfirm(false));
els["confirm-backdrop"].addEventListener("click", (event) => {
if (event.target === els["confirm-backdrop"]) closeConfirm(false);
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
closeConfirm(false);
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();
});
}
function bindAvailableEvents() {
els["avail-search"].addEventListener(
"input",
debounce(() => {
state.available.search = els["avail-search"].value.trim();
loadAvailable();
}, 300)
);
els["avail-category"].addEventListener("change", () => {
state.available.category = els["avail-category"].value;
loadAvailable();
});
els["avail-sort"].addEventListener("change", () => {
state.available.sort = els["avail-sort"].value;
loadAvailable();
});
}
function bindOrderEvents() {
els["order-search"].addEventListener(
"input",
debounce(() => {
state.orders.search = els["order-search"].value.trim();
loadOrders();
}, 300)
);
els["order-category"].addEventListener("change", () => {
state.orders.category = els["order-category"].value;
loadOrders();
});
els["order-status"].addEventListener("change", () => {
state.orders.status = els["order-status"].value;
loadOrders();
});
els["order-sort"].addEventListener("change", () => {
state.orders.sort = els["order-sort"].value;
loadOrders();
});
}
/* ---------- boot ---------- */
document.addEventListener("DOMContentLoaded", async () => {
cacheElements();
bindEvents();
bindAuthEvents();
bindInventoryEvents();
bindAvailableEvents();
bindOrderEvents();
initHeader();
await initAuth();
loadItems();
});