555 lines
30 KiB
JavaScript
555 lines
30 KiB
JavaScript
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) => ({
|
||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||
})[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 preferences</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>`;
|
||
});
|