Studio Notebook: vision, Phase 1 designs, and working prototype

- VISION.md: the full studio-backbone product vision (craft core, operations,
  selling, daily driver), Bonna's approved design decisions, and phasing
- design/: two clickable Phase 1 mockups (moonlit palette, CSS-only tabs)
- prototype/: zero-dependency local app (Node built-ins + SQLite) — Today with
  jot-first capture + subtask outliner, Clay & Glazes library, fill-and-go
  Firing Log with emoji unload ratings feeding evidence tables, Pieces with
  core line/tiles/one-of-a-kind + money card, Files with per-piece attachment,
  Sourcing with price ledger + live supplier search links (9 suppliers seeded)
- CLAUDE.md: access notes updated for the provisioned site + this repo's state

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bonna
2026-08-06 21:46:54 -05:00
commit cd83fd61cb
11 changed files with 1916 additions and 0 deletions

73
prototype/README.md Normal file
View File

@@ -0,0 +1,73 @@
# Studio Notebook — Phase 1 prototype
A zero-dependency local prototype of the studio backbone's Phase 1 (see
[../VISION.md](../VISION.md)). Node's built-in `http` + `node:sqlite` — no npm
install, no node_modules, total footprint a few hundred KB plus photos.
**Run it:** double-click `Start Studio Notebook.command` in the repo root, or:
```sh
node prototype/server.mjs
```
Then open http://localhost:6161. Data lives in `prototype/data/` (gitignored):
`studio.db` (SQLite) and `photos/`. Back that folder up = back up the notebook.
## What works (v0.1)
- **Today** — jot box with "file it as…" chips (note / to-do / shopping / glaze
idea), to-dos **with subtasks**, the one shopping list, kiln status, recent notes
- **Keyboard-first capture** — Enter saves and stays in the box; Shift+Enter
adds subtask lines (first line = task, rest = its subtasks); multiline
shopping jots become one item per line; Tab cycles the file-as chips;
`/` focuses the jot box from anywhere; per-todo opens an inline subtask
input (Enter saves, Esc closes)
- **Clay & Glazes** — one library, three kinds: glazes, casting slips, and clay
bodies, each grouped under its own heading; clay bodies feed an autocomplete
in the firing form so compatibility evidence never splits over spelling;
cards with swatches; new glaze/slip form (name-only minimum,
chips for cone/atmosphere/surface/source); detail page with recipe,
batch scaling (500 g5 kg), journal, photo uploads, and the **"plays well
with" table built exclusively from unload-day ratings** (never hand-edited)
- **Firing Log** — fill-the-kiln-and-go start (type + cone, everything else
optional); load items (piece + clay + glaze) addable any time incl. unload;
**unload day** with 😍🙂😕💔 one-tap ratings that feed the glaze evidence
- **Notes** — the jot stream with tags
- **Pieces** — Bonna's "together but separate" product tracking: **Core line**
(repeatable products with on-hand counts, / / "sold one") vs **Small
batch & one of a kind** (individual pieces with in progress → for sale → sold
status). Each piece carries price + rough cost estimate.
- **Money** — sales recorded on one tap; the money card shows this-month
revenue, all-time revenue, and estimated profit (sales cost estimates).
Real materials-based costing is a later phase.
- **Sidebar navigation** (FMB-style) on desktop; collapses to top chips on
narrow screens.
- **Piece pages** (FMB recipe/listing-style) — click any piece name for its
page: price/cost/stock or status controls, sales history, notes, photos, and
attached files.
- **Files** — upload anything (STL master molds, foam form templates, cut
files); tag as master molds / foam forms / prototypes / other; attach to a
specific piece from its page, or keep in the general studio files area.
Downloads preserve original filenames. Stored in `prototype/data/files/`.
- **Sourcing** — suppliers (online vs 🚗 drivable-with-distance, website,
shipping notes) + a price ledger: log every buy or price-check (item,
supplier, price, shipping). Entries group by item; each shows ▲/▼ trend vs
that supplier's previous price, shipping-inclusive totals, and a "best 🏆"
pill on the cheapest current source. Suppliers may carry a search-URL
template (`…?q={q}`); the price-check box then opens any item's live search
on every such supplier in one click. Fully-automated scraping is deliberately
out (fragile, silently stale) — live-open links + the ledger habit instead;
Claude can also be asked to browse suppliers and log price checks.
## Deliberately not here yet
Stull chart (needs UMF chemistry data — Phase 1.5), molds/slip-casting screens,
inventory, costs, calendar, market scanner, Glazy import, kiln control. See
VISION.md phasing.
## Status
Prototype for design validation + real daily use while the platform question
(where the production code lives — Tony's call, see VISION.md "Open architecture
questions") is pending. The schema here (typed recipes, evidence-only
compatibility, firing lifecycle) is the reference for the production build.

612
prototype/public/app.js vendored Normal file
View File

@@ -0,0 +1,612 @@
// 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);
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, notes })[current](); }
// ── jot box (shared) ─────────────────────────────────────────────
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">⌨️ Enter saves · Shift+Enter = subtask line · Tab switches type · press / from anywhere to jot</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";
await api("/api/jot", { text, kind });
await after();
const j = $("#jot");
if (j) { j.value = ""; j.focus(); } // stay in the box — rapid-fire jotting
};
$("#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);
}
// "/" 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) {
return `<div class="todo" ${sub ? 'style="margin-left:26px"' : ""}>
<input type="checkbox" data-t="${t.id}" ${t.done ? "checked" : ""}>
<span class="${t.done ? "done" : ""}" style="flex:1">${sub ? "↳ " : ""}${esc(t.text)}</span>
${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"]]) +
`<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-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>` : ""}
<button class="chip" data-fdel="${f.id}" title="delete">🗑</button></span></div>`;
}
function uploadWidget(entity, entityId, after) {
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>`,
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 🏺")}${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"]]) +
`<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);
}
render();

109
prototype/public/index.html Normal file
View File

@@ -0,0 +1,109 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>🌙 Studio Notebook</title>
<style>
:root {
--bg: #fdf7f2; --card: #ffffff; --ink: #4a3b47;
--plum: #8d6b94; --plum-deep: #6e4f78; --blush: #f3dbe4; --blush-deep: #c98da8;
--sage: #8fa68a; --sage-soft: #e4ecdf; --butter: #f7ecd8; --line: #eddfe3;
--moon: #f0e6f4;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: "Avenir Next", "Nunito", "Trebuchet MS", sans-serif; background: var(--bg); color: var(--ink); line-height: 1.55; }
.layout { display: flex; min-height: 100vh; }
aside { width: 212px; flex-shrink: 0; background: var(--card); border-right: 1.5px solid var(--line); padding: 26px 16px; position: sticky; top: 0; height: 100vh; }
.wrap { flex: 1; max-width: 900px; margin: 0 auto; padding: 0 20px 80px; min-width: 0; }
header { padding: 26px 0 0; }
h1 { font-family: Georgia, "Iowan Old Style", serif; font-size: 22px; font-weight: 600; color: var(--plum-deep); }
.sub { color: #9c8698; font-size: 14.5px; margin-top: 2px; font-style: italic; font-family: Georgia, serif; }
nav { display: flex; flex-direction: column; gap: 6px; margin-top: 22px; }
nav button { border: none; background: none; color: #a08fa0; padding: 10px 14px; border-radius: 12px; font-size: 14.5px; cursor: pointer; font-weight: 600; font-family: inherit; text-align: left; }
nav button:hover { background: var(--moon); color: var(--plum-deep); }
nav button.on { background: var(--plum); color: #fff; }
@media (max-width: 700px) {
.layout { flex-direction: column; }
aside { width: 100%; height: auto; position: static; border-right: none; border-bottom: 1.5px solid var(--line); padding: 16px 20px; }
nav { flex-direction: row; flex-wrap: wrap; margin-top: 12px; }
nav button { padding: 8px 14px; border-radius: 999px; background: var(--bg); }
nav button.on { background: var(--plum); }
}
.grid { display: grid; gap: 14px; } .g2 { grid-template-columns: 1fr 1fr; } .g3 { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 640px){ .g2, .g3 { grid-template-columns: 1fr; } }
.card { background: var(--card); border: 1.5px solid var(--line); border-radius: 18px; padding: 18px 20px; box-shadow: 0 2px 8px rgba(141,107,148,0.06); }
.card h3 { font-size: 12.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--blush-deep); font-weight: 700; margin-bottom: 12px; }
h4 { font-family: Georgia, serif; font-size: 17px; font-weight: 600; color: var(--plum-deep); }
.meta { color: #9c8698; font-size: 13.5px; }
.capture { background: var(--card); border: 1.5px solid var(--line); border-radius: 18px; padding: 14px 16px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(141,107,148,0.06); }
.capture .row1 { display: flex; gap: 10px; }
input[type=text], textarea, select { border: 1.5px solid var(--line); border-radius: 12px; padding: 11px 14px; font-size: 15px; background: var(--bg); color: var(--ink); font-family: inherit; }
.capture input[type=text] { flex: 1; }
button.go, .btn { border: none; border-radius: 12px; padding: 11px 20px; font-size: 14.5px; font-weight: 700; cursor: pointer; font-family: inherit; background: var(--plum); color: #fff; }
.btn.soft { background: var(--blush); color: var(--plum-deep); }
.btn.mini { padding: 6px 14px; font-size: 13px; }
.chips { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; align-items: center; }
.chips .hint { font-size: 12.5px; color: #b1a0ae; }
.chip { font-size: 12.5px; font-weight: 650; padding: 4px 12px; border-radius: 999px; background: var(--moon); color: var(--plum-deep); border: 1.5px solid transparent; cursor: pointer; }
.chip.on { border-color: var(--plum); background: var(--plum); color: #fff; }
.todo { display: flex; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 14.5px; }
.todo:last-child { border: none; }
.todo input { width: 17px; height: 17px; accent-color: var(--sage); flex-shrink: 0; }
.todo .done { text-decoration: line-through; color: #b1a0ae; }
.pill { display: inline-block; font-size: 12px; font-weight: 650; padding: 3px 11px; border-radius: 999px; background: var(--blush); color: var(--plum-deep); }
.pill.sage { background: var(--sage-soft); color: #5c7157; }
.pill.butter { background: var(--butter); color: #8d6a52; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { text-align: left; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.08em; color: #b1a0ae; font-weight: 700; padding: 4px; }
th:last-child, td:last-child { text-align: right; }
td { padding: 7px 4px; border-bottom: 1px solid var(--line); }
tr:last-child td { border: none; }
.swatch { height: 64px; border-radius: 12px; border: 1px solid rgba(0,0,0,0.05); margin-bottom: 10px; }
.result { background: var(--moon); border-radius: 12px; padding: 10px 14px; font-size: 14px; margin-top: 10px; }
.sparkle { background: var(--sage-soft); border-radius: 12px; padding: 10px 14px; font-size: 13.5px; color: #55624c; margin-top: 12px; }
.photos { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.photos img { width: 72px; height: 72px; border-radius: 10px; object-fit: cover; }
.ph-add { width: 72px; height: 72px; border-radius: 10px; border: 1.5px dashed var(--blush-deep); color: var(--blush-deep); font-size: 26px; background: none; cursor: pointer; }
.mt { margin-top: 14px; }
.clickable { cursor: pointer; }
.verdict { display: flex; gap: 5px; }
.verdict button { font-size: 16px; padding: 4px 9px; border-radius: 10px; background: var(--bg); border: 1.5px solid var(--line); cursor: pointer; }
.verdict button.on { background: var(--sage-soft); border-color: var(--sage); }
.loadrow { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--line); font-size: 14.5px; flex-wrap: wrap; }
.loadrow:last-child { border: none; }
.empty { color: #b1a0ae; font-style: italic; font-size: 14px; padding: 8px 0; }
.back { background: none; border: none; color: var(--plum); font-weight: 700; cursor: pointer; font-size: 14px; font-family: inherit; padding: 0 0 10px; }
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 12px; font-weight: 700; color: var(--blush-deep); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 5px; }
.matrow { display: flex; gap: 8px; margin-bottom: 8px; }
.matrow input:first-child { flex: 1; }
.matrow input:last-of-type { width: 80px; }
footer { text-align: center; color: #b1a0ae; font-size: 13px; margin-top: 32px; font-style: italic; font-family: Georgia, serif; }
</style>
</head>
<body>
<div class="layout">
<aside>
<h1>🌙 Studio<br>Notebook</h1>
<nav id="nav">
<button data-v="today" class="on">☀️ Today</button>
<button data-v="glazes">🧪 Clay &amp; Glazes</button>
<button data-v="firings">🔥 Firing Log</button>
<button data-v="pieces">🏺 Pieces</button>
<button data-v="files">🗂️ Files</button>
<button data-v="sourcing">🧭 Sourcing</button>
<button data-v="notes">📒 Notes</button>
</nav>
</aside>
<div class="wrap">
<header style="padding-bottom:18px">
<div class="sub" id="greeting">welcome back</div>
</header>
<main id="view"></main>
<footer>🌙 Studio Notebook · Phase 1 prototype · your data lives safely on this Mac</footer>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>

282
prototype/server.mjs Normal file
View File

@@ -0,0 +1,282 @@
// Studio Notebook — Phase 1 prototype server
// Zero dependencies: Node's built-in http + sqlite. Run: node prototype/server.mjs
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";
import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, unlinkSync } from "node:fs";
import { join, dirname, extname } from "node:path";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
const ROOT = dirname(fileURLToPath(import.meta.url));
const DATA = join(ROOT, "data");
const PHOTOS = join(DATA, "photos");
mkdirSync(PHOTOS, { recursive: true });
const db = new DatabaseSync(join(DATA, "studio.db"));
db.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY, text TEXT NOT NULL, tags TEXT DEFAULT '',
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY, text TEXT NOT NULL, done INTEGER DEFAULT 0,
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS shopping (
id INTEGER PRIMARY KEY, text TEXT NOT NULL, done INTEGER DEFAULT 0,
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS glazes (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, kind TEXT DEFAULT 'glaze',
cone TEXT DEFAULT '', atmosphere TEXT DEFAULT '', surface TEXT DEFAULT '',
source TEXT DEFAULT 'mine', sg TEXT DEFAULT '', swatch TEXT DEFAULT '',
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS glaze_materials (
id INTEGER PRIMARY KEY, glaze_id INTEGER NOT NULL, name TEXT NOT NULL,
pct REAL NOT NULL, addition INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS glaze_journal (
id INTEGER PRIMARY KEY, glaze_id INTEGER NOT NULL, text TEXT NOT NULL,
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS firings (
id INTEGER PRIMARY KEY, type TEXT DEFAULT 'glaze', cone TEXT DEFAULT '∆6',
schedule TEXT DEFAULT '', status TEXT DEFAULT 'firing',
result_notes TEXT DEFAULT '',
started TEXT DEFAULT (datetime('now','localtime')), unloaded TEXT);
CREATE TABLE IF NOT EXISTS firing_items (
id INTEGER PRIMARY KEY, firing_id INTEGER NOT NULL, piece TEXT NOT NULL,
clay TEXT DEFAULT '', glaze TEXT DEFAULT '', rating TEXT DEFAULT '', note TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, kind TEXT DEFAULT 'core line',
category TEXT DEFAULT '', price REAL DEFAULT 0, cost REAL DEFAULT 0,
qty INTEGER DEFAULT 0, status TEXT DEFAULT 'in progress',
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS sales (
id INTEGER PRIMARY KEY, product_id INTEGER NOT NULL, qty INTEGER DEFAULT 1,
price REAL DEFAULT 0, cost REAL DEFAULT 0,
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS photos (
id INTEGER PRIMARY KEY, entity TEXT NOT NULL, entity_id INTEGER NOT NULL,
filename TEXT NOT NULL, created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS suppliers (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, kind TEXT DEFAULT 'online',
distance TEXT DEFAULT '', url TEXT DEFAULT '', notes TEXT DEFAULT '',
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS price_entries (
id INTEGER PRIMARY KEY, item TEXT NOT NULL, supplier_id INTEGER NOT NULL,
price REAL NOT NULL, unit TEXT DEFAULT '', shipping REAL DEFAULT 0,
note TEXT DEFAULT '', created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY, entity TEXT DEFAULT '', entity_id INTEGER DEFAULT 0,
stored TEXT NOT NULL, name TEXT NOT NULL, tag TEXT DEFAULT '',
size INTEGER DEFAULT 0, created TEXT DEFAULT (datetime('now','localtime')));
`);
const FILES = join(DATA, "files");
mkdirSync(FILES, { recursive: true });
try { db.exec("ALTER TABLE todos ADD COLUMN parent_id INTEGER"); } catch { /* column exists */ }
try { db.exec("ALTER TABLE products ADD COLUMN notes TEXT DEFAULT ''"); } catch { /* column exists */ }
try { db.exec("ALTER TABLE suppliers ADD COLUMN search_url TEXT DEFAULT ''"); } catch { /* column exists */ }
const all = (sql, ...p) => db.prepare(sql).all(...p);
const get = (sql, ...p) => db.prepare(sql).get(...p);
const run = (sql, ...p) => db.prepare(sql).run(...p);
// ── the "plays well with" evidence table, built from unload ratings only ──
function compatibility(glazeName) {
return all(
`SELECT fi.clay, fi.rating, COUNT(*) n, GROUP_CONCAT(NULLIF(fi.note,''), ' · ') notes
FROM firing_items fi JOIN firings f ON f.id = fi.firing_id
WHERE fi.glaze = ? AND fi.rating != '' AND fi.clay != '' AND f.status = 'unloaded'
GROUP BY fi.clay, fi.rating ORDER BY fi.clay, n DESC`, glazeName);
}
const routes = {
"GET /api/summary": () => ({
todos: all("SELECT * FROM todos ORDER BY id DESC LIMIT 60"),
shopping: all("SELECT * FROM shopping ORDER BY done, id DESC LIMIT 12"),
notes: all("SELECT * FROM notes ORDER BY id DESC LIMIT 6"),
activeFiring: get("SELECT * FROM firings WHERE status='firing' ORDER BY id DESC LIMIT 1") ?? null,
lastUnload: get("SELECT * FROM firings WHERE status='unloaded' ORDER BY unloaded DESC LIMIT 1") ?? null,
}),
"POST /api/jot": (b) => {
const kind = b.kind || "note";
const lines = String(b.text).split("\n").map((l) => l.replace(/^[\s\-•↳]+/, "").trim()).filter(Boolean);
if (kind === "todo") {
// first line = the task, following lines = its subtasks
const r = run("INSERT INTO todos (text) VALUES (?)", lines[0]);
for (const sub of lines.slice(1)) run("INSERT INTO todos (text, parent_id) VALUES (?, ?)", sub, r.lastInsertRowid);
return { ok: true };
}
if (kind === "shopping") {
for (const l of lines) run("INSERT INTO shopping (text) VALUES (?)", l);
return { ok: true };
}
return run("INSERT INTO notes (text, tags) VALUES (?, ?)", b.text, b.tags || (kind !== "note" ? kind : ""));
},
"POST /api/todos/add": (b) => {
const r = run("INSERT INTO todos (text, parent_id) VALUES (?, ?)", b.text, b.parent_id ?? null);
return { id: Number(r.lastInsertRowid) };
},
"POST /api/todos/delete": (b) => {
run("DELETE FROM todos WHERE parent_id = ?", b.id);
return run("DELETE FROM todos WHERE id = ?", b.id);
},
"POST /api/todos/toggle": (b) => run("UPDATE todos SET done = 1 - done WHERE id = ?", b.id),
"POST /api/shopping/toggle": (b) => run("UPDATE shopping SET done = 1 - done WHERE id = ?", b.id),
"GET /api/notes": () => all("SELECT * FROM notes ORDER BY id DESC"),
"GET /api/glazes": () => all("SELECT * FROM glazes ORDER BY name").map(g => ({
...g, materials: all("SELECT * FROM glaze_materials WHERE glaze_id = ? ORDER BY addition, pct DESC", g.id),
})),
"GET /api/glaze": (_, q) => {
const g = get("SELECT * FROM glazes WHERE id = ?", Number(q.get("id")));
if (!g) return null;
return { ...g,
materials: all("SELECT * FROM glaze_materials WHERE glaze_id = ? ORDER BY addition, pct DESC", g.id),
journal: all("SELECT * FROM glaze_journal WHERE glaze_id = ? ORDER BY id DESC", g.id),
compatibility: compatibility(g.name),
photos: all("SELECT * FROM photos WHERE entity='glaze' AND entity_id = ?", g.id) };
},
"POST /api/glazes": (b) => {
const r = run(
"INSERT INTO glazes (name, kind, cone, atmosphere, surface, source, sg, swatch) VALUES (?,?,?,?,?,?,?,?)",
b.name, b.kind || "glaze", b.cone || "", b.atmosphere || "", b.surface || "", b.source || "mine", b.sg || "", b.swatch || "");
for (const m of b.materials || [])
run("INSERT INTO glaze_materials (glaze_id, name, pct, addition) VALUES (?,?,?,?)",
r.lastInsertRowid, m.name, m.pct, m.addition ? 1 : 0);
return { id: Number(r.lastInsertRowid) };
},
"POST /api/glaze/journal": (b) => run("INSERT INTO glaze_journal (glaze_id, text) VALUES (?,?)", b.glaze_id, b.text),
"GET /api/firings": () => all("SELECT * FROM firings ORDER BY id DESC").map(f => ({
...f, items: all("SELECT * FROM firing_items WHERE firing_id = ?", f.id) })),
"POST /api/firings": (b) => {
const r = run("INSERT INTO firings (type, cone, schedule) VALUES (?,?,?)", b.type || "glaze", b.cone || "∆6", b.schedule || "");
for (const it of b.items || [])
run("INSERT INTO firing_items (firing_id, piece, clay, glaze) VALUES (?,?,?,?)", r.lastInsertRowid, it.piece, it.clay || "", it.glaze || "");
return { id: Number(r.lastInsertRowid) };
},
"POST /api/firing/items": (b) => run("INSERT INTO firing_items (firing_id, piece, clay, glaze) VALUES (?,?,?,?)", b.firing_id, b.piece, b.clay || "", b.glaze || ""),
"POST /api/firing/rate": (b) => run("UPDATE firing_items SET rating = ?, note = ? WHERE id = ?", b.rating, b.note || "", b.id),
"POST /api/firing/unload": (b) => run("UPDATE firings SET status='unloaded', unloaded=datetime('now','localtime'), result_notes=? WHERE id=?", b.result_notes || "", b.id),
"GET /api/products": () => ({
products: all("SELECT * FROM products ORDER BY kind, category, name"),
money: {
revenue: get("SELECT COALESCE(SUM(qty*price),0) v FROM sales").v,
cogs: get("SELECT COALESCE(SUM(qty*cost),0) v FROM sales").v,
monthRevenue: get("SELECT COALESCE(SUM(qty*price),0) v FROM sales WHERE created >= date('now','start of month')").v,
salesCount: get("SELECT COALESCE(SUM(qty),0) v FROM sales").v,
},
}),
"POST /api/products": (b) => {
const r = run("INSERT INTO products (name, kind, category, price, cost, qty) VALUES (?,?,?,?,?,?)",
b.name, b.kind || "core line", b.category || "", Number(b.price) || 0, Number(b.cost) || 0, Number(b.qty) || 0);
return { id: Number(r.lastInsertRowid) };
},
"GET /api/product": (_, q) => {
const p = get("SELECT * FROM products WHERE id = ?", Number(q.get("id")));
if (!p) return null;
return { ...p,
photos: all("SELECT * FROM photos WHERE entity='product' AND entity_id = ?", p.id),
files: all("SELECT * FROM files WHERE entity='product' AND entity_id = ? ORDER BY id DESC", p.id),
sales: all("SELECT * FROM sales WHERE product_id = ? ORDER BY id DESC LIMIT 10", p.id) };
},
"POST /api/products/notes": (b) => run("UPDATE products SET notes = ? WHERE id = ?", b.notes, b.id),
"GET /api/files": () => all(`SELECT f.*, p.name AS product_name FROM files f
LEFT JOIN products p ON f.entity = 'product' AND p.id = f.entity_id ORDER BY f.id DESC`),
"POST /api/files/delete": (b) => {
const f = get("SELECT * FROM files WHERE id = ?", b.id);
if (f) { try { unlinkSync(join(FILES, f.stored)); } catch { /* already gone */ } run("DELETE FROM files WHERE id = ?", b.id); }
return { ok: true };
},
"GET /api/sourcing": () => ({
suppliers: all("SELECT * FROM suppliers ORDER BY name"),
entries: all(`SELECT pe.*, s.name AS supplier, s.kind AS supplier_kind, s.distance
FROM price_entries pe JOIN suppliers s ON s.id = pe.supplier_id ORDER BY pe.item, pe.id DESC`),
}),
"POST /api/suppliers": (b) => {
const r = run("INSERT INTO suppliers (name, kind, distance, url, notes, search_url) VALUES (?,?,?,?,?,?)",
b.name, b.kind || "online", b.distance || "", b.url || "", b.notes || "", b.search_url || "");
return { id: Number(r.lastInsertRowid) };
},
"POST /api/price-entries": (b) => run(
"INSERT INTO price_entries (item, supplier_id, price, unit, shipping, note) VALUES (?,?,?,?,?,?)",
b.item, b.supplier_id, Number(b.price) || 0, b.unit || "", Number(b.shipping) || 0, b.note || ""),
"POST /api/products/qty": (b) => run("UPDATE products SET qty = MAX(0, qty + ?) WHERE id = ?", b.delta, b.id),
"POST /api/products/status": (b) => run("UPDATE products SET status = ? WHERE id = ?", b.status, b.id),
"POST /api/sales": (b) => {
const p = get("SELECT * FROM products WHERE id = ?", b.product_id);
if (!p) return { error: "no such product" };
run("INSERT INTO sales (product_id, qty, price, cost) VALUES (?,?,?,?)", p.id, b.qty || 1, b.price ?? p.price, p.cost);
if (p.kind === "core line" || p.kind === "tiles") run("UPDATE products SET qty = MAX(0, qty - ?) WHERE id = ?", b.qty || 1, p.id);
else run("UPDATE products SET status = 'sold' WHERE id = ?", p.id);
return { ok: true };
},
};
const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css",
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".heic": "image/heic" };
createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
const send = (code, body, type = "application/json") => {
res.writeHead(code, { "content-type": type });
res.end(type === "application/json" ? JSON.stringify(body) : body);
};
try {
// photo upload: POST /api/photos?entity=note&id=3 (raw image body)
if (req.method === "POST" && url.pathname === "/api/photos") {
const chunks = [];
for await (const c of req) chunks.push(c);
const ext = (MIME[extname(url.searchParams.get("name") || "").toLowerCase()] ? extname(url.searchParams.get("name")) : ".jpg").toLowerCase();
const filename = randomUUID() + ext;
writeFileSync(join(PHOTOS, filename), Buffer.concat(chunks));
run("INSERT INTO photos (entity, entity_id, filename) VALUES (?,?,?)",
url.searchParams.get("entity") || "note", Number(url.searchParams.get("id") || 0), filename);
return send(200, { ok: true, filename });
}
// file upload: POST /api/files?name=mold.stl&tag=master+molds[&entity=product&id=3]
if (req.method === "POST" && url.pathname === "/api/files") {
const chunks = [];
for await (const c of req) chunks.push(c);
const buf = Buffer.concat(chunks);
const orig = (url.searchParams.get("name") || "file").replace(/[\/\\]/g, "_");
const stored = randomUUID() + extname(orig).toLowerCase();
writeFileSync(join(FILES, stored), buf);
run("INSERT INTO files (entity, entity_id, stored, name, tag, size) VALUES (?,?,?,?,?,?)",
url.searchParams.get("entity") || "", Number(url.searchParams.get("id") || 0),
stored, orig, url.searchParams.get("tag") || "", buf.length);
return send(200, { ok: true });
}
if (url.pathname.startsWith("/file/")) {
const stored = url.pathname.slice(6).replace(/[^A-Za-z0-9.\-]/g, "");
const f = get("SELECT * FROM files WHERE stored = ?", stored);
if (!f || !existsSync(join(FILES, stored))) return send(404, { error: "not found" });
res.writeHead(200, {
"content-type": MIME[extname(stored)] || "application/octet-stream",
"content-disposition": `attachment; filename="${f.name.replace(/"/g, "")}"`,
});
return res.end(readFileSync(join(FILES, stored)));
}
if (url.pathname.startsWith("/photos/")) {
const f = join(PHOTOS, url.pathname.slice(8).replace(/[^A-Za-z0-9.\-]/g, ""));
if (existsSync(f)) return send(200, readFileSync(f), MIME[extname(f).toLowerCase()] || "application/octet-stream");
return send(404, { error: "not found" });
}
const handler = routes[`${req.method} ${url.pathname}`];
if (handler) {
let body = {};
if (req.method === "POST") {
const chunks = [];
for await (const c of req) chunks.push(c);
body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {};
}
return send(200, handler(body, url.searchParams) ?? { ok: true });
}
// static app
const file = url.pathname === "/" ? "index.html" : url.pathname.slice(1).replace(/[^A-Za-z0-9.\-\/]/g, "");
const path = join(ROOT, "public", file);
if (existsSync(path)) return send(200, readFileSync(path), MIME[extname(path).toLowerCase()] || "text/plain");
send(404, { error: "not found" });
} catch (e) {
send(500, { error: String(e.message || e) });
}
}).listen(6161, "127.0.0.1", () => {
console.log("🌙 Studio Notebook running at http://localhost:6161");
});