Test and deploy / test-and-deploy (push) Successful in 55s
- New flavor-wheel.js: two-ring sunburst (families inner, descriptors outer) in SCA hue families; tapping a wedge toggles the same flavor tag as the checkbox list; selections render bold with a dark outline; respects the tag limit; labels drawn above all wedges so long names never clip (screenshot-verified in headless Chromium) - Cupping radar: one hue per attribute on labels + vertex dots Co-Authored-By: Claude Fable 5 <[email protected]>
619 lines
19 KiB
JavaScript
619 lines
19 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 { wireWhyPanels } from "./why-panels.js?v=__ASSET_VERSION__";
|
||
import { renderFlavorWheel } from "./flavor-wheel.js?v=__ASSET_VERSION__";
|
||
import {
|
||
SCORE_ATTRS,
|
||
SCORE_LABELS,
|
||
TICK_ATTRS,
|
||
TICK_LABELS,
|
||
FLAVOR_TAXONOMY,
|
||
MAX_FLAVOR_TAGS,
|
||
MAX_CUP_COUNT,
|
||
computeTotalScore,
|
||
} from "/shared/cupping.js?v=__ASSET_VERSION__";
|
||
|
||
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);
|
||
}
|
||
renderWheel();
|
||
renderFlavorFamilies();
|
||
renderFlavorChips();
|
||
scheduleSave();
|
||
}
|
||
|
||
function renderWheel() {
|
||
const svg = document.getElementById("flavor-wheel");
|
||
if (!svg) return;
|
||
renderFlavorWheel(
|
||
svg,
|
||
FLAVOR_TAXONOMY,
|
||
data.flavor_tags,
|
||
(tag, on) => toggleFlavorTag(tag, on),
|
||
data.flavor_tags.length >= MAX_FLAVOR_TAGS,
|
||
);
|
||
}
|
||
|
||
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;
|
||
// One hue per attribute so the wheel reads as ten distinct spokes, not a grey web.
|
||
const AXIS_COLOR = (i) => `hsl(${(i * 36 + 8) % 360} 46% 40%)`;
|
||
|
||
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",
|
||
"font-weight": "600",
|
||
fill: AXIS_COLOR(i),
|
||
});
|
||
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;
|
||
const points = radarPoints();
|
||
shape.setAttribute("points", points.map((p) => `${p.x},${p.y}`).join(" "));
|
||
// Colored vertex dots matching each axis label.
|
||
const svg = document.getElementById("cup-radar");
|
||
for (const old of svg.querySelectorAll("[data-vertex]")) old.remove();
|
||
points.forEach((p, i) => {
|
||
const dot = svgEl("circle", {
|
||
cx: p.x,
|
||
cy: p.y,
|
||
r: 3.2,
|
||
fill: AXIS_COLOR(i),
|
||
"data-vertex": "1",
|
||
});
|
||
svg.append(dot);
|
||
});
|
||
}
|
||
|
||
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();
|
||
renderWheel();
|
||
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();
|