feat: add secure auth, admin and postgres persistence
This commit is contained in:
+38
-16
@@ -9,6 +9,9 @@ import { initPrint } from "./print.js";
|
||||
|
||||
const FIELD_ID_SET = new Set(FIELD_IDS);
|
||||
const STORAGE_KEY = "roastPlannerPlan.v1";
|
||||
let remotePlanId = null;
|
||||
const csrfToken = () => document.cookie.split("; ").find((v) => v.startsWith("rp_csrf="))?.split("=")[1] || "";
|
||||
export const protectedFetch = (url, options = {}) => fetch(url, { ...options, headers: { ...options.headers, "x-csrf-token": csrfToken() } });
|
||||
const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section
|
||||
|
||||
export const state = { plan: loadFromStorage() ?? blankPlan() };
|
||||
@@ -59,34 +62,34 @@ function setValueForName(name, value) {
|
||||
// which is kept in sync by renderFormFromPlan() and only needs to look right on paper.
|
||||
function renderBlendPrintRows() {
|
||||
const tbody = document.getElementById("blend-rows");
|
||||
tbody.innerHTML = "";
|
||||
tbody.replaceChildren();
|
||||
state.plan.blendComponents.forEach((_, i) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
tr.append(document.createRange().createContextualFragment(`
|
||||
<td><input class="f w ws-input" name="blendComponents.${i}.cultivar"><span class="pv"></span></td>
|
||||
<td><input class="f ws-input" style="min-width:18mm" name="blendComponents.${i}.group"><span class="pv"></span></td>
|
||||
<td><input class="f ws-input" style="min-width:20mm" name="blendComponents.${i}.process"><span class="pv"></span></td>
|
||||
<td class="num"><input class="f n ws-input" name="blendComponents.${i}.sharePct"><span class="pv"></span></td>
|
||||
<td class="num"><input class="f n ws-input" name="blendComponents.${i}.fcAnchor"><span class="pv"></span></td>
|
||||
`;
|
||||
`));
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderBlendCards() {
|
||||
const wrap = document.getElementById("blend-cards");
|
||||
wrap.innerHTML = "";
|
||||
wrap.replaceChildren();
|
||||
state.plan.blendComponents.forEach((_, i) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "blend-card";
|
||||
card.innerHTML = `
|
||||
card.append(document.createRange().createContextualFragment(`
|
||||
<label class="field"><span class="field-label">Cultivar</span><input class="field-input" name="blendComponents.${i}.cultivar" placeholder="e.g. Caturra"></label>
|
||||
<label class="field"><span class="field-label">Group</span><input class="field-input" name="blendComponents.${i}.group"></label>
|
||||
<label class="field"><span class="field-label">Process</span><input class="field-input" name="blendComponents.${i}.process"></label>
|
||||
<label class="field"><span class="field-label">Share %</span><input class="field-input sm" name="blendComponents.${i}.sharePct"></label>
|
||||
<label class="field"><span class="field-label">FC anchor</span><input class="field-input sm" name="blendComponents.${i}.fcAnchor"></label>
|
||||
<button type="button" class="blend-remove" data-remove-blend="${i}" aria-label="Remove component" ${state.plan.blendComponents.length <= 1 ? "disabled" : ""}>✕</button>
|
||||
`;
|
||||
`));
|
||||
wrap.appendChild(card);
|
||||
});
|
||||
for (const btn of wrap.querySelectorAll("[data-remove-blend]")) {
|
||||
@@ -124,27 +127,27 @@ function renderBlend() {
|
||||
|
||||
function renderActuatorPrintRows() {
|
||||
const tbody = document.getElementById("actuator-rows");
|
||||
tbody.innerHTML = "";
|
||||
tbody.replaceChildren();
|
||||
state.plan.actuators.forEach((_, i) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
tr.append(document.createRange().createContextualFragment(`
|
||||
<td class="num"><input class="f n ws-input" name="actuators.${i}.time"><span class="pv"></span></td>
|
||||
<td class="num"><input class="f n ws-input" name="actuators.${i}.heatPct"><span class="pv"></span></td>
|
||||
<td class="num"><input class="f n ws-input" name="actuators.${i}.fanPct"><span class="pv"></span></td>
|
||||
<td class="num"><input class="f n ws-input" name="actuators.${i}.expectedBt"><span class="pv"></span></td>
|
||||
<td><input class="f ws-input" style="min-width:78mm" name="actuators.${i}.why"><span class="pv"></span></td>
|
||||
`;
|
||||
`));
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderActuatorTimeline() {
|
||||
const wrap = document.getElementById("actuator-timeline");
|
||||
wrap.innerHTML = "";
|
||||
wrap.replaceChildren();
|
||||
state.plan.actuators.forEach((_, i) => {
|
||||
const step = document.createElement("div");
|
||||
step.className = "actuator-step";
|
||||
step.innerHTML = `
|
||||
step.append(document.createRange().createContextualFragment(`
|
||||
<div class="actuator-rail"><div class="actuator-dot"></div><div class="actuator-line"></div></div>
|
||||
<div class="actuator-card">
|
||||
<label class="field"><span class="field-label">Time</span><input class="field-input sm" name="actuators.${i}.time" placeholder="m:ss"></label>
|
||||
@@ -154,7 +157,7 @@ function renderActuatorTimeline() {
|
||||
<label class="field"><span class="field-label">Expected BT</span><input class="field-input sm" name="actuators.${i}.expectedBt"></label>
|
||||
<label class="field actuator-why"><span class="field-label">Why this change</span><input class="field-input" name="actuators.${i}.why" placeholder="What you're watching for"></label>
|
||||
</div>
|
||||
`;
|
||||
`));
|
||||
wrap.appendChild(step);
|
||||
});
|
||||
for (const btn of wrap.querySelectorAll("[data-remove-actuator]")) {
|
||||
@@ -279,7 +282,7 @@ function renderBandMarkers() {
|
||||
}
|
||||
|
||||
function paintCurveInto(planGroup, refGroup, planPoints, ref) {
|
||||
planGroup.innerHTML = "";
|
||||
planGroup.replaceChildren();
|
||||
if (planPoints.length > 0) {
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("d", pointsToPathD(planPoints));
|
||||
@@ -299,7 +302,7 @@ function paintCurveInto(planGroup, refGroup, planPoints, ref) {
|
||||
}
|
||||
}
|
||||
|
||||
refGroup.innerHTML = "";
|
||||
refGroup.replaceChildren();
|
||||
if (ref?.curve?.length) {
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
const d = ref.curve
|
||||
@@ -365,6 +368,14 @@ function autosave() {
|
||||
autosave._t = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.plan));
|
||||
// Local storage preserves edits while offline; the account copy is authoritative when online.
|
||||
if (navigator.onLine && csrfToken()) {
|
||||
const method = remotePlanId ? "PUT" : "POST";
|
||||
const url = remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans";
|
||||
protectedFetch(url, { method, headers: { "content-type": "application/json" }, body: JSON.stringify({ plan: state.plan }) })
|
||||
.then((r) => r.ok ? r.json() : null).then((body) => { if (body?.plan?.id) remotePlanId = body.plan.id; })
|
||||
.catch(() => { /* local copy remains available */ });
|
||||
}
|
||||
const savedAt = Date.now();
|
||||
chip.classList.remove("saving");
|
||||
chip.classList.add("saved");
|
||||
@@ -403,7 +414,11 @@ function cultivarAutofill(name) {
|
||||
|
||||
function wireCultivarDatalist() {
|
||||
const list = document.getElementById("cultivar-list");
|
||||
list.innerHTML = CULTIVARS.map((c) => `<option value="${c.name}">`).join("");
|
||||
list.replaceChildren(...CULTIVARS.map((c) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = c.name;
|
||||
return option;
|
||||
}));
|
||||
}
|
||||
|
||||
function wireForm() {
|
||||
@@ -535,7 +550,11 @@ function wireSectionNav() {
|
||||
for (const s of sections) observer.observe(s);
|
||||
}
|
||||
|
||||
function init() {
|
||||
async function init() {
|
||||
try {
|
||||
const response = await fetch("/api/plans");
|
||||
if (response.ok) { const body = await response.json(); const latest = body.plans?.[0]; if (latest) { state.plan = latest.plan; remotePlanId = latest.id; } }
|
||||
} catch { /* offline starts from the local cache */ }
|
||||
renderBlend();
|
||||
renderActuators();
|
||||
wireCultivarDatalist();
|
||||
@@ -546,6 +565,9 @@ function init() {
|
||||
wireToolbar();
|
||||
wirePwa();
|
||||
wireSectionNav();
|
||||
fetch("/api/auth/me").then((r) => r.ok ? r.json() : null).then((body) => {
|
||||
if (body?.user?.role === "admin") { const button = document.getElementById("btn-admin"); button.classList.remove("hidden"); button.onclick = () => { location.href = "/admin"; }; }
|
||||
}).catch(() => {});
|
||||
initPrefillPanel({ state, renderBlendRows: renderBlend, renderActuatorRows: renderActuators, renderFormFromPlan, recompute });
|
||||
initAlogPanel({ state, recompute });
|
||||
initPrint({ beforePrint: renderFormFromPlan });
|
||||
|
||||
Reference in New Issue
Block a user