working with login and building inventory

This commit is contained in:
2026-11-12 11:40:31 -07:00
parent d11e5e70c7
commit 0a93925e11
45 changed files with 2389 additions and 77 deletions

View File

@@ -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 / {

View File

@@ -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();
});

View File

@@ -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">&larr; Prev</button>
<span id="page-info">Page 1 of 1</span>
<button id="next-page" class="btn-secondary">Next &rarr;</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">&larr; Prev</button>
<span id="page-info">Page 1 of 1</span>
<button id="next-page" class="btn-secondary">Next &rarr;</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">&times;</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>

View File

@@ -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%; }
}