diff --git a/app/lib/version.mjs b/app/lib/version.mjs index be79714..ea5d250 100644 --- a/app/lib/version.mjs +++ b/app/lib/version.mjs @@ -1 +1 @@ -export const APP_VERSION = "0.4.0"; +export const APP_VERSION = "0.5.0"; diff --git a/app/package-lock.json b/app/package-lock.json index d6fb391..c409951 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "bms-backbone", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bms-backbone", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "@prisma/client": "^6.7.0", "bcryptjs": "^3.0.2" diff --git a/app/package.json b/app/package.json index b14be17..78821d2 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "bms-backbone", - "version": "0.4.0", + "version": "0.5.0", "private": true, "type": "module", "scripts": { diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index 3aad118..ca51339 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -167,3 +167,36 @@ model PriceEntry { note String @default("") 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()) +} diff --git a/app/public/app.js b/app/public/app.js index 7a22feb..f4709c8 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -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() { ? `
🔥 Firing #${s.activeFiring.id} in progress — ${esc(s.activeFiring.type)} ${esc(s.activeFiring.cone)}
` : `
Kiln idle${s.lastUnload ? ` — last unload ${esc(s.lastUnload.unloaded?.slice(0, 10))}` : ""}
`; - view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "✓ to-do"], ["shopping", "🧺 shopping"], ["glaze idea", "🧪 glaze idea"], ["suggestion", "💡 suggestion"]]) + + view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "✓ to-do"], ["shopping", "🧺 shopping"], ["glaze idea", "🧪 glaze idea"], ["idea", "💭 idea"], ["suggestion", "💡 suggestion"]]) + `

To-do

${todoRows}

Shopping list 🧺

