// 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 = () => `
| Material | % | ${size >= 1000 ? size / 1000 + " kg" : size + " g"} batch |
${g.materials.map((m) => `| ${m.addition ? "+ " : ""}${esc(m.name)} | ${m.pct} | ${batch(m.pct, size)} g |
`).join("") || '| no recipe yet |
'}
`;
const compat = g.compatibility.length
? `| Clay body | Result | Evidence |
${g.compatibility.map((c) => `| ${esc(c.clay)} | ${esc(c.rating)} | ${c.n} firing${c.n > 1 ? "s" : ""}${c.notes ? " · " + esc(c.notes) : ""} |
`).join("")}
`
: `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
'}
`;
$("#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
'}
`;
}
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 = `
`;
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: ``,
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))}
` : ""}
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 ? `` : ""}
${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…
'}
`;
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.
`;
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("")}
Handouts & files
${c.files.map((f) => fileRow(f, false)).join("") || '
nothing attached yet
'}
${up.html}
${sectionH("New page ✍️")}
${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);
})();