/* 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

${rows}
`; } 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 = ` ${item.description ? `` : ""} ${tags ? `
${tags}
` : ""}

Specifications

${rows}
${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 = ``; } } 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(); });