${shopRows}
@@ -434,14 +435,15 @@ function fileRow(f, showHome) { ${fmtSize(f.size)} · ${esc(f.created.slice(0, 10))} ${f.tag ? `${esc(f.tag)}` : ""} ${showHome && f.product_name ? `🏺 ${esc(f.product_name)}` : ""} + ${showHome && f.course_name ? `📚 ${esc(f.course_name)}` : ""}
`; } -function uploadWidget(entity, entityId, after) { +function uploadWidget(entity, entityId, after, tags = FILE_TAGS) { return { html: `
-
tag as…${FILE_TAGS.map((t, i) => `${t}`).join("")}
`, +
tag as…${tags.map((t, i) => `${t}`).join("")}
`, 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}
${section("Studio files 🗂️")} ${loose.map((f) => fileRow(f, false)).join("") || '
STLs for master molds, foam form templates, cut files… upload anything ⬆️
'}
- ${attached.length ? `
${section("Attached to pieces 🏺")}${attached.map((f) => fileRow(f, true)).join("")}
` : ""}`; + ${attached.length ? `
${section("Attached to pieces & classes")}${attached.map((f) => fileRow(f, true)).join("")}
` : ""}`; 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) => `

${label}

`; +async function classes() { + const list = await api("/api/courses"); + const card = (c) => `
+

${esc(c.title)}

+
${c.subject ? `${esc(c.subject)} ` : ""}${c.page_count} page${c.page_count === 1 ? "" : "s"}
+ ${c.source ? `
${esc(c.source)}
` : ""}
`; + const group = (key) => list.filter((c) => (c.status || "taking") === key); + view.innerHTML = `
+ courses, workshops, books, video series — one binder tab each
+ ${CLASS_STATUS.map(([k, label]) => { + const g = group(k); + if (!g.length && k !== "taking") return ""; + return sectionH(label) + `
${g.map(card).join("") || + '
your binder starts here — add a class, a workshop, a book, or a video series 📚
'}
`; + }).join("")}`; + $("#new-class").addEventListener("click", newClassForm); + view.querySelectorAll("[data-c]").forEach((c) => c.addEventListener("click", () => classDetail(Number(c.dataset.c)))); +} + +function newClassForm() { + view.innerHTML = ` +

New class

+
A title is enough — this can be a course, a workshop, a book, a YouTube series, anything you're learning from.
+
+
+
+ ${SUBJECTS.map((s) => ``).join("")}
+
${CLASS_STATUS.map(([k, l], i) => `${l}`).join("")}
+
+
+
+
+
`; + view.querySelectorAll("[data-cs]").forEach((c) => + c.addEventListener("click", () => view.querySelectorAll("[data-cs]").forEach((x) => x.classList.toggle("on", x === c)))); + $("#back").addEventListener("click", classes); + $("#c-title").focus(); + $("#save-class").addEventListener("click", async () => { + const title = $("#c-title").value.trim(); + if (!title) return alert("A title is all it needs 🙂"); + const r = await api("/api/courses", { title, subject: $("#c-subject").value.trim(), source: $("#c-source").value.trim(), + url: $("#c-url").value.trim(), notes: $("#c-notes").value.trim(), status: view.querySelector("[data-cs].on")?.dataset.cs || "taking" }); + classDetail(r.id); + }); +} + +async function classDetail(id) { + const c = await api(`/api/course?id=${id}`); + if (!c) return classes(); + const up = uploadWidget("course", id, () => classDetail(id), CLASS_TAGS); + const page = (p) => `
+

${esc(p.title || "Page from " + p.created.slice(0, 10))}

+ ${esc(p.created.slice(0, 10))} +
+

${esc(p.text)}

+
`; + view.innerHTML = ` +
+

${esc(c.title)}

+
${c.subject ? `${esc(c.subject)} ` : ""}${c.source ? `${esc(c.source)} ` : ""}${c.url ? `open link ↗` : ""}
+
${CLASS_STATUS.map(([k, l]) => `${l}`).join("")}
+
+ +
+

Photos

+ ${c.photos.map((ph) => ``).join("")} +
+

Handouts & files

+ ${c.files.map((f) => fileRow(f, false)).join("") || '
nothing attached yet
'} + ${up.html}
+
+
+ ${sectionH("New page ✍️")} +
+ + +
⌘/Ctrl+Enter also saves
+ ${sectionH(`Pages (${c.pages.length})`)} + ${c.pages.map(page).join("") || '
no pages yet — your first class notes go above ✨
'}`; + $("#back").addEventListener("click", classes); + view.querySelectorAll("[data-cs]").forEach((ch) => + ch.addEventListener("click", async () => { await api("/api/course/update", { id, status: ch.dataset.cs }); classDetail(id); })); + $("#save-notes").addEventListener("click", async () => { + await api("/api/course/update", { id, notes: $("#c-notes").value }); + $("#notes-said").textContent = "saved ✓"; + setTimeout(() => { const s = $("#notes-said"); if (s) s.textContent = ""; }, 3000); + }); + $("#ph-add").addEventListener("click", () => $("#ph-file").click()); + $("#ph-file").addEventListener("change", async (e) => { + const f = e.target.files[0]; + if (f) { await fetch(`/api/photos?entity=course&id=${id}&name=${encodeURIComponent(f.name)}`, { method: "POST", body: f }); classDetail(id); } + }); + up.wire(); + wireFileDeletes(() => classDetail(id)); + $("#del-class").addEventListener("click", async () => { + if (confirm(`Delete "${c.title}" and all its pages?`)) { await api("/api/course/delete", { id }); classes(); } + }); + const addPage = async () => { + const text = $("#np-text").value.trim(); + if (!text) return $("#np-text").focus(); + await api("/api/course/pages", { course_id: id, title: $("#np-title").value.trim(), text }); + classDetail(id); + }; + $("#np-save").addEventListener("click", addPage); + $("#np-text").addEventListener("keydown", (e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); addPage(); } }); + $("#np-title").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); $("#np-text").focus(); } }); + const pageEl = (pid) => view.querySelector(`[data-page="${pid}"]`); + const toggleEdit = (pid, on) => { + const el = pageEl(pid); + el.querySelector(".pageform").hidden = !on; + el.querySelector(".page-text").hidden = on; + if (on) el.querySelector(".pe-text").focus(); + }; + view.querySelectorAll("[data-pedit]").forEach((b) => b.addEventListener("click", () => toggleEdit(Number(b.dataset.pedit), true))); + view.querySelectorAll("[data-pcancel]").forEach((b) => b.addEventListener("click", () => toggleEdit(Number(b.dataset.pcancel), false))); + view.querySelectorAll("[data-psave]").forEach((b) => + b.addEventListener("click", async () => { + const pid = Number(b.dataset.psave), el = pageEl(pid); + await api("/api/course/pages/update", { id: pid, title: el.querySelector(".pe-title").value.trim(), text: el.querySelector(".pe-text").value }); + classDetail(id); + })); + view.querySelectorAll("[data-pdel]").forEach((b) => + b.addEventListener("click", async () => { + if (confirm("Delete this page?")) { await api("/api/course/pages/delete", { id: Number(b.dataset.pdel) }); classDetail(id); } + })); +} + +// ── Mind Dump (ideas, categorized) ─────────────────────────────── +const IDEA_CATS = [["make", "🏺 things to make"], ["marketing", "📣 marketing"], ["packaging", "📦 packaging"], ["display", "🎪 display & booth"], ["shop", "🛍️ shop & site"], ["other", "💭 other"]]; +const MEDIA = ["clay", "wood", "silver", "metal", "mixed"]; +const IDEA_STATUS = [["spark", "✨ spark"], ["trying", "🔧 trying it"], ["made", "✅ did it"], ["parked", "💤 parked"]]; +const IDEA_HINT = "⌨️ Enter saves · Shift+Enter = new line · Tab switches category · press / from anywhere"; +let ideaFilter = "all"; +const catLabel = (k) => (IDEA_CATS.find(([c]) => c === k) || [k, "🗂️ unsorted"])[1]; +async function ideas() { + const list = await api("/api/ideas"); + const count = (fn) => list.filter(fn).length; + const live = (i) => i.status !== "parked"; + const shown = list.filter((i) => + ideaFilter === "all" ? live(i) + : ideaFilter === "unsorted" ? !i.category && live(i) + : ideaFilter === "parked" ? i.status === "parked" + : i.category === ideaFilter && live(i)); + const small = 'style="font-size:11.5px;padding:3px 9px"'; + const card = (i) => `
+

