Files
PartsInventorySystem/static/app.js
T
thejayman77 59949e91ca Add a browser render check, and fix the login error it found
tools/render_check.py drives the real UI in Playwright's Chromium and asserts
what a person actually sees: the login gate, live search narrowing on a spec
value, the quick-adjust buttons, the category spec template pre-filling and
switching the unit to grams, the settings and password panels, and the phone
layout at a true 390px viewport.

Playwright's own Chromium rather than the installed Chrome: headless Chrome
returns nothing while a desktop Chrome is open, silently enough that it reads
as the app being broken.

It found a real bug on its first run. The fetch helper treated any 401 as a
lapsed session, so a wrong password bounced to "Not authenticated" instead of
saying the password was wrong. 401s from the login call itself are now left to
report their own reason.

It also confirmed the CSP is doing its job from the other direction:
Playwright's wait_for_function compiles predicates with eval() and is refused,
so the check polls from Python rather than the app loosening script-src.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 13:45:39 -04:00

756 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Parts inventory frontend.
*
* Deliberately dependency-free: one file, no build step, so deploying is
* `docker compose up -d --build` and nothing else. The whole app is a search
* box over /api/parts plus a form; everything else is filters onto that.
*/
const $ = (sel) => document.querySelector(sel);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const state = {
q: "",
category_id: null,
location_id: null,
tag: "",
low_stock: false,
sort: "relevance",
offset: 0,
limit: 50,
total: 0,
categories: [],
locations: [],
tags: [],
authRequired: true,
usingBootstrap: false,
minPasswordLength: 8,
};
// --- plumbing ---------------------------------------------------------------
async function api(path, options = {}) {
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
...options,
body: options.body ? JSON.stringify(options.body) : undefined,
});
// A 401 anywhere else means the session lapsed and the login screen should
// take over. A 401 from the login call itself just means the password was
// wrong — bouncing to "Not authenticated" there would hide the real reason.
if (res.status === 401 && !path.startsWith("/api/login")) {
showLogin();
throw new Error("Not authenticated");
}
if (!res.ok) {
let detail = res.statusText;
try { detail = (await res.json()).detail || detail; } catch (_) {}
throw new Error(typeof detail === "string" ? detail : "Request failed");
}
return res.status === 204 ? null : res.json();
}
let toastTimer;
function toast(message, bad = false) {
document.querySelectorAll(".toast").forEach((t) => t.remove());
const node = document.createElement("div");
node.className = "toast" + (bad ? " bad" : "");
node.textContent = message;
document.body.appendChild(node);
clearTimeout(toastTimer);
toastTimer = setTimeout(() => node.remove(), 2600);
}
const fmtQty = (n) => (Number.isInteger(n) ? String(n) : String(Math.round(n * 100) / 100));
// --- auth -------------------------------------------------------------------
function showLogin() {
$("#app").classList.add("hidden");
$("#login").classList.remove("hidden");
setTimeout(() => $("#password").focus(), 50);
}
function showApp() {
$("#login").classList.add("hidden");
$("#app").classList.remove("hidden");
$("#logout-btn").classList.toggle("hidden", !state.authRequired);
$("#bootstrap-banner").classList.toggle("hidden", !state.usingBootstrap);
}
$("#login-form").addEventListener("submit", async (e) => {
e.preventDefault();
const err = $("#login-error");
err.classList.add("hidden");
try {
const session = await api("/api/login", { method: "POST", body: { password: $("#password").value } });
$("#password").value = "";
state.usingBootstrap = !!session.using_bootstrap_password;
showApp();
await boot();
} catch (ex) {
err.textContent = ex.message;
err.classList.remove("hidden");
}
});
$("#logout-btn").addEventListener("click", async () => {
await api("/api/logout", { method: "POST" });
showLogin();
});
// --- sidebar ----------------------------------------------------------------
function renderSidebar() {
const cats = $("#category-list");
cats.innerHTML = "";
const roots = state.categories.filter((c) => !c.parent_id);
for (const root of roots) {
cats.appendChild(filterNode(root, "category_id", false));
for (const child of state.categories.filter((c) => c.parent_id === root.id)) {
if (child.part_count > 0 || state.category_id === child.id) {
cats.appendChild(filterNode(child, "category_id", true));
}
}
}
const locs = $("#location-list");
locs.innerHTML = "";
for (const loc of state.locations) {
locs.appendChild(filterNode(loc, "location_id", !!loc.parent_id, loc.path));
}
const tags = $("#tag-list");
tags.innerHTML = "";
if (!state.tags.length) {
tags.innerHTML = '<div class="filter" style="color:var(--dim);cursor:default">No tags yet</div>';
}
for (const tag of state.tags) {
const node = document.createElement("div");
node.className = "filter" + (state.tag === tag.name ? " active" : "");
node.innerHTML = `<span>${esc(tag.name)}</span><span class="count">${tag.n}</span>`;
node.onclick = () => {
state.tag = state.tag === tag.name ? "" : tag.name;
resetAndSearch();
};
tags.appendChild(node);
}
$("#filter-all").classList.toggle(
"active",
!state.category_id && !state.location_id && !state.tag && !state.low_stock
);
$("#filter-low").classList.toggle("active", state.low_stock);
const btn = $("#filters-btn");
const anyActive = !!(state.category_id || state.location_id || state.tag || state.low_stock);
btn.classList.toggle("active", anyActive);
if (!$("#sidebar").classList.contains("open")) btn.textContent = filtersLabel();
}
function filterNode(item, key, isChild, label) {
const node = document.createElement("div");
node.className = "filter" + (isChild ? " child" : "") + (state[key] === item.id ? " active" : "");
const name = label ? label.split(" / ").pop() : item.name;
node.innerHTML = `<span>${esc(name)}</span><span class="count">${item.part_count}</span>`;
node.onclick = () => {
state[key] = state[key] === item.id ? null : item.id;
resetAndSearch();
};
return node;
}
// --- results ----------------------------------------------------------------
function partCard(part) {
const node = document.createElement("div");
node.className = "part";
const bits = [];
if (part.location_path) bits.push("📍 " + part.location_path);
if (part.category_path) bits.push(part.category_path);
if (part.manufacturer) bits.push(part.manufacturer);
if (part.mpn) bits.push(part.mpn);
const chips = [
...part.specs.filter((s) => s.value).slice(0, 5)
.map((s) => `<span class="chip spec">${esc(s.key)}: ${esc(s.value)}</span>`),
...part.tags.map((t) => `<span class="chip tag">${esc(t)}</span>`),
].join("");
const qtyClass = part.quantity <= 0 ? "zero" : part.low_stock ? "low" : "";
node.innerHTML = `
<div class="body">
<div class="name">${esc(part.name)}</div>
<div class="meta">${esc(bits.join(" · ")) || "&nbsp;"}</div>
${chips ? `<div class="chips">${chips}</div>` : ""}
</div>
<div class="qty ${qtyClass}">
<button class="small" data-delta="-1" title="Take one"></button>
<span class="value">${fmtQty(part.quantity)}<span class="unit">${esc(part.unit)}</span></span>
<button class="small" data-delta="1" title="Add one">+</button>
</div>`;
node.querySelector(".body").onclick = () => openPartModal(part);
node.querySelectorAll("[data-delta]").forEach((btn) => {
btn.onclick = async (e) => {
e.stopPropagation();
const delta = Number(btn.dataset.delta) * (part.unit === "g" || part.unit === "ml" ? 10 : 1);
try {
const updated = await api(`/api/parts/${part.id}/adjust`, {
method: "POST",
body: { delta, reason: "quick adjust" },
});
node.replaceWith(partCard(updated));
refreshStats();
} catch (ex) {
toast(ex.message, true);
}
};
});
return node;
}
async function search(append = false) {
if (!append) state.offset = 0;
const params = new URLSearchParams({
q: state.q,
sort: state.sort,
limit: String(state.limit),
offset: String(state.offset),
});
if (state.category_id) params.set("category_id", state.category_id);
if (state.location_id) params.set("location_id", state.location_id);
if (state.tag) params.set("tag", state.tag);
if (state.low_stock) params.set("low_stock", "true");
let data;
try {
data = await api("/api/parts?" + params);
} catch (ex) {
toast(ex.message, true);
return;
}
state.total = data.total;
const box = $("#results");
if (!append) box.innerHTML = "";
for (const part of data.items) box.appendChild(partCard(part));
if (!data.total) {
box.innerHTML = `<div class="empty">${
state.q || state.category_id || state.location_id || state.tag || state.low_stock
? "Nothing matches those filters."
: "No parts yet. Hit <b>+ Add</b> to catalog the first one."
}</div>`;
}
const shown = Math.min(state.offset + data.items.length, data.total);
$("#result-summary").textContent = data.total
? `Showing ${shown} of ${data.total}`
: "";
$("#more-btn").classList.toggle("hidden", shown >= data.total);
state.offset = shown;
}
function resetAndSearch() {
renderSidebar();
search(false);
// On a phone, choosing a filter should reveal the results it produced.
if (window.matchMedia("(max-width: 820px)").matches) setSidebar(false);
}
function setSidebar(open) {
$("#sidebar").classList.toggle("open", open);
$("#filters-btn").textContent = open ? "Close" : filtersLabel();
}
function filtersLabel() {
const active = [state.category_id, state.location_id, state.tag || null, state.low_stock || null]
.filter(Boolean).length;
return active ? `Filters (${active})` : "Filters";
}
// --- part modal -------------------------------------------------------------
function closeModal() {
$("#modal-root").innerHTML = "";
document.body.style.overflow = "";
}
function openModal(html) {
const overlay = document.createElement("div");
overlay.className = "overlay";
overlay.innerHTML = `<div class="modal">${html}</div>`;
overlay.onclick = (e) => { if (e.target === overlay) closeModal(); };
$("#modal-root").innerHTML = "";
$("#modal-root").appendChild(overlay);
document.body.style.overflow = "hidden";
return overlay;
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeModal();
if (e.key === "/" && document.activeElement.tagName !== "INPUT" && document.activeElement.tagName !== "TEXTAREA") {
e.preventDefault();
$("#search").focus();
}
});
function categoryOptions(selected) {
return ['<option value="">— none —</option>']
.concat(state.categories.map((c) =>
`<option value="${c.id}"${c.id === selected ? " selected" : ""}>${esc(c.path)}</option>`))
.join("");
}
function locationOptions(selected) {
return ['<option value="">— none —</option>']
.concat(state.locations.map((l) =>
`<option value="${l.id}"${l.id === selected ? " selected" : ""}>${esc(l.path)}</option>`))
.join("");
}
function specRowHTML(key = "", value = "") {
return `<div class="spec-row">
<input class="spec-key" placeholder="Property" value="${esc(key)}">
<input class="spec-value" placeholder="Value" value="${esc(value)}">
<button type="button" class="small ghost danger remove-spec" title="Remove">✕</button>
</div>`;
}
function openPartModal(part) {
const isNew = !part;
const p = part || {
name: "", description: "", category_id: null, location_id: null, manufacturer: "",
mpn: "", quantity: 0, unit: "pcs", min_quantity: null, cost_each: null,
datasheet_url: "", product_url: "", notes: "", specs: [], tags: [],
};
const overlay = openModal(`
<header>
<strong>${isNew ? "Add part" : esc(p.name)}</strong>
<button class="ghost small" data-close>✕</button>
</header>
<div class="content">
<div class="grid">
<div class="full">
<label>Name</label>
<input id="f-name" value="${esc(p.name)}" placeholder="e.g. 10k resistor, PLA black, M3×8 socket cap">
</div>
<div>
<label>Category</label>
<select id="f-category">${categoryOptions(p.category_id)}</select>
</div>
<div>
<label>Location</label>
<select id="f-location">${locationOptions(p.location_id)}</select>
</div>
<div>
<label>Quantity</label>
<input id="f-quantity" type="number" step="any" value="${p.quantity}">
</div>
<div>
<label>Unit</label>
<input id="f-unit" value="${esc(p.unit)}" placeholder="pcs, g, m, ml">
</div>
<div>
<label>Low-stock at</label>
<input id="f-min" type="number" step="any" value="${p.min_quantity ?? ""}" placeholder="optional">
</div>
<div>
<label>Cost each</label>
<input id="f-cost" type="number" step="any" value="${p.cost_each ?? ""}" placeholder="optional">
</div>
<div>
<label>Manufacturer</label>
<input id="f-manufacturer" value="${esc(p.manufacturer)}">
</div>
<div>
<label>Part number</label>
<input id="f-mpn" value="${esc(p.mpn)}">
</div>
<div class="full">
<label>Specs</label>
<div id="spec-rows">${p.specs.map((s) => specRowHTML(s.key, s.value)).join("")}</div>
<button type="button" class="small ghost" id="add-spec">+ Add property</button>
</div>
<div class="full">
<label>Tags (comma separated)</label>
<input id="f-tags" value="${esc(p.tags.join(", "))}" placeholder="smd, salvaged, project-x">
</div>
<div class="full">
<label>Notes</label>
<textarea id="f-notes" placeholder="Where it came from, what it's good for, gotchas…">${esc(p.notes)}</textarea>
</div>
<div>
<label>Datasheet URL</label>
<input id="f-datasheet" value="${esc(p.datasheet_url)}">
</div>
<div>
<label>Product URL</label>
<input id="f-product" value="${esc(p.product_url)}">
</div>
</div>
${isNew ? "" : '<div style="margin-top:18px"><label>Recent stock changes</label><div class="history" id="history">…</div></div>'}
</div>
<footer>
${isNew ? "" : '<button class="danger ghost" id="delete-btn">Delete</button>'}
<span class="grow"></span>
<button data-close>Cancel</button>
<button class="primary" id="save-btn">${isNew ? "Add part" : "Save"}</button>
</footer>`);
overlay.querySelectorAll("[data-close]").forEach((b) => (b.onclick = closeModal));
const specRows = overlay.querySelector("#spec-rows");
const bindRemove = () =>
specRows.querySelectorAll(".remove-spec").forEach((b) => (b.onclick = () => b.closest(".spec-row").remove()));
bindRemove();
overlay.querySelector("#add-spec").onclick = () => {
specRows.insertAdjacentHTML("beforeend", specRowHTML());
bindRemove();
};
// Picking a category pre-fills the properties worth recording for that kind
// of thing — the difference between a blank form and a checklist.
overlay.querySelector("#f-category").onchange = (e) => {
const cat = state.categories.find((c) => String(c.id) === e.target.value);
if (!cat) return;
const unitField = overlay.querySelector("#f-unit");
if (cat.unit && (!unitField.value || unitField.value === "pcs")) unitField.value = cat.unit;
const existing = new Set(
[...specRows.querySelectorAll(".spec-key")].map((i) => i.value.trim().toLowerCase())
);
for (const tpl of cat.spec_template || []) {
if (tpl.key && !existing.has(tpl.key.toLowerCase())) {
specRows.insertAdjacentHTML("beforeend", specRowHTML(tpl.key, ""));
}
}
bindRemove();
};
overlay.querySelector("#save-btn").onclick = async () => {
const num = (sel) => {
const raw = overlay.querySelector(sel).value.trim();
return raw === "" ? null : Number(raw);
};
const body = {
name: overlay.querySelector("#f-name").value.trim(),
category_id: Number(overlay.querySelector("#f-category").value) || null,
location_id: Number(overlay.querySelector("#f-location").value) || null,
manufacturer: overlay.querySelector("#f-manufacturer").value.trim(),
mpn: overlay.querySelector("#f-mpn").value.trim(),
quantity: num("#f-quantity") ?? 0,
unit: overlay.querySelector("#f-unit").value.trim() || "pcs",
min_quantity: num("#f-min"),
cost_each: num("#f-cost"),
datasheet_url: overlay.querySelector("#f-datasheet").value.trim(),
product_url: overlay.querySelector("#f-product").value.trim(),
notes: overlay.querySelector("#f-notes").value.trim(),
specs: [...specRows.querySelectorAll(".spec-row")]
.map((r) => ({
key: r.querySelector(".spec-key").value.trim(),
value: r.querySelector(".spec-value").value.trim(),
}))
.filter((s) => s.key),
tags: overlay.querySelector("#f-tags").value.split(",").map((t) => t.trim()).filter(Boolean),
};
if (!body.name) return toast("Give it a name", true);
try {
if (isNew) await api("/api/parts", { method: "POST", body });
else await api(`/api/parts/${p.id}`, { method: "PATCH", body });
closeModal();
toast(isNew ? "Added" : "Saved");
await loadRefData();
resetAndSearch();
} catch (ex) {
toast(ex.message, true);
}
};
if (!isNew) {
overlay.querySelector("#delete-btn").onclick = async () => {
if (!confirm(`Delete "${p.name}"? This cannot be undone.`)) return;
try {
await api(`/api/parts/${p.id}`, { method: "DELETE" });
closeModal();
toast("Deleted");
await loadRefData();
resetAndSearch();
} catch (ex) {
toast(ex.message, true);
}
};
api(`/api/parts/${p.id}/history`).then((data) => {
const box = overlay.querySelector("#history");
if (!box) return;
box.innerHTML = data.items.length
? data.items.map((h) =>
`<div><span class="${h.delta >= 0 ? "pos" : "neg"}">${h.delta >= 0 ? "+" : ""}${fmtQty(h.delta)}</span>
<span>${esc(h.reason || "adjusted")}</span>
<span>→ ${fmtQty(h.quantity_after)} · ${esc(h.created_at)}</span></div>`).join("")
: "<div>No changes recorded yet.</div>";
}).catch(() => {});
}
setTimeout(() => overlay.querySelector("#f-name").focus(), 50);
}
// --- manage categories / locations ------------------------------------------
function openManageModal(focusPassword = false) {
const overlay = openModal(`
<header>
<strong>Settings</strong>
<button class="ghost small" data-close>✕</button>
</header>
<div class="content">
${state.authRequired ? `
<section id="password-section" style="margin-bottom:24px">
<label>Password</label>
${state.usingBootstrap
? '<p style="color:var(--warn);font-size:13px;margin:0 0 10px">You\'re still using the password you were handed. Setting your own here also signs out every other device.</p>'
: '<p style="color:var(--dim);font-size:13px;margin:0 0 10px">Changing your password signs out every other device.</p>'}
<div class="grid">
<div class="full">
<input id="pw-current" type="password" autocomplete="current-password" placeholder="Current password">
</div>
<div>
<input id="pw-new" type="password" autocomplete="new-password" placeholder="New password">
</div>
<div>
<input id="pw-confirm" type="password" autocomplete="new-password" placeholder="Repeat new password">
</div>
</div>
<div style="display:flex;gap:10px;align-items:center;margin-top:10px;flex-wrap:wrap">
<button class="small primary" id="pw-save">Change password</button>
<button class="small ghost" id="pw-revoke" title="Keeps this device signed in">Sign out other devices</button>
<span id="pw-msg" style="font-size:13px"></span>
</div>
</section>` : ""}
<div class="grid">
<div>
<label>New category</label>
<input id="new-cat-name" placeholder="Name">
<select id="new-cat-parent" style="margin-top:8px">${categoryOptions(null)}</select>
<input id="new-cat-unit" placeholder="Default unit (pcs)" style="margin-top:8px">
<input id="new-cat-specs" placeholder="Suggested properties, comma separated" style="margin-top:8px">
<button class="small primary" id="add-cat" style="margin-top:8px">Add category</button>
</div>
<div>
<label>New location</label>
<input id="new-loc-name" placeholder="e.g. Bin A3">
<select id="new-loc-parent" style="margin-top:8px">${locationOptions(null)}</select>
<input id="new-loc-notes" placeholder="Notes (optional)" style="margin-top:8px">
<button class="small primary" id="add-loc" style="margin-top:8px">Add location</button>
</div>
</div>
<div style="margin-top:20px">
<label>Locations</label>
<div id="loc-rows"></div>
</div>
<div style="margin-top:20px">
<label>Categories</label>
<div id="cat-rows"></div>
</div>
</div>
<footer><span class="grow"></span><button data-close>Done</button></footer>`);
overlay.querySelectorAll("[data-close]").forEach((b) => (b.onclick = closeModal));
const pwSave = overlay.querySelector("#pw-save");
if (pwSave) {
const msg = overlay.querySelector("#pw-msg");
const say = (text, bad) => {
msg.textContent = text;
msg.style.color = bad ? "var(--bad)" : "var(--good)";
};
pwSave.onclick = async () => {
const current = overlay.querySelector("#pw-current").value;
const next = overlay.querySelector("#pw-new").value;
const confirmed = overlay.querySelector("#pw-confirm").value;
if (!current) return say("Enter your current password", true);
if (next !== confirmed) return say("New passwords don't match", true);
if (next.length < state.minPasswordLength)
return say(`At least ${state.minPasswordLength} characters`, true);
pwSave.disabled = true;
try {
await api("/api/password", {
method: "POST",
body: { current_password: current, new_password: next },
});
overlay.querySelectorAll("#pw-current, #pw-new, #pw-confirm").forEach((i) => (i.value = ""));
state.usingBootstrap = false;
$("#bootstrap-banner").classList.add("hidden");
say("Password changed. Other devices signed out.", false);
toast("Password changed");
} catch (ex) {
say(ex.message, true);
} finally {
pwSave.disabled = false;
}
};
overlay.querySelector("#pw-revoke").onclick = async () => {
if (!confirm("Sign out every other device? This one stays signed in.")) return;
try {
await api("/api/sessions/revoke", { method: "POST" });
say("Other devices signed out.", false);
} catch (ex) {
say(ex.message, true);
}
};
if (focusPassword) setTimeout(() => overlay.querySelector("#pw-current").focus(), 50);
}
const renderRows = () => {
const draw = (items, box, kind) => {
box.innerHTML = "";
for (const item of items) {
const row = document.createElement("div");
row.className = "node-row";
row.innerHTML = `<span class="path">${esc(item.path)}</span>
<span class="count">${item.part_count}</span>
<button class="small ghost danger">Delete</button>`;
row.querySelector("button").onclick = async () => {
if (!confirm(`Delete "${item.path}"? Parts filed there keep existing, just unfiled.`)) return;
try {
await api(`/api/${kind}/${item.id}`, { method: "DELETE" });
await loadRefData();
renderRows();
resetAndSearch();
} catch (ex) {
toast(ex.message, true);
}
};
box.appendChild(row);
}
};
draw(state.locations, overlay.querySelector("#loc-rows"), "locations");
draw(state.categories, overlay.querySelector("#cat-rows"), "categories");
};
renderRows();
overlay.querySelector("#add-cat").onclick = async () => {
const name = overlay.querySelector("#new-cat-name").value.trim();
if (!name) return toast("Name it first", true);
try {
await api("/api/categories", {
method: "POST",
body: {
name,
parent_id: Number(overlay.querySelector("#new-cat-parent").value) || null,
unit: overlay.querySelector("#new-cat-unit").value.trim() || "pcs",
spec_template: overlay.querySelector("#new-cat-specs").value
.split(",").map((s) => s.trim()).filter(Boolean).map((key) => ({ key, value: "" })),
},
});
overlay.querySelector("#new-cat-name").value = "";
overlay.querySelector("#new-cat-specs").value = "";
await loadRefData();
renderRows();
renderSidebar();
toast("Category added");
} catch (ex) {
toast(ex.message, true);
}
};
overlay.querySelector("#add-loc").onclick = async () => {
const name = overlay.querySelector("#new-loc-name").value.trim();
if (!name) return toast("Name it first", true);
try {
await api("/api/locations", {
method: "POST",
body: {
name,
parent_id: Number(overlay.querySelector("#new-loc-parent").value) || null,
notes: overlay.querySelector("#new-loc-notes").value.trim(),
},
});
overlay.querySelector("#new-loc-name").value = "";
overlay.querySelector("#new-loc-notes").value = "";
await loadRefData();
renderRows();
renderSidebar();
toast("Location added");
} catch (ex) {
toast(ex.message, true);
}
};
}
// --- boot -------------------------------------------------------------------
async function refreshStats() {
const s = await api("/api/stats");
$("#stats").textContent =
`${s.parts} part${s.parts === 1 ? "" : "s"}` +
(s.low_stock ? ` · ${s.low_stock} low` : "") +
(s.estimated_value ? ` · ~${s.estimated_value.toFixed(2)}` : "");
$("#count-low").textContent = s.low_stock || "";
$("#count-all").textContent = s.parts || "";
}
async function loadRefData() {
const [cats, locs, tags] = await Promise.all([
api("/api/categories"),
api("/api/locations"),
api("/api/tags"),
]);
state.categories = cats.items;
state.locations = locs.items;
state.tags = tags.items.filter((t) => t.n > 0);
await refreshStats();
}
let searchTimer;
$("#search").addEventListener("input", (e) => {
state.q = e.target.value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => search(false), 180);
});
$("#sort").addEventListener("change", (e) => {
state.sort = e.target.value;
search(false);
});
$("#filters-btn").onclick = () => setSidebar(!$("#sidebar").classList.contains("open"));
$("#add-btn").onclick = () => openPartModal(null);
$("#manage-btn").onclick = () => openManageModal(false);
$("#banner-change").onclick = () => openManageModal(true);
$("#more-btn").onclick = () => search(true);
$("#filter-all").onclick = () => {
state.category_id = state.location_id = null;
state.tag = "";
state.low_stock = false;
resetAndSearch();
};
$("#filter-low").onclick = () => {
state.low_stock = !state.low_stock;
resetAndSearch();
};
async function boot() {
await loadRefData();
renderSidebar();
await search(false);
}
(async function start() {
try {
const me = await api("/api/me");
state.authRequired = me.auth_required;
state.usingBootstrap = !!me.using_bootstrap_password;
state.minPasswordLength = me.min_password_length || state.minPasswordLength;
if (!me.authenticated) return showLogin();
showApp();
await boot();
} catch (_) {
showLogin();
}
})();