// Studio Notebook prototype — vanilla JS, no build step const $ = (s, el = document) => el.querySelector(s); const view = $("#view"); const api = async (path, body) => { const r = await fetch(path, body ? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) } : undefined); if (r.status === 401) { location.href = "/login"; return new Promise(() => {}); } return r.json(); }; const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); const hour = new Date().getHours(); $("#greeting").textContent = (hour < 12 ? "good morning" : hour < 17 ? "good afternoon" : "good evening") + ", Bonna"; let current = "today"; let subState = null; // inline to-do entry: {anchor: parent todo id, last: last-added id} document.querySelectorAll("#nav button").forEach((b) => b.addEventListener("click", () => { document.querySelectorAll("#nav button").forEach((x) => x.classList.toggle("on", x === b)); current = b.dataset.v; render(); })); function render() { ({ today, glazes, firings, pieces, files, sourcing, classes, ideas, notes })[current](); } // ── jot box (shared) ───────────────────────────────────────────── const JOT_HINT = "⌨️ Enter saves · Shift+Enter = subtask line · Tab switches type · press / from anywhere to jot"; function captureHTML(kinds) { const chips = kinds.map(([k, label], i) => `${label}`).join(""); return `
file it as…${chips}
${JOT_HINT}
`; } function wireCapture(after) { const jot = $("#jot"); const chips = [...document.querySelectorAll(".capture .chip")]; const select = (c) => chips.forEach((x) => x.classList.toggle("on", x === c)); chips.forEach((c) => c.addEventListener("click", () => select(c))); const save = async () => { const text = jot.value.trim(); if (!text) return; const kind = $(".capture .chip.on")?.dataset.kind || "note"; const r = await api("/api/jot", { text, kind }); await after(); const j = $("#jot"); if (j) { j.value = ""; j.focus(); } // stay in the box — rapid-fire jotting if (kind === "idea") jotSaid("💭 dropped in the mind dump — file it there whenever"); if (kind === "suggestion") jotSaid(r.issue ? `💡 sent to Tony — suggestion #${r.issue}, thank you!` : "💡 kept in your notes — it'll get passed along"); }; $("#jot-save").addEventListener("click", save); jot.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); save(); } else if (e.key === "Enter" && e.shiftKey) { autogrow(); } else if (e.key === "Tab") { e.preventDefault(); const i = chips.findIndex((c) => c.classList.contains("on")); select(chips[(i + (e.shiftKey ? chips.length - 1 : 1)) % chips.length]); } }); const autogrow = () => setTimeout(() => { jot.style.height = "auto"; jot.style.height = jot.scrollHeight + "px"; }); jot.addEventListener("input", autogrow); } // the keyboard-hint line doubles as the jot box's confirmation slot function jotSaid(msg) { const h = $("#jot-hint"); if (!h) return; h.textContent = msg; setTimeout(() => { const x = $("#jot-hint"); if (x && x.textContent === msg) x.textContent = JOT_HINT; }, 7000); } // "/" from anywhere jumps to the jot box document.addEventListener("keydown", (e) => { if (e.key === "/" && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName || "")) { const j = $("#jot"); if (j) { e.preventDefault(); j.focus(); } } }); // ── Today ──────────────────────────────────────────────────────── function todoRow(t, sub) { const going = t.started && !t.done; return `
${sub ? "↳ " : ""}${esc(t.text)}${going ? ` ◐ in progress` : ""} ${t.done ? "" : ``} ${sub ? "" : ``}
${sub ? "" : ``}`; } async function today() { const s = await api("/api/summary"); const parents = s.todos.filter((t) => !t.parent_id); const kids = (id) => s.todos.filter((t) => t.parent_id === id).sort((a, b) => a.id - b.id); const open = parents.filter((t) => !t.done || kids(t.id).some((k) => !k.done)); const rows = (list) => list.map((t) => todoRow(t) + kids(t.id).map((k) => todoRow(k, true)).join("")).join(""); const todoRows = rows(open.slice(0, 14)) || `
nothing yet — jot one above ✨
`; const shopRows = s.shopping.map((t) => `
${esc(t.text)}
`).join("") || `
list is empty — lucky you 🧺
`; const noteRows = s.notes.map((n) => `
“${esc(n.text.slice(0, 80))}${n.text.length > 80 ? "…" : ""}”
`).join("") || `
no notes yet
`; const kiln = s.activeFiring ? `
🔥 Firing #${s.activeFiring.id} in progress — ${esc(s.activeFiring.type)} ${esc(s.activeFiring.cone)}
` : `
Kiln idle${s.lastUnload ? ` — last unload ${esc(s.lastUnload.unloaded?.slice(0, 10))}` : ""}
`; view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "✓ to-do"], ["shopping", "🧺 shopping"], ["glaze idea", "🧪 glaze idea"], ["idea", "💭 idea"], ["suggestion", "💡 suggestion"]]) + `

To-do

${todoRows}

Shopping list 🧺

${shopRows}

Kiln

${kiln}

Recent notes

