Add brewing section, plan chat, roaster learning, API tokens, Swagger docs, and full backup
Test and deploy / test-and-deploy (push) Successful in 1m6s
Test and deploy / test-and-deploy (push) Successful in 1m6s
Brewing: - roasted_beans + brews tables; /beans (bean management with LLM URL prefill) and /brews (silhouette brewer picker across immersion/ percolation/espresso, recipe fields, auto ratio, 0-10 rating, tasting notes); bean remaining weight derived from logged brew doses - Green inventory lot form also prefills from a product URL Navigation/UX: - Side nav is now generated from one definition in nav.js, grouped Roasting / Brewing / account, consistent on every page LLM: - 'Ask the LLM' chat drawer on the planner (stateless /api/plan-chat) grounded in the plan, computed ledger, learned pace, and a new roaster-behavior profile aggregated from uploaded .alogs (/api/roaster-profile: TP lag, phase RoR, median milestone temps) - The profile also feeds roast reviews and the planner curve's fallback milestone temps API platform: - User-generated bearer tokens (rpt_…) with account-page management; token requests skip CSRF; hand-authored OpenAPI 3 spec at /api/openapi.json rendered by self-hosted Swagger UI at /api-docs - Full-database backup export/import (admin) + per-user data export Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5efaeb63c9
commit
38c7d01e03
+69
-1
@@ -331,6 +331,73 @@ function wirePwaButtons() {
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTokens() {
|
||||
const body = document.getElementById("tokens-body");
|
||||
try {
|
||||
const { tokens } = await api("/api/tokens");
|
||||
if (!tokens.length) {
|
||||
body.innerHTML = `<tr><td colspan="4" class="empty-state">No API tokens yet.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...tokens.map((token) => {
|
||||
const tr = document.createElement("tr");
|
||||
const name = document.createElement("td");
|
||||
name.textContent = token.name || "(unnamed)";
|
||||
const created = document.createElement("td");
|
||||
created.textContent = fmtDate(token.createdAt);
|
||||
const used = document.createElement("td");
|
||||
used.textContent = token.lastUsedAt ? fmtDate(token.lastUsedAt) : "Never";
|
||||
const actions = document.createElement("td");
|
||||
actions.className = "data-table-actions";
|
||||
const revoke = document.createElement("button");
|
||||
revoke.className = "ghost-btn small";
|
||||
revoke.type = "button";
|
||||
revoke.textContent = "Revoke";
|
||||
revoke.addEventListener("click", async () => {
|
||||
if (!confirm(`Revoke "${token.name || "this token"}"? Anything using it stops working immediately.`))
|
||||
return;
|
||||
try {
|
||||
await api(`/api/tokens/${token.id}`, { method: "DELETE" });
|
||||
showToast("Token revoked.");
|
||||
await loadTokens();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
actions.append(revoke);
|
||||
tr.append(name, created, used, actions);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
body.innerHTML = `<tr><td colspan="4" class="empty-state">Could not load tokens.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function wireTokenForm() {
|
||||
document.getElementById("token-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const button = document.getElementById("token-create");
|
||||
button.disabled = true;
|
||||
try {
|
||||
const created = await api("/api/tokens", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: event.target.name.value.trim() }),
|
||||
});
|
||||
// Shown exactly once — the server only stores a hash.
|
||||
document.getElementById("token-reveal").textContent = created.token;
|
||||
document.getElementById("token-reveal-wrap").classList.remove("hidden");
|
||||
event.target.reset();
|
||||
await loadTokens();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("btn-logout").addEventListener("click", async () => {
|
||||
await protectedFetch("/api/auth/logout", { method: "POST" });
|
||||
location.assign("/");
|
||||
@@ -345,7 +412,8 @@ async function init() {
|
||||
await loadProfile(user);
|
||||
wireForms(user);
|
||||
wirePwaButtons();
|
||||
await Promise.all([loadSessions(), loadPlans()]);
|
||||
wireTokenForm();
|
||||
await Promise.all([loadSessions(), loadPlans(), loadTokens()]);
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
@@ -151,6 +151,55 @@ function wireLlmSave() {
|
||||
});
|
||||
}
|
||||
|
||||
function wireBackupImport() {
|
||||
const input = document.getElementById("backup-import-file");
|
||||
const note = document.getElementById("backup-note");
|
||||
input.addEventListener("change", async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
let backup;
|
||||
try {
|
||||
backup = JSON.parse(await file.text());
|
||||
} catch {
|
||||
note.textContent = "That file is not valid JSON.";
|
||||
return;
|
||||
}
|
||||
if (backup.format !== "roast-planner-backup") {
|
||||
note.textContent =
|
||||
"That file is not a Roast Planner backup export (expected the file downloaded by “Export full backup”).";
|
||||
return;
|
||||
}
|
||||
const userCount = backup.tables?.users?.length ?? 0;
|
||||
if (
|
||||
!confirm(
|
||||
`Replace the ENTIRE database with "${file.name}" (${userCount} user${userCount === 1 ? "" : "s"}, exported ${backup.exportedAt ?? "unknown date"})?\n\nEverything currently stored will be deleted. This cannot be undone.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
note.textContent = "Importing…";
|
||||
try {
|
||||
const result = await api("/api/admin/backup/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(backup),
|
||||
});
|
||||
note.textContent = `Imported: ${Object.entries(result.counts)
|
||||
.map(([table, count]) => `${table} ${count}`)
|
||||
.join(", ")}.`;
|
||||
showToast("Backup imported.");
|
||||
if (!result.sessionKept) {
|
||||
alert("The restored data does not include your current session's account — log in again with the restored credentials.");
|
||||
location.assign("/login");
|
||||
return;
|
||||
}
|
||||
await Promise.all([loadMetrics(), loadUsers(), loadPlans(), loadAudit()]);
|
||||
} catch (error) {
|
||||
note.textContent = `Import failed: ${error.message}. Nothing was changed.`;
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function roleBadge(user) {
|
||||
return user.role === "admin"
|
||||
? `<span class="badge badge-admin">Admin</span>`
|
||||
@@ -376,6 +425,7 @@ async function init() {
|
||||
currentUserId = user.id;
|
||||
wireSignupToggle();
|
||||
wireLlmSave();
|
||||
wireBackupImport();
|
||||
await Promise.all([
|
||||
loadMetrics(),
|
||||
loadResetLinks(),
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
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__";
|
||||
|
||||
let beans = [];
|
||||
let showArchived = false;
|
||||
let editingId = null;
|
||||
|
||||
const fmtDate = (v) => (v ? new Date(v).toLocaleDateString() : "—");
|
||||
|
||||
async function guarded(button, run) {
|
||||
if (!button || button.disabled) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBeans() {
|
||||
const body = document.getElementById("beans-body");
|
||||
const visible = showArchived ? beans : beans.filter((b) => !b.archived);
|
||||
if (!visible.length) {
|
||||
body.innerHTML = `<tr><td colspan="6" class="empty-state">No beans yet. Add a bag below — paste the roaster's product URL to prefill the details.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...visible.map((bean) => {
|
||||
const tr = document.createElement("tr");
|
||||
if (bean.archived) tr.className = "archived";
|
||||
|
||||
const beanCell = document.createElement("td");
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = bean.name;
|
||||
const sub = document.createElement("div");
|
||||
sub.className = "muted";
|
||||
sub.style.fontSize = "11.5px";
|
||||
sub.textContent =
|
||||
[bean.roaster, bean.roastLevel].filter(Boolean).join(" · ") || "—";
|
||||
beanCell.append(strong, sub);
|
||||
|
||||
const originCell = document.createElement("td");
|
||||
originCell.textContent =
|
||||
[bean.origin, bean.process].filter(Boolean).join(" · ") || "—";
|
||||
|
||||
const roastedCell = document.createElement("td");
|
||||
roastedCell.textContent = fmtDate(bean.roastDate);
|
||||
|
||||
const remainingCell = document.createElement("td");
|
||||
if (bean.initialWeightG == null) remainingCell.textContent = "—";
|
||||
else {
|
||||
const pct = bean.initialWeightG > 0
|
||||
? Math.max(0, Math.min(100, (bean.remainingWeightG / bean.initialWeightG) * 100))
|
||||
: 0;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "lot-remaining";
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "blend-total-bar";
|
||||
const fill = document.createElement("div");
|
||||
fill.className = "blend-total-fill";
|
||||
if (bean.remainingWeightG < 0) fill.classList.add("over");
|
||||
fill.style.width = `${bean.remainingWeightG < 0 ? 0 : pct}%`;
|
||||
bar.append(fill);
|
||||
const label = document.createElement("span");
|
||||
label.className = "blend-total-label";
|
||||
label.textContent = `${Math.round(bean.remainingWeightG)} g of ${Math.round(bean.initialWeightG)} g`;
|
||||
wrap.append(bar, label);
|
||||
remainingCell.append(wrap);
|
||||
}
|
||||
|
||||
const brewsCell = document.createElement("td");
|
||||
brewsCell.className = "num";
|
||||
const brewsLink = document.createElement("a");
|
||||
brewsLink.href = `/brews?bean=${encodeURIComponent(bean.id)}`;
|
||||
brewsLink.textContent = String(bean.brewCount ?? 0);
|
||||
brewsCell.append(brewsLink);
|
||||
|
||||
const actions = document.createElement("td");
|
||||
actions.className = "data-table-actions";
|
||||
const brewBtn = document.createElement("a");
|
||||
brewBtn.className = "ghost-btn small";
|
||||
brewBtn.href = `/brews?new=1&bean=${encodeURIComponent(bean.id)}`;
|
||||
brewBtn.textContent = "Brew";
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.className = "ghost-btn small";
|
||||
editBtn.type = "button";
|
||||
editBtn.textContent = "Edit";
|
||||
editBtn.addEventListener("click", () => startEdit(bean));
|
||||
const archiveBtn = document.createElement("button");
|
||||
archiveBtn.className = "ghost-btn small";
|
||||
archiveBtn.type = "button";
|
||||
archiveBtn.textContent = bean.archived ? "Unarchive" : "Archive";
|
||||
archiveBtn.addEventListener("click", (event) =>
|
||||
guarded(event.currentTarget, async () => {
|
||||
try {
|
||||
await api(`/api/beans/${bean.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ archived: !bean.archived }),
|
||||
});
|
||||
showToast(bean.archived ? "Bean unarchived." : "Bean archived.");
|
||||
await loadBeans();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
}),
|
||||
);
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.className = "ghost-btn small";
|
||||
deleteBtn.type = "button";
|
||||
deleteBtn.textContent = "Delete";
|
||||
deleteBtn.addEventListener("click", (event) =>
|
||||
guarded(event.currentTarget, async () => {
|
||||
if (
|
||||
!confirm(
|
||||
`Delete "${bean.name}"? Its logged brews are kept but lose the bean link.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api(`/api/beans/${bean.id}`, { method: "DELETE" });
|
||||
showToast("Bean deleted.");
|
||||
if (editingId === bean.id) cancelEdit();
|
||||
await loadBeans();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
}),
|
||||
);
|
||||
actions.append(brewBtn, editBtn, archiveBtn, deleteBtn);
|
||||
|
||||
tr.append(beanCell, originCell, roastedCell, remainingCell, brewsCell, actions);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadBeans() {
|
||||
try {
|
||||
beans = (await api("/api/beans")).beans;
|
||||
renderBeans();
|
||||
} catch {
|
||||
document.getElementById("beans-body").innerHTML =
|
||||
`<tr><td colspan="6" class="empty-state">Could not load beans.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(bean) {
|
||||
editingId = bean.id;
|
||||
const form = document.getElementById("bean-form");
|
||||
form.name.value = bean.name;
|
||||
form.roaster.value = bean.roaster;
|
||||
form.origin.value = bean.origin;
|
||||
form.process.value = bean.process;
|
||||
form.variety.value = bean.variety;
|
||||
form.roastLevel.value = bean.roastLevel;
|
||||
form.roastDate.value = bean.roastDate ? bean.roastDate.slice(0, 10) : "";
|
||||
form.initialWeightG.value = bean.initialWeightG ?? "";
|
||||
form.tastingNotes.value = bean.tastingNotes;
|
||||
form.url.value = bean.url;
|
||||
form.notes.value = bean.notes;
|
||||
document.getElementById("bean-form-title").textContent = "Edit bean";
|
||||
document.getElementById("bean-form-submit").textContent = "Save changes";
|
||||
document.getElementById("bean-form-cancel").classList.remove("hidden");
|
||||
document.getElementById("bean-form-card").scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = null;
|
||||
const form = document.getElementById("bean-form");
|
||||
form.reset();
|
||||
document.getElementById("bean-form-title").textContent = "Add a bean";
|
||||
document.getElementById("bean-form-submit").textContent = "Add bean";
|
||||
document.getElementById("bean-form-cancel").classList.add("hidden");
|
||||
}
|
||||
|
||||
// The LLM prefill endpoint returns raw `extracted` page facts — map them straight into the
|
||||
// bean form (only overwriting fields the page actually stated).
|
||||
function wirePrefill() {
|
||||
const button = document.getElementById("bean-prefill-btn");
|
||||
const note = document.getElementById("bean-prefill-note");
|
||||
button.addEventListener("click", () =>
|
||||
guarded(button, async () => {
|
||||
const url = document.getElementById("bean-prefill-url").value.trim();
|
||||
if (!url) return;
|
||||
note.classList.remove("hidden");
|
||||
note.textContent = "Reading the page with the LLM…";
|
||||
try {
|
||||
const body = await api("/api/prefill", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
const x = body.extracted ?? {};
|
||||
const form = document.getElementById("bean-form");
|
||||
const setIf = (field, value) => {
|
||||
if (value) form[field].value = value;
|
||||
};
|
||||
setIf("name", x.coffeeName);
|
||||
setIf("roaster", x.producer);
|
||||
setIf("origin", x.origin);
|
||||
setIf("process", x.process);
|
||||
setIf("variety", x.cultivar);
|
||||
setIf("roastLevel", x.roastLevel);
|
||||
if (Array.isArray(x.tastingNotes) && x.tastingNotes.length)
|
||||
form.tastingNotes.value = x.tastingNotes.join(", ");
|
||||
form.url.value = url;
|
||||
const filled = ["coffeeName", "producer", "origin", "process", "cultivar", "roastLevel"]
|
||||
.filter((k) => x[k]).length + (x.tastingNotes?.length ? 1 : 0);
|
||||
note.textContent = filled
|
||||
? `Prefilled ${filled} field${filled === 1 ? "" : "s"} — check them, then save.`
|
||||
: "The page didn't state anything usable — fill the form by hand.";
|
||||
} catch (error) {
|
||||
note.textContent = `Prefill failed: ${error.message}`;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
document.getElementById("bean-form-cancel").addEventListener("click", cancelEdit);
|
||||
document.getElementById("show-archived").addEventListener("change", (event) => {
|
||||
showArchived = event.target.checked;
|
||||
renderBeans();
|
||||
});
|
||||
document.getElementById("bean-form").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
guarded(document.getElementById("bean-form-submit"), async () => {
|
||||
try {
|
||||
const payload = {
|
||||
name: data.name,
|
||||
roaster: data.roaster,
|
||||
origin: data.origin,
|
||||
process: data.process,
|
||||
variety: data.variety,
|
||||
roastLevel: data.roastLevel,
|
||||
roastDate: data.roastDate || null,
|
||||
initialWeightG: data.initialWeightG === "" ? null : Number(data.initialWeightG),
|
||||
tastingNotes: data.tastingNotes,
|
||||
url: data.url,
|
||||
notes: data.notes,
|
||||
};
|
||||
if (editingId) {
|
||||
await api(`/api/beans/${editingId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
showToast("Bean updated.");
|
||||
} else {
|
||||
await api("/api/beans", { method: "POST", body: JSON.stringify(payload) });
|
||||
showToast("Bean added.");
|
||||
}
|
||||
cancelEdit();
|
||||
await loadBeans();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
wirePrefill();
|
||||
await loadBeans();
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,376 @@
|
||||
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,
|
||||
findBrewMethod,
|
||||
} from "/shared/brew-data.js?v=__ASSET_VERSION__";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
let beans = [];
|
||||
let brews = [];
|
||||
let editingId = null;
|
||||
let selectedMethod = null;
|
||||
let beanFilter = null;
|
||||
|
||||
const fmtDate = (v) => (v ? new Date(v).toLocaleDateString() : "—");
|
||||
const fmtTime = (s) =>
|
||||
s == null
|
||||
? null
|
||||
: `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
function parseTime(str) {
|
||||
if (!str || !str.trim()) return null;
|
||||
const m = str.trim().match(/^(\d+):(\d{1,2})$/);
|
||||
if (m) return Number(m[1]) * 60 + Number(m[2]);
|
||||
const n = Number(str);
|
||||
return Number.isInteger(n) && n >= 0 ? n : undefined; // undefined = unparseable
|
||||
}
|
||||
|
||||
function silhouetteSvg(methodKey, size = 42) {
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.setAttribute("viewBox", "0 0 64 64");
|
||||
svg.setAttribute("width", size);
|
||||
svg.setAttribute("height", size);
|
||||
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;
|
||||
}
|
||||
|
||||
const isEspresso = () => findBrewMethod(selectedMethod)?.category === "espresso";
|
||||
|
||||
function renderBrewerPicker() {
|
||||
const mount = document.getElementById("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";
|
||||
tile.dataset.method = method.key;
|
||||
tile.classList.toggle("selected", method.key === selectedMethod);
|
||||
tile.setAttribute("aria-pressed", String(method.key === selectedMethod));
|
||||
tile.append(silhouetteSvg(method.key));
|
||||
const name = document.createElement("span");
|
||||
name.className = "brewer-name";
|
||||
name.textContent = method.name;
|
||||
tile.append(name);
|
||||
tile.addEventListener("click", () => selectMethod(method.key));
|
||||
grid.append(tile);
|
||||
}
|
||||
mount.append(grid);
|
||||
}
|
||||
}
|
||||
|
||||
function selectMethod(key) {
|
||||
selectedMethod = key;
|
||||
for (const tile of document.querySelectorAll(".brewer-tile")) {
|
||||
const on = tile.dataset.method === key;
|
||||
tile.classList.toggle("selected", on);
|
||||
tile.setAttribute("aria-pressed", String(on));
|
||||
}
|
||||
// Espresso-style methods log yield-out instead of brew water and have no bloom.
|
||||
const espresso = isEspresso();
|
||||
for (const el of document.querySelectorAll("[data-espresso-show]"))
|
||||
el.classList.toggle("hidden", !espresso);
|
||||
for (const el of document.querySelectorAll("[data-espresso-hide]"))
|
||||
el.classList.toggle("hidden", espresso);
|
||||
updateRatio();
|
||||
}
|
||||
|
||||
function updateRatio() {
|
||||
const form = document.getElementById("brew-form");
|
||||
const dose = Number.parseFloat(form.doseG.value);
|
||||
const out = Number.parseFloat(isEspresso() ? form.yieldG.value : form.waterG.value);
|
||||
document.getElementById("brew-ratio").value =
|
||||
Number.isFinite(dose) && dose > 0 && Number.isFinite(out) && out > 0
|
||||
? `1:${Math.round((out / dose) * 10) / 10}`
|
||||
: "";
|
||||
}
|
||||
|
||||
function updateRatingPill() {
|
||||
const value = document.getElementById("brew-rating").value;
|
||||
const pill = document.getElementById("rating-value");
|
||||
pill.textContent = value;
|
||||
pill.classList.toggle("good", Number(value) >= 7.5);
|
||||
pill.classList.toggle("poor", Number(value) <= 4);
|
||||
}
|
||||
|
||||
function renderBeanSelect() {
|
||||
const select = document.getElementById("brew-bean-select");
|
||||
const current = select.value;
|
||||
select.replaceChildren(
|
||||
Object.assign(document.createElement("option"), {
|
||||
value: "",
|
||||
textContent: "— no bean selected —",
|
||||
}),
|
||||
...beans
|
||||
.filter((b) => !b.archived || b.id === current)
|
||||
.map((bean) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = bean.id;
|
||||
const remaining =
|
||||
bean.remainingWeightG == null ? "" : ` (${Math.round(bean.remainingWeightG)} g left)`;
|
||||
option.textContent = `${bean.name}${bean.roaster ? ` — ${bean.roaster}` : ""}${remaining}`;
|
||||
return option;
|
||||
}),
|
||||
);
|
||||
select.value = current;
|
||||
}
|
||||
|
||||
function ratingPill(rating) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "rating-pill";
|
||||
if (rating == null) pill.textContent = "—";
|
||||
else {
|
||||
pill.textContent = String(rating);
|
||||
if (rating >= 7.5) pill.classList.add("good");
|
||||
if (rating <= 4) pill.classList.add("poor");
|
||||
}
|
||||
return pill;
|
||||
}
|
||||
|
||||
function recipeText(brew) {
|
||||
const method = findBrewMethod(brew.method);
|
||||
const parts = [];
|
||||
const out = method?.category === "espresso" ? brew.yieldG : brew.waterG;
|
||||
if (brew.doseG != null && out != null)
|
||||
parts.push(`${brew.doseG} g → ${out} g (1:${Math.round((out / brew.doseG) * 10) / 10})`);
|
||||
else if (brew.doseG != null) parts.push(`${brew.doseG} g`);
|
||||
if (brew.waterTempC != null) parts.push(`${brew.waterTempC}°C`);
|
||||
if (brew.brewTimeS != null) parts.push(fmtTime(brew.brewTimeS));
|
||||
return parts.join(" · ") || "—";
|
||||
}
|
||||
|
||||
function renderBrews() {
|
||||
const body = document.getElementById("brews-body");
|
||||
const visible = beanFilter ? brews.filter((b) => b.beanId === beanFilter) : brews;
|
||||
document
|
||||
.getElementById("clear-bean-filter")
|
||||
.classList.toggle("hidden", !beanFilter);
|
||||
if (!visible.length) {
|
||||
body.innerHTML = `<tr><td colspan="6" class="empty-state">No brews logged yet. Pick a brewer above and log your first cup.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...visible.map((brew) => {
|
||||
const tr = document.createElement("tr");
|
||||
|
||||
const whenCell = document.createElement("td");
|
||||
whenCell.textContent = fmtDate(brew.brewedAt);
|
||||
|
||||
const methodCell = document.createElement("td");
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "method-chip";
|
||||
chip.append(silhouetteSvg(brew.method, 20));
|
||||
const methodName = document.createElement("span");
|
||||
methodName.textContent = findBrewMethod(brew.method)?.name ?? brew.method;
|
||||
chip.append(methodName);
|
||||
methodCell.append(chip);
|
||||
|
||||
const beanCell = document.createElement("td");
|
||||
beanCell.textContent = brew.beanName ?? "—";
|
||||
|
||||
const recipeCell = document.createElement("td");
|
||||
recipeCell.textContent = recipeText(brew);
|
||||
if (brew.tastingNotes) {
|
||||
const notes = document.createElement("div");
|
||||
notes.className = "muted";
|
||||
notes.style.fontSize = "11.5px";
|
||||
notes.style.maxWidth = "340px";
|
||||
notes.textContent = brew.tastingNotes;
|
||||
recipeCell.append(notes);
|
||||
}
|
||||
|
||||
const ratingCell = document.createElement("td");
|
||||
ratingCell.append(ratingPill(brew.rating));
|
||||
|
||||
const actions = document.createElement("td");
|
||||
actions.className = "data-table-actions";
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.className = "ghost-btn small";
|
||||
editBtn.type = "button";
|
||||
editBtn.textContent = "Edit";
|
||||
editBtn.addEventListener("click", () => startEdit(brew));
|
||||
const againBtn = document.createElement("button");
|
||||
againBtn.className = "ghost-btn small";
|
||||
againBtn.type = "button";
|
||||
againBtn.textContent = "Brew again";
|
||||
againBtn.title = "Copy this brew's recipe into the form as a new brew";
|
||||
againBtn.addEventListener("click", () => {
|
||||
startEdit(brew);
|
||||
editingId = null;
|
||||
document.getElementById("brew-form-title").textContent = "Log a brew";
|
||||
document.getElementById("brew-form-submit").textContent = "Save brew";
|
||||
document.getElementById("brew-form-cancel").classList.add("hidden");
|
||||
});
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.className = "ghost-btn small";
|
||||
deleteBtn.type = "button";
|
||||
deleteBtn.textContent = "Delete";
|
||||
deleteBtn.addEventListener("click", async () => {
|
||||
if (!confirm("Delete this brew? This cannot be undone.")) return;
|
||||
try {
|
||||
await api(`/api/brews/${brew.id}`, { method: "DELETE" });
|
||||
showToast("Brew deleted.");
|
||||
if (editingId === brew.id) cancelEdit();
|
||||
await Promise.all([loadBrews(), loadBeans()]);
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
actions.append(editBtn, againBtn, deleteBtn);
|
||||
|
||||
tr.append(whenCell, methodCell, beanCell, recipeCell, ratingCell, actions);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadBeans() {
|
||||
try {
|
||||
beans = (await api("/api/beans")).beans;
|
||||
renderBeanSelect();
|
||||
} catch {
|
||||
/* bean select stays empty; brews still work without beans */
|
||||
}
|
||||
}
|
||||
async function loadBrews() {
|
||||
try {
|
||||
brews = (await api("/api/brews")).brews;
|
||||
renderBrews();
|
||||
} catch {
|
||||
document.getElementById("brews-body").innerHTML =
|
||||
`<tr><td colspan="6" class="empty-state">Could not load brews.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(brew) {
|
||||
editingId = brew.id;
|
||||
const form = document.getElementById("brew-form");
|
||||
form.beanId.value = brew.beanId ?? "";
|
||||
selectMethod(brew.method);
|
||||
form.doseG.value = brew.doseG ?? "";
|
||||
form.waterG.value = brew.waterG ?? "";
|
||||
form.yieldG.value = brew.yieldG ?? "";
|
||||
form.waterTempC.value = brew.waterTempC ?? "";
|
||||
form.grinder.value = brew.grinder;
|
||||
form.grindSetting.value = brew.grindSetting;
|
||||
form.brewTime.value = fmtTime(brew.brewTimeS) ?? "";
|
||||
form.bloomTime.value = fmtTime(brew.bloomTimeS) ?? "";
|
||||
form.rating.value = brew.rating ?? 5;
|
||||
form.tastingNotes.value = brew.tastingNotes;
|
||||
form.notes.value = brew.notes;
|
||||
updateRatio();
|
||||
updateRatingPill();
|
||||
document.getElementById("brew-form-title").textContent = "Edit brew";
|
||||
document.getElementById("brew-form-submit").textContent = "Save changes";
|
||||
document.getElementById("brew-form-cancel").classList.remove("hidden");
|
||||
document.getElementById("brew-form-card").scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = null;
|
||||
const form = document.getElementById("brew-form");
|
||||
form.reset();
|
||||
updateRatio();
|
||||
updateRatingPill();
|
||||
document.getElementById("brew-form-title").textContent = "Log a brew";
|
||||
document.getElementById("brew-form-submit").textContent = "Save brew";
|
||||
document.getElementById("brew-form-cancel").classList.add("hidden");
|
||||
}
|
||||
|
||||
document.getElementById("brew-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!selectedMethod) {
|
||||
showToast("Pick a brewer first.", "fail");
|
||||
return;
|
||||
}
|
||||
const form = event.target;
|
||||
const brewTimeS = parseTime(form.brewTime.value);
|
||||
const bloomTimeS = parseTime(form.bloomTime.value);
|
||||
if (brewTimeS === undefined || bloomTimeS === undefined) {
|
||||
showToast("Times must look like m:ss (e.g. 2:45).", "fail");
|
||||
return;
|
||||
}
|
||||
const num = (v) => (v === "" ? null : Number(v));
|
||||
const payload = {
|
||||
beanId: form.beanId.value || null,
|
||||
method: selectedMethod,
|
||||
doseG: num(form.doseG.value),
|
||||
waterG: num(form.waterG.value),
|
||||
yieldG: num(form.yieldG.value),
|
||||
waterTempC: num(form.waterTempC.value),
|
||||
grinder: form.grinder.value,
|
||||
grindSetting: form.grindSetting.value,
|
||||
brewTimeS,
|
||||
bloomTimeS,
|
||||
rating: Number(form.rating.value),
|
||||
tastingNotes: form.tastingNotes.value,
|
||||
notes: form.notes.value,
|
||||
};
|
||||
const submit = document.getElementById("brew-form-submit");
|
||||
submit.disabled = true;
|
||||
try {
|
||||
if (editingId) {
|
||||
await api(`/api/brews/${editingId}`, { method: "PUT", body: JSON.stringify(payload) });
|
||||
showToast("Brew updated.");
|
||||
} else {
|
||||
await api("/api/brews", { method: "POST", body: JSON.stringify(payload) });
|
||||
showToast("Brew logged.");
|
||||
}
|
||||
cancelEdit();
|
||||
await Promise.all([loadBrews(), loadBeans()]);
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("brew-form-cancel").addEventListener("click", cancelEdit);
|
||||
document.getElementById("brew-form").addEventListener("input", (event) => {
|
||||
if (["doseG", "waterG", "yieldG"].includes(event.target.name)) updateRatio();
|
||||
if (event.target.name === "rating") updateRatingPill();
|
||||
});
|
||||
document.getElementById("clear-bean-filter").addEventListener("click", () => {
|
||||
beanFilter = null;
|
||||
renderBrews();
|
||||
});
|
||||
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;
|
||||
renderBrewerPicker();
|
||||
updateRatingPill();
|
||||
await Promise.all([loadBeans(), loadBrews()]);
|
||||
const params = new URLSearchParams(location.search);
|
||||
const requestedBean = params.get("bean");
|
||||
if (requestedBean && beans.some((b) => b.id === requestedBean)) {
|
||||
if (params.get("new")) {
|
||||
document.getElementById("brew-bean-select").value = requestedBean;
|
||||
} else {
|
||||
beanFilter = requestedBean;
|
||||
renderBrews();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -250,6 +250,38 @@ document.getElementById("lot-form").addEventListener("submit", (event) => {
|
||||
});
|
||||
});
|
||||
|
||||
// The LLM prefill endpoint returns raw `extracted` page facts — map the green-relevant ones
|
||||
// into the lot form (only overwriting what the page actually stated).
|
||||
document.getElementById("lot-prefill-btn").addEventListener("click", (event) =>
|
||||
guarded(event.currentTarget, async () => {
|
||||
const url = document.getElementById("lot-prefill-url").value.trim();
|
||||
const note = document.getElementById("lot-prefill-note");
|
||||
if (!url) return;
|
||||
note.classList.remove("hidden");
|
||||
note.textContent = "Reading the page with the LLM…";
|
||||
try {
|
||||
const body = await api("/api/prefill", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
const x = body.extracted ?? {};
|
||||
const form = document.getElementById("lot-form");
|
||||
if (x.origin) form.origin.value = x.origin;
|
||||
if (x.cultivar) form.variety.value = x.cultivar;
|
||||
if (x.process) form.process.value = x.process;
|
||||
if (x.producer) form.producer.value = x.producer;
|
||||
if (x.moisturePct) form.moisturePct.value = x.moisturePct;
|
||||
const filled = ["origin", "cultivar", "process", "producer", "moisturePct"]
|
||||
.filter((k) => x[k]).length;
|
||||
note.textContent = filled
|
||||
? `Prefilled ${filled} field${filled === 1 ? "" : "s"} — check them, then save.`
|
||||
: "The page didn't state anything usable — fill the form by hand.";
|
||||
} catch (error) {
|
||||
note.textContent = `Prefill failed: ${error.message}`;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
document.getElementById("btn-logout").addEventListener("click", async () => {
|
||||
await protectedFetch("/api/auth/logout", { method: "POST" });
|
||||
location.assign("/");
|
||||
|
||||
+17
-2
@@ -20,6 +20,7 @@ import { api, protectedFetch, csrfToken } from "./api.js?v=__ASSET_VERSION__";
|
||||
import { initSideNav } from "./nav.js?v=__ASSET_VERSION__";
|
||||
import { wireWhyPanels } from "./why-panels.js?v=__ASSET_VERSION__";
|
||||
import { initFieldHelp } from "./field-help.js?v=__ASSET_VERSION__";
|
||||
import { initPlanChat } from "./plan-chat-ui.js?v=__ASSET_VERSION__";
|
||||
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
|
||||
|
||||
const FIELD_ID_SET = new Set(FIELD_IDS);
|
||||
@@ -38,6 +39,18 @@ const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the
|
||||
// the plan draft cache below.
|
||||
const MACHINE_PROFILE_KEY_PREFIX = "roastPlannerMachineProfile.v1";
|
||||
let machineProfile = null;
|
||||
// Learned roaster behavior from uploaded .alogs (server-aggregated) — used as the plan
|
||||
// curve's fallback milestone temps so the suggested curve matches this user's machine.
|
||||
let roasterProfile = null;
|
||||
|
||||
async function loadRoasterProfile() {
|
||||
try {
|
||||
const response = await fetch("/api/roaster-profile");
|
||||
if (response.ok) roasterProfile = (await response.json()).profile;
|
||||
} catch {
|
||||
/* offline — curve falls back to reference machine temps */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMachineProfile(userId) {
|
||||
const cacheKey = `${MACHINE_PROFILE_KEY_PREFIX}:${userId}`;
|
||||
@@ -464,7 +477,7 @@ function paintCurveInto(planGroup, refGroup, planPoints, ref) {
|
||||
}
|
||||
|
||||
function renderCurve(ledger) {
|
||||
const planPoints = buildPlanCurve(state.plan, ledger);
|
||||
const planPoints = buildPlanCurve(state.plan, ledger, roasterProfile);
|
||||
const ref = state.plan.reference;
|
||||
|
||||
paintCurveInto(
|
||||
@@ -775,6 +788,7 @@ function wireDrawers() {
|
||||
["nav-prefill", "panel-prefill"],
|
||||
["nav-alog", "panel-alog"],
|
||||
["nav-plans", "panel-plans"],
|
||||
["nav-chat", "panel-chat"],
|
||||
["nav-settings", "panel-settings"],
|
||||
["nav-import-export", "panel-import-export"],
|
||||
])
|
||||
@@ -1089,7 +1103,7 @@ async function init() {
|
||||
state.plan = localDraft ?? blankPlan();
|
||||
const draftRemoteId = remotePlanId;
|
||||
const draftSyncedAtAtLoad = draftSyncedAt;
|
||||
await Promise.all([loadPlans(), loadMachineProfile(user.id)]);
|
||||
await Promise.all([loadPlans(), loadMachineProfile(user.id), loadRoasterProfile()]);
|
||||
const requestedId = new URLSearchParams(location.search).get("plan");
|
||||
const selected = plans.find((plan) => plan.id === requestedId);
|
||||
// Prefer the local draft only when it targets this exact plan AND was last confirmed
|
||||
@@ -1165,6 +1179,7 @@ async function init() {
|
||||
renderFormFromPlan,
|
||||
});
|
||||
wireCuppingLink();
|
||||
initPlanChat({ getPlan: () => state.plan });
|
||||
renderMachineProfileNote();
|
||||
recompute();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,73 @@
|
||||
// Shared side-nav behavior (collapse/expand, mobile drawer) for every authenticated page
|
||||
// (planner, account, admin) so each page's own script doesn't reimplement it.
|
||||
|
||||
// Single source of truth for the page links, grouped by workflow — every page renders this
|
||||
// into its <div id="nav-links"> instead of hand-maintaining a diverging copy of the nav.
|
||||
// Page-specific tool buttons (the planner's drawers) stay in that page's own markup below it.
|
||||
const NAV_GROUPS = [
|
||||
{
|
||||
title: "Roasting",
|
||||
items: [
|
||||
{ href: "/app", icon: "◐", label: "Planner" },
|
||||
{ href: "/roasts", icon: "∿", label: "Roasts" },
|
||||
{ href: "/inventory", icon: "▥", label: "Green lots" },
|
||||
{ href: "/cupping", icon: "◒", label: "Cupping" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Brewing",
|
||||
items: [
|
||||
{ href: "/brews", icon: "◉", label: "Brews" },
|
||||
{ href: "/beans", icon: "◍", label: "Beans" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: null,
|
||||
items: [
|
||||
{ href: "/account", icon: "◔", label: "Account" },
|
||||
{ href: "/api-docs", icon: "⌗", label: "API docs" },
|
||||
{ href: "/admin", icon: "⚙", label: "Admin", id: "nav-admin", hidden: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function renderNavLinks() {
|
||||
const mount = document.getElementById("nav-links");
|
||||
if (!mount) return;
|
||||
mount.replaceChildren(
|
||||
...NAV_GROUPS.map((group) => {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "nav-group";
|
||||
if (group.title) {
|
||||
const title = document.createElement("div");
|
||||
title.className = "nav-group-title";
|
||||
title.textContent = group.title;
|
||||
wrap.append(title);
|
||||
}
|
||||
for (const item of group.items) {
|
||||
const a = document.createElement("a");
|
||||
a.className = "nav-item";
|
||||
if (item.hidden) a.classList.add("hidden");
|
||||
if (item.id) a.id = item.id;
|
||||
a.href = item.href;
|
||||
if (item.href === location.pathname) a.setAttribute("aria-current", "page");
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "nav-icon";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
icon.textContent = item.icon;
|
||||
const label = document.createElement("span");
|
||||
label.className = "nav-label";
|
||||
label.textContent = item.label;
|
||||
a.append(icon, label);
|
||||
wrap.append(a);
|
||||
}
|
||||
return wrap;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function initSideNav() {
|
||||
renderNavLinks();
|
||||
const nav = document.getElementById("side-nav");
|
||||
const hamburger = document.getElementById("nav-hamburger");
|
||||
const collapseBtn = document.getElementById("nav-collapse");
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Chat-with-the-LLM drawer on the planner: stateless per page load, grounded server-side in
|
||||
// the current plan + learned profiles. The whole visible conversation is resent each turn.
|
||||
import { api } from "./api.js?v=__ASSET_VERSION__";
|
||||
|
||||
export function initPlanChat({ getPlan }) {
|
||||
const thread = document.getElementById("chat-thread");
|
||||
const form = document.getElementById("chat-form");
|
||||
const input = document.getElementById("chat-input");
|
||||
const send = document.getElementById("chat-send");
|
||||
if (!thread || !form) return;
|
||||
const messages = [];
|
||||
|
||||
function bubble(role, content, pending = false) {
|
||||
const div = document.createElement("div");
|
||||
div.className = `chat-msg ${role}${pending ? " pending" : ""}`;
|
||||
div.textContent = content;
|
||||
thread.append(div);
|
||||
thread.scrollTop = thread.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
bubble(
|
||||
"assistant",
|
||||
"Ask me anything about this plan — why a number is what it is, what to change for a different cup, or how your machine's history should shape it.",
|
||||
);
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const content = input.value.trim();
|
||||
if (!content || send.disabled) return;
|
||||
input.value = "";
|
||||
messages.push({ role: "user", content });
|
||||
bubble("user", content);
|
||||
const pending = bubble("assistant", "Thinking…", true);
|
||||
send.disabled = true;
|
||||
try {
|
||||
const body = await api("/api/plan-chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ plan: getPlan(), messages }),
|
||||
});
|
||||
messages.push({ role: "assistant", content: body.reply });
|
||||
pending.classList.remove("pending");
|
||||
pending.textContent = body.reply;
|
||||
} catch (error) {
|
||||
messages.pop(); // keep history consistent with what's on screen
|
||||
pending.classList.remove("pending");
|
||||
pending.textContent =
|
||||
error.code === "no_model"
|
||||
? "No LLM model is configured on the server — ask an admin to set one on the Admin page."
|
||||
: `That didn't work: ${error.message}`;
|
||||
} finally {
|
||||
send.disabled = false;
|
||||
thread.scrollTop = thread.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Boots the self-hosted Swagger UI (the app's CSP forbids inline scripts and CDNs).
|
||||
// "Try it out" calls run same-origin, so the browser session cookie authenticates them;
|
||||
// write endpoints additionally need the x-csrf-token header, which is injected below.
|
||||
/* global SwaggerUIBundle */
|
||||
const csrf = () =>
|
||||
document.cookie
|
||||
.split("; ")
|
||||
.find((v) => v.startsWith("rp_csrf="))
|
||||
?.split("=")[1] || "";
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
SwaggerUIBundle({
|
||||
url: "/api/openapi.json",
|
||||
dom_id: "#swagger-ui",
|
||||
docExpansion: "none",
|
||||
defaultModelsExpandDepth: -1,
|
||||
requestInterceptor: (request) => {
|
||||
if (!/^(GET|HEAD)$/i.test(request.method) && !request.headers.Authorization)
|
||||
request.headers["x-csrf-token"] = csrf();
|
||||
return request;
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user