Add green-bean inventory and cupping features
Ports inventory management and SCA-style cupping scoring from hope_roaster: lot tracking with audit-logged consumption, cupping sessions with server-authoritative scoring and a live radar chart, and links from the planner (draw-from-lot, open-cupping-session). Also fixes the Blend/Single-origin toggle layout, replaces tooltips with an in-context "why" teaching layer, and stages the planner UI into pre-roast vs. post-roast phases.
This commit is contained in:
@@ -0,0 +1,586 @@
|
||||
import { api, protectedFetch } from "./api.js";
|
||||
import { initSideNav, loadNavUser } from "./nav.js";
|
||||
import { showToast } from "./toast.js";
|
||||
import { wireWhyPanels } from "./why-panels.js";
|
||||
import {
|
||||
SCORE_ATTRS,
|
||||
SCORE_LABELS,
|
||||
TICK_ATTRS,
|
||||
TICK_LABELS,
|
||||
FLAVOR_TAXONOMY,
|
||||
MAX_FLAVOR_TAGS,
|
||||
MAX_CUP_COUNT,
|
||||
computeTotalScore,
|
||||
} from "/shared/cupping.js";
|
||||
|
||||
const sessionId = new URLSearchParams(location.search).get("session");
|
||||
|
||||
function fmtDate(value) {
|
||||
return value ? new Date(value).toLocaleString() : "—";
|
||||
}
|
||||
|
||||
/** Disables `el` for the duration of `run()` so a slow request can't be double-submitted. */
|
||||
async function guarded(el, run) {
|
||||
if (!el || el.disabled) return;
|
||||
el.disabled = true;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
el.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── List view ───────────────────────────────────────────────────────────
|
||||
|
||||
async function loadSessions() {
|
||||
const body = document.getElementById("sessions-body");
|
||||
try {
|
||||
const { sessions } = await api("/api/cupping");
|
||||
if (!sessions.length) {
|
||||
body.innerHTML = `<tr><td colspan="6" class="empty-state">No cupping sessions yet.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...sessions.map((s) => {
|
||||
const tr = document.createElement("tr");
|
||||
const coffee = document.createElement("td");
|
||||
coffee.textContent = s.planTitle || "Untitled";
|
||||
const score = document.createElement("td");
|
||||
score.className = "num";
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "badge badge-current";
|
||||
badge.textContent = s.totalScore.toFixed(2);
|
||||
score.append(badge);
|
||||
const cups = document.createElement("td");
|
||||
cups.className = "num";
|
||||
cups.textContent = s.cupCount;
|
||||
const flavors = document.createElement("td");
|
||||
const shown = s.flavorTags.slice(0, 3).map((t) => t.split(".").pop());
|
||||
flavors.textContent =
|
||||
shown.join(", ") + (s.flavorTags.length > 3 ? ` +${s.flavorTags.length - 3}` : "");
|
||||
const updated = document.createElement("td");
|
||||
updated.textContent = fmtDate(s.updatedAt);
|
||||
const actions = document.createElement("td");
|
||||
actions.className = "data-table-actions";
|
||||
const open = document.createElement("a");
|
||||
open.className = "ghost-btn small";
|
||||
open.href = `/cupping?session=${s.id}`;
|
||||
open.textContent = "Open";
|
||||
const del = document.createElement("button");
|
||||
del.className = "ghost-btn small";
|
||||
del.type = "button";
|
||||
del.textContent = "Delete";
|
||||
del.addEventListener("click", (event) => {
|
||||
if (!confirm("Delete this cupping session?")) return;
|
||||
guarded(event.currentTarget, async () => {
|
||||
try {
|
||||
await api(`/api/cupping/${s.id}`, { method: "DELETE" });
|
||||
showToast("Session deleted.");
|
||||
loadSessions();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
});
|
||||
actions.append(open, del);
|
||||
tr.append(coffee, score, cups, flavors, updated, actions);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
body.innerHTML = `<tr><td colspan="6" class="empty-state">Could not load sessions.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlanOptions() {
|
||||
const select = document.getElementById("new-session-plan");
|
||||
try {
|
||||
const { plans } = await api("/api/plans");
|
||||
select.append(
|
||||
...plans.map((p) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = p.id;
|
||||
option.textContent = p.plan?.fields?.["0.1"] || "Untitled plan";
|
||||
return option;
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* the plan-less option still works */
|
||||
}
|
||||
}
|
||||
|
||||
function wireNewSessionForm() {
|
||||
const form = document.getElementById("new-session-form");
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const roastPlanId = document.getElementById("new-session-plan").value || undefined;
|
||||
const cupCount = Number(document.getElementById("new-session-cups").value) || 5;
|
||||
guarded(form.querySelector("button[type=submit]"), async () => {
|
||||
try {
|
||||
const { session } = await api("/api/cupping", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ roastPlanId, cupCount }),
|
||||
});
|
||||
location.assign(`/cupping?session=${session.id}`);
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Session view ────────────────────────────────────────────────────────
|
||||
|
||||
let data = null; // the coerced session document (snake_case, matches shared/cupping.js)
|
||||
let saveTimer = null;
|
||||
|
||||
function setAutosaveStatus(status) {
|
||||
const chip = document.getElementById("cupping-autosave-status");
|
||||
const text = chip.querySelector(".autosave-text");
|
||||
chip.classList.remove("saving", "saved", "failed");
|
||||
chip.classList.add(status);
|
||||
text.textContent =
|
||||
{ saving: "Saving…", saved: "Synced", failed: "Sync failed" }[status] || "Not saved yet";
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
setAutosaveStatus("saving");
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(save, 500);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
const body = await api(`/api/cupping/${sessionId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ data }),
|
||||
});
|
||||
document.getElementById("cup-total").textContent = body.session.totalScore.toFixed(2);
|
||||
setAutosaveStatus("saved");
|
||||
} catch (error) {
|
||||
setAutosaveStatus("failed");
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
}
|
||||
|
||||
function liveTotal() {
|
||||
return computeTotalScore(
|
||||
data.scores,
|
||||
data.ticks,
|
||||
data.taint_cups,
|
||||
data.fault_cups,
|
||||
data.cup_count,
|
||||
);
|
||||
}
|
||||
|
||||
function renderTotal() {
|
||||
document.getElementById("cup-total").textContent = liveTotal().toFixed(2);
|
||||
}
|
||||
|
||||
// ── Score sliders ──
|
||||
function renderScoreRows() {
|
||||
const wrap = document.getElementById("cup-score-rows");
|
||||
wrap.replaceChildren(
|
||||
...SCORE_ATTRS.map((attr) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "cup-score-row";
|
||||
row.dataset.attr = attr;
|
||||
const unscored = !(data.scores[attr] > 0);
|
||||
row.classList.toggle("unscored", unscored);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "cup-score-label";
|
||||
label.textContent = SCORE_LABELS[attr];
|
||||
|
||||
const slider = document.createElement("input");
|
||||
slider.type = "range";
|
||||
slider.min = "6";
|
||||
slider.max = "10";
|
||||
slider.step = "0.25";
|
||||
slider.value = unscored ? "6" : data.scores[attr];
|
||||
slider.setAttribute("aria-label", `${SCORE_LABELS[attr]} score`);
|
||||
|
||||
const output = document.createElement("output");
|
||||
output.className = "cup-score-value";
|
||||
output.textContent = unscored ? "—" : data.scores[attr].toFixed(2);
|
||||
|
||||
const clearBtn = document.createElement("button");
|
||||
clearBtn.type = "button";
|
||||
clearBtn.className = "ghost-btn small cup-score-clear";
|
||||
clearBtn.hidden = unscored;
|
||||
clearBtn.textContent = "✕";
|
||||
clearBtn.setAttribute("aria-label", `Clear ${SCORE_LABELS[attr]} score`);
|
||||
|
||||
slider.addEventListener("input", () => {
|
||||
data.scores[attr] = Number(slider.value);
|
||||
row.classList.remove("unscored");
|
||||
output.textContent = data.scores[attr].toFixed(2);
|
||||
clearBtn.hidden = false;
|
||||
renderTotal();
|
||||
renderRadar();
|
||||
scheduleSave();
|
||||
});
|
||||
clearBtn.addEventListener("click", () => {
|
||||
data.scores[attr] = 0;
|
||||
row.classList.add("unscored");
|
||||
slider.value = "6";
|
||||
output.textContent = "—";
|
||||
clearBtn.hidden = true;
|
||||
renderTotal();
|
||||
renderRadar();
|
||||
scheduleSave();
|
||||
});
|
||||
|
||||
row.append(label, slider, output, clearBtn);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tick attributes (fill-up-to cup cells) ──
|
||||
function renderTickRows() {
|
||||
const wrap = document.getElementById("cup-tick-rows");
|
||||
wrap.replaceChildren(
|
||||
...TICK_ATTRS.map((attr) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "cup-tick-row";
|
||||
row.dataset.tick = attr;
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "cup-score-label";
|
||||
label.textContent = TICK_LABELS[attr];
|
||||
|
||||
const cells = document.createElement("div");
|
||||
cells.className = "cup-tick-cells";
|
||||
cells.setAttribute("role", "group");
|
||||
cells.setAttribute("aria-label", `${TICK_LABELS[attr]} — cups passed`);
|
||||
|
||||
const output = document.createElement("output");
|
||||
|
||||
function renderCells() {
|
||||
const count = data.ticks[attr] || 0;
|
||||
cells.replaceChildren(
|
||||
...Array.from({ length: data.cup_count }, (_, i) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "cup-tick";
|
||||
btn.setAttribute("aria-label", `Cup ${i + 1}`);
|
||||
const pressed = i < count;
|
||||
btn.setAttribute("aria-pressed", String(pressed));
|
||||
btn.addEventListener("click", () => {
|
||||
data.ticks[attr] = i >= (data.ticks[attr] || 0) ? i + 1 : i;
|
||||
renderCells();
|
||||
renderTotal();
|
||||
scheduleSave();
|
||||
});
|
||||
return btn;
|
||||
}),
|
||||
);
|
||||
output.textContent = `${count}/${data.cup_count}`;
|
||||
}
|
||||
renderCells();
|
||||
row._renderCells = renderCells;
|
||||
|
||||
row.append(label, cells, output);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Defects (taint/fault steppers) ──
|
||||
function renderDefectRows() {
|
||||
const wrap = document.getElementById("cup-defect-rows");
|
||||
const specs = [
|
||||
{ key: "taint_cups", label: "Taint cups", note: "−2 pts each" },
|
||||
{ key: "fault_cups", label: "Fault cups", note: "−4 pts each" },
|
||||
];
|
||||
wrap.replaceChildren(
|
||||
...specs.map(({ key, label, note }) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "cup-stepper-row";
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.className = "cup-score-label";
|
||||
labelEl.textContent = `${label} (${note})`;
|
||||
const minus = document.createElement("button");
|
||||
minus.type = "button";
|
||||
minus.className = "ghost-btn small";
|
||||
minus.textContent = "−";
|
||||
minus.setAttribute("aria-label", `Decrease ${label}`);
|
||||
const count = document.createElement("output");
|
||||
const plus = document.createElement("button");
|
||||
plus.type = "button";
|
||||
plus.className = "ghost-btn small";
|
||||
plus.textContent = "+";
|
||||
plus.setAttribute("aria-label", `Increase ${label}`);
|
||||
|
||||
function update() {
|
||||
count.textContent = data[key];
|
||||
}
|
||||
minus.addEventListener("click", () => {
|
||||
data[key] = Math.max(0, data[key] - 1);
|
||||
update();
|
||||
renderTotal();
|
||||
scheduleSave();
|
||||
});
|
||||
plus.addEventListener("click", () => {
|
||||
data[key] = Math.min(data.cup_count, data[key] + 1);
|
||||
update();
|
||||
renderTotal();
|
||||
scheduleSave();
|
||||
});
|
||||
update();
|
||||
row.append(labelEl, minus, count, plus);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function reclampForCupCount() {
|
||||
for (const attr of TICK_ATTRS) data.ticks[attr] = Math.min(data.ticks[attr] || 0, data.cup_count);
|
||||
data.taint_cups = Math.min(data.taint_cups, data.cup_count);
|
||||
data.fault_cups = Math.min(data.fault_cups, data.cup_count);
|
||||
}
|
||||
|
||||
function wireCupCount() {
|
||||
const input = document.getElementById("cup-count");
|
||||
input.min = "1";
|
||||
input.max = String(MAX_CUP_COUNT);
|
||||
input.value = data.cup_count;
|
||||
input.addEventListener("change", () => {
|
||||
const next = Math.max(1, Math.min(MAX_CUP_COUNT, Number(input.value) || 1));
|
||||
data.cup_count = next;
|
||||
input.value = next;
|
||||
reclampForCupCount();
|
||||
renderTickRows();
|
||||
renderDefectRows();
|
||||
renderTotal();
|
||||
scheduleSave();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Flavor checklist ──
|
||||
function renderFlavorChips() {
|
||||
const chips = document.getElementById("flavor-chips");
|
||||
chips.replaceChildren(
|
||||
...data.flavor_tags.map((tag) => {
|
||||
// A dedicated class, not .chip-opt (a checkbox-option style whose CSS uses a
|
||||
// descendant `span` selector — reusing it here with a nested span for the remove
|
||||
// button doubled-up borders/padding onto that inner span too).
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "flavor-chip";
|
||||
const text = document.createElement("span");
|
||||
text.textContent = tag.split(".").pop().replaceAll("_", " ");
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "flavor-chip-remove";
|
||||
remove.setAttribute("aria-label", `Remove ${text.textContent}`);
|
||||
remove.textContent = "✕";
|
||||
remove.addEventListener("click", () => toggleFlavorTag(tag, false));
|
||||
chip.append(text, remove);
|
||||
return chip;
|
||||
}),
|
||||
);
|
||||
document
|
||||
.getElementById("flavor-limit-note")
|
||||
.classList.toggle("hidden", data.flavor_tags.length < MAX_FLAVOR_TAGS);
|
||||
}
|
||||
|
||||
function toggleFlavorTag(tag, checked) {
|
||||
if (checked) {
|
||||
if (data.flavor_tags.length >= MAX_FLAVOR_TAGS || data.flavor_tags.includes(tag)) return;
|
||||
data.flavor_tags.push(tag);
|
||||
} else {
|
||||
data.flavor_tags = data.flavor_tags.filter((t) => t !== tag);
|
||||
}
|
||||
renderFlavorFamilies();
|
||||
renderFlavorChips();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function renderFlavorFamilies() {
|
||||
const wrap = document.getElementById("flavor-families");
|
||||
const atLimit = data.flavor_tags.length >= MAX_FLAVOR_TAGS;
|
||||
wrap.replaceChildren(
|
||||
...Object.entries(FLAVOR_TAXONOMY).map(([familyId, family]) => {
|
||||
const selectedCount = data.flavor_tags.filter((t) => t.startsWith(`${familyId}.`)).length;
|
||||
const details = document.createElement("details");
|
||||
details.className = "flavor-family";
|
||||
const summary = document.createElement("summary");
|
||||
summary.textContent = family.label + (selectedCount ? ` (${selectedCount})` : "");
|
||||
details.append(summary);
|
||||
for (const [subId, descriptors] of Object.entries(family.subgroups)) {
|
||||
const subhead = document.createElement("p");
|
||||
subhead.className = "subhead";
|
||||
subhead.textContent = subId.replaceAll("_", " ");
|
||||
const group = document.createElement("div");
|
||||
group.className = "chip-group";
|
||||
for (const descriptor of descriptors) {
|
||||
const tag = `${familyId}.${subId}.${descriptor}`;
|
||||
const label = document.createElement("label");
|
||||
label.className = "chip-opt";
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.checked = data.flavor_tags.includes(tag);
|
||||
input.disabled = atLimit && !input.checked;
|
||||
input.addEventListener("change", () => toggleFlavorTag(tag, input.checked));
|
||||
const span = document.createElement("span");
|
||||
span.textContent = descriptor.replaceAll("_", " ");
|
||||
label.append(input, span);
|
||||
group.append(label);
|
||||
}
|
||||
details.append(subhead, group);
|
||||
}
|
||||
return details;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Radar chart (pure SVG, no dependency) ──
|
||||
const RADAR_CENTER = 120;
|
||||
const RADAR_RADIUS = 90;
|
||||
|
||||
function radarPoints() {
|
||||
const n = SCORE_ATTRS.length;
|
||||
return SCORE_ATTRS.map((attr, i) => {
|
||||
const raw = data.scores[attr] || 0;
|
||||
const frac = raw > 0 ? Math.max(0, Math.min(1, (raw - 6) / 4)) : 0;
|
||||
const angle = -Math.PI / 2 + (2 * Math.PI * i) / n;
|
||||
return {
|
||||
x: RADAR_CENTER + frac * RADAR_RADIUS * Math.cos(angle),
|
||||
y: RADAR_CENTER + frac * RADAR_RADIUS * Math.sin(angle),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function svgEl(tag, attrs) {
|
||||
const el = document.createElementNS("http://www.w3.org/2000/svg", tag);
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
||||
return el;
|
||||
}
|
||||
|
||||
function initRadarStatic() {
|
||||
const svg = document.getElementById("cup-radar");
|
||||
svg.replaceChildren();
|
||||
// Rings at scores 7/8/9/10 (6 is the center — an unscored/floor axis point).
|
||||
for (const score of [7, 8, 9, 10]) {
|
||||
const r = ((score - 6) / 4) * RADAR_RADIUS;
|
||||
svg.append(
|
||||
svgEl("circle", {
|
||||
cx: RADAR_CENTER,
|
||||
cy: RADAR_CENTER,
|
||||
r,
|
||||
fill: "none",
|
||||
stroke: "var(--line)",
|
||||
"stroke-width": "1",
|
||||
}),
|
||||
);
|
||||
}
|
||||
const n = SCORE_ATTRS.length;
|
||||
SCORE_ATTRS.forEach((attr, i) => {
|
||||
const angle = -Math.PI / 2 + (2 * Math.PI * i) / n;
|
||||
const x2 = RADAR_CENTER + RADAR_RADIUS * Math.cos(angle);
|
||||
const y2 = RADAR_CENTER + RADAR_RADIUS * Math.sin(angle);
|
||||
svg.append(
|
||||
svgEl("line", {
|
||||
x1: RADAR_CENTER,
|
||||
y1: RADAR_CENTER,
|
||||
x2,
|
||||
y2,
|
||||
stroke: "var(--line)",
|
||||
"stroke-width": "1",
|
||||
}),
|
||||
);
|
||||
const lx = RADAR_CENTER + (RADAR_RADIUS + 14) * Math.cos(angle);
|
||||
const ly = RADAR_CENTER + (RADAR_RADIUS + 14) * Math.sin(angle);
|
||||
const label = svgEl("text", {
|
||||
x: lx,
|
||||
y: ly,
|
||||
"text-anchor": "middle",
|
||||
"dominant-baseline": "middle",
|
||||
"font-size": "9",
|
||||
fill: "var(--ink-2)",
|
||||
});
|
||||
label.textContent = SCORE_LABELS[attr].split("/")[0];
|
||||
svg.append(label);
|
||||
});
|
||||
const shape = svgEl("polygon", {
|
||||
id: "radar-shape",
|
||||
fill: "var(--ember)",
|
||||
"fill-opacity": "0.25",
|
||||
stroke: "var(--ember)",
|
||||
"stroke-width": "1.5",
|
||||
});
|
||||
svg.append(shape);
|
||||
}
|
||||
|
||||
function renderRadar() {
|
||||
const shape = document.getElementById("radar-shape");
|
||||
if (!shape) return;
|
||||
shape.setAttribute("points", radarPoints().map((p) => `${p.x},${p.y}`).join(" "));
|
||||
}
|
||||
|
||||
function wireNotes() {
|
||||
const textarea = document.getElementById("cup-notes-text");
|
||||
textarea.value = data.notes;
|
||||
document.getElementById("cup-notes-count").textContent = data.notes.length;
|
||||
textarea.addEventListener("input", () => {
|
||||
data.notes = textarea.value;
|
||||
document.getElementById("cup-notes-count").textContent = data.notes.length;
|
||||
scheduleSave();
|
||||
});
|
||||
}
|
||||
|
||||
async function initSessionView(user) {
|
||||
document.getElementById("cupping-list-view").classList.add("hidden");
|
||||
document.getElementById("cupping-session-view").classList.remove("hidden");
|
||||
document.getElementById("session-autosave-wrap").classList.remove("hidden");
|
||||
setAutosaveStatus("saved");
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await api(`/api/cupping/${sessionId}`);
|
||||
} catch (error) {
|
||||
showToast(error.message || "Could not load this session.", "fail");
|
||||
location.assign("/cupping");
|
||||
return;
|
||||
}
|
||||
data = body.session.data;
|
||||
document.getElementById("cupping-title").textContent =
|
||||
body.session.planTitle || "Cupping session";
|
||||
document.getElementById("cupping-subtitle").textContent = `${data.cup_count} cups`;
|
||||
|
||||
wireCupCount();
|
||||
renderScoreRows();
|
||||
renderTickRows();
|
||||
renderDefectRows();
|
||||
renderFlavorFamilies();
|
||||
renderFlavorChips();
|
||||
wireNotes();
|
||||
initRadarStatic();
|
||||
renderRadar();
|
||||
renderTotal();
|
||||
wireWhyPanels();
|
||||
void user;
|
||||
}
|
||||
|
||||
async function initListView() {
|
||||
document.getElementById("cupping-list-view").classList.remove("hidden");
|
||||
document.getElementById("cupping-session-view").classList.add("hidden");
|
||||
wireNewSessionForm();
|
||||
await Promise.all([loadSessions(), loadPlanOptions()]);
|
||||
}
|
||||
|
||||
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;
|
||||
if (sessionId) await initSessionView(user);
|
||||
else await initListView();
|
||||
}
|
||||
|
||||
init();
|
||||
Reference in New Issue
Block a user