Files
Bonna-Moon-Studio/app/public/app.js
Bonna ca642046c7 app: Classes binder + Mind Dump — v0.5.0
Two new tabs in the Studio Notebook:

- 📚 Classes: a binder for courses, workshops, books, and video series.
  Each class has a subject, source, link, status (taking / want / done),
  a what-it's-about note, photos, handout files, and dated pages of
  class notes that can be edited in place.
- 💭 Mind Dump: idea capture filed by category (things to make — with a
  medium: clay / wood / silver / metal / mixed — marketing, packaging,
  display & booth, shop & site, other) with spark / trying / did it /
  parked status, sketch photos, one-tap 'make it a to-do', and filters.
  The Today jot box gains a 💭 idea chip; those land as 'unsorted' to
  file later.

Schema: Course, CoursePage, Idea (additive; prisma db push at boot).
Files tab now labels attachments that belong to a class.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 13:10:55 -05:00

939 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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) => `<span class="chip${i === 0 ? " on" : ""}" data-kind="${k}">${label}</span>`).join("");
return `<div class="capture"><div class="row1">
<textarea id="jot" rows="1" style="flex:1;resize:none" placeholder="Jot anything… a thought, a task, something to buy"></textarea>
<button class="go" id="jot-save">Save</button></div>
<div class="chips"><span class="hint">file it as…</span>${chips}</div>
<div class="chips"><span class="hint" id="jot-hint">${JOT_HINT}</span></div></div>`;
}
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 `<div class="todo" ${sub ? 'style="margin-left:26px"' : ""}>
<input type="checkbox" data-t="${t.id}" ${t.done ? "checked" : ""}>
<span class="${t.done ? "done" : going ? "started" : ""}" style="flex:1">${sub ? "↳ " : ""}${esc(t.text)}${going ? ` <span class="pill butter">◐ in progress</span>` : ""}</span>
${t.done ? "" : `<button class="startbtn${going ? " on" : ""}" data-start="${t.id}" title="${going ? "not started after all" : "mark as started"}">◐</button>`}
${sub ? "" : `<button class="chip" data-sub="${t.id}" title="add subtask"></button>`}</div>
${sub ? "" : `<div class="subform" data-subform="${t.id}" hidden style="margin:2px 0 6px 26px">
<input type="text" placeholder="subtask… ⇧Enter = another · Enter = new task · ⌫ empty = undo · Esc" style="width:88%;padding:7px 12px;font-size:13.5px"></div>`}`;
}
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)) || `<div class="empty">nothing yet — jot one above ✨</div>`;
const shopRows = s.shopping.map((t) =>
`<div class="todo"><input type="checkbox" data-s="${t.id}" ${t.done ? "checked" : ""}><span class="${t.done ? "done" : ""}">${esc(t.text)}</span></div>`).join("") || `<div class="empty">list is empty — lucky you 🧺</div>`;
const noteRows = s.notes.map((n) => `<div class="todo"><span>“${esc(n.text.slice(0, 80))}${n.text.length > 80 ? "…" : ""}”</span></div>`).join("") || `<div class="empty">no notes yet</div>`;
const kiln = s.activeFiring
? `<div class="todo"><span>🔥 <b>Firing #${s.activeFiring.id}</b> in progress — ${esc(s.activeFiring.type)} ${esc(s.activeFiring.cone)}</span></div>
<div class="mt"><button class="btn mini" id="goto-firing">go to firing</button></div>`
: `<div class="todo"><span>Kiln idle${s.lastUnload ? ` — last unload ${esc(s.lastUnload.unloaded?.slice(0, 10))}` : ""}</span></div>`;
view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "✓ to-do"], ["shopping", "🧺 shopping"], ["glaze idea", "🧪 glaze idea"], ["idea", "💭 idea"], ["suggestion", "💡 suggestion"]]) +
`<div class="grid g2">
<div class="card"><h3>To-do</h3>${todoRows}</div>
<div class="card"><h3>Shopping list 🧺</h3>${shopRows}</div>
<div class="card"><h3>Kiln</h3>${kiln}</div>
<div class="card"><h3>Recent notes</h3>${noteRows}</div>
</div>`;
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) => `
<div class="card clickable" data-g="${g.id}">
<div class="swatch" style="background:linear-gradient(150deg,${g.swatch || "#cfc8ba"},${g.swatch || "#cfc8ba"}cc)"></div>
<h4>${esc(g.name)}</h4>
<div class="meta">${esc([g.cone, g.atmosphere, g.surface].filter(Boolean).join(" · ") || g.kind)}</div>
</div>`;
const section = (label) => `<h3 style="font-size:12.5px;text-transform:uppercase;letter-spacing:0.1em;color:var(--blush-deep);font-weight:700;margin:22px 0 12px">${label}</h3>`;
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 = `
<div class="mt" style="margin-bottom:14px"><button class="btn" id="new-glaze"> New glaze, slip, or clay body</button></div>
<div class="grid g3">${glazeCards || '<div class="empty">no glazes yet — add your first 🧪</div>'}</div>
${slipCards ? section("Casting slips 🏺") + `<div class="grid g3">${slipCards}</div>` : ""}
${clayCards ? section("Clay bodies 🧱") + `<div class="grid g3">${clayCards}</div>` : ""}`;
$("#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) => `<span class="chip${v === on ? " on" : ""}" data-group="${group}" data-val="${v}">${v}</span>`).join("");
view.innerHTML = `<button class="back" id="back">← back to glazes</button>
<div class="card">
<h4>New glaze</h4>
<div class="meta" style="margin-bottom:14px">Only the name is required — everything else can arrive later.</div>
<div class="field"><label>Name</label><input type="text" id="g-name" placeholder="e.g. Moonrise Blue" style="width:100%"></div>
<div class="grid g2">
<div class="field"><label>Kind</label><div class="chips">${chip("kind", ["glaze", "casting slip", "clay body"], "glaze")}</div></div>
<div class="field"><label>Cone</label><div class="chips">${chip("cone", ["∆04", "∆6", "∆10"], "∆6")}</div></div>
<div class="field"><label>Atmosphere</label><div class="chips">${chip("atmosphere", ["oxidation", "reduction"], "oxidation")}</div></div>
<div class="field"><label>Surface</label><div class="chips">${chip("surface", ["glossy", "satin", "matte"], "")}</div></div>
<div class="field"><label>Where's it from?</label><div class="chips">${chip("source", ["mine", "Glazy", "book / friend"], "mine")}</div></div>
<div class="field"><label>Swatch color</label><div class="chips">${SWATCHES.map((s, i) => `<span class="chip${i === 0 ? " on" : ""}" data-group="swatch" data-val="${s}" style="background:${s};color:#fff">●</span>`).join("")}</div></div>
</div>
<div class="field"><label>Recipe (material + %)</label><div id="mats"></div>
<button class="btn soft mini" id="add-mat"> material</button>
<button class="btn soft mini" id="add-addition"> addition (colorant etc.)</button></div>
<button class="btn" id="save-glaze">Save glaze</button>
</div>`;
const mats = $("#mats");
const addRow = (addition) => {
const row = document.createElement("div");
row.className = "matrow";
row.innerHTML = `<input type="text" placeholder="${addition ? "+ e.g. Copper Carbonate" : "e.g. Custer Feldspar"}" data-add="${addition ? 1 : 0}"><input type="text" placeholder="%" inputmode="decimal">`;
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 = () => `
<table><tr><th>Material</th><th>%</th><th>${size >= 1000 ? size / 1000 + " kg" : size + " g"} batch</th></tr>
${g.materials.map((m) => `<tr><td>${m.addition ? " " : ""}${esc(m.name)}</td><td>${m.pct}</td><td>${batch(m.pct, size)} g</td></tr>`).join("") || '<tr><td colspan="3" class="empty">no recipe yet</td></tr>'}</table>`;
const compat = g.compatibility.length
? `<table><tr><th>Clay body</th><th>Result</th><th>Evidence</th></tr>
${g.compatibility.map((c) => `<tr><td>${esc(c.clay)}</td><td>${esc(c.rating)}</td><td>${c.n} firing${c.n > 1 ? "s" : ""}${c.notes ? " · " + esc(c.notes) : ""}</td></tr>`).join("")}</table>`
: `<div class="empty">no evidence yet — it builds itself from unload days 🌱</div>`;
view.innerHTML = `<button class="back" id="back">← back to glazes</button>
<div class="card">
<div class="swatch" style="max-width:140px;background:linear-gradient(150deg,${g.swatch || "#cfc8ba"},${g.swatch || "#cfc8ba"}cc)"></div>
<h4>${esc(g.name)}</h4>
<div class="meta">${esc([g.kind !== "glaze" ? g.kind : "", g.cone, g.atmosphere, g.surface, g.source].filter(Boolean).join(" · "))}</div>
<div class="mt"><h3>Recipe</h3><div id="recipe">${recipeTable()}</div>
<div class="chips">${batchSizes.map((b) => `<span class="chip${b === size ? " on" : ""}" data-b="${b}">${b >= 1000 ? b / 1000 + " kg" : b + " g"}</span>`).join("")}</div></div>
<div class="mt"><h3>Plays well with…</h3>${compat}</div>
<div class="mt"><h3>Journal</h3>
${g.journal.map((j) => `<div class="result">📒 <b>${esc(j.created.slice(0, 10))}</b> — ${esc(j.text)}</div>`).join("") || '<div class="empty">no entries yet</div>'}
<div class="capture mt"><div class="row1"><input type="text" id="j-text" placeholder="New journal entry…"><button class="go" id="j-save">Add</button></div></div></div>
<div class="mt"><h3>Photos</h3><div class="photos" id="photos">
${g.photos.map((p) => `<img src="/photos/${p.filename}" alt="">`).join("")}
<button class="ph-add" id="ph-add"></button>
<input type="file" id="ph-file" accept="image/*" hidden></div></div>
</div>`;
$("#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 += `<div class="card" style="margin-bottom:14px"><h3>Start a firing 🔥</h3>
<div class="meta" style="margin-bottom:10px">Fill-the-kiln-and-go: pick a type and hit start. Load list is optional, always.</div>
<div class="chips">${["bisque", "glaze"].map((t, i) => `<span class="chip${i === 1 ? " on" : ""}" data-ft="${t}">${t}</span>`).join("")}
${["∆04", "∆6", "∆10"].map((c, i) => `<span class="chip${i === 1 ? " on" : ""}" data-fc="${c}">${c}</span>`).join("")}</div>
<div class="mt"><button class="btn" id="start-firing">Kiln is loaded — start 🔥</button></div></div>`;
} else {
const items = active.items.map((it) => `<div class="loadrow"><span>${esc(it.piece)}</span><span class="meta">${esc([it.clay, it.glaze].filter(Boolean).join(" · "))}</span></div>`).join("");
html += `<div class="card" style="margin-bottom:14px"><h3>Firing #${active.id} — in progress 🔥</h3>
<div class="meta">${esc(active.type)} ${esc(active.cone)} · started ${esc(active.started.slice(0, 16))}</div>
<div class="mt">${items || '<div class="empty">no load list — totally fine! add pieces whenever, even at unload</div>'}</div>
<div class="capture mt"><div class="row1"><input type="text" id="fi-piece" placeholder="piece, e.g. 6 carved mugs"></div>
<div class="row1 mt"><input type="text" id="fi-clay" list="clay-list" placeholder="clay (optional)" style="flex:1">
<datalist id="clay-list">${clayNames.map((n) => `<option>${esc(n)}</option>`).join("")}</datalist>
<select id="fi-glaze"><option value="">glaze (optional)</option>${glazeNames.map((n) => `<option>${esc(n)}</option>`).join("")}</select>
<button class="go" id="fi-add">Add</button></div></div>
<div class="mt"><button class="btn" id="unload">Unload day ✨</button></div></div>`;
}
html += list.filter((f) => f.status === "unloaded").map((f) => `
<div class="card" style="margin-bottom:14px"><h4>Firing #${f.id}${esc(f.type)} ${esc(f.cone)}</h4>
<div class="meta">${esc(f.started.slice(0, 10))} → unloaded ${esc((f.unloaded || "").slice(0, 10))}</div>
${f.items.map((it) => `<div class="loadrow"><span>${it.rating || ""} ${esc(it.piece)}</span><span class="meta">${esc([it.clay, it.glaze].filter(Boolean).join(" · "))}${it.note ? " — " + esc(it.note) : ""}</span></div>`).join("")}
${f.result_notes ? `<div class="result">✨ ${esc(f.result_notes)}</div>` : ""}</div>`).join("");
view.innerHTML = html || '<div class="empty">no firings yet</div>';
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 = `<button class="back" id="back">← firing log</button>
<div class="card"><h4>Firing #${fresh.id} — unload day! ✨</h4>
<div class="meta" style="margin-bottom:10px">Rate each thing as it comes out. One tap each.</div>
${fresh.items.map((it) => `<div class="loadrow"><span>${esc(it.piece)} <span class="meta">${esc([it.clay, it.glaze].filter(Boolean).join(" · "))}</span></span>
<span class="verdict" data-item="${it.id}">${EMOJI.map((e) => `<button data-e="${e}" class="${it.rating === e ? "on" : ""}">${e}</button>`).join("")}</span></div>`).join("") ||
'<div class="empty">no items listed — add what came out below, then rate it</div>'}
<div class="capture mt"><div class="row1"><input type="text" id="fi-piece" placeholder="add something that came out…"><button class="go" id="fi-add">Add</button></div></div>
<div class="field mt"><label>Notes on this firing</label><textarea id="f-notes" rows="2" style="width:100%" placeholder="anything future-you should know"></textarea></div>
<button class="btn" id="save-unload">Save unload ✨</button></div>`;
$("#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 ? `<span class="pill butter">${esc(p.category)}</span>` : "");
const coreCard = (p) => `
<div class="card"><h4 class="clickable" data-open="${p.id}" title="open piece page">${esc(p.name)} <span class="meta"></span></h4>
<div class="mt">${catPill(p)} <span class="pill">${money(p.price)}</span></div>
<div class="loadrow mt"><span>on hand: <b>${p.qty}</b></span>
<span><button class="chip" data-q="${p.id}" data-d="-1"></button>
<button class="chip" data-q="${p.id}" data-d="1"></button>
<button class="btn mini" data-sell="${p.id}" ${p.qty < 1 ? "disabled" : ""}>sold one 💰</button></span></div></div>`;
const STATUSES = ["in progress", "for sale", "sold"];
const oneoffCard = (p) => `
<div class="card"><h4 class="clickable" data-open="${p.id}" title="open piece page">${esc(p.name)} <span class="meta"></span></h4>
<div class="mt">${catPill(p)} <span class="pill sage">${esc(p.kind)}</span> <span class="pill">${money(p.price)}</span></div>
<div class="loadrow mt"><span class="chips">${STATUSES.map((s) => `<span class="chip${p.status === s ? " on" : ""}" data-st="${p.id}" data-v="${s}">${s}</span>`).join("")}</span>
${p.status !== "sold" ? `<button class="btn mini" data-sell="${p.id}">sold 💰</button>` : ""}</div></div>`;
const section = (label) => `<h3 style="font-size:12.5px;text-transform:uppercase;letter-spacing:0.1em;color:var(--blush-deep);font-weight:700;margin:22px 0 12px">${label}</h3>`;
view.innerHTML = `
<div class="card" style="margin-bottom:14px"><h3>Money 💰</h3>
<div class="grid g3">
<div><div class="meta">this month</div><h4>${money(m.monthRevenue)}</h4></div>
<div><div class="meta">all-time sales (${m.salesCount})</div><h4>${money(m.revenue)}</h4></div>
<div><div class="meta">est. profit</div><h4>${money(m.revenue - m.cogs)}</h4></div>
</div>
<div class="meta mt">profit = sales minus each piece's cost estimate · real materials-based costing comes later</div></div>
<div style="margin-bottom:14px"><button class="btn" id="new-piece"> New piece</button></div>
${section("Core line ✨")}
<div class="grid g2">${core.map(coreCard).join("") || '<div class="empty">the reintroduced line starts here — kitchen tools, serveware…</div>'}</div>
${tiles.length ? section("Tiles 🔷") + `<div class="grid g2">${tiles.map(coreCard).join("")}</div>` : ""}
${section("Small batch & one of a kind 🌙")}
<div class="grid g2">${oneoff.map(oneoffCard).join("") || '<div class="empty">sculptural pieces, home scenting, experiments…</div>'}</div>`;
$("#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) => `<span class="chip${v === on ? " on" : ""}" data-group="${group}" data-val="${v}">${v}</span>`).join("");
view.innerHTML = `<button class="back" id="back">← back to pieces</button>
<div class="card"><h4>New piece</h4>
<div class="meta" style="margin-bottom:14px">Name is enough — price, cost, and count can arrive later.</div>
<div class="field"><label>Name</label><input type="text" id="p-name" placeholder="e.g. Carved mug — celadon" style="width:100%"></div>
<div class="grid g2">
<div class="field"><label>Kind</label><div class="chips">${chip("kind", ["core line", "tiles", "small batch", "one of a kind"], "core line")}</div></div>
<div class="field"><label>Category</label><input type="text" id="p-cat" list="cat-list" placeholder="kitchen tools, serveware, sculpture…" style="width:100%">
<datalist id="cat-list"><option>kitchen tools</option><option>serveware</option><option>sculpture</option><option>home scent</option><option>jewelry</option><option>wood</option></datalist></div>
<div class="field"><label>Price $</label><input type="text" id="p-price" inputmode="decimal" placeholder="0" style="width:100%"></div>
<div class="field"><label>Cost estimate $ <span style="text-transform:none;font-weight:400">(materials etc., rough is fine)</span></label><input type="text" id="p-cost" inputmode="decimal" placeholder="0" style="width:100%"></div>
<div class="field"><label>On hand (core line)</label><input type="text" id="p-qty" inputmode="numeric" placeholder="0" style="width:100%"></div>
</div>
<button class="btn" id="save-piece">Save piece</button></div>`;
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 `<div class="loadrow"><span>${fileIcon(f.name)} <a href="/file/${f.stored}" style="color:var(--plum-deep);font-weight:600">${esc(f.name)}</a>
<span class="meta">${fmtSize(f.size)} · ${esc(f.created.slice(0, 10))}</span></span>
<span>${f.tag ? `<span class="pill butter">${esc(f.tag)}</span>` : ""}
${showHome && f.product_name ? `<span class="pill sage">🏺 ${esc(f.product_name)}</span>` : ""}
${showHome && f.course_name ? `<span class="pill sage">📚 ${esc(f.course_name)}</span>` : ""}
<button class="chip" data-fdel="${f.id}" title="delete">🗑</button></span></div>`;
}
function uploadWidget(entity, entityId, after, tags = FILE_TAGS) {
return {
html: `<div class="capture mt"><div class="row1">
<button class="btn soft" id="file-pick">⬆️ Upload file(s)</button>
<input type="file" id="file-input" multiple hidden></div>
<div class="chips"><span class="hint">tag as…</span>${tags.map((t, i) => `<span class="chip${i === 0 ? " on" : ""}" data-ftag="${t}">${t}</span>`).join("")}</div></div>`,
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) => `<h3 style="font-size:12.5px;text-transform:uppercase;letter-spacing:0.1em;color:var(--blush-deep);font-weight:700;margin:22px 0 12px">${label}</h3>`;
view.innerHTML = `${up.html}
<div class="card mt">${section("Studio files 🗂️")}
${loose.map((f) => fileRow(f, false)).join("") || '<div class="empty">STLs for master molds, foam form templates, cut files… upload anything ⬆️</div>'}</div>
${attached.length ? `<div class="card mt">${section("Attached to pieces & classes")}${attached.map((f) => fileRow(f, true)).join("")}</div>` : ""}`;
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 = `<button class="back" id="back">← back to pieces</button>
<div class="card">
<h4>${esc(p.name)}</h4>
<div class="mt">${p.category ? `<span class="pill butter">${esc(p.category)}</span>` : ""}
<span class="pill sage">${esc(p.kind)}</span> <span class="pill">${money(p.price)}</span>
${p.cost ? `<span class="pill">cost ~${money(p.cost)}</span>` : ""}</div>
<div class="loadrow mt">${counted
? `<span>on hand: <b>${p.qty}</b></span><span>
<button class="chip" data-q="-1"></button><button class="chip" data-q="1"></button>
<button class="btn mini" id="sell" ${p.qty < 1 ? "disabled" : ""}>sold one 💰</button></span>`
: `<span class="chips">${STATUSES.map((s) => `<span class="chip${p.status === s ? " on" : ""}" data-st="${s}">${s}</span>`).join("")}</span>
${p.status !== "sold" ? `<button class="btn mini" id="sell">sold 💰</button>` : ""}`}</div>
${p.sales.length ? `<div class="result mt">💰 ${p.sales.length} recent sale${p.sales.length > 1 ? "s" : ""} — last ${esc(p.sales[0].created.slice(0, 10))}</div>` : ""}
<div class="field mt"><label>Notes</label>
<textarea id="p-notes" rows="3" style="width:100%" placeholder="dimensions, glaze combo, what makes it special…">${esc(p.notes || "")}</textarea>
<button class="btn mini soft mt" id="save-notes">save notes</button></div>
<div class="mt"><h3>Photos</h3><div class="photos">
${p.photos.map((ph) => `<img src="/photos/${ph.filename}" alt="">`).join("")}
<button class="ph-add" id="ph-add"></button><input type="file" id="ph-file" accept="image/*" hidden></div></div>
<div class="mt"><h3>Files — molds, templates, cut forms</h3>
${p.files.map((f) => fileRow(f, false)).join("") || '<div class="empty">nothing attached yet</div>'}
${up.html}</div>
</div>`;
$("#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) => `<h3 style="font-size:12.5px;text-transform:uppercase;letter-spacing:0.1em;color:var(--blush-deep);font-weight:700;margin:22px 0 12px">${label}</h3>`;
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) ? ` <span title="was ${money(total(prev))}" style="color:#b3556b">▲ costs more now</span>`
: total(e) < total(prev) ? ` <span title="was ${money(total(prev))}" style="color:#5c7157">▼ cheaper</span>` : "") : "";
const drivable = e.supplier_kind === "local" ? ` <span class="pill sage">🚗 ${esc(e.distance || "drivable")}</span>` : "";
return `<div class="loadrow"><span>${esc(e.supplier)}${drivable} <span class="meta">${esc(e.created.slice(0, 10))}${e.unit ? " · " + esc(e.unit) : ""}${e.note ? " · " + esc(e.note) : ""}</span></span>
<span>${money(e.price)}${e.shipping ? ` <span class="meta">+ ${money(e.shipping)} ship = <b>${money(total(e))}</b></span>` : ""}${trend}
${e.id === bestId && currents.length > 1 ? ' <span class="pill sage">best 🏆</span>' : ""}</span></div>`;
}).join("");
return `<div class="card" style="margin-bottom:14px"><h4>${esc(it.name)}</h4>${rows}</div>`;
};
const supplierCard = (s) => `
<div class="card"><h4>${esc(s.name)}</h4>
<div class="mt"><span class="pill ${s.kind === "local" ? "sage" : ""}">${s.kind === "local" ? "🚗 " + esc(s.distance || "drivable") : "📦 online"}</span>
${s.url ? ` <a href="${esc(s.url)}" target="_blank" class="pill butter">visit site ↗</a>` : ""}</div>
${s.notes ? `<div class="meta mt">${esc(s.notes)}</div>` : ""}</div>`;
const searchable = suppliers.filter((s) => s.search_url);
view.innerHTML = `
${searchable.length ? `<div class="capture"><div class="row1">
<input type="text" id="pc-q" placeholder="🔎 price-check an item across your suppliers — e.g. custer feldspar">
<button class="go" id="pc-go">Search all</button></div>
<div class="chips" id="pc-links"><span class="hint">opens each supplier's live search — real prices, real stock, then log what you see</span></div></div>` : ""}
<div class="capture"><div class="row1">
<input type="text" id="pe-item" placeholder="item — e.g. Custer Feldspar 50 lb" style="flex:2">
<select id="pe-supplier">${suppliers.map((s) => `<option value="${s.id}">${esc(s.name)}</option>`).join("") || "<option value=''>add a supplier first ↓</option>"}</select>
<input type="text" id="pe-price" inputmode="decimal" placeholder="$" style="width:70px">
<input type="text" id="pe-ship" inputmode="decimal" placeholder="ship $" style="width:80px">
<button class="go" id="pe-save">Log price</button></div>
<div class="chips"><span class="hint">log every buy or price-check — the comparisons build themselves 🌱</span></div></div>
${Object.values(items).map(itemCard).join("") || '<div class="empty">no prices logged yet — start with your next order 🧭</div>'}
${section("Suppliers")}
<div class="grid g2">${suppliers.map(supplierCard).join("") || '<div class="empty">MN Clay, the online places…</div>'}</div>
<div class="card mt"><h3> New supplier</h3>
<div class="grid g2">
<div class="field"><label>Name</label><input type="text" id="s-name" placeholder="e.g. Minnesota Clay" style="width:100%"></div>
<div class="field"><label>Type</label><div class="chips">
<span class="chip on" data-skind="online">📦 online</span><span class="chip" data-skind="local">🚗 can drive there</span></div></div>
<div class="field"><label>Distance (if drivable)</label><input type="text" id="s-dist" placeholder="e.g. 45 min" style="width:100%"></div>
<div class="field"><label>Website</label><input type="text" id="s-url" placeholder="https://…" style="width:100%"></div>
<div class="field"><label>Search link <span style="text-transform:none;font-weight:400">(their search page URL with {q} where the words go)</span></label>
<input type="text" id="s-search" placeholder="https://shop.example.com/search?q={q}" style="width:100%"></div>
</div>
<div class="field"><label>Notes</label><input type="text" id="s-notes" placeholder="free shipping over $150, closed Mondays…" style="width:100%"></div>
<button class="btn" id="s-save">Save supplier</button></div>`;
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) =>
`<a class="chip" target="_blank" href="${esc(s.search_url.replace("{q}", encodeURIComponent(q)))}">🔎 ${esc(s.name)} ↗</a>`).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"]]) +
`<div class="grid g2">${list.map((n) => `
<div class="card"><p style="font-size:14.5px">${esc(n.text)}</p>
<div class="mt"><span class="meta">${esc(n.created.slice(0, 16))}</span>
${n.tags ? `<span class="pill">${esc(n.tags)}</span>` : ""}</div></div>`).join("") ||
'<div class="empty">no notes yet — the jot box awaits ✨</div>'}</div>`;
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) => `<h3 style="font-size:12.5px;text-transform:uppercase;letter-spacing:0.1em;color:var(--blush-deep);font-weight:700;margin:22px 0 12px">${label}</h3>`;
async function classes() {
const list = await api("/api/courses");
const card = (c) => `<div class="card clickable" data-c="${c.id}">
<h4>${esc(c.title)}</h4>
<div class="mt">${c.subject ? `<span class="pill butter">${esc(c.subject)}</span> ` : ""}<span class="pill sage">${c.page_count} page${c.page_count === 1 ? "" : "s"}</span></div>
${c.source ? `<div class="meta" style="margin-top:8px">${esc(c.source)}</div>` : ""}</div>`;
const group = (key) => list.filter((c) => (c.status || "taking") === key);
view.innerHTML = `<div style="margin-bottom:14px"><button class="btn" id="new-class"> New class</button>
<span class="meta" style="margin-left:10px">courses, workshops, books, video series — one binder tab each</span></div>
${CLASS_STATUS.map(([k, label]) => {
const g = group(k);
if (!g.length && k !== "taking") return "";
return sectionH(label) + `<div class="grid g3">${g.map(card).join("") ||
'<div class="empty">your binder starts here — add a class, a workshop, a book, or a video series 📚</div>'}</div>`;
}).join("")}`;
$("#new-class").addEventListener("click", newClassForm);
view.querySelectorAll("[data-c]").forEach((c) => c.addEventListener("click", () => classDetail(Number(c.dataset.c))));
}
function newClassForm() {
view.innerHTML = `<button class="back" id="back">← back to classes</button>
<div class="card"><h4>New class</h4>
<div class="meta" style="margin-bottom:14px">A title is enough — this can be a course, a workshop, a book, a YouTube series, anything you're learning from.</div>
<div class="field"><label>Title</label><input type="text" id="c-title" placeholder="e.g. Slipcasting basics — Sarah's workshop" style="width:100%"></div>
<div class="grid g2">
<div class="field"><label>Subject</label><input type="text" id="c-subject" list="subject-list" placeholder="methods, marketing, skills…" style="width:100%">
<datalist id="subject-list">${SUBJECTS.map((s) => `<option>${s}</option>`).join("")}</datalist></div>
<div class="field"><label>Status</label><div class="chips">${CLASS_STATUS.map(([k, l], i) => `<span class="chip${i === 0 ? " on" : ""}" data-cs="${k}">${l}</span>`).join("")}</div></div>
<div class="field"><label>Teacher / where it's from</label><input type="text" id="c-source" placeholder="teacher, school, author, channel…" style="width:100%"></div>
<div class="field"><label>Link</label><input type="text" id="c-url" placeholder="https://…" style="width:100%"></div>
</div>
<div class="field"><label>What it's about</label><textarea id="c-notes" rows="2" style="width:100%" placeholder="why you're taking it, what you hope to get out of it…"></textarea></div>
<button class="btn" id="save-class">Save class</button></div>`;
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) => `<div class="card mt" data-page="${p.id}">
<div class="loadrow" style="border:none;padding:0"><h4>${esc(p.title || "Page from " + p.created.slice(0, 10))}</h4>
<span><span class="meta">${esc(p.created.slice(0, 10))}</span>
<button class="chip" data-pedit="${p.id}">✏️ edit</button> <button class="chip" data-pdel="${p.id}" title="delete page">🗑</button></span></div>
<p class="page-text" style="white-space:pre-wrap;font-size:14.5px;margin-top:8px">${esc(p.text)}</p>
<div class="pageform" hidden style="margin-top:8px">
<input type="text" class="pe-title" value="${esc(p.title)}" placeholder="page title (optional)" style="width:100%;margin-bottom:8px">
<textarea class="pe-text" rows="8" style="width:100%">${esc(p.text)}</textarea>
<div class="mt"><button class="btn mini" data-psave="${p.id}">save</button> <button class="btn mini soft" data-pcancel="${p.id}">cancel</button></div></div></div>`;
view.innerHTML = `<button class="back" id="back">← back to classes</button>
<div class="card">
<h4>${esc(c.title)}</h4>
<div class="mt">${c.subject ? `<span class="pill butter">${esc(c.subject)}</span> ` : ""}${c.source ? `<span class="meta">${esc(c.source)}</span> ` : ""}${c.url ? `<a href="${esc(c.url)}" target="_blank" class="pill">open link ↗</a>` : ""}</div>
<div class="chips mt">${CLASS_STATUS.map(([k, l]) => `<span class="chip${(c.status || "taking") === k ? " on" : ""}" data-cs="${k}">${l}</span>`).join("")}</div>
<div class="field mt"><label>What it's about</label>
<textarea id="c-notes" rows="2" style="width:100%" placeholder="why you're taking it, what you hope to get out of it…">${esc(c.notes || "")}</textarea>
<button class="btn mini soft mt" id="save-notes">save</button> <span class="meta" id="notes-said"></span></div>
<div class="mt"><h3>Photos</h3><div class="photos">
${c.photos.map((ph) => `<img src="/photos/${ph.filename}" alt="">`).join("")}
<button class="ph-add" id="ph-add"></button><input type="file" id="ph-file" accept="image/*" hidden></div></div>
<div class="mt"><h3>Handouts &amp; files</h3>
${c.files.map((f) => fileRow(f, false)).join("") || '<div class="empty">nothing attached yet</div>'}
${up.html}</div>
<div class="mt" style="text-align:right"><button class="chip" id="del-class">🗑 delete this class</button></div>
</div>
${sectionH("New page ✍️")}
<div class="capture">
<input type="text" id="np-title" placeholder="page title (optional) — e.g. Week 2: pricing your work" style="width:100%;margin-bottom:8px">
<textarea id="np-text" rows="4" style="width:100%" placeholder="notes from class… write as much as you like, this is your binder"></textarea>
<div class="row1 mt"><button class="go" id="np-save">Add page</button><span class="meta" style="align-self:center">⌘/Ctrl+Enter also saves</span></div></div>
${sectionH(`Pages (${c.pages.length})`)}
${c.pages.map(page).join("") || '<div class="empty">no pages yet — your first class notes go above ✨</div>'}`;
$("#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) => `<div class="card" data-idea="${i.id}">
<p style="font-size:14.5px;white-space:pre-wrap">${esc(i.text)}</p>
<div class="mt"><span class="pill${i.category ? " butter" : ""}">${catLabel(i.category)}</span> ${i.medium ? `<span class="pill sage">${esc(i.medium)}</span> ` : ""}<span class="meta">${esc(i.created.slice(0, 10))}</span></div>
${i.photos.length ? `<div class="photos">${i.photos.map((p) => `<img src="/photos/${p.filename}" alt="">`).join("")}</div>` : ""}
<div class="chips" style="margin-top:10px">${IDEA_STATUS.map(([k, l]) => `<span class="chip${i.status === k ? " on" : ""}" data-ist="${k}" ${small}>${l}</span>`).join("")}
<span style="flex:1"></span><button class="chip" data-irefile ${small}>${i.category ? "🗂️ re-file" : "🗂️ file it"}</button><button class="chip" data-itodo ${small}>✓ to-do</button><button class="chip" data-iphoto ${small}>📷</button><button class="chip" data-idel ${small}>🗑</button></div>
<div class="refile" ${i.category ? "hidden" : ""}>
<div class="chips"><span class="hint">file under</span>${IDEA_CATS.map(([k, l]) => `<span class="chip${i.category === k ? " on" : ""}" data-icat="${k}" ${small}>${l}</span>`).join("")}</div>
${i.category === "make" ? `<div class="chips"><span class="hint">medium</span>${MEDIA.map((m) => `<span class="chip${i.medium === m ? " on" : ""}" data-imed="${m}" ${small}>${m}</span>`).join("")}</div>` : ""}
</div></div>`;
const filterChip = (k, label, n) => n || k === "all" ? `<span class="chip${ideaFilter === k ? " on" : ""}" data-if="${k}">${label}${n ? ` <span style="opacity:.7">${n}</span>` : ""}</span>` : "";
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) + `<div class="grid g2">${g.map(card).join("")}</div>`).join("")
|| '<div class="empty">empty head, full studio — dump the first idea above 💭</div>';
} else {
body = `<div class="grid g2" style="margin-top:14px">${shown.map(card).join("") || '<div class="empty">nothing here yet</div>'}</div>`;
}
view.innerHTML = `<div class="capture"><div class="row1">
<textarea id="jot" rows="1" style="flex:1;resize:none" placeholder="Dump it here… a piece to make, a booth idea, a packaging thought, anything"></textarea>
<button class="go" id="idea-save">Save</button></div>
<div class="chips"><span class="hint">file under…</span>${IDEA_CATS.map(([k, l], i) => `<span class="chip${i === 0 ? " on" : ""}" data-ncat="${k}">${l}</span>`).join("")}</div>
<div class="chips" id="medium-row"><span class="hint">medium</span>${MEDIA.map((m) => `<span class="chip" data-nmed="${m}">${m}</span>`).join("")}</div>
<div class="chips"><span class="hint" id="jot-hint">${IDEA_HINT}</span></div></div>
<div class="chips" style="margin:0 0 4px">${filters}</div>
${body}
<input type="file" id="idea-ph" accept="image/*" hidden>`;
// 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) ? ` <span class="chip" id="up-go">✨ update to v${esc(s.latest)}</span>` : "")
+ `<span id="up-said">${updating ? " · updating the studio… ✨" : ""}</span>`;
$("#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);
})();