/* 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 = `
${esc(caption || ` + (caption ? `
${esc(caption)}
` : "") + "
"; 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 = '
No tags yet
'; } for (const tag of state.tags) { const node = document.createElement("div"); node.className = "filter" + (state.tag === tag.name ? " active" : ""); node.innerHTML = `${esc(tag.name)}${tag.n}`; 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 = `${esc(name)}${item.part_count}`; 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 ? [`\u{1F4F7} ${part.images.length}`] : []), ...part.specs.filter((s) => s.value).slice(0, 5) .map((s) => `${esc(s.key)}: ${esc(s.value)}`), ...part.tags.map((t) => `${esc(t)}`), ].join(""); const qtyClass = part.quantity <= 0 ? "zero" : part.low_stock ? "low" : ""; node.innerHTML = `
${esc(part.name)}
${esc(bits.join(" Β· ")) || " "}
${chips ? `
${chips}
` : ""}
${fmtQty(part.quantity)}${esc(part.unit)}
`; 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 = `
${ state.q || state.category_id || state.location_id || state.tag || state.low_stock ? "Nothing matches those filters." : "No parts yet. Hit + Add to catalog the first one." }
`; } 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 = ``; 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 [''] .concat(state.categories.map((c) => ``)) .join(""); } function locationOptions(selected) { return [''] .concat(state.locations.map((l) => ``)) .join(""); } function specRowHTML(key = "", value = "") { return `
`; } 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(`
${isNew ? "Add part" : esc(p.name)}
${p.specs.map((s) => specRowHTML(s.key, s.value)).join("")}
Snap the bag or the markings. Large photos are shrunk before upload.
${isNew ? "" : '
…
'}
`); 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 = `${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 = `Queued photo `; 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) => `
${h.delta >= 0 ? "+" : ""}${fmtQty(h.delta)} ${esc(h.reason || "adjusted")} β†’ ${fmtQty(h.quantity_after)} Β· ${esc(h.created_at)}
`).join("") : "
No changes recorded yet.
"; }).catch(() => {}); } setTimeout(() => overlay.querySelector("#f-name").focus(), 50); } // --- manage categories / locations ------------------------------------------ function openManageModal(focusPassword = false) { const overlay = openModal(`
Settings
${state.authRequired ? `
${state.usingBootstrap ? '

You\'re still using the password you were handed. Setting your own here also signs out every other device.

' : '

Changing your password signs out every other device.

'}
` : ""}
`); 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 = `${esc(item.path)} ${item.part_count} `; 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(); } })();