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

@@ -1 +1 @@
export const APP_VERSION = "0.4.0"; export const APP_VERSION = "0.5.0";

4
app/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "bms-backbone", "name": "bms-backbone",
"version": "0.4.0", "version": "0.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bms-backbone", "name": "bms-backbone",
"version": "0.4.0", "version": "0.5.0",
"dependencies": { "dependencies": {
"@prisma/client": "^6.7.0", "@prisma/client": "^6.7.0",
"bcryptjs": "^3.0.2" "bcryptjs": "^3.0.2"

View File

@@ -1,6 +1,6 @@
{ {
"name": "bms-backbone", "name": "bms-backbone",
"version": "0.4.0", "version": "0.5.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -167,3 +167,36 @@ model PriceEntry {
note String @default("") note String @default("")
created DateTime @default(now()) created DateTime @default(now())
} }
// ── Classes binder — a course, workshop, book, or video series Bonna learns from
model Course {
id Int @id @default(autoincrement())
title String
subject String @default("") // methods · skills · marketing · business · glaze chemistry …
source String @default("") // teacher / school / author / channel
url String @default("")
status String @default("taking") // taking · want · done
notes String @default("")
created DateTime @default(now())
pages CoursePage[]
}
model CoursePage {
id Int @id @default(autoincrement())
courseId Int
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
title String @default("")
text String
created DateTime @default(now())
updated DateTime @default(now())
}
// ── Mind dump — ideas, filed by category ("" = unsorted, from the Today jot box)
model Idea {
id Int @id @default(autoincrement())
text String
category String @default("") // make · marketing · packaging · display · shop · other
medium String @default("") // for "make": clay · wood · silver · metal · mixed
status String @default("spark") // spark · trying · made · parked
created DateTime @default(now())
}

View File

@@ -19,7 +19,7 @@ document.querySelectorAll("#nav button").forEach((b) =>
render(); 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) ───────────────────────────────────────────── // ── jot box (shared) ─────────────────────────────────────────────
const JOT_HINT = "⌨️ Enter saves · Shift+Enter = subtask line · Tab switches type · press / from anywhere to jot"; 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(); await after();
const j = $("#jot"); const j = $("#jot");
if (j) { j.value = ""; j.focus(); } // stay in the box — rapid-fire jotting 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!` if (kind === "suggestion") jotSaid(r.issue ? `💡 sent to Tony — suggestion #${r.issue}, thank you!`
: "💡 kept in your notes — it'll get passed along"); : "💡 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="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="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>`; : `<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="grid g2">
<div class="card"><h3>To-do</h3>${todoRows}</div> <div class="card"><h3>To-do</h3>${todoRows}</div>
<div class="card"><h3>Shopping list 🧺</h3>${shopRows}</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 class="meta">${fmtSize(f.size)} · ${esc(f.created.slice(0, 10))}</span></span>
<span>${f.tag ? `<span class="pill butter">${esc(f.tag)}</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.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>`; <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 { return {
html: `<div class="capture mt"><div class="row1"> html: `<div class="capture mt"><div class="row1">
<button class="btn soft" id="file-pick">⬆️ Upload file(s)</button> <button class="btn soft" id="file-pick">⬆️ Upload file(s)</button>
<input type="file" id="file-input" multiple hidden></div> <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() { wire() {
view.querySelectorAll("[data-ftag]").forEach((c) => view.querySelectorAll("[data-ftag]").forEach((c) =>
c.addEventListener("click", () => view.querySelectorAll("[data-ftag]").forEach((x) => x.classList.toggle("on", x === 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} view.innerHTML = `${up.html}
<div class="card mt">${section("Studio files 🗂️")} <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> ${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(); up.wire();
wireFileDeletes(files); wireFileDeletes(files);
} }
@@ -623,6 +625,254 @@ async function notes() {
wireCapture(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 () => { document.getElementById("signout")?.addEventListener("click", async () => {
await api("/api/logout", {}); await api("/api/logout", {});
location.href = "/login"; location.href = "/login";

View File

@@ -98,6 +98,8 @@
<button data-v="pieces">🏺 Pieces</button> <button data-v="pieces">🏺 Pieces</button>
<button data-v="files">🗂️ Files</button> <button data-v="files">🗂️ Files</button>
<button data-v="sourcing">🧭 Sourcing</button> <button data-v="sourcing">🧭 Sourcing</button>
<button data-v="classes">📚 Classes</button>
<button data-v="ideas">💭 Mind Dump</button>
<button data-v="notes">📒 Notes</button> <button data-v="notes">📒 Notes</button>
</nav> </nav>
<nav style="margin-top:18px"><button id="signout" style="font-size:13px">👋 sign out</button></nav> <nav style="margin-top:18px"><button id="signout" style="font-size:13px">👋 sign out</button></nav>

View File

@@ -109,6 +109,10 @@ const sFile = (f) => ({ id: f.id, entity: f.entity, entity_id: f.entityId, store
const sProduct = (p) => ({ id: p.id, name: p.name, kind: p.kind, category: p.category, price: p.price, const sProduct = (p) => ({ id: p.id, name: p.name, kind: p.kind, category: p.category, price: p.price,
cost: p.cost, qty: p.qty, status: p.status, notes: p.notes, created: fmtDate(p.created) }); cost: p.cost, qty: p.qty, status: p.status, notes: p.notes, created: fmtDate(p.created) });
const sSale = (s) => ({ id: s.id, product_id: s.productId, qty: s.qty, price: s.price, cost: s.cost, created: fmtDate(s.created) }); const sSale = (s) => ({ id: s.id, product_id: s.productId, qty: s.qty, price: s.price, cost: s.cost, created: fmtDate(s.created) });
const sCourse = (c) => ({ id: c.id, title: c.title, subject: c.subject, source: c.source, url: c.url, status: c.status,
notes: c.notes, created: fmtDate(c.created), page_count: c._count?.pages ?? 0 });
const sPage = (p) => ({ id: p.id, course_id: p.courseId, title: p.title, text: p.text, created: fmtDate(p.created), updated: fmtDate(p.updated) });
const sIdea = (i) => ({ id: i.id, text: i.text, category: i.category, medium: i.medium, status: i.status, created: fmtDate(i.created) });
const sSupplier = (s) => ({ id: s.id, name: s.name, kind: s.kind, distance: s.distance, url: s.url, const sSupplier = (s) => ({ id: s.id, name: s.name, kind: s.kind, distance: s.distance, url: s.url,
notes: s.notes, search_url: s.searchUrl, created: fmtDate(s.created) }); notes: s.notes, search_url: s.searchUrl, created: fmtDate(s.created) });
@@ -249,6 +253,10 @@ const routes = {
for (const l of lines) await db.shopping.create({ data: { text: l } }); for (const l of lines) await db.shopping.create({ data: { text: l } });
return { ok: true }; return { ok: true };
} }
if (kind === "idea") { // → mind dump, unsorted
await db.idea.create({ data: { text: b.text } });
return { ok: true };
}
if (kind === "suggestion") { if (kind === "suggestion") {
try { try {
const issue = await fileSuggestion(b.text, session?.email || ""); const issue = await fileSuggestion(b.text, session?.email || "");
@@ -405,11 +413,78 @@ const routes = {
"GET /api/files": async () => { "GET /api/files": async () => {
const files = await db.fileAsset.findMany({ orderBy: { id: "desc" } }); const files = await db.fileAsset.findMany({ orderBy: { id: "desc" } });
const productIds = [...new Set(files.filter((f) => f.entity === "product").map((f) => f.entityId))]; const idsOf = (entity) => [...new Set(files.filter((f) => f.entity === entity).map((f) => f.entityId))];
const products = await db.product.findMany({ where: { id: { in: productIds } } }); const products = await db.product.findMany({ where: { id: { in: idsOf("product") } } });
const courses = await db.course.findMany({ where: { id: { in: idsOf("course") } } });
const nameOf = Object.fromEntries(products.map((p) => [p.id, p.name])); const nameOf = Object.fromEntries(products.map((p) => [p.id, p.name]));
return files.map((f) => ({ ...sFile(f), product_name: f.entity === "product" ? nameOf[f.entityId] ?? null : null })); const courseOf = Object.fromEntries(courses.map((c) => [c.id, c.title]));
return files.map((f) => ({ ...sFile(f), product_name: f.entity === "product" ? nameOf[f.entityId] ?? null : null,
course_name: f.entity === "course" ? courseOf[f.entityId] ?? null : null }));
}, },
// ── Classes binder ─────────────────────────────────────────────
"GET /api/courses": async () =>
(await db.course.findMany({ orderBy: [{ status: "asc" }, { title: "asc" }], include: { _count: { select: { pages: true } } } })).map(sCourse),
"GET /api/course": async (_b, q) => {
const id = Number(q.get("id"));
const c = await db.course.findUnique({ where: { id }, include: { pages: { orderBy: { id: "desc" } } } });
if (!c) return null;
return { ...sCourse(c), pages: c.pages.map(sPage),
photos: (await db.photo.findMany({ where: { entity: "course", entityId: id } })).map(sPhoto),
files: (await db.fileAsset.findMany({ where: { entity: "course", entityId: id }, orderBy: { id: "desc" } })).map(sFile) };
},
"POST /api/courses": async (b) => {
const c = await db.course.create({ data: { title: b.title, subject: b.subject || "", source: b.source || "",
url: b.url || "", status: b.status || "taking", notes: b.notes || "" } });
return { id: c.id };
},
"POST /api/course/update": async (b) => {
const data = {};
for (const k of ["title", "subject", "source", "url", "status", "notes"]) if (typeof b[k] === "string") data[k] = b[k];
await db.course.update({ where: { id: b.id }, data });
return { ok: true };
},
"POST /api/course/delete": async (b) => {
await db.course.delete({ where: { id: b.id } }); // pages cascade
await db.photo.deleteMany({ where: { entity: "course", entityId: b.id } });
for (const f of await db.fileAsset.findMany({ where: { entity: "course", entityId: b.id } })) {
try { unlinkSync(join(FILES, f.stored)); } catch { /* already gone */ }
}
await db.fileAsset.deleteMany({ where: { entity: "course", entityId: b.id } });
return { ok: true };
},
"POST /api/course/pages": async (b) => {
const p = await db.coursePage.create({ data: { courseId: b.course_id, title: b.title || "", text: b.text || "" } });
return { id: p.id };
},
"POST /api/course/pages/update": async (b) => {
await db.coursePage.update({ where: { id: b.id }, data: {
...(typeof b.title === "string" ? { title: b.title } : {}), ...(typeof b.text === "string" ? { text: b.text } : {}), updated: new Date() } });
return { ok: true };
},
"POST /api/course/pages/delete": async (b) => { await db.coursePage.delete({ where: { id: b.id } }); return { ok: true }; },
// ── Mind dump ──────────────────────────────────────────────────
"GET /api/ideas": async () => {
const list = await db.idea.findMany({ orderBy: { id: "desc" } });
const photos = await db.photo.findMany({ where: { entity: "idea" } });
return list.map((i) => ({ ...sIdea(i), photos: photos.filter((p) => p.entityId === i.id).map(sPhoto) }));
},
"POST /api/ideas": async (b) => {
const i = await db.idea.create({ data: { text: b.text, category: b.category || "", medium: b.medium || "", status: b.status || "spark" } });
return { id: i.id };
},
"POST /api/ideas/update": async (b) => {
const data = {};
for (const k of ["text", "category", "medium", "status"]) if (typeof b[k] === "string") data[k] = b[k];
await db.idea.update({ where: { id: b.id }, data });
return { ok: true };
},
"POST /api/ideas/delete": async (b) => {
await db.idea.delete({ where: { id: b.id } });
await db.photo.deleteMany({ where: { entity: "idea", entityId: b.id } });
return { ok: true };
},
"POST /api/files/delete": async (b) => { "POST /api/files/delete": async (b) => {
const f = await db.fileAsset.findUnique({ where: { id: b.id } }); const f = await db.fileAsset.findUnique({ where: { id: b.id } });
if (f) { if (f) {