bravo-forever/static/app.js

555 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const state = {
data: null,
view: "mine",
propertyId: "class",
drafts: {},
sort: { key: "displayName", direction: 1 },
groupMode: "rank",
search: "",
busy: false,
notice: "",
};
const app = document.querySelector("#app");
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (character) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[character]);
}
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: "same-origin",
...options,
headers: {
...(options.body ? { "Content-Type": "application/json", "X-Requested-With": "PreferencesSPA" } : {}),
...(options.headers || {}),
},
});
const payload = response.status === 204 ? null : await response.json();
if (!response.ok) {
const error = new Error(payload?.error || "Something went wrong. Please try again.");
error.status = response.status;
error.suggestions = payload?.suggestions || [];
throw error;
}
return payload;
}
async function load(preserveDrafts = false) {
const data = await api("/api/bootstrap");
state.data = data;
if (!preserveDrafts) {
state.drafts = {};
for (const property of data.properties) {
state.drafts[property.id] = property.id === "content"
? { ...(data.preferences?.[property.id]?.choices || {}) }
: [...(data.preferences?.[property.id]?.ranking || property.categories)];
}
}
render();
}
function property() {
return state.data.properties.find((item) => item.id === state.propertyId);
}
function propertyTabs() {
return `<div class="property-tabs" role="tablist" aria-label="Character property">
${state.data.properties.map((item) => `<button type="button" class="property-tab ${item.id === state.propertyId ? "selected" : ""}" role="tab" aria-selected="${item.id === state.propertyId}" data-property="${item.id}">${escapeHtml(item.name)}</button>`).join("")}
</div>`;
}
function shell(content) {
const username = state.data.user.displayName;
app.innerHTML = `<div class="site-shell">
<header class="topbar">
<div class="brand"><div class="brand-text"><strong>Bravo Team</strong><small>Forever Interest</small></div></div>
<div class="topbar-right"><span class="account">Signed in as <strong>${escapeHtml(username)}</strong></span><button class="text-button" id="logout" type="button">Sign out</button></div>
</header>
<main class="main">
<div class="page-heading"><div><h1>Share your preferences</h1><p>Rank your class and role choices, and tell the group which content you would play. You can update your choices any time.</p></div><div class="heading-sigil circle-icon" aria-hidden="true"><img src="/icon.webp" alt=""></div></div>
<nav class="main-nav" aria-label="Main navigation">
<button type="button" class="nav-item ${state.view === "mine" ? "active" : ""}" data-view="mine"><span class="nav-icon">◇</span> My priorities</button>
<button type="button" class="nav-item ${state.view === "group" ? "active" : ""}" data-view="group"><span class="nav-icon">▦</span> Group preferences <span class="nav-count">${state.data.people.length}</span></button>
<button type="button" class="nav-item ${state.view === "account" ? "active" : ""}" data-view="account"><span class="nav-icon">○</span> Account</button>
</nav>
${state.notice ? `<div class="notice" role="status">${escapeHtml(state.notice)}</div>` : ""}
${content}
</main>
<footer class="footer">Bravo Team <span>·</span> Forever Interest</footer>
</div>`;
app.querySelector("#logout").addEventListener("click", logout);
app.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => {
state.view = button.dataset.view;
state.notice = "";
render();
}));
app.querySelectorAll("[data-property]").forEach((button) => button.addEventListener("click", () => {
state.propertyId = button.dataset.property;
state.sort = { key: "displayName", direction: 1 };
state.notice = "";
render();
}));
}
function renderLogin(error = "", success = "") {
app.innerHTML = `<div class="login-page">
<div class="login-glow"></div>
<div class="login-card">
<div class="login-emblem circle-icon" aria-hidden="true"><img src="/icon.webp" alt=""></div>
<h1>Bravo Team<br><em>Forever Interest</em></h1>
<p class="login-intro">Share your preferences for classes, roles, and content; the guild can see everyone’s choices in one place.</p>
<form id="login-form">
<label for="username">LOGIN USERNAME</label>
<input id="username" name="username" type="text" maxlength="32" autocomplete="username" placeholder="Choose a login username" required>
<div class="username-suggestions" id="username-suggestions" aria-live="polite"></div>
<label for="password">GROUP PASSWORD</label>
<input id="password" name="password" type="password" autocomplete="current-password" placeholder="Enter the shared password" required>
<div class="form-error" role="alert">${escapeHtml(error)}</div>
${success ? `<div class="form-success" role="status">${escapeHtml(success)}</div>` : ""}
<button class="primary-button full" type="submit">Enter the roster <span>→</span></button>
</form>
<div class="login-footnote">Use the same username each time to update your preferences.</div>
</div>
<div class="login-bottom">Bravo Team <span>·</span> Forever Interest</div>
</div>`;
const form = app.querySelector("#login-form");
const nameField = form.elements.namedItem("username");
const passwordField = form.elements.namedItem("password");
const suggestionBox = form.querySelector("#username-suggestions");
let requestId = 0;
let timer;
let createNewName = null;
function showSuggestions(names, confirmNewName = null) {
if (!names.length) {
suggestionBox.innerHTML = "";
return;
}
suggestionBox.innerHTML = `<p>Existing usernames — is one of these yours?</p>
<div class="suggestion-list">${names.map((name) => `<button type="button" class="suggestion-item" data-suggested-name="${escapeHtml(name)}">${escapeHtml(name)}</button>`).join("")}</div>
${confirmNewName ? `<button type="button" class="create-anyway">Create “${escapeHtml(confirmNewName)}” anyway</button>` : ""}`;
suggestionBox.querySelectorAll("[data-suggested-name]").forEach((button) => button.addEventListener("click", () => {
requestId += 1;
clearTimeout(timer);
nameField.value = button.dataset.suggestedName;
createNewName = null;
suggestionBox.innerHTML = "";
form.querySelector(".form-error").textContent = "";
nameField.focus();
}));
suggestionBox.querySelector(".create-anyway")?.addEventListener("click", () => {
createNewName = nameField.value.trim();
form.requestSubmit();
});
}
function updateSuggestions() {
createNewName = null;
requestId += 1;
clearTimeout(timer);
suggestionBox.innerHTML = "";
const username = nameField.value.trim();
if (username.length < 2) return;
const currentRequest = requestId;
timer = setTimeout(async () => {
try {
const result = await api("/api/username-suggestions", {
method: "POST",
body: JSON.stringify({ loginUsername: username }),
});
if (currentRequest === requestId) showSuggestions(result.suggestions);
} catch {
if (currentRequest === requestId) suggestionBox.innerHTML = "";
}
}, 500);
}
nameField.addEventListener("input", updateSuggestions);
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector('button[type="submit"]');
button.disabled = true;
clearTimeout(timer);
requestId += 1;
const username = nameField.value.trim();
try {
await api("/api/login", {
method: "POST",
body: JSON.stringify({
loginUsername: username,
password: passwordField.value,
createNew: createNewName === username,
}),
});
await load();
} catch (error) {
form.querySelector(".form-error").textContent = error.message;
if (error.status === 409) showSuggestions(error.suggestions, username);
button.disabled = false;
}
});
}
function renderEditor() {
const item = property();
if (item.id === "content") {
renderContentEditor();
return;
}
const draft = state.drafts[item.id];
const saved = state.data.preferences?.[item.id]?.ranking;
const excluded = item.categories.filter((category) => !draft.includes(category));
const changed = !!saved && (draft.length !== saved.length || draft.some((value, index) => value !== saved[index]));
const status = !saved ? "Not submitted" : changed ? "Unsaved changes" : "Saved";
const cards = draft.map((category, index) => `<li class="rank-card" data-index="${index}">
<span class="rank-number">${String(index + 1).padStart(2, "0")}</span>
<span class="category-name">${escapeHtml(category)}</span>
<div class="rank-actions">
<button type="button" class="move-button" data-move="up" data-index="${index}" aria-label="Move ${escapeHtml(category)} up" ${index === 0 ? "disabled" : ""}>↑</button>
<button type="button" class="move-button" data-move="down" data-index="${index}" aria-label="Move ${escapeHtml(category)} down" ${index === draft.length - 1 ? "disabled" : ""}>↓</button>
<button type="button" class="drag-handle" data-index="${index}" aria-label="Drag ${escapeHtml(category)} to reorder; use arrow keys when focused" title="Drag to reorder">⠿</button>
<button type="button" class="exclude-button" data-exclude="${escapeHtml(category)}" aria-label="Exclude ${escapeHtml(category)} from your ranking" title="No preference for ${escapeHtml(category)}">×</button>
</div>
</li>`).join("");
const excludedCards = excluded.map((category) => `<li class="excluded-card"><span class="category-name">${escapeHtml(category)}</span><span class="excluded-tag">NO PREFERENCE</span><button type="button" class="restore-button" data-restore="${escapeHtml(category)}" aria-label="Restore ${escapeHtml(category)} to your ranking">Restore</button></li>`).join("");
shell(`<section class="content-card editor-card">
<div class="card-topline"><span class="section-kicker">01 / YOUR RANKING</span><span class="status ${status === "Saved" ? "saved" : status === "Unsaved changes" ? "changed" : ""}"><span class="status-dot"></span>${status}</span></div>
<div class="section-heading"><div><h2>Rank your ${escapeHtml(item.name.toLowerCase())} choices</h2><p>Put your favorites at the top. Select × for any choice you do not want to rank; you can restore it below.</p></div><div class="count-badge">${draft.length} RANKED</div></div>
${propertyTabs()}
<div class="ranking-layout"><div class="ranking-labels"><span>MOST PREFERRED</span><span>DRAG TO ORDER</span></div><ol class="rank-list" id="rank-list">${cards}</ol>${draft.length ? `<div class="least-label">LOWEST RANKED</div>` : `<p class="rank-empty">No ranked choices. Restore a category below to rank it.</p>`}
<div class="excluded-section"><div class="excluded-heading"><strong>No preference</strong><span>${excluded.length} EXCLUDED</span></div><p>Excluded choices have no rank. They appear blank in group tables and sort as the lowest preference.</p>${excluded.length ? `<ul class="excluded-list">${excludedCards}</ul>` : `<p class="excluded-empty">No categories excluded.</p>`}</div>
</div>
<div class="card-footer"><span>${saved ? `Last saved ${formatDate(state.data.preferences[item.id].updatedAt)}` : "Save to share your ranking with the group."}</span><button type="button" class="primary-button" id="save" ${state.busy ? "disabled" : ""}>${state.busy ? "Saving…" : saved ? "Update ranking" : "Save ranking"} <span>→</span></button></div>
</section>`);
app.querySelectorAll("[data-move]").forEach((button) => button.addEventListener("click", () => move(Number(button.dataset.index), button.dataset.move === "up" ? -1 : 1)));
app.querySelectorAll("[data-exclude]").forEach((button) => button.addEventListener("click", () => {
state.drafts[item.id] = draft.filter((category) => category !== button.dataset.exclude);
state.notice = "";
render();
}));
app.querySelectorAll("[data-restore]").forEach((button) => button.addEventListener("click", () => {
state.drafts[item.id].push(button.dataset.restore);
state.notice = "";
render();
}));
app.querySelectorAll(".drag-handle").forEach((handle) => {
handle.addEventListener("pointerdown", startDrag);
handle.addEventListener("keydown", (event) => {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
move(Number(handle.dataset.index), event.key === "ArrowUp" ? -1 : 1, true);
}
});
});
app.querySelector("#save").addEventListener("click", savePreference);
}
function renderContentEditor() {
const item = property();
const draft = state.drafts.content;
const saved = state.data.preferences?.content?.choices;
const answered = item.categories.filter((category) => ["Yes", "Maybe", "No"].includes(draft[category])).length;
const changed = !!saved && item.categories.some((category) => draft[category] !== saved[category]);
const status = !saved ? "Not submitted" : changed ? "Unsaved changes" : "Saved";
const rows = item.categories.map((category) => `<div class="content-row">
<span class="content-name">${escapeHtml(category)}</span>
<div class="content-options" role="group" aria-label="${escapeHtml(category)}">
${["Yes", "Maybe", "No"].map((choice) => `<button type="button" class="content-option ${draft[category] === choice ? `selected ${choice.toLowerCase()}` : ""}" aria-pressed="${draft[category] === choice}" data-content-category="${escapeHtml(category)}" data-content-choice="${choice}">${choice}</button>`).join("")}
</div>
</div>`).join("");
shell(`<section class="content-card editor-card">
<div class="card-topline"><span class="section-kicker">01 / YOUR CONTENT</span><span class="status ${status === "Saved" ? "saved" : status === "Unsaved changes" ? "changed" : ""}"><span class="status-dot"></span>${status}</span></div>
<div class="section-heading"><div><h2>Choose your content interests</h2><p>Select Yes, Maybe, or No for each activity.</p></div><div class="count-badge">${answered} / ${item.categories.length} ANSWERED</div></div>
${propertyTabs()}
<div class="content-list">${rows}</div>
<div class="card-footer"><span>${saved ? `Last saved ${formatDate(state.data.preferences.content.updatedAt)}` : `${answered} of ${item.categories.length} answered. Choose one response for each activity.`}</span><button type="button" class="primary-button" id="save" ${state.busy || answered !== item.categories.length ? "disabled" : ""}>${state.busy ? "Saving…" : saved ? "Update content" : "Save content"} <span>→</span></button></div>
</section>`);
app.querySelectorAll("[data-content-category]").forEach((button) => button.addEventListener("click", () => {
state.drafts.content[button.dataset.contentCategory] = button.dataset.contentChoice;
state.notice = "";
render();
}));
app.querySelector("#save").addEventListener("click", savePreference);
}
function move(index, delta, refocus = false) {
const list = state.drafts[state.propertyId];
const next = index + delta;
if (next < 0 || next >= list.length) return;
[list[index], list[next]] = [list[next], list[index]];
state.notice = "";
render();
if (refocus) app.querySelector(`.drag-handle[data-index="${next}"]`)?.focus();
}
function startDrag(event) {
if (event.button !== 0) return;
event.preventDefault();
const handle = event.currentTarget;
const row = handle.closest(".rank-card");
const list = app.querySelector("#rank-list");
const from = Number(handle.dataset.index);
const startY = event.clientY;
row.classList.add("dragging");
handle.setPointerCapture(event.pointerId);
const onMove = (moveEvent) => {
const delta = moveEvent.clientY - startY;
row.style.transform = `translateY(${delta}px)`;
};
const finish = (endEvent) => {
handle.removeEventListener("pointermove", onMove);
handle.removeEventListener("pointerup", finish);
handle.removeEventListener("pointercancel", cancel);
const others = [...list.children].filter((child) => child !== row);
let to = others.findIndex((child) => endEvent.clientY < child.getBoundingClientRect().top + child.getBoundingClientRect().height / 2);
if (to < 0) to = others.length;
row.style.transform = "";
row.classList.remove("dragging");
if (to !== from) {
const draft = state.drafts[state.propertyId];
const [value] = draft.splice(from, 1);
draft.splice(to, 0, value);
state.notice = "";
render();
}
};
const cancel = () => {
handle.removeEventListener("pointermove", onMove);
handle.removeEventListener("pointerup", finish);
handle.removeEventListener("pointercancel", cancel);
row.style.transform = "";
row.classList.remove("dragging");
};
handle.addEventListener("pointermove", onMove);
handle.addEventListener("pointerup", finish);
handle.addEventListener("pointercancel", cancel);
}
async function savePreference() {
if (state.busy) return;
const propertyId = state.propertyId;
const item = property();
if (propertyId === "content" && item.categories.some((category) => !["Yes", "Maybe", "No"].includes(state.drafts.content[category]))) return;
state.busy = true;
render();
try {
await api(`/api/preferences/${propertyId}`, {
method: "PUT",
body: JSON.stringify(propertyId === "content"
? { choices: state.drafts.content }
: { ranking: state.drafts[propertyId] }),
});
await load(true);
state.notice = `${item.name} preferences saved.`;
} catch (error) {
state.notice = error.message;
} finally {
state.busy = false;
render();
}
}
function formatDate(value) {
if (!value) return "—";
return new Date(value).toLocaleString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit" });
}
function renderAccount() {
const { loginUsername, displayName } = state.data.user;
shell(`<section class="content-card account-card">
<div class="card-topline"><span class="section-kicker">03 / YOUR ACCOUNT</span></div>
<div class="section-heading"><div><h2>Account settings</h2><p>Change the name you sign in with or the name shown to the group.</p></div></div>
<form class="account-details" id="account-form">
<div class="account-fields"><div><label for="login-username">LOGIN USERNAME</label><input id="login-username" name="loginUsername" type="text" maxlength="32" autocomplete="username" value="${escapeHtml(loginUsername)}" required><p>Unique, matched without regard to capitalization.</p></div>
<div><label for="display-name">DISPLAY NAME</label><input id="display-name" name="displayName" type="text" maxlength="32" value="${escapeHtml(displayName)}" required><p>Shown in the group tables. Display names may be shared.</p></div></div>
<div class="account-save"><div class="form-error" role="alert"></div><button class="primary-button" type="submit">Save account changes <span>→</span></button></div>
</form>
<div class="danger-zone"><div><span class="danger-kicker">DELETE ACCOUNT</span><h3>Remove yourself from the roster</h3><p>This permanently deletes your account, all saved preferences, and all active sessions. Type <strong>${escapeHtml(displayName)}</strong> exactly to confirm.</p></div>
<form id="delete-form"><label for="confirm-display-name">YOUR DISPLAY NAME</label><input id="confirm-display-name" type="text" autocomplete="off" placeholder="Type ${escapeHtml(displayName)}" required><div class="form-error" id="delete-error" role="alert"></div><button class="delete-button" type="submit" disabled>Delete my account</button></form>
</div>
</section>`);
const accountForm = app.querySelector("#account-form");
accountForm.addEventListener("submit", async (event) => {
event.preventDefault();
const button = accountForm.querySelector('button[type="submit"]');
button.disabled = true;
accountForm.querySelector(".form-error").textContent = "";
try {
await api("/api/me", {
method: "PUT",
body: JSON.stringify({
loginUsername: accountForm.elements.namedItem("loginUsername").value,
displayName: accountForm.elements.namedItem("displayName").value,
}),
});
await load(true);
state.notice = "Account changes saved.";
render();
} catch (error) {
accountForm.querySelector(".form-error").textContent = error.message;
button.disabled = false;
}
});
const form = app.querySelector("#delete-form");
const input = form.querySelector("#confirm-display-name");
const button = form.querySelector('button[type="submit"]');
input.addEventListener("input", () => {
button.disabled = input.value !== displayName;
form.querySelector("#delete-error").textContent = "";
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (input.value !== displayName) return;
button.disabled = true;
try {
await api("/api/me", { method: "DELETE", body: JSON.stringify({ confirmDisplayName: input.value }) });
state.data = null;
state.drafts = {};
state.view = "mine";
state.notice = "";
renderLogin("", "Your account and preferences were deleted.");
} catch (error) {
form.querySelector("#delete-error").textContent = error.message;
button.disabled = false;
}
});
}
function ordinal(number) {
const suffix = number % 100 >= 11 && number % 100 <= 13 ? "th" : ({ 1: "st", 2: "nd", 3: "rd" }[number % 10] || "th");
return `${number}${suffix}`;
}
function sortPeople(people, propertyId) {
const { key, direction } = state.sort;
return [...people].sort((a, b) => {
const value = (person) => {
if (key === "displayName") return person.displayName;
const preference = person.preferences[propertyId];
if (key === "updated") return preference?.updatedAt;
if (key.startsWith("category:")) {
if (propertyId === "content") {
return { Yes: 1, Maybe: 2, No: 3 }[preference?.choices[key.slice(9)]] ?? null;
}
if (!preference) return null;
const index = preference?.ranking.indexOf(key.slice(9));
return index < 0 ? Number.MAX_SAFE_INTEGER : index + 1;
}
return preference?.ranking[Number(key.slice(4))];
};
const aValue = value(a);
const bValue = value(b);
if (aValue == null && bValue != null) return 1;
if (bValue == null && aValue != null) return -1;
if (aValue == null && bValue == null && !!a.preferences[propertyId] !== !!b.preferences[propertyId]) {
return a.preferences[propertyId] ? -1 : 1;
}
const comparison = typeof aValue === "number" && typeof bValue === "number"
? aValue - bValue
: String(aValue || "").localeCompare(String(bValue || ""), undefined, { sensitivity: "base" });
return comparison * direction
|| a.loginUsername.localeCompare(b.loginUsername, undefined, { sensitivity: "base" })
|| a.userId - b.userId;
});
}
function sortHeader(key, label) {
const active = state.sort.key === key;
return `<button type="button" class="sort-button ${active ? "active" : ""}" data-sort="${key}">${label}<span aria-hidden="true">${active ? (state.sort.direction === 1 ? "↑" : "↓") : "↕"}</span></button>`;
}
function renderGroup() {
const item = property();
const isContent = item.id === "content";
const query = state.search.trim().toLocaleLowerCase();
const people = sortPeople(state.data.people.filter((person) =>
person.displayName.toLocaleLowerCase().includes(query) || person.loginUsername.toLocaleLowerCase().includes(query)
), item.id);
const submitted = state.data.people.filter((person) => person.preferences[item.id]).length;
const columns = !isContent && state.groupMode === "rank"
? item.categories.map((_, index) => ({ key: `rank${index}`, label: ordinal(index + 1), index }))
: item.categories.map((category) => ({ key: `category:${category}`, label: category, category }));
const headers = columns.map((column) => `<th scope="col" aria-sort="${state.sort.key === column.key ? (state.sort.direction === 1 ? "ascending" : "descending") : "none"}">${sortHeader(column.key, escapeHtml(column.label))}</th>`).join("");
const rows = people.map((person) => {
const preference = person.preferences[item.id];
const ranks = columns.map((column) => {
if (!preference) return `<td><span class="table-dash">—</span></td>`;
if (isContent) {
const choice = preference.choices[column.category];
return `<td><span class="content-table-choice ${choice.toLowerCase()}">${choice}</span></td>`;
}
const rank = column.category ? preference.ranking.indexOf(column.category) + 1 : column.index + 1;
if (column.category && rank === 0) return `<td class="excluded-cell" aria-label="No preference"></td>`;
if (!column.category && !preference.ranking[column.index]) return `<td class="excluded-cell" aria-label="No ranked choice"></td>`;
const value = column.category ? ordinal(rank) : preference.ranking[column.index];
return `<td><span class="table-choice ${rank === 1 ? "first" : ""}">${escapeHtml(value)}</span></td>`;
}).join("");
return `<tr><th scope="row"><div class="person-cell"><span class="person-name">${escapeHtml(person.displayName)}${person.userId === state.data.user.userId ? `<small>YOU</small>` : ""}</span>${person.loginUsername !== person.displayName ? `<span class="person-login">@${escapeHtml(person.loginUsername)}</span>` : ""}</div></th>${ranks}<td class="date-cell">${preference ? formatDate(preference.updatedAt) : `<span class="pending">Pending</span>`}</td></tr>`;
}).join("");
shell(`<section class="content-card group-card">
<div class="card-topline"><span class="section-kicker">02 / THE GROUP</span><span class="group-total">${submitted} OF ${state.data.people.length} ${isContent ? "RESPONDED" : "RANKED"}</span></div>
<div class="section-heading"><div><h2>Everyone’s preferences</h2><p>Explore the group’s choices. Select any column heading to sort.</p></div></div>
${propertyTabs()}
<div class="table-tools"><div class="table-description"><strong>${escapeHtml(item.name)} ${isContent ? "preferences" : "rankings"}</strong><span>${people.length} ${people.length === 1 ? "person" : "people"} shown</span></div><div class="table-actions">${isContent ? "" : `<div class="view-toggle" role="group" aria-label="Table columns"><button type="button" data-group-mode="rank" aria-pressed="${state.groupMode === "rank"}" class="${state.groupMode === "rank" ? "selected" : ""}">By rank</button><button type="button" data-group-mode="category" aria-pressed="${state.groupMode === "category"}" class="${state.groupMode === "category" ? "selected" : ""}">By category</button></div>`}<label class="search-box"><span aria-hidden="true">⌕</span><input id="search" type="search" placeholder="Find a person" value="${escapeHtml(state.search)}" aria-label="Find a person"></label><button type="button" class="secondary-button" id="refresh">Refresh</button></div></div>
<div class="table-scroll"><table><thead><tr><th scope="col" aria-sort="${state.sort.key === "displayName" ? (state.sort.direction === 1 ? "ascending" : "descending") : "none"}">${sortHeader("displayName", "PERSON")}</th>${headers}<th scope="col" aria-sort="${state.sort.key === "updated" ? (state.sort.direction === 1 ? "ascending" : "descending") : "none"}">${sortHeader("updated", "UPDATED")}</th></tr></thead><tbody>${rows || `<tr><td class="empty-table" colspan="${item.categories.length + 2}">No people match that search.</td></tr>`}</tbody></table></div>
<div class="table-footer">${isContent ? "Only saved preferences appear here. A dash means the person has not completed this property yet." : "Only saved preferences appear here. A blank cell means no preference was ranked; a dash means the person has not submitted this property."}</div>
</section>`);
app.querySelectorAll("[data-group-mode]").forEach((button) => button.addEventListener("click", () => {
state.groupMode = button.dataset.groupMode;
state.sort = { key: "displayName", direction: 1 };
render();
}));
app.querySelectorAll("[data-sort]").forEach((button) => button.addEventListener("click", () => {
const key = button.dataset.sort;
state.sort = { key, direction: state.sort.key === key ? -state.sort.direction : 1 };
render();
}));
app.querySelector("#search").addEventListener("input", (event) => {
const position = event.target.selectionStart;
state.search = event.target.value;
render();
const input = app.querySelector("#search");
input.focus();
input.setSelectionRange(position, position);
});
app.querySelector("#refresh").addEventListener("click", async () => {
try {
await load(true);
state.notice = "Group preferences refreshed.";
} catch (error) {
state.notice = error.message;
}
render();
});
}
async function logout() {
try {
await api("/api/logout", { method: "POST", body: "{}" });
} finally {
state.data = null;
state.drafts = {};
state.notice = "";
renderLogin();
}
}
function render() {
if (!state.data?.user) {
renderLogin();
} else if (state.view === "group") {
renderGroup();
} else if (state.view === "account") {
renderAccount();
} else {
renderEditor();
}
}
load().catch((error) => {
app.innerHTML = `<div class="fatal"><h1>Could not load preferences</h1><p>${escapeHtml(error.message)}</p><button onclick="location.reload()">Try again</button></div>`;
});