working with login and building inventory
This commit is contained in:
@@ -8,18 +8,27 @@ server {
|
||||
|
||||
# API and health checks are proxied to the backend container on the
|
||||
# shared podman network (service DNS name: backend).
|
||||
#
|
||||
# X-Real-IP and X-Forwarded-Proto are set here (not passed through), so the
|
||||
# backend can trust them for login throttling and redirect/scheme decisions.
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
# $http_host keeps the client's port, which the backend's Origin check
|
||||
# compares against; $host would drop ":8080" and reject real browsers.
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
location = /healthz {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"use strict";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const INVENTORY_PAGE_SIZE = 100;
|
||||
|
||||
const state = {
|
||||
category: "",
|
||||
@@ -14,17 +15,28 @@ const state = {
|
||||
size: "",
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
view: "catalog",
|
||||
user: null,
|
||||
inventory: { search: "", category: "" },
|
||||
};
|
||||
|
||||
let authMode = "login";
|
||||
let pendingView = null;
|
||||
|
||||
const els = {};
|
||||
|
||||
function cacheElements() {
|
||||
const ids = [
|
||||
"api-status", "stats-line", "category-tabs", "search-input",
|
||||
"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-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);
|
||||
}
|
||||
@@ -39,13 +51,45 @@ function escapeHtml(value) {
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
const detail = await resp.json().catch(() => ({}));
|
||||
throw new Error(detail.detail || `Request failed: ${resp.status}`);
|
||||
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 || {}) };
|
||||
}
|
||||
return resp.json();
|
||||
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 ---------- */
|
||||
@@ -78,7 +122,104 @@ async function initHeader() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- item list ---------- */
|
||||
/* ---------- 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();
|
||||
@@ -111,9 +252,15 @@ function renderItems(items) {
|
||||
<div class="item-name">${escapeHtml(item.name)}</div>
|
||||
<div class="item-manufacturer">${escapeHtml(manufacturer)}</div>
|
||||
<div class="item-classification">${escapeHtml(item.classification || item.type || "")}</div>
|
||||
<div class="item-meta">${categoryBadge(item.category)}${size}${grade}</div>
|
||||
<div class="item-meta">${categoryBadge(item.category)}${size}${grade}
|
||||
<button type="button" class="card-add" aria-label="Add to my inventory" title="Add to my inventory">+</button>
|
||||
</div>
|
||||
`;
|
||||
card.addEventListener("click", () => openModal(item.class_name));
|
||||
card.querySelector(".card-add").addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
addToInventory(item.class_name, 1);
|
||||
});
|
||||
grid.appendChild(card);
|
||||
}
|
||||
}
|
||||
@@ -201,7 +348,18 @@ async function openModal(class_name) {
|
||||
<h3>Specifications</h3>
|
||||
<table class="spec-table">${rows}</table>
|
||||
${item.category === "ships" ? "" : renderPorts(item.details?.ports)}
|
||||
<div class="add-row">
|
||||
<label for="add-qty">Qty</label>
|
||||
<input type="number" id="add-qty" min="1" max="1000000" value="1">
|
||||
<button type="button" id="add-to-inventory" class="btn-primary">
|
||||
${state.user ? "Add to my inventory" : "Log in to add"}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
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 = `<p class="modal-desc">Failed to load item: ${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
@@ -211,6 +369,191 @@ 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 = "";
|
||||
els["inv-search"].value = "";
|
||||
els["inv-category"].value = "";
|
||||
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("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 =
|
||||
`<div class="inv-item-name">${escapeHtml(entry.name)}</div>` +
|
||||
`<div class="inv-item-sub">${escapeHtml(entry.class_name)}</div>` +
|
||||
(entry.available ? "" : '<div class="unavailable-note">Not in current catalog</div>');
|
||||
|
||||
const categoryCell = document.createElement("td");
|
||||
categoryCell.innerHTML = entry.category
|
||||
? categoryBadge(entry.category)
|
||||
: '<span class="badge size">—</span>';
|
||||
|
||||
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) {
|
||||
@@ -225,12 +568,22 @@ 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;
|
||||
document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
if (tab.dataset.view === "mine") {
|
||||
switchToMine();
|
||||
return;
|
||||
}
|
||||
setActiveTab(tab);
|
||||
showCatalogView();
|
||||
state.category = tab.dataset.category;
|
||||
resetPage();
|
||||
loadItems();
|
||||
@@ -292,15 +645,45 @@ function bindEvents() {
|
||||
if (event.target === els["modal-backdrop"]) closeModal();
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") closeModal();
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- boot ---------- */
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
cacheElements();
|
||||
bindEvents();
|
||||
bindAuthEvents();
|
||||
bindInventoryEvents();
|
||||
initHeader();
|
||||
await initAuth();
|
||||
loadItems();
|
||||
});
|
||||
|
||||
@@ -13,46 +13,83 @@
|
||||
<h1>STAR CITIZEN <span>INVENTORY</span></h1>
|
||||
<p class="subtitle">Item catalog from scunpacked game data</p>
|
||||
</div>
|
||||
<div class="header-status">
|
||||
<div id="api-status" class="status-pill checking">CHECKING API…</div>
|
||||
<div id="stats-line" class="stats-line">Loading counts…</div>
|
||||
<div class="header-right">
|
||||
<div class="header-status">
|
||||
<div id="api-status" class="status-pill checking">CHECKING API…</div>
|
||||
<div id="stats-line" class="stats-line">Loading counts…</div>
|
||||
</div>
|
||||
<div class="account-bar" id="account-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" id="category-tabs">
|
||||
<button class="tab active" data-category="">All Items</button>
|
||||
<button class="tab" data-category="ships">Ships</button>
|
||||
<button class="tab" data-category="ship-items">Ship Equipment</button>
|
||||
<button class="tab" data-category="fps-items">FPS Items</button>
|
||||
<button class="tab active" data-view="catalog" data-category="">All Items</button>
|
||||
<button class="tab" data-view="catalog" data-category="ships">Ships</button>
|
||||
<button class="tab" data-view="catalog" data-category="ship-items">Ship Equipment</button>
|
||||
<button class="tab" data-view="catalog" data-category="fps-items">FPS Items</button>
|
||||
<button class="tab tab-mine" id="tab-mine" data-view="mine">My Inventory</button>
|
||||
</nav>
|
||||
|
||||
<section class="controls">
|
||||
<input type="search" id="search-input" placeholder="Search name or class name…" autocomplete="off">
|
||||
<select id="manufacturer-select">
|
||||
<option value="">All manufacturers</option>
|
||||
</select>
|
||||
<input type="text" id="type-input" placeholder="Type (e.g. WeaponPersonal)">
|
||||
<input type="number" id="size-input" placeholder="Size" min="0" max="12">
|
||||
<button id="reset-filters" class="btn-secondary">Reset</button>
|
||||
<div id="catalog-view">
|
||||
<section class="controls">
|
||||
<input type="search" id="search-input" placeholder="Search name or class name…" autocomplete="off">
|
||||
<select id="manufacturer-select">
|
||||
<option value="">All manufacturers</option>
|
||||
</select>
|
||||
<input type="text" id="type-input" placeholder="Type (e.g. WeaponPersonal)">
|
||||
<input type="number" id="size-input" placeholder="Size" min="0" max="12">
|
||||
<button id="reset-filters" class="btn-secondary">Reset</button>
|
||||
</section>
|
||||
|
||||
<main>
|
||||
<div id="error-banner" class="error-banner hidden"></div>
|
||||
<div id="loading" class="loading">Loading inventory…</div>
|
||||
<div id="empty-state" class="empty-state hidden">
|
||||
<p>No items match your filters.</p>
|
||||
<p class="hint">Try clearing the search or resetting the filters.</p>
|
||||
</div>
|
||||
<div id="results-grid" class="results-grid"></div>
|
||||
|
||||
<div class="pagination" id="pagination">
|
||||
<button id="prev-page" class="btn-secondary">← Prev</button>
|
||||
<span id="page-info">Page 1 of 1</span>
|
||||
<button id="next-page" class="btn-secondary">Next →</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<section id="inventory-view" class="hidden">
|
||||
<div class="inventory-controls">
|
||||
<input type="search" id="inv-search" placeholder="Search my inventory…" autocomplete="off">
|
||||
<select id="inv-category">
|
||||
<option value="">All categories</option>
|
||||
<option value="ships">Ships</option>
|
||||
<option value="ship-items">Ship Equipment</option>
|
||||
<option value="fps-items">FPS Items</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="inv-summary" class="inv-summary"></div>
|
||||
<div id="inv-empty" class="empty-state hidden">
|
||||
<p>Your inventory is empty.</p>
|
||||
<p class="hint">Browse the catalog and use “Add to my inventory” on any item.</p>
|
||||
</div>
|
||||
<div id="inv-table-wrap" class="inv-table-wrap hidden">
|
||||
<table class="inv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th>Category</th>
|
||||
<th>Qty</th>
|
||||
<th>Notes</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="inv-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main>
|
||||
<div id="error-banner" class="error-banner hidden"></div>
|
||||
<div id="loading" class="loading">Loading inventory…</div>
|
||||
<div id="empty-state" class="empty-state hidden">
|
||||
<p>No items match your filters.</p>
|
||||
<p class="hint">Try clearing the search or resetting the filters.</p>
|
||||
</div>
|
||||
<div id="results-grid" class="results-grid"></div>
|
||||
|
||||
<div class="pagination" id="pagination">
|
||||
<button id="prev-page" class="btn-secondary">← Prev</button>
|
||||
<span id="page-info">Page 1 of 1</span>
|
||||
<button id="next-page" class="btn-secondary">Next →</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p>Data source:
|
||||
<a href="https://github.com/richardthombs/scunpacked" target="_blank" rel="noopener noreferrer">scunpacked</a>
|
||||
@@ -68,6 +105,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="auth-backdrop" class="modal-backdrop hidden">
|
||||
<div class="modal modal-narrow" role="dialog" aria-modal="true" aria-labelledby="auth-title">
|
||||
<button class="modal-close" id="auth-close" aria-label="Close">×</button>
|
||||
<h2 id="auth-title">Log in</h2>
|
||||
<form id="auth-form" class="auth-form" novalidate>
|
||||
<label>Username
|
||||
<input type="text" id="auth-username" name="username" autocomplete="username"
|
||||
minlength="3" maxlength="32" required>
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" id="auth-password" name="password"
|
||||
autocomplete="current-password" minlength="8" maxlength="128" required>
|
||||
</label>
|
||||
<p id="auth-error" class="auth-error" role="alert"></p>
|
||||
<button type="submit" id="auth-submit" class="btn-primary">Log in</button>
|
||||
</form>
|
||||
<p class="auth-switch">
|
||||
<button type="button" id="auth-toggle" class="btn-link">Need an account? Register</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast hidden" role="status"></div>
|
||||
|
||||
<script src="app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -84,6 +84,7 @@ body {
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.tab-mine { margin-left: auto; }
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
@@ -269,8 +270,225 @@ main {
|
||||
.spec-table th { color: var(--text-dim); font-weight: 600; width: 40%; }
|
||||
.spec-table td { font-family: ui-monospace, monospace; }
|
||||
|
||||
/* Account controls */
|
||||
.header-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.account-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.account-bar .who {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #04121c;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
}
|
||||
.btn-link:hover { color: var(--accent); }
|
||||
|
||||
/* Card add button */
|
||||
.card-add {
|
||||
margin-left: auto;
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
line-height: 1;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
background: var(--bg-panel);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-add:hover { border-color: var(--accent); }
|
||||
|
||||
/* Personal inventory view */
|
||||
.inventory-controls {
|
||||
max-width: 1200px;
|
||||
margin: 1rem auto 0;
|
||||
padding: 0 1.25rem;
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.inventory-controls input,
|
||||
.inventory-controls select {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
#inv-search { flex: 2 1 280px; }
|
||||
#inv-category { flex: 1 1 180px; }
|
||||
.inventory-controls input:focus,
|
||||
.inventory-controls select:focus { outline: none; border-color: var(--accent); }
|
||||
#inventory-view {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.25rem 0;
|
||||
}
|
||||
.inv-summary {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
margin: 0.75rem 0 1rem;
|
||||
}
|
||||
.inv-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.inv-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||
.inv-table th,
|
||||
.inv-table td {
|
||||
text-align: left;
|
||||
padding: 0.65rem 0.8rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.inv-table th {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.inv-table tr:last-child td { border-bottom: none; }
|
||||
.inv-item-name { font-weight: 600; }
|
||||
.inv-item-sub {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.inv-qty {
|
||||
width: 5rem;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
.inv-notes {
|
||||
width: 100%;
|
||||
min-width: 12rem;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
.inv-qty:focus, .inv-notes:focus { outline: none; border-color: var(--accent); }
|
||||
.inv-remove {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--danger);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.inv-remove:hover { border-color: var(--danger); }
|
||||
.unavailable-note { color: var(--accent-warm); font-size: 0.75rem; }
|
||||
|
||||
/* Auth modal */
|
||||
.modal-narrow { max-width: 400px; }
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.auth-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.auth-form input {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.auth-form input:focus { outline: none; border-color: var(--accent); }
|
||||
.auth-error {
|
||||
color: #fca5a5;
|
||||
font-size: 0.85rem;
|
||||
min-height: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
.auth-switch { margin-top: 0.9rem; font-size: 0.85rem; color: var(--text-dim); }
|
||||
|
||||
/* Detail modal add row */
|
||||
.add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1.2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.add-row input {
|
||||
width: 5rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
.add-row input:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 1.2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--text);
|
||||
padding: 0.6rem 1.1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
z-index: 100;
|
||||
max-width: calc(100vw - 2rem);
|
||||
}
|
||||
.toast.error { border-color: var(--danger); color: #fca5a5; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.header-inner { flex-direction: column; align-items: flex-start; }
|
||||
.header-status { text-align: left; }
|
||||
.header-right { align-items: flex-start; }
|
||||
.controls input, .controls select { flex: 1 1 100%; }
|
||||
.inventory-controls input, .inventory-controls select { flex: 1 1 100%; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user