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>
This commit is contained in:
Bonna
2026-09-03 13:10:55 -05:00
parent 9e21895232
commit ca642046c7
7 changed files with 372 additions and 12 deletions

View File

@@ -19,7 +19,7 @@ document.querySelectorAll("#nav button").forEach((b) =>
render();
}));
function render() { ({ today, glazes, firings, pieces, files, sourcing, notes })[current](); }
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";
@@ -44,6 +44,7 @@ function wireCapture(after) {
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");
};
@@ -100,7 +101,7 @@ async function today() {
? `<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"], ["suggestion", "💡 suggestion"]]) +
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>
@@ -434,14 +435,15 @@ function fileRow(f, showHome) {
<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) {
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>${FILE_TAGS.map((t, i) => `<span class="chip${i === 0 ? " on" : ""}" data-ftag="${t}">${t}</span>`).join("")}</div></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))));
@@ -472,7 +474,7 @@ async function files() {
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 🏺")}${attached.map((f) => fileRow(f, true)).join("")}</div>` : ""}`;
${attached.length ? `<div class="card mt">${section("Attached to pieces & classes")}${attached.map((f) => fileRow(f, true)).join("")}</div>` : ""}`;
up.wire();
wireFileDeletes(files);
}
@@ -623,6 +625,254 @@ async function notes() {
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";