${esc(i.text)}

+
${catLabel(i.category)} ${i.medium ? `${esc(i.medium)} ` : ""}${esc(i.created.slice(0, 10))}
+ ${i.photos.length ? `
${i.photos.map((p) => ``).join("")}
` : ""} +
${IDEA_STATUS.map(([k, l]) => `${l}`).join("")} +
+
+
file under${IDEA_CATS.map(([k, l]) => `${l}`).join("")}
+ ${i.category === "make" ? `
medium${MEDIA.map((m) => `${m}`).join("")}
` : ""} +
`; + const filterChip = (k, label, n) => n || k === "all" ? `${label}${n ? ` ${n}` : ""}` : ""; + const filters = filterChip("all", "everything", count(live)) + + filterChip("unsorted", "🗂️ unsorted", count((i) => !i.category && live(i))) + + IDEA_CATS.map(([k, l]) => filterChip(k, l, count((i) => i.category === k && live(i)))).join("") + + filterChip("parked", "💤 parked", count((i) => i.status === "parked")); + let body; + if (ideaFilter === "all") { + const groups = [["", "🗂️ unsorted — file these when you have a minute"], ...IDEA_CATS].map(([k, l]) => [k, l, shown.filter((i) => (i.category || "") === k)]).filter(([, , g]) => g.length); + body = groups.map(([, l, g]) => sectionH(l) + `
${g.map(card).join("")}
`).join("") + || '
empty head, full studio — dump the first idea above 💭
'; + } else { + body = `
${shown.map(card).join("") || '
nothing here yet
'}
`; + } + view.innerHTML = `
+ +
+
file under…${IDEA_CATS.map(([k, l], i) => `${l}`).join("")}
+
medium${MEDIA.map((m) => `${m}`).join("")}
+
${IDEA_HINT}
+
${filters}
+ ${body} + `; + // capture + const jot = $("#jot"); + const catChips = [...view.querySelectorAll("[data-ncat]")]; + const selectCat = (c) => { catChips.forEach((x) => x.classList.toggle("on", x === c)); $("#medium-row").hidden = c.dataset.ncat !== "make"; }; + catChips.forEach((c) => c.addEventListener("click", () => selectCat(c))); + view.querySelectorAll("[data-nmed]").forEach((c) => + c.addEventListener("click", () => view.querySelectorAll("[data-nmed]").forEach((x) => x.classList.toggle("on", x === c && !x.classList.contains("on"))))); + const save = async () => { + const text = jot.value.trim(); + if (!text) return; + const category = view.querySelector("[data-ncat].on")?.dataset.ncat || "make"; + await api("/api/ideas", { text, category, medium: category === "make" ? view.querySelector("[data-nmed].on")?.dataset.nmed || "" : "" }); + await ideas(); + const j = $("#jot"); + if (j) { j.value = ""; j.focus(); } + jotSaid("💭 saved — it's in the pile"); + }; + $("#idea-save").addEventListener("click", save); + jot.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); save(); } + else if (e.key === "Tab") { + e.preventDefault(); + const i = catChips.findIndex((c) => c.classList.contains("on")); + selectCat(catChips[(i + (e.shiftKey ? catChips.length - 1 : 1)) % catChips.length]); + } + }); + jot.addEventListener("input", () => setTimeout(() => { jot.style.height = "auto"; jot.style.height = jot.scrollHeight + "px"; })); + // filters + view.querySelectorAll("[data-if]").forEach((c) => c.addEventListener("click", () => { ideaFilter = c.dataset.if; ideas(); })); + // per-card actions + const idOf = (el) => Number(el.closest("[data-idea]").dataset.idea); + view.querySelectorAll("[data-ist]").forEach((c) => + c.addEventListener("click", async () => { await api("/api/ideas/update", { id: idOf(c), status: c.dataset.ist }); ideas(); })); + view.querySelectorAll("[data-irefile]").forEach((b) => + b.addEventListener("click", () => { const r = b.closest("[data-idea]").querySelector(".refile"); r.hidden = !r.hidden; })); + view.querySelectorAll("[data-icat]").forEach((c) => + c.addEventListener("click", async () => { + const cat = c.dataset.icat; + await api("/api/ideas/update", { id: idOf(c), category: cat, ...(cat === "make" ? {} : { medium: "" }) }); + ideas(); + })); + view.querySelectorAll("[data-imed]").forEach((c) => + c.addEventListener("click", async () => { + await api("/api/ideas/update", { id: idOf(c), medium: c.classList.contains("on") ? "" : c.dataset.imed }); ideas(); + })); + view.querySelectorAll("[data-itodo]").forEach((b) => + b.addEventListener("click", async () => { + const text = b.closest("[data-idea]").querySelector("p").textContent; + await api("/api/todos/add", { text: text.split("\n")[0].slice(0, 200) }); + b.textContent = "✓ on your to-do list"; b.disabled = true; + })); + let photoTarget = null; + view.querySelectorAll("[data-iphoto]").forEach((b) => + b.addEventListener("click", () => { photoTarget = idOf(b); $("#idea-ph").click(); })); + $("#idea-ph").addEventListener("change", async (e) => { + const f = e.target.files[0]; + if (f && photoTarget) { await fetch(`/api/photos?entity=idea&id=${photoTarget}&name=${encodeURIComponent(f.name)}`, { method: "POST", body: f }); ideas(); } + }); + view.querySelectorAll("[data-idel]").forEach((b) => + b.addEventListener("click", async () => { + if (confirm("Delete this idea?")) { await api("/api/ideas/delete", { id: idOf(b) }); ideas(); } + })); +} + document.getElementById("signout")?.addEventListener("click", async () => { await api("/api/logout", {}); location.href = "/login"; diff --git a/app/public/index.html b/app/public/index.html index 6e73566..2e14450 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -98,6 +98,8 @@ + + diff --git a/app/server.mjs b/app/server.mjs index c0046a4..457baa2 100644 --- a/app/server.mjs +++ b/app/server.mjs @@ -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) {