import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__"; import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__"; import { showToast } from "./toast.js?v=__ASSET_VERSION__"; let beans = []; let showArchived = false; let editingId = null; const fmtDate = (v) => (v ? new Date(v).toLocaleDateString() : "—"); async function guarded(button, run) { if (!button || button.disabled) return; button.disabled = true; try { await run(); } finally { button.disabled = false; } } function renderBeans() { const body = document.getElementById("beans-body"); const visible = showArchived ? beans : beans.filter((b) => !b.archived); if (!visible.length) { body.innerHTML = `No beans yet. Add a bag below — paste the roaster's product URL to prefill the details.`; return; } body.replaceChildren( ...visible.map((bean) => { const tr = document.createElement("tr"); if (bean.archived) tr.className = "archived"; const beanCell = document.createElement("td"); const strong = document.createElement("strong"); strong.textContent = bean.name; const sub = document.createElement("div"); sub.className = "muted"; sub.style.fontSize = "11.5px"; sub.textContent = [bean.roaster, bean.roastLevel].filter(Boolean).join(" · ") || "—"; beanCell.append(strong, sub); const originCell = document.createElement("td"); originCell.textContent = [bean.origin, bean.process].filter(Boolean).join(" · ") || "—"; const roastedCell = document.createElement("td"); roastedCell.textContent = fmtDate(bean.roastDate); const remainingCell = document.createElement("td"); if (bean.initialWeightG == null) remainingCell.textContent = "—"; else { const pct = bean.initialWeightG > 0 ? Math.max(0, Math.min(100, (bean.remainingWeightG / bean.initialWeightG) * 100)) : 0; const wrap = document.createElement("div"); wrap.className = "lot-remaining"; const bar = document.createElement("div"); bar.className = "blend-total-bar"; const fill = document.createElement("div"); fill.className = "blend-total-fill"; if (bean.remainingWeightG < 0) fill.classList.add("over"); fill.style.width = `${bean.remainingWeightG < 0 ? 0 : pct}%`; bar.append(fill); const label = document.createElement("span"); label.className = "blend-total-label"; label.textContent = `${Math.round(bean.remainingWeightG)} g of ${Math.round(bean.initialWeightG)} g`; wrap.append(bar, label); remainingCell.append(wrap); } const brewsCell = document.createElement("td"); brewsCell.className = "num"; const brewsLink = document.createElement("a"); brewsLink.href = `/brews?bean=${encodeURIComponent(bean.id)}`; brewsLink.textContent = String(bean.brewCount ?? 0); brewsCell.append(brewsLink); const actions = document.createElement("td"); actions.className = "data-table-actions"; const brewBtn = document.createElement("a"); brewBtn.className = "ghost-btn small"; brewBtn.href = `/brews?new=1&bean=${encodeURIComponent(bean.id)}`; brewBtn.textContent = "Brew"; const editBtn = document.createElement("button"); editBtn.className = "ghost-btn small"; editBtn.type = "button"; editBtn.textContent = "Edit"; editBtn.addEventListener("click", () => startEdit(bean)); const archiveBtn = document.createElement("button"); archiveBtn.className = "ghost-btn small"; archiveBtn.type = "button"; archiveBtn.textContent = bean.archived ? "Unarchive" : "Archive"; archiveBtn.addEventListener("click", (event) => guarded(event.currentTarget, async () => { try { await api(`/api/beans/${bean.id}`, { method: "PUT", body: JSON.stringify({ archived: !bean.archived }), }); showToast(bean.archived ? "Bean unarchived." : "Bean archived."); await loadBeans(); } catch (error) { showToast(error.message, "fail"); } }), ); const deleteBtn = document.createElement("button"); deleteBtn.className = "ghost-btn small"; deleteBtn.type = "button"; deleteBtn.textContent = "Delete"; deleteBtn.addEventListener("click", (event) => guarded(event.currentTarget, async () => { if ( !confirm( `Delete "${bean.name}"? Its logged brews are kept but lose the bean link.`, ) ) return; try { await api(`/api/beans/${bean.id}`, { method: "DELETE" }); showToast("Bean deleted."); if (editingId === bean.id) cancelEdit(); await loadBeans(); } catch (error) { showToast(error.message, "fail"); } }), ); actions.append(brewBtn, editBtn, archiveBtn, deleteBtn); tr.append(beanCell, originCell, roastedCell, remainingCell, brewsCell, actions); return tr; }), ); } async function loadBeans() { try { beans = (await api("/api/beans")).beans; renderBeans(); } catch { document.getElementById("beans-body").innerHTML = `Could not load beans.`; } } function startEdit(bean) { editingId = bean.id; const form = document.getElementById("bean-form"); form.name.value = bean.name; form.roaster.value = bean.roaster; form.origin.value = bean.origin; form.process.value = bean.process; form.variety.value = bean.variety; form.roastLevel.value = bean.roastLevel; form.roastDate.value = bean.roastDate ? bean.roastDate.slice(0, 10) : ""; form.initialWeightG.value = bean.initialWeightG ?? ""; form.tastingNotes.value = bean.tastingNotes; form.url.value = bean.url; form.notes.value = bean.notes; document.getElementById("bean-form-title").textContent = "Edit bean"; document.getElementById("bean-form-submit").textContent = "Save changes"; document.getElementById("bean-form-cancel").classList.remove("hidden"); document.getElementById("bean-form-card").scrollIntoView({ behavior: "smooth" }); } function cancelEdit() { editingId = null; const form = document.getElementById("bean-form"); form.reset(); document.getElementById("bean-form-title").textContent = "Add a bean"; document.getElementById("bean-form-submit").textContent = "Add bean"; document.getElementById("bean-form-cancel").classList.add("hidden"); } // The LLM prefill endpoint returns raw `extracted` page facts — map them straight into the // bean form (only overwriting fields the page actually stated). function wirePrefill() { const button = document.getElementById("bean-prefill-btn"); const note = document.getElementById("bean-prefill-note"); button.addEventListener("click", () => guarded(button, async () => { const url = document.getElementById("bean-prefill-url").value.trim(); if (!url) return; note.classList.remove("hidden"); note.textContent = "Reading the page with the LLM…"; try { const body = await api("/api/prefill", { method: "POST", body: JSON.stringify({ url }), }); const x = body.extracted ?? {}; const form = document.getElementById("bean-form"); const setIf = (field, value) => { if (value) form[field].value = value; }; setIf("name", x.coffeeName); setIf("roaster", x.producer); setIf("origin", x.origin); setIf("process", x.process); setIf("variety", x.cultivar); setIf("roastLevel", x.roastLevel); if (Array.isArray(x.tastingNotes) && x.tastingNotes.length) form.tastingNotes.value = x.tastingNotes.join(", "); form.url.value = url; const filled = ["coffeeName", "producer", "origin", "process", "cultivar", "roastLevel"] .filter((k) => x[k]).length + (x.tastingNotes?.length ? 1 : 0); note.textContent = filled ? `Prefilled ${filled} field${filled === 1 ? "" : "s"} — check them, then save.` : "The page didn't state anything usable — fill the form by hand."; } catch (error) { note.textContent = `Prefill failed: ${error.message}`; } }), ); } document.getElementById("bean-form-cancel").addEventListener("click", cancelEdit); document.getElementById("show-archived").addEventListener("change", (event) => { showArchived = event.target.checked; renderBeans(); }); document.getElementById("bean-form").addEventListener("submit", (event) => { event.preventDefault(); const form = event.target; const data = Object.fromEntries(new FormData(form)); guarded(document.getElementById("bean-form-submit"), async () => { try { const payload = { name: data.name, roaster: data.roaster, origin: data.origin, process: data.process, variety: data.variety, roastLevel: data.roastLevel, roastDate: data.roastDate || null, initialWeightG: data.initialWeightG === "" ? null : Number(data.initialWeightG), tastingNotes: data.tastingNotes, url: data.url, notes: data.notes, }; if (editingId) { await api(`/api/beans/${editingId}`, { method: "PUT", body: JSON.stringify(payload), }); showToast("Bean updated."); } else { await api("/api/beans", { method: "POST", body: JSON.stringify(payload) }); showToast("Bean added."); } cancelEdit(); await loadBeans(); } catch (error) { showToast(error.message, "fail"); } }); }); document.getElementById("btn-logout").addEventListener("click", async () => { await protectedFetch("/api/auth/logout", { method: "POST" }); location.assign("/"); }); async function init() { initSideNav(); const user = await loadNavUser(); if (!user) return; wirePrefill(); await loadBeans(); } init();