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

@@ -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,
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 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,
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 } });
return { ok: true };
}
if (kind === "idea") { // → mind dump, unsorted
await db.idea.create({ data: { text: b.text } });
return { ok: true };
}
if (kind === "suggestion") {
try {
const issue = await fileSuggestion(b.text, session?.email || "");
@@ -405,11 +413,78 @@ const routes = {
"GET /api/files": async () => {
const files = await db.fileAsset.findMany({ orderBy: { id: "desc" } });
const productIds = [...new Set(files.filter((f) => f.entity === "product").map((f) => f.entityId))];
const products = await db.product.findMany({ where: { id: { in: productIds } } });
const idsOf = (entity) => [...new Set(files.filter((f) => f.entity === entity).map((f) => f.entityId))];
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]));
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) => {
const f = await db.fileAsset.findUnique({ where: { id: b.id } });
if (f) {