Files
PartsInventorySystem/static/app.js
T
thejayman77 96d2a1a087 Add photo attachments to parts
A bag falling apart after forty years still has the part number printed on it,
and the picture carries more than any field you could retype it into. Photos
attach from the part form; on a phone the picker opens the camera directly.

Files live beside the database in the same volume, with only metadata in SQLite
— blobs there bloat the database and complicate the VACUUM INTO backup. The
browser downscales to 2000px before uploading, honouring EXIF orientation via
createImageBitmap, which keeps an image library and its native dependencies out
of the server entirely.

Uploads are sniffed by content rather than trusted by their declared type, so a
file that merely claims to be a JPEG cannot be stored and served back from this
origin as something a browser will execute. SVG is refused for the same reason.
Reads are capped rather than trusting Content-Length, at most 12 photos per
part, and a row that fails to insert takes its file with it.

Deleting a photo or a part removes the files, not just the rows, and
`app.admin prune-images` sweeps anything a crash stranded. The README's backup
procedure now covers both halves; capturing only the database would have
silently lost every photo.

python-multipart returns for the upload, pinned at 0.0.32 — the version removed
earlier was 0.0.20, which carried advisories. Audit is clean.

Two frontend bugs surfaced while testing this in the browser: a photo count
changing left the list row stale, and the part form opened from the list's
cached copy rather than fetching current data.

Checks go from 197 to 271.

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

923 lines
34 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();
}
async function upload(partId, file, caption = "") {
const form = new FormData();
form.append("file", file);
form.append("caption", caption);
// Not via api(): that sets a JSON content-type, and multipart needs the
// browser to supply its own boundary.
const res = await fetch(`/api/parts/${partId}/images`, { method: "POST", body: form });
if (res.status === 401) { 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 : "Upload failed");
}
return res.json();
}
// Phone photos run 3-12MB, which is wasteful to store and slow to open over a
// phone connection. Shrinking in the browser keeps the server free of an image
// library and its native dependencies.
const MAX_EDGE = 2000;
async function shrink(file) {
let bitmap;
try {
// from-image honours the EXIF rotation phones write, which canvas would
// otherwise ignore and hand back a sideways photo.
bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
} catch (_) {
return file; // can't decode it here — let the server accept or reject it
}
const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height));
if (scale === 1 && file.size <= 600 * 1024) {
bitmap.close?.();
return file;
}
const w = Math.round(bitmap.width * scale);
const h = Math.round(bitmap.height * scale);
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
canvas.getContext("2d").drawImage(bitmap, 0, 0, w, h);
bitmap.close?.();
const blob = await new Promise((r) => canvas.toBlob(r, "image/jpeg", 0.85));
if (!blob || blob.size >= file.size) return file;
return new File([blob], (file.name || "photo").replace(/\.\w+$/, "") + ".jpg",
{ type: "image/jpeg" });
}
function openLightbox(src, caption) {
const box = document.createElement("div");
box.className = "lightbox";
box.innerHTML = `<div><img src="${esc(src)}" alt="${esc(caption || "")}">` +
(caption ? `<div class="cap">${esc(caption)}</div>` : "") + "</div>";
box.onclick = () => box.remove();
document.body.appendChild(box);
}
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.images && part.images.length
? [`<span class="chip">\u{1F4F7} ${part.images.length}</span>`] : []),
...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 = async () => {
// Fetch fresh rather than opening the copy this row was drawn from: photos,
// captions and quantities can all have moved on since the list was built.
try {
openPartModal(await api(`/api/parts/${part.id}`));
} catch (ex) {
toast(ex.message, true);
}
};
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") {
const box = document.querySelector(".lightbox");
// A lightbox sits above the form, so Escape should close that first rather
// than throwing away a half-filled part.
if (box) box.remove();
else 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>Photos</label>
<div class="shots" id="shots"></div>
<input type="file" id="f-photos" accept="image/jpeg,image/png,image/webp,image/gif"
multiple capture="environment" style="font-size:13px;padding:6px">
<div id="shot-msg" style="font-size:12px;color:var(--dim);margin-top:5px">
Snap the bag or the markings. Large photos are shrunk before upload.
</div>
</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();
};
// Photos for a part that doesn't exist yet are held here and uploaded once
// saving gives us an id.
const pending = [];
const shots = overlay.querySelector("#shots");
const shotMsg = overlay.querySelector("#shot-msg");
// The row behind the modal shows a photo count, so it has to be redrawn when
// photos are added or removed — otherwise you close the form and the count
// you just changed is still the old one.
const refreshRow = () => { if (!isNew) search(false).catch(() => {}); };
const renderShots = () => {
shots.innerHTML = "";
for (const img of p.images || []) {
const el = document.createElement("div");
el.className = "shot";
const src = `/api/parts/${p.id}/images/${img.token}`;
el.innerHTML = `<img src="${src}" loading="lazy" alt="${esc(img.caption || "Photo")}">
<button type="button" class="small rm" title="Remove">✕</button>
<input class="cap" placeholder="Caption" value="${esc(img.caption || "")}">`;
el.querySelector("img").onclick = () => openLightbox(src, img.caption);
el.querySelector(".rm").onclick = async () => {
if (!confirm("Remove this photo?")) return;
try {
await api(`/api/parts/${p.id}/images/${img.token}`, { method: "DELETE" });
p.images = p.images.filter((i) => i.token !== img.token);
renderShots();
refreshRow();
} catch (ex) { toast(ex.message, true); }
};
const cap = el.querySelector(".cap");
cap.onchange = async () => {
try {
await api(`/api/parts/${p.id}/images/${img.token}`,
{ method: "PATCH", body: { caption: cap.value } });
img.caption = cap.value;
} catch (ex) { toast(ex.message, true); }
};
shots.appendChild(el);
}
for (const [i, file] of pending.entries()) {
const el = document.createElement("div");
el.className = "shot pending";
const url = URL.createObjectURL(file);
el.innerHTML = `<img src="${url}" alt="Queued photo">
<button type="button" class="small rm" title="Remove">✕</button>`;
el.querySelector("img").onclick = () => openLightbox(url, "Not uploaded yet");
el.querySelector(".rm").onclick = () => { pending.splice(i, 1); renderShots(); };
shots.appendChild(el);
}
};
renderShots();
overlay.querySelector("#f-photos").onchange = async (e) => {
const files = [...e.target.files];
e.target.value = "";
if (!files.length) return;
shotMsg.textContent = `Preparing ${files.length} photo${files.length === 1 ? "" : "s"}`;
for (const raw of files) {
try {
const file = await shrink(raw);
if (isNew) {
pending.push(file);
} else {
const added = await upload(p.id, file);
p.images = (p.images || []).concat(added);
refreshRow();
}
} catch (ex) {
toast(ex.message, true);
}
}
shotMsg.textContent = isNew
? "Queued — they upload when you add the part."
: "Uploaded.";
renderShots();
};
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) {
const created = await api("/api/parts", { method: "POST", body });
for (const file of pending) {
try { await upload(created.id, file); }
catch (ex) { toast(`Photo failed: ${ex.message}`, true); }
}
} 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();
}
})();