Test and deploy / test-and-deploy (push) Successful in 1m8s
The planner's Artisan .alog drawer now shows what's attached with a "Remove reference curve" control (re-rendered per drawer open), so adding a reference curve is no longer a one-way door; /api/alog shares the 8 MB body cap so real-sized logs parse instead of failing with "bad_request". Deep-evaluation fixes: editing a brew of an archived bean no longer silently detaches the bean; the roasts pending-review poll no longer wipes in-progress after-roast edits; roasters gain an Edit (rename/model) action; the gear page refuses to autosave over a failed load; duplicating a plan carries its custom name; cupping sessions can attach a plan after creation (ownership-checked PUT + selector); admin user deletion also refreshes plans/audit; cupping cup-count subtitle stays live; roasts error-row colspan corrected. Regression tests cover the new cupping PUT and the /api/alog body cap. Academy scenes drop the flat paper-cutout look: shared defs provide radial-gradient shading on every bean/half-bean/particle, flame gradients with radiant halos, soft ground shadows, and a warm-lit stage background; fill-shift animations now ride a partial-opacity tint overlay so shading survives the color change. Co-Authored-By: Claude Fable 5 <[email protected]>
163 lines
4.8 KiB
JavaScript
163 lines
4.8 KiB
JavaScript
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
|
|
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
|
|
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
|
|
import {
|
|
BREW_CATEGORIES,
|
|
BREW_METHODS,
|
|
BREW_SILHOUETTES,
|
|
} from "/shared/brew-data.js?v=__ASSET_VERSION__";
|
|
|
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
let gear = { brewers: [], grinders: [] };
|
|
let saveTimer = null;
|
|
// Never save over the server until we've successfully read its state: the PUT replaces both
|
|
// arrays wholesale, so saving after a failed load would wipe the user's stored equipment.
|
|
let loaded = false;
|
|
|
|
function silhouetteSvg(methodKey) {
|
|
const svg = document.createElementNS(SVG_NS, "svg");
|
|
svg.setAttribute("viewBox", "0 0 64 64");
|
|
svg.setAttribute("width", 42);
|
|
svg.setAttribute("height", 42);
|
|
svg.setAttribute("aria-hidden", "true");
|
|
for (const d of BREW_SILHOUETTES[methodKey] ?? []) {
|
|
const path = document.createElementNS(SVG_NS, "path");
|
|
path.setAttribute("d", d);
|
|
path.setAttribute("fill", "currentColor");
|
|
svg.append(path);
|
|
}
|
|
return svg;
|
|
}
|
|
|
|
// Saves shortly after the last toggle rather than needing a Save button — an equipment page
|
|
// is set-and-forget, so every change should just stick.
|
|
function scheduleSave() {
|
|
if (!loaded) {
|
|
showToast("Equipment didn't load — reload the page before making changes.", "fail");
|
|
return;
|
|
}
|
|
clearTimeout(saveTimer);
|
|
saveTimer = setTimeout(async () => {
|
|
try {
|
|
const body = await api("/api/gear", {
|
|
method: "PUT",
|
|
body: JSON.stringify(gear),
|
|
});
|
|
gear = body.gear;
|
|
showToast("Equipment saved.");
|
|
} catch (error) {
|
|
showToast(error.message, "fail");
|
|
}
|
|
}, 500);
|
|
}
|
|
|
|
function renderCount() {
|
|
document.getElementById("brewers-count").textContent = gear.brewers.length
|
|
? `${gear.brewers.length} selected`
|
|
: "none selected — Brews shows all";
|
|
}
|
|
|
|
function renderBrewerPicker() {
|
|
const mount = document.getElementById("gear-brewer-picker");
|
|
mount.replaceChildren();
|
|
for (const category of BREW_CATEGORIES) {
|
|
const title = document.createElement("div");
|
|
title.className = "brewer-cat-title";
|
|
title.textContent = category.name;
|
|
mount.append(title);
|
|
const grid = document.createElement("div");
|
|
grid.className = "brewer-grid";
|
|
for (const method of BREW_METHODS.filter((m) => m.category === category.key)) {
|
|
const tile = document.createElement("button");
|
|
tile.type = "button";
|
|
tile.className = "brewer-tile";
|
|
const owned = () => gear.brewers.includes(method.key);
|
|
tile.classList.toggle("selected", owned());
|
|
tile.setAttribute("aria-pressed", String(owned()));
|
|
tile.append(silhouetteSvg(method.key));
|
|
const name = document.createElement("span");
|
|
name.className = "brewer-name";
|
|
name.textContent = method.name;
|
|
tile.append(name);
|
|
tile.addEventListener("click", () => {
|
|
gear.brewers = owned()
|
|
? gear.brewers.filter((k) => k !== method.key)
|
|
: [...gear.brewers, method.key];
|
|
tile.classList.toggle("selected", owned());
|
|
tile.setAttribute("aria-pressed", String(owned()));
|
|
renderCount();
|
|
scheduleSave();
|
|
});
|
|
grid.append(tile);
|
|
}
|
|
mount.append(grid);
|
|
}
|
|
renderCount();
|
|
}
|
|
|
|
function renderGrinders() {
|
|
const list = document.getElementById("grinder-list");
|
|
if (!gear.grinders.length) {
|
|
list.replaceChildren(
|
|
Object.assign(document.createElement("li"), {
|
|
textContent: "No grinders yet.",
|
|
className: "muted",
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
list.replaceChildren(
|
|
...gear.grinders.map((grinder) => {
|
|
const li = document.createElement("li");
|
|
li.style.display = "flex";
|
|
li.style.alignItems = "center";
|
|
li.style.gap = "10px";
|
|
const name = document.createElement("span");
|
|
name.textContent = grinder;
|
|
const remove = document.createElement("button");
|
|
remove.className = "ghost-btn small";
|
|
remove.type = "button";
|
|
remove.textContent = "Remove";
|
|
remove.addEventListener("click", () => {
|
|
gear.grinders = gear.grinders.filter((g) => g !== grinder);
|
|
renderGrinders();
|
|
scheduleSave();
|
|
});
|
|
li.append(name, remove);
|
|
return li;
|
|
}),
|
|
);
|
|
}
|
|
|
|
document.getElementById("grinder-form").addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
const input = document.getElementById("grinder-name");
|
|
const name = input.value.trim();
|
|
if (!name) return;
|
|
if (!gear.grinders.includes(name)) gear.grinders = [...gear.grinders, name];
|
|
input.value = "";
|
|
renderGrinders();
|
|
scheduleSave();
|
|
});
|
|
|
|
document.getElementById("btn-logout").addEventListener("click", async () => {
|
|
await protectedFetch("/api/auth/logout", { method: "POST" });
|
|
location.assign("/");
|
|
});
|
|
|
|
async function init() {
|
|
initSideNav();
|
|
const user = await loadNavUser();
|
|
if (!user) return;
|
|
try {
|
|
gear = (await api("/api/gear")).gear;
|
|
loaded = true;
|
|
} catch {
|
|
showToast("Could not load equipment.", "fail");
|
|
}
|
|
renderBrewerPicker();
|
|
renderGrinders();
|
|
}
|
|
|
|
init();
|