${noteRows}
`; wireCapture(today); // outliner-style inline entry: ⇧Enter = another subtask, Enter = new top-level // task (input follows it), ⌫ on empty = undo last add, Esc closes const openForm = () => { if (!subState) return; const form = view.querySelector(`[data-subform="${subState.anchor}"]`); if (!form) { subState = null; return; } form.hidden = false; form.querySelector("input").focus(); }; openForm(); view.querySelectorAll("[data-sub]").forEach((b) => b.addEventListener("click", () => { const id = Number(b.dataset.sub); subState = subState?.anchor === id ? null : { anchor: id, last: null }; view.querySelectorAll(".subform").forEach((f) => (f.hidden = true)); openForm(); })); view.querySelectorAll("[data-subform] input").forEach((inp) => inp.addEventListener("keydown", async (e) => { const text = inp.value.trim(); if (e.key === "Enter" && e.shiftKey && text) { e.preventDefault(); const r = await api("/api/todos/add", { text, parent_id: subState.anchor }); subState.last = r.id; today(); } else if (e.key === "Enter" && !e.shiftKey && text) { e.preventDefault(); const r = await api("/api/todos/add", { text }); subState = { anchor: r.id, last: r.id }; today(); } else if (e.key === "Backspace" && !inp.value && subState?.last) { e.preventDefault(); const undoing = subState.last; await api("/api/todos/delete", { id: undoing }); subState = undoing === subState.anchor ? null : { ...subState, last: null }; today(); } else if (e.key === "Escape" || (e.key === "Enter" && !text)) { subState = null; today(); } })); view.querySelectorAll("[data-t]").forEach((c) => c.addEventListener("change", async () => { await api("/api/todos/toggle", { id: Number(c.dataset.t) }); today(); })); view.querySelectorAll("[data-start]").forEach((b) => b.addEventListener("click", async () => { await api("/api/todos/start", { id: Number(b.dataset.start) }); today(); })); view.querySelectorAll("[data-s]").forEach((c) => c.addEventListener("change", async () => { await api("/api/shopping/toggle", { id: Number(c.dataset.s) }); today(); })); $("#goto-firing")?.addEventListener("click", () => $('#nav button[data-v="firings"]').click()); } // ── Glazes ─────────────────────────────────────────────────────── const SWATCHES = ["#8fb0a3", "#c98d5f", "#cfc8ba", "#7f9bb3", "#b3849a", "#8a8f6a", "#6e4f78", "#c9b8a4"]; async function glazes() { const list = await api("/api/glazes"); const card = (g) => `

${esc(g.name)}

${esc([g.cone, g.atmosphere, g.surface].filter(Boolean).join(" · ") || g.kind)}
`; const section = (label) => `

${label}

`; const glazeCards = list.filter((g) => !g.kind || g.kind === "glaze").map(card).join(""); const slipCards = list.filter((g) => g.kind === "casting slip").map(card).join(""); const clayCards = list.filter((g) => g.kind === "clay body").map(card).join(""); view.innerHTML = `
${glazeCards || '
no glazes yet — add your first 🧪
'}
${slipCards ? section("Casting slips 🏺") + `
${slipCards}
` : ""} ${clayCards ? section("Clay bodies 🧱") + `
${clayCards}
` : ""}`; $("#new-glaze").addEventListener("click", newGlazeForm); view.querySelectorAll("[data-g]").forEach((c) => c.addEventListener("click", () => glazeDetail(Number(c.dataset.g)))); } function newGlazeForm() { const chip = (group, vals, on) => vals.map((v) => `${v}`).join(""); view.innerHTML = `

New glaze

Only the name is required — everything else can arrive later.
${chip("kind", ["glaze", "casting slip", "clay body"], "glaze")}
${chip("cone", ["∆04", "∆6", "∆10"], "∆6")}
${chip("atmosphere", ["oxidation", "reduction"], "oxidation")}
${chip("surface", ["glossy", "satin", "matte"], "")}
${chip("source", ["mine", "Glazy", "book / friend"], "mine")}
${SWATCHES.map((s, i) => ``).join("")}
`; const mats = $("#mats"); const addRow = (addition) => { const row = document.createElement("div"); row.className = "matrow"; row.innerHTML = ``; mats.appendChild(row); }; addRow(false); addRow(false); addRow(false); $("#add-mat").addEventListener("click", () => addRow(false)); $("#add-addition").addEventListener("click", () => addRow(true)); view.querySelectorAll(".chip[data-group]").forEach((c) => c.addEventListener("click", () => { view.querySelectorAll(`.chip[data-group="${c.dataset.group}"]`).forEach((x) => x.classList.toggle("on", x === c)); })); $("#back").addEventListener("click", glazes); $("#save-glaze").addEventListener("click", async () => { const name = $("#g-name").value.trim(); if (!name) return alert("A name is all it needs 🙂"); const pick = (g) => view.querySelector(`.chip[data-group="${g}"].on`)?.dataset.val || ""; const materials = [...mats.querySelectorAll(".matrow")].map((r) => { const [n, p] = r.querySelectorAll("input"); return n.value.trim() && p.value.trim() ? { name: n.value.trim(), pct: parseFloat(p.value), addition: n.dataset.add === "1" } : null; }).filter(Boolean); await api("/api/glazes", { name, kind: pick("kind"), cone: pick("cone"), atmosphere: pick("atmosphere"), surface: pick("surface"), source: pick("source"), swatch: pick("swatch"), materials }); glazes(); }); } async function glazeDetail(id) { const g = await api(`/api/glaze?id=${id}`); const batchSizes = [500, 1000, 2000, 5000]; const batch = (pct, size) => Math.round(pct * size) / 100; let size = 2000; const recipeTable = () => ` ${g.materials.map((m) => ``).join("") || ''}
Material%${size >= 1000 ? size / 1000 + " kg" : size + " g"} batch
${m.addition ? "+ " : ""}${esc(m.name)}${m.pct}${batch(m.pct, size)} g
no recipe yet
`; const compat = g.compatibility.length ? ` ${g.compatibility.map((c) => ``).join("")}
Clay bodyResultEvidence
${esc(c.clay)}${esc(c.rating)}${c.n} firing${c.n > 1 ? "s" : ""}${c.notes ? " · " + esc(c.notes) : ""}
` : `
no evidence yet — it builds itself from unload days 🌱
`; view.innerHTML = `

${esc(g.name)}

${esc([g.kind !== "glaze" ? g.kind : "", g.cone, g.atmosphere, g.surface, g.source].filter(Boolean).join(" · "))}

Recipe

${recipeTable()}
${batchSizes.map((b) => `${b >= 1000 ? b / 1000 + " kg" : b + " g"}`).join("")}

Plays well with…

${compat}

Journal

${g.journal.map((j) => `
📒 ${esc(j.created.slice(0, 10))} — ${esc(j.text)}
`).join("") || '
no entries yet
'}

Photos

${g.photos.map((p) => ``).join("")}
`; $("#back").addEventListener("click", glazes); view.querySelectorAll("[data-b]").forEach((c) => c.addEventListener("click", () => { size = Number(c.dataset.b); $("#recipe").innerHTML = recipeTable(); view.querySelectorAll("[data-b]").forEach((x) => x.classList.toggle("on", x === c)); })); $("#j-save").addEventListener("click", async () => { const text = $("#j-text").value.trim(); if (text) { await api("/api/glaze/journal", { glaze_id: id, text }); glazeDetail(id); } }); $("#ph-add").addEventListener("click", () => $("#ph-file").click()); $("#ph-file").addEventListener("change", async (e) => { const f = e.target.files[0]; if (!f) return; await fetch(`/api/photos?entity=glaze&id=${id}&name=${encodeURIComponent(f.name)}`, { method: "POST", body: f }); glazeDetail(id); }); } // ── Firing Log ─────────────────────────────────────────────────── async function firings() { const list = await api("/api/firings"); const library = await api("/api/glazes"); const glazeNames = library.filter((g) => g.kind !== "clay body").map((g) => g.name); const clayNames = library.filter((g) => g.kind === "clay body").map((g) => g.name); const active = list.find((f) => f.status === "firing"); let html = ""; if (!active) { html += `

Start a firing 🔥

Fill-the-kiln-and-go: pick a type and hit start. Load list is optional, always.
${["bisque", "glaze"].map((t, i) => `${t}`).join("")} ${["∆04", "∆6", "∆10"].map((c, i) => `${c}`).join("")}
`; } else { const items = active.items.map((it) => `
${esc(it.piece)}${esc([it.clay, it.glaze].filter(Boolean).join(" · "))}
`).join(""); html += `

Firing #${active.id} — in progress 🔥

${esc(active.type)} ${esc(active.cone)} · started ${esc(active.started.slice(0, 16))}
${items || '
no load list — totally fine! add pieces whenever, even at unload
'}
${clayNames.map((n) => ``).join("")}
`; } html += list.filter((f) => f.status === "unloaded").map((f) => `

Firing #${f.id} — ${esc(f.type)} ${esc(f.cone)}

${esc(f.started.slice(0, 10))} → unloaded ${esc((f.unloaded || "").slice(0, 10))}
${f.items.map((it) => `
${it.rating || ""} ${esc(it.piece)}${esc([it.clay, it.glaze].filter(Boolean).join(" · "))}${it.note ? " — " + esc(it.note) : ""}
`).join("")} ${f.result_notes ? `
✨ ${esc(f.result_notes)}
` : ""}
`).join(""); view.innerHTML = html || '
no firings yet
'; view.querySelectorAll("[data-ft],[data-fc]").forEach((c) => c.addEventListener("click", () => { const sel = c.dataset.ft !== undefined ? "[data-ft]" : "[data-fc]"; view.querySelectorAll(sel).forEach((x) => x.classList.toggle("on", x === c)); })); $("#start-firing")?.addEventListener("click", async () => { await api("/api/firings", { type: view.querySelector("[data-ft].on").dataset.ft, cone: view.querySelector("[data-fc].on").dataset.fc }); firings(); }); $("#fi-add")?.addEventListener("click", async () => { const piece = $("#fi-piece").value.trim(); if (!piece) return; await api("/api/firing/items", { firing_id: active.id, piece, clay: $("#fi-clay").value.trim(), glaze: $("#fi-glaze").value }); firings(); }); $("#unload")?.addEventListener("click", () => unloadDay(active)); } async function unloadDay(f) { const fresh = (await api("/api/firings")).find((x) => x.id === f.id); const EMOJI = ["😍", "🙂", "😕", "💔"]; view.innerHTML = `

Firing #${fresh.id} — unload day! ✨

Rate each thing as it comes out. One tap each.
${fresh.items.map((it) => `
${esc(it.piece)} ${esc([it.clay, it.glaze].filter(Boolean).join(" · "))} ${EMOJI.map((e) => ``).join("")}
`).join("") || '
no items listed — add what came out below, then rate it
'}
`; $("#back").addEventListener("click", firings); view.querySelectorAll(".verdict").forEach((v) => v.querySelectorAll("button").forEach((b) => b.addEventListener("click", async () => { await api("/api/firing/rate", { id: Number(v.dataset.item), rating: b.dataset.e }); v.querySelectorAll("button").forEach((x) => x.classList.toggle("on", x === b)); }))); $("#fi-add").addEventListener("click", async () => { const piece = $("#fi-piece").value.trim(); if (piece) { await api("/api/firing/items", { firing_id: fresh.id, piece }); unloadDay(fresh); } }); $("#save-unload").addEventListener("click", async () => { await api("/api/firing/unload", { id: fresh.id, result_notes: $("#f-notes").value.trim() }); firings(); }); } // ── Pieces ─────────────────────────────────────────────────────── const money = (n) => "$" + (Math.round(n * 100) / 100).toLocaleString(); async function pieces() { const { products, money: m } = await api("/api/products"); const core = products.filter((p) => p.kind === "core line"); const tiles = products.filter((p) => p.kind === "tiles"); const oneoff = products.filter((p) => p.kind !== "core line" && p.kind !== "tiles"); const catPill = (p) => (p.category ? `${esc(p.category)}` : ""); const coreCard = (p) => `

${esc(p.name)}

${catPill(p)} ${money(p.price)}
on hand: ${p.qty}
`; const STATUSES = ["in progress", "for sale", "sold"]; const oneoffCard = (p) => `

${esc(p.name)}

${catPill(p)} ${esc(p.kind)} ${money(p.price)}
${STATUSES.map((s) => `${s}`).join("")} ${p.status !== "sold" ? `` : ""}
`; const section = (label) => `

${label}

`; view.innerHTML = `

Money 💰

this month

${money(m.monthRevenue)}

all-time sales (${m.salesCount})

${money(m.revenue)}

est. profit

${money(m.revenue - m.cogs)}

profit = sales minus each piece's cost estimate · real materials-based costing comes later
${section("Core line ✨")}
${core.map(coreCard).join("") || '
the reintroduced line starts here — kitchen tools, serveware…
'}
${tiles.length ? section("Tiles 🔷") + `
${tiles.map(coreCard).join("")}
` : ""} ${section("Small batch & one of a kind 🌙")}
${oneoff.map(oneoffCard).join("") || '
sculptural pieces, home scenting, experiments…
'}
`; $("#new-piece").addEventListener("click", newPieceForm); view.querySelectorAll("[data-open]").forEach((h) => h.addEventListener("click", () => pieceDetail(Number(h.dataset.open)))); view.querySelectorAll("[data-q]").forEach((b) => b.addEventListener("click", async () => { await api("/api/products/qty", { id: Number(b.dataset.q), delta: Number(b.dataset.d) }); pieces(); })); view.querySelectorAll("[data-st]").forEach((c) => c.addEventListener("click", async () => { await api("/api/products/status", { id: Number(c.dataset.st), status: c.dataset.v }); pieces(); })); view.querySelectorAll("[data-sell]").forEach((b) => b.addEventListener("click", async () => { await api("/api/sales", { product_id: Number(b.dataset.sell) }); pieces(); })); } function newPieceForm() { const chip = (group, vals, on) => vals.map((v) => `${v}`).join(""); view.innerHTML = `

New piece

Name is enough — price, cost, and count can arrive later.
${chip("kind", ["core line", "tiles", "small batch", "one of a kind"], "core line")}
`; view.querySelectorAll(".chip[data-group]").forEach((c) => c.addEventListener("click", () => view.querySelectorAll(`.chip[data-group="${c.dataset.group}"]`).forEach((x) => x.classList.toggle("on", x === c)))); $("#back").addEventListener("click", pieces); $("#save-piece").addEventListener("click", async () => { const name = $("#p-name").value.trim(); if (!name) return alert("Just a name is fine 🙂"); await api("/api/products", { name, kind: view.querySelector('.chip[data-group="kind"].on')?.dataset.val, category: $("#p-cat").value.trim(), price: $("#p-price").value, cost: $("#p-cost").value, qty: $("#p-qty").value }); pieces(); }); } // ── Files ──────────────────────────────────────────────────────── const FILE_TAGS = ["master molds", "foam forms", "prototypes", "other"]; const fmtSize = (n) => n > 1048576 ? (n / 1048576).toFixed(1) + " MB" : Math.max(1, Math.round(n / 1024)) + " KB"; const fileIcon = (name) => /\.(stl|obj|3mf|step|stp)$/i.test(name) ? "🧊" : /\.(svg|dxf|ai|eps)$/i.test(name) ? "✂️" : /\.(png|jpe?g|webp|heic)$/i.test(name) ? "🖼️" : /\.(pdf)$/i.test(name) ? "📄" : "📁"; function fileRow(f, showHome) { return `
${fileIcon(f.name)} ${esc(f.name)} ${fmtSize(f.size)} · ${esc(f.created.slice(0, 10))} ${f.tag ? `${esc(f.tag)}` : ""} ${showHome && f.product_name ? `🏺 ${esc(f.product_name)}` : ""} ${showHome && f.course_name ? `📚 ${esc(f.course_name)}` : ""}
`; } function uploadWidget(entity, entityId, after, tags = FILE_TAGS) { return { html: `
tag as…${tags.map((t, i) => `${t}`).join("")}
`, wire() { view.querySelectorAll("[data-ftag]").forEach((c) => c.addEventListener("click", () => view.querySelectorAll("[data-ftag]").forEach((x) => x.classList.toggle("on", x === c)))); $("#file-pick").addEventListener("click", () => $("#file-input").click()); $("#file-input").addEventListener("change", async (e) => { const tag = view.querySelector("[data-ftag].on")?.dataset.ftag || ""; for (const f of e.target.files) { const q = new URLSearchParams({ name: f.name, tag, ...(entity ? { entity, id: entityId } : {}) }); await fetch(`/api/files?${q}`, { method: "POST", body: f }); } after(); }); }, }; } function wireFileDeletes(after) { view.querySelectorAll("[data-fdel]").forEach((b) => b.addEventListener("click", async () => { if (confirm("Delete this file?")) { await api("/api/files/delete", { id: Number(b.dataset.fdel) }); after(); } })); } async function files() { const list = await api("/api/files"); const up = uploadWidget("", 0, files); const loose = list.filter((f) => !f.entity_id); const attached = list.filter((f) => f.entity_id); const section = (label) => `

${label}

`; view.innerHTML = `${up.html}
${section("Studio files 🗂️")} ${loose.map((f) => fileRow(f, false)).join("") || '
STLs for master molds, foam form templates, cut files… upload anything ⬆️
'}
${attached.length ? `
${section("Attached to pieces & classes")}${attached.map((f) => fileRow(f, true)).join("")}
` : ""}`; up.wire(); wireFileDeletes(files); } // ── Piece detail ───────────────────────────────────────────────── async function pieceDetail(id) { const p = await api(`/api/product?id=${id}`); if (!p) return pieces(); const counted = p.kind === "core line" || p.kind === "tiles"; const STATUSES = ["in progress", "for sale", "sold"]; const up = uploadWidget("product", id, () => pieceDetail(id)); view.innerHTML = `

${esc(p.name)}

${p.category ? `${esc(p.category)}` : ""} ${esc(p.kind)} ${money(p.price)} ${p.cost ? `cost ~${money(p.cost)}` : ""}
${counted ? `on hand: ${p.qty} ` : `${STATUSES.map((s) => `${s}`).join("")} ${p.status !== "sold" ? `` : ""}`}
${p.sales.length ? `
💰 ${p.sales.length} recent sale${p.sales.length > 1 ? "s" : ""} — last ${esc(p.sales[0].created.slice(0, 10))}
` : ""}

Photos

${p.photos.map((ph) => ``).join("")}

Files — molds, templates, cut forms

${p.files.map((f) => fileRow(f, false)).join("") || '
nothing attached yet
'} ${up.html}
`; $("#back").addEventListener("click", pieces); view.querySelectorAll("[data-q]").forEach((b) => b.addEventListener("click", async () => { await api("/api/products/qty", { id, delta: Number(b.dataset.q) }); pieceDetail(id); })); view.querySelectorAll("[data-st]").forEach((c) => c.addEventListener("click", async () => { await api("/api/products/status", { id, status: c.dataset.st }); pieceDetail(id); })); $("#sell")?.addEventListener("click", async () => { await api("/api/sales", { product_id: id }); pieceDetail(id); }); $("#save-notes").addEventListener("click", async () => { await api("/api/products/notes", { id, notes: $("#p-notes").value }); }); $("#ph-add").addEventListener("click", () => $("#ph-file").click()); $("#ph-file").addEventListener("change", async (e) => { const f = e.target.files[0]; if (f) { await fetch(`/api/photos?entity=product&id=${id}&name=${encodeURIComponent(f.name)}`, { method: "POST", body: f }); pieceDetail(id); } }); up.wire(); wireFileDeletes(() => pieceDetail(id)); } // ── Sourcing ───────────────────────────────────────────────────── async function sourcing() { const { suppliers, entries } = await api("/api/sourcing"); const section = (label) => `

${label}

`; const total = (e) => e.price + e.shipping; // group price entries by item (case-insensitive) const items = {}; for (const e of entries) (items[e.item.toLowerCase()] ??= { name: e.item, entries: [] }).entries.push(e); const itemCard = (it) => { // newest entry per supplier decides "current" prices; lowest total wins const latestBySupplier = {}; for (const e of it.entries) latestBySupplier[e.supplier_id] ??= e; // entries are newest-first const currents = Object.values(latestBySupplier).sort((a, b) => total(a) - total(b)); const bestId = currents[0]?.id; const rows = it.entries.map((e) => { // trend vs previous entry from the same supplier const prev = it.entries.find((x) => x.supplier_id === e.supplier_id && x.id < e.id); const trend = prev ? (total(e) > total(prev) ? ` ▲ costs more now` : total(e) < total(prev) ? ` ▼ cheaper` : "") : ""; const drivable = e.supplier_kind === "local" ? ` 🚗 ${esc(e.distance || "drivable")}` : ""; return `
${esc(e.supplier)}${drivable} ${esc(e.created.slice(0, 10))}${e.unit ? " · " + esc(e.unit) : ""}${e.note ? " · " + esc(e.note) : ""} ${money(e.price)}${e.shipping ? ` + ${money(e.shipping)} ship = ${money(total(e))}` : ""}${trend} ${e.id === bestId && currents.length > 1 ? ' best 🏆' : ""}
`; }).join(""); return `

${esc(it.name)}

${rows}
`; }; const supplierCard = (s) => `

${esc(s.name)}

${s.kind === "local" ? "🚗 " + esc(s.distance || "drivable") : "📦 online"} ${s.url ? ` visit site ↗` : ""}
${s.notes ? `
${esc(s.notes)}
` : ""}
`; const searchable = suppliers.filter((s) => s.search_url); view.innerHTML = ` ${searchable.length ? `
` : ""}
log every buy or price-check — the comparisons build themselves 🌱
${Object.values(items).map(itemCard).join("") || '
no prices logged yet — start with your next order 🧭
'} ${section("Suppliers")}
${suppliers.map(supplierCard).join("") || '
MN Clay, the online places…
'}

+ New supplier

📦 online🚗 can drive there
`; view.querySelectorAll("[data-skind]").forEach((c) => c.addEventListener("click", () => view.querySelectorAll("[data-skind]").forEach((x) => x.classList.toggle("on", x === c)))); $("#s-save").addEventListener("click", async () => { const name = $("#s-name").value.trim(); if (!name) return alert("Name is all it needs 🙂"); await api("/api/suppliers", { name, kind: view.querySelector("[data-skind].on").dataset.skind, distance: $("#s-dist").value.trim(), url: $("#s-url").value.trim(), notes: $("#s-notes").value.trim(), search_url: $("#s-search").value.trim() }); sourcing(); }); const priceCheck = () => { const q = $("#pc-q")?.value.trim(); if (!q) return; $("#pc-links").innerHTML = searchable.map((s) => `🔎 ${esc(s.name)} ↗`).join(""); }; $("#pc-go")?.addEventListener("click", priceCheck); $("#pc-q")?.addEventListener("keydown", (e) => e.key === "Enter" && priceCheck()); const logPrice = async () => { const item = $("#pe-item").value.trim(), sup = $("#pe-supplier").value; if (!item || !sup) return; await api("/api/price-entries", { item, supplier_id: Number(sup), price: $("#pe-price").value, shipping: $("#pe-ship").value }); sourcing(); }; $("#pe-save").addEventListener("click", logPrice); ["pe-item", "pe-price", "pe-ship"].forEach((id) => $("#" + id).addEventListener("keydown", (e) => e.key === "Enter" && logPrice())); } // ── Notes ──────────────────────────────────────────────────────── async function notes() { const list = await api("/api/notes"); view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "✓ to-do"], ["shopping", "🧺 shopping"], ["suggestion", "💡 suggestion"]]) + `
${list.map((n) => `

${esc(n.text)}

${esc(n.created.slice(0, 16))} ${n.tags ? `${esc(n.tags)}` : ""}
`).join("") || '
no notes yet — the jot box awaits ✨
'}
`; wireCapture(notes); } // ── Classes (the education binder) ────────────────────────────── const SUBJECTS = ["methods", "skills", "marketing", "business", "glaze chemistry", "other"]; const CLASS_STATUS = [["taking", "📖 taking now"], ["want", "🔖 want to take"], ["done", "✅ finished"]]; const CLASS_TAGS = ["handout", "slides", "worksheet", "reference", "other"]; const sectionH = (label) => `

${label}

`; async function classes() { const list = await api("/api/courses"); const card = (c) => `

${esc(c.title)}

${c.subject ? `${esc(c.subject)} ` : ""}${c.page_count} page${c.page_count === 1 ? "" : "s"}
${c.source ? `
${esc(c.source)}
` : ""}
`; const group = (key) => list.filter((c) => (c.status || "taking") === key); view.innerHTML = `
courses, workshops, books, video series — one binder tab each
${CLASS_STATUS.map(([k, label]) => { const g = group(k); if (!g.length && k !== "taking") return ""; return sectionH(label) + `
${g.map(card).join("") || '
your binder starts here — add a class, a workshop, a book, or a video series 📚
'}
`; }).join("")}`; $("#new-class").addEventListener("click", newClassForm); view.querySelectorAll("[data-c]").forEach((c) => c.addEventListener("click", () => classDetail(Number(c.dataset.c)))); } function newClassForm() { view.innerHTML = `

New class

A title is enough — this can be a course, a workshop, a book, a YouTube series, anything you're learning from.
${SUBJECTS.map((s) => ``).join("")}
${CLASS_STATUS.map(([k, l], i) => `${l}`).join("")}
`; view.querySelectorAll("[data-cs]").forEach((c) => c.addEventListener("click", () => view.querySelectorAll("[data-cs]").forEach((x) => x.classList.toggle("on", x === c)))); $("#back").addEventListener("click", classes); $("#c-title").focus(); $("#save-class").addEventListener("click", async () => { const title = $("#c-title").value.trim(); if (!title) return alert("A title is all it needs 🙂"); const r = await api("/api/courses", { title, subject: $("#c-subject").value.trim(), source: $("#c-source").value.trim(), url: $("#c-url").value.trim(), notes: $("#c-notes").value.trim(), status: view.querySelector("[data-cs].on")?.dataset.cs || "taking" }); classDetail(r.id); }); } async function classDetail(id) { const c = await api(`/api/course?id=${id}`); if (!c) return classes(); const up = uploadWidget("course", id, () => classDetail(id), CLASS_TAGS); const page = (p) => `

${esc(p.title || "Page from " + p.created.slice(0, 10))}

${esc(p.created.slice(0, 10))}

${esc(p.text)}

`; view.innerHTML = `

${esc(c.title)}

${c.subject ? `${esc(c.subject)} ` : ""}${c.source ? `${esc(c.source)} ` : ""}${c.url ? `open link ↗` : ""}
${CLASS_STATUS.map(([k, l]) => `${l}`).join("")}

Photos

${c.photos.map((ph) => ``).join("")}

Handouts & files

${c.files.map((f) => fileRow(f, false)).join("") || '
nothing attached yet
'} ${up.html}
${sectionH("New page ✍️")}
⌘/Ctrl+Enter also saves
${sectionH(`Pages (${c.pages.length})`)} ${c.pages.map(page).join("") || '
no pages yet — your first class notes go above ✨
'}`; $("#back").addEventListener("click", classes); view.querySelectorAll("[data-cs]").forEach((ch) => ch.addEventListener("click", async () => { await api("/api/course/update", { id, status: ch.dataset.cs }); classDetail(id); })); $("#save-notes").addEventListener("click", async () => { await api("/api/course/update", { id, notes: $("#c-notes").value }); $("#notes-said").textContent = "saved ✓"; setTimeout(() => { const s = $("#notes-said"); if (s) s.textContent = ""; }, 3000); }); $("#ph-add").addEventListener("click", () => $("#ph-file").click()); $("#ph-file").addEventListener("change", async (e) => { const f = e.target.files[0]; if (f) { await fetch(`/api/photos?entity=course&id=${id}&name=${encodeURIComponent(f.name)}`, { method: "POST", body: f }); classDetail(id); } }); up.wire(); wireFileDeletes(() => classDetail(id)); $("#del-class").addEventListener("click", async () => { if (confirm(`Delete "${c.title}" and all its pages?`)) { await api("/api/course/delete", { id }); classes(); } }); const addPage = async () => { const text = $("#np-text").value.trim(); if (!text) return $("#np-text").focus(); await api("/api/course/pages", { course_id: id, title: $("#np-title").value.trim(), text }); classDetail(id); }; $("#np-save").addEventListener("click", addPage); $("#np-text").addEventListener("keydown", (e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); addPage(); } }); $("#np-title").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); $("#np-text").focus(); } }); const pageEl = (pid) => view.querySelector(`[data-page="${pid}"]`); const toggleEdit = (pid, on) => { const el = pageEl(pid); el.querySelector(".pageform").hidden = !on; el.querySelector(".page-text").hidden = on; if (on) el.querySelector(".pe-text").focus(); }; view.querySelectorAll("[data-pedit]").forEach((b) => b.addEventListener("click", () => toggleEdit(Number(b.dataset.pedit), true))); view.querySelectorAll("[data-pcancel]").forEach((b) => b.addEventListener("click", () => toggleEdit(Number(b.dataset.pcancel), false))); view.querySelectorAll("[data-psave]").forEach((b) => b.addEventListener("click", async () => { const pid = Number(b.dataset.psave), el = pageEl(pid); await api("/api/course/pages/update", { id: pid, title: el.querySelector(".pe-title").value.trim(), text: el.querySelector(".pe-text").value }); classDetail(id); })); view.querySelectorAll("[data-pdel]").forEach((b) => b.addEventListener("click", async () => { if (confirm("Delete this page?")) { await api("/api/course/pages/delete", { id: Number(b.dataset.pdel) }); classDetail(id); } })); } // ── Mind Dump (ideas, categorized) ─────────────────────────────── const IDEA_CATS = [["make", "🏺 things to make"], ["marketing", "📣 marketing"], ["packaging", "📦 packaging"], ["display", "🎪 display & booth"], ["shop", "🛍️ shop & site"], ["other", "💭 other"]]; const MEDIA = ["clay", "wood", "silver", "metal", "mixed"]; const IDEA_STATUS = [["spark", "✨ spark"], ["trying", "🔧 trying it"], ["made", "✅ did it"], ["parked", "💤 parked"]]; const IDEA_HINT = "⌨️ Enter saves · Shift+Enter = new line · Tab switches category · press / from anywhere"; let ideaFilter = "all"; const catLabel = (k) => (IDEA_CATS.find(([c]) => c === k) || [k, "🗂️ unsorted"])[1]; async function ideas() { const list = await api("/api/ideas"); const count = (fn) => list.filter(fn).length; const live = (i) => i.status !== "parked"; const shown = list.filter((i) => ideaFilter === "all" ? live(i) : ideaFilter === "unsorted" ? !i.category && live(i) : ideaFilter === "parked" ? i.status === "parked" : i.category === ideaFilter && live(i)); const small = 'style="font-size:11.5px;padding:3px 9px"'; const card = (i) => `

${esc(i.text)}

${catLabel(i.category)} ${i.medium ? `${esc(i.medium)} ` : ""}${esc(i.created.slice(0, 10))}
${i.photos.length ? `
${i.photos.map((p) => ``).join("")}
` : ""}
${IDEA_STATUS.map(([k, l]) => `${l}`).join("")}
file under${IDEA_CATS.map(([k, l]) => `${l}`).join("")}
${i.category === "make" ? `
medium${MEDIA.map((m) => `${m}`).join("")}
` : ""}
`; const filterChip = (k, label, n) => n || k === "all" ? `${label}${n ? ` ${n}` : ""}` : ""; const filters = filterChip("all", "everything", count(live)) + filterChip("unsorted", "🗂️ unsorted", count((i) => !i.category && live(i))) + IDEA_CATS.map(([k, l]) => filterChip(k, l, count((i) => i.category === k && live(i)))).join("") + filterChip("parked", "💤 parked", count((i) => i.status === "parked")); let body; if (ideaFilter === "all") { const groups = [["", "🗂️ unsorted — file these when you have a minute"], ...IDEA_CATS].map(([k, l]) => [k, l, shown.filter((i) => (i.category || "") === k)]).filter(([, , g]) => g.length); body = groups.map(([, l, g]) => sectionH(l) + `
${g.map(card).join("")}
`).join("") || '
empty head, full studio — dump the first idea above 💭
'; } else { body = `
${shown.map(card).join("") || '
nothing here yet
'}
`; } view.innerHTML = `
file under…${IDEA_CATS.map(([k, l], i) => `${l}`).join("")}
medium${MEDIA.map((m) => `${m}`).join("")}
${IDEA_HINT}
${filters}
${body} `; // capture const jot = $("#jot"); const catChips = [...view.querySelectorAll("[data-ncat]")]; const selectCat = (c) => { catChips.forEach((x) => x.classList.toggle("on", x === c)); $("#medium-row").hidden = c.dataset.ncat !== "make"; }; catChips.forEach((c) => c.addEventListener("click", () => selectCat(c))); view.querySelectorAll("[data-nmed]").forEach((c) => c.addEventListener("click", () => view.querySelectorAll("[data-nmed]").forEach((x) => x.classList.toggle("on", x === c && !x.classList.contains("on"))))); const save = async () => { const text = jot.value.trim(); if (!text) return; const category = view.querySelector("[data-ncat].on")?.dataset.ncat || "make"; await api("/api/ideas", { text, category, medium: category === "make" ? view.querySelector("[data-nmed].on")?.dataset.nmed || "" : "" }); await ideas(); const j = $("#jot"); if (j) { j.value = ""; j.focus(); } jotSaid("💭 saved — it's in the pile"); }; $("#idea-save").addEventListener("click", save); jot.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); save(); } else if (e.key === "Tab") { e.preventDefault(); const i = catChips.findIndex((c) => c.classList.contains("on")); selectCat(catChips[(i + (e.shiftKey ? catChips.length - 1 : 1)) % catChips.length]); } }); jot.addEventListener("input", () => setTimeout(() => { jot.style.height = "auto"; jot.style.height = jot.scrollHeight + "px"; })); // filters view.querySelectorAll("[data-if]").forEach((c) => c.addEventListener("click", () => { ideaFilter = c.dataset.if; ideas(); })); // per-card actions const idOf = (el) => Number(el.closest("[data-idea]").dataset.idea); view.querySelectorAll("[data-ist]").forEach((c) => c.addEventListener("click", async () => { await api("/api/ideas/update", { id: idOf(c), status: c.dataset.ist }); ideas(); })); view.querySelectorAll("[data-irefile]").forEach((b) => b.addEventListener("click", () => { const r = b.closest("[data-idea]").querySelector(".refile"); r.hidden = !r.hidden; })); view.querySelectorAll("[data-icat]").forEach((c) => c.addEventListener("click", async () => { const cat = c.dataset.icat; await api("/api/ideas/update", { id: idOf(c), category: cat, ...(cat === "make" ? {} : { medium: "" }) }); ideas(); })); view.querySelectorAll("[data-imed]").forEach((c) => c.addEventListener("click", async () => { await api("/api/ideas/update", { id: idOf(c), medium: c.classList.contains("on") ? "" : c.dataset.imed }); ideas(); })); view.querySelectorAll("[data-itodo]").forEach((b) => b.addEventListener("click", async () => { const text = b.closest("[data-idea]").querySelector("p").textContent; await api("/api/todos/add", { text: text.split("\n")[0].slice(0, 200) }); b.textContent = "✓ on your to-do list"; b.disabled = true; })); let photoTarget = null; view.querySelectorAll("[data-iphoto]").forEach((b) => b.addEventListener("click", () => { photoTarget = idOf(b); $("#idea-ph").click(); })); $("#idea-ph").addEventListener("change", async (e) => { const f = e.target.files[0]; if (f && photoTarget) { await fetch(`/api/photos?entity=idea&id=${photoTarget}&name=${encodeURIComponent(f.name)}`, { method: "POST", body: f }); ideas(); } }); view.querySelectorAll("[data-idel]").forEach((b) => b.addEventListener("click", async () => { if (confirm("Delete this idea?")) { await api("/api/ideas/delete", { id: idOf(b) }); ideas(); } })); } document.getElementById("signout")?.addEventListener("click", async () => { await api("/api/logout", {}); location.href = "/login"; }); // ── version + one-tap update (OTM self-update) ─────────────────── const foot = $("#foot"); const said = (m) => { const s = $("#up-said"); if (s) s.textContent = m ? " · " + m : ""; }; const get = (path) => fetch(path).then((r) => r.json()).catch(() => ({})); const newerThan = (a, b) => { // "0.4.1" is newer than "0.4.0" const p = (v) => [0, 1, 2].map((i) => Number(String(v || "").split(".")[i]) || 0); const [x, y] = [p(a), p(b)]; for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i]; return false; }; // the container restarts mid-update, so tolerate failures and watch for the version to flip async function awaitUpdate(from) { for (let i = 0; i < 130; i++) { await new Promise((r) => setTimeout(r, 3000)); const v = (await get("/api/health")).version; if (v && v !== from) return location.reload(); } said("still building — check back in a few minutes"); } async function versionLine() { if (!foot) return; const running = (await get("/api/health")).version || ""; if (!running) return; // unknown version → no chip (never a false "update!") // the update check costs OTM a Gitea round-trip — cache it for 10 minutes let s; try { s = JSON.parse(sessionStorage.getItem("bms-update") || "null"); } catch { s = null; } if (!s || Date.now() - s._at > 600e3 || s.status?.state === "running") { s = await get("/api/account/self-update"); // 503 when self-update isn't wired — just no chip s._at = Date.now(); try { sessionStorage.setItem("bms-update", JSON.stringify(s)); } catch { /* private mode */ } } const updating = s.status?.state === "running"; foot.innerHTML = `🌙 Studio Notebook${running ? " v" + esc(running) : ""} · bms.poweredbyotm.com` + (!updating && newerThan(s.latest, running) ? ` ✨ update to v${esc(s.latest)}` : "") + `${updating ? " · updating the studio… ✨" : ""}`; $("#up-go")?.addEventListener("click", async () => { $("#up-go").remove(); said("starting the update — this takes a few minutes ✨"); const r = await fetch("/api/account/self-update", { method: "POST" }).then((x) => x.json()).catch(() => ({})); if (!r.ok) return said(r.message || r.error || "couldn't start the update"); awaitUpdate(running); }); if (updating) awaitUpdate(running); } render(); versionLine(); // a session opened through OTM "Log in as admin" is visibly marked — the owner // (and the operator) should always be able to tell it apart from a real login (async () => { const me = await get("/api/me"); if (!me.operator) return; const b = document.createElement("div"); b.textContent = `🔑 support session — ${me.operator} is signed in as the studio owner`; b.style.cssText = "background:#f3e7f5;color:#6e4f78;text-align:center;padding:6px 10px;font-size:13px;"; document.body.prepend(b); })();