app/: production port — Postgres + Prisma + OTM-account auth (v0.2.0)

Prototype stays untouched (Bonna's laptop daily driver until cutover).
app/ is the deployable bms-backbone: same routes/frontend, Prisma models
replacing node:sqlite, session-cookie login backed by a User table seeded
via the platform SEED_OWNER_* convention (bcryptjs), /api/health with
APP_VERSION, photos/files under DATA_DIR (/app/data uploads mount),
Dockerfile + entrypoint (prisma db push + owner seed + serve), and a
one-shot sqlite→postgres migration script for Bonna's existing data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bonna
2026-08-06 22:10:55 -05:00
parent f4c1d6ca94
commit c4ba1591c7
13 changed files with 1512 additions and 0 deletions

618
app/public/app.js Normal file
View File

@@ -0,0 +1,618 @@
// 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);
if (r.status === 401) { location.href = "/login"; return new Promise(() => {}); }
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);
}
document.getElementById("signout")?.addEventListener("click", async () => {
await api("/api/logout", {});
location.href = "/login";
});
render();

110
app/public/index.html Normal file
View File

@@ -0,0 +1,110 @@
<!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>
<nav style="margin-top:18px"><button id="signout" style="font-size:13px">👋 sign out</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 · bms.poweredbyotm.com</footer>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>

47
app/public/login.html Normal file
View File

@@ -0,0 +1,47 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>🌙 Studio Notebook — sign in</title>
<style>
:root { --bg: #fdf7f2; --card: #fff; --ink: #4a3b47; --plum: #8d6b94; --plum-deep: #6e4f78;
--blush-deep: #c98da8; --line: #eddfe3; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: "Avenir Next", "Nunito", "Trebuchet MS", sans-serif; background: var(--bg);
color: var(--ink); min-height: 100vh; display: grid; place-items: center; }
.card { background: var(--card); border: 1.5px solid var(--line); border-radius: 20px;
padding: 34px 36px; width: min(92vw, 380px); box-shadow: 0 4px 16px rgba(141,107,148,0.1); }
h1 { font-family: Georgia, serif; font-size: 24px; color: var(--plum-deep); margin-bottom: 4px; }
.sub { color: #9c8698; font-size: 14px; font-style: italic; font-family: Georgia, serif; margin-bottom: 22px; }
label { display: block; font-size: 12px; font-weight: 700; color: var(--blush-deep);
text-transform: uppercase; letter-spacing: 0.08em; margin: 14px 0 5px; }
input { width: 100%; border: 1.5px solid var(--line); border-radius: 12px; padding: 12px 15px;
font-size: 15px; background: var(--bg); color: var(--ink); font-family: inherit; }
button { width: 100%; margin-top: 22px; border: none; border-radius: 12px; padding: 13px;
font-size: 15px; font-weight: 700; cursor: pointer; background: var(--plum); color: #fff; font-family: inherit; }
.err { color: #b3556b; font-size: 13.5px; margin-top: 12px; min-height: 18px; }
</style>
</head>
<body>
<div class="card">
<h1>🌙 Studio Notebook</h1>
<div class="sub">welcome back to the studio</div>
<form id="f">
<label>Email</label><input type="email" id="email" autocomplete="username" required>
<label>Password</label><input type="password" id="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
<div class="err" id="err"></div>
</form>
</div>
<script>
document.getElementById("f").addEventListener("submit", async (e) => {
e.preventDefault();
const r = await fetch("/api/login", { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ email: email.value, password: password.value }) }).then((x) => x.json());
if (r.ok) location.href = "/";
else document.getElementById("err").textContent = r.error || "sign-in failed";
});
</script>
</body>
</html>