Files
movie-select/app.js
2026-03-01 11:44:21 +01:00

651 lines
20 KiB
JavaScript

import { decideMovie } from "/movies/algorithm.js";
import { collectCompleteRatings } from "/movies/round-state.js";
const mount = document.getElementById("app");
const path = window.location.pathname.replace(/^\/movies\/?/, "");
const segments = path.split("/").filter(Boolean);
const uuid = segments[0] || "";
const isAdmin = segments[1] === "admin";
const params = new URLSearchParams(window.location.search);
let user = (params.get("user") || "").trim();
let state = null;
let draftRatings = {};
let adminUserDraft = "";
let busy = false;
function isValidUuid(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}
function apiUrl(action) {
return `/movies/api?action=${encodeURIComponent(action)}&uuid=${encodeURIComponent(uuid)}`;
}
async function apiGetState() {
const res = await fetch(apiUrl("state"));
return res.json();
}
async function apiPost(action, body) {
const res = await fetch(apiUrl(action), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...body, action, uuid }),
});
const data = await res.json();
if (!res.ok || !data.ok) {
throw new Error(data.error || "Request failed");
}
return data;
}
function escapeHtml(text) {
return text.replace(/[&<>"']/g, (char) => {
const map = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
return map[char] || char;
});
}
function showError(message) {
const target = mount.querySelector("#error");
if (!target) {
return;
}
target.hidden = false;
target.textContent = message;
}
function doneUsersForPhase(phase) {
if (phase === 1) {
return state.doneUsersPhase1 || [];
}
if (phase === 2) {
return state.doneUsersPhase2 || [];
}
return [];
}
function isUserDone(phase, name) {
if (!name) {
return false;
}
return doneUsersForPhase(phase).includes(name);
}
function computeRoundResult() {
const movies = state.movies.map((m) => m.title);
if (movies.length === 0) {
return { kind: "no_movies" };
}
const participants = state.users;
const ratings = collectCompleteRatings(participants, movies, state.votes || {});
const voters = Object.keys(ratings);
if (voters.length === 0) {
return { kind: "no_votes" };
}
return {
kind: "ok",
voterCount: voters.length,
totalUsers: participants.length,
result: decideMovie({ movies, people: voters, ratings }),
};
}
function resultSection(title) {
const info = computeRoundResult();
if (info.kind === "no_movies") {
return `<section class="card inset"><h3>${escapeHtml(title)}</h3><p class="hint">No movies were added.</p></section>`;
}
if (info.kind === "no_votes") {
return `<section class="card inset"><h3>${escapeHtml(title)}</h3><p class="hint">No complete votes yet.</p></section>`;
}
return `<section class="card inset"><h3>${escapeHtml(title)}</h3><p class="winner">Winner: ${escapeHtml(info.result.winner.movie)}</p><p class="hint">Complete voters: ${info.voterCount}/${info.totalUsers}</p><ol>${info.result.ranking.map((r) => `<li>${escapeHtml(r.movie)} (Nash ${r.nash.toFixed(2)})</li>`).join("")}</ol></section>`;
}
function buildCreateScreen() {
mount.innerHTML = `
<h2>Create Round</h2>
<p class="hint">Create a new round, add users, then share each user link.</p>
<div class="actions">
<button id="createRound" class="primary">Create New Round</button>
</div>
`;
mount.querySelector("#createRound").addEventListener("click", () => {
const id = crypto.randomUUID();
window.location.href = `/movies/${id}/admin`;
});
}
function renderAdminSetup() {
mount.innerHTML = `
<h2>Add Users</h2>
<p class="hint">Add all users, then press Next to start the round.</p>
<div class="row">
<input id="userInput" type="text" maxlength="30" placeholder="User name" aria-label="User name">
<button id="addUser">Add</button>
</div>
<ul class="chips" id="userList"></ul>
<div class="actions">
<button id="nextSetup" class="primary" ${state.users.length === 0 ? "disabled" : ""}>Next</button>
</div>
<p id="error" class="error" hidden></p>
`;
const userInput = mount.querySelector("#userInput");
userInput.value = adminUserDraft;
userInput.focus();
userInput.addEventListener("input", () => {
adminUserDraft = userInput.value;
});
mount.querySelector("#userList").innerHTML = state.users.map((name) => `<li>${escapeHtml(name)}</li>`).join("");
const addUser = async () => {
const name = adminUserDraft.trim();
if (!name || busy) {
return;
}
if (busy) {
return;
}
try {
busy = true;
await apiPost("add_user", { user: name });
adminUserDraft = "";
draftRatings = {};
await loadAndRender();
} catch (error) {
showError(error.message);
} finally {
busy = false;
}
};
mount.querySelector("#addUser").addEventListener("click", addUser);
userInput.addEventListener("keydown", async (event) => {
if (event.key !== "Enter") {
return;
}
event.preventDefault();
await addUser();
});
mount.querySelector("#nextSetup").addEventListener("click", async () => {
if (busy) {
return;
}
try {
busy = true;
await apiPost("finish_setup", {});
await loadAndRender();
} catch (error) {
showError(error.message);
} finally {
busy = false;
}
});
}
function renderAdminStatus() {
const donePhase1 = state.doneUsersPhase1 || [];
const donePhase2 = state.doneUsersPhase2 || [];
const totalSteps = state.users.length * 2;
const completedSteps = donePhase1.length + donePhase2.length;
const progressPercent = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
const base = window.location.origin;
const shareRows = state.users.map((name) => {
const link = `${base}/movies/${uuid}?user=${encodeURIComponent(name)}`;
const userSteps = (donePhase1.includes(name) ? 1 : 0) + (donePhase2.includes(name) ? 1 : 0);
return `<li class="shareRow"><strong class="shareName">${escapeHtml(name)} <span class="muted">(${userSteps}/2)</span></strong><div class="shareControls"><input class="shareInput" type="text" readonly value="${escapeHtml(link)}" aria-label="Invite link for ${escapeHtml(name)}"><button class="copyLink" data-link="${escapeHtml(link)}" aria-label="Copy invite link for ${escapeHtml(name)}">Copy</button></div></li>`;
}).join("");
let phaseTitle = "";
if (state.phase === 1) {
phaseTitle = "Phase 1: Movie Collection";
} else if (state.phase === 2) {
phaseTitle = "Phase 2: Voting";
} else {
phaseTitle = "Phase 3: Result";
}
const phasePanel = `
<section class="card progressCard">
<h3>${escapeHtml(phaseTitle)}</h3>
<p class="hint">Overall progress: <strong>${completedSteps}/${totalSteps}</strong> user-steps (${progressPercent}%).</p>
<div class="progressTrack" role="progressbar" aria-label="Round progress" aria-valuemin="0" aria-valuemax="${totalSteps}" aria-valuenow="${completedSteps}">
<div class="progressFill" style="width: ${progressPercent}%;"></div>
</div>
</section>
`;
mount.innerHTML = `
<h2>Admin Status</h2>
${phasePanel}
<h3>Invite Links</h3>
<ul class="links">${shareRows}</ul>
<div class="actions">
<button id="refreshAdmin" class="primary">Refresh</button>
</div>
${state.phase === 3 ? resultSection("Final Result") : ""}
${state.phase === 3 ? `<div class="actions"><button id="newRound" class="primary">New Round</button></div>` : ""}
<p id="error" class="error" hidden></p>
`;
mount.querySelectorAll(".copyLink").forEach((button) => {
button.addEventListener("click", async () => {
const link = button.dataset.link;
try {
await navigator.clipboard.writeText(link);
button.textContent = "Copied";
window.setTimeout(() => {
button.textContent = "Copy";
}, 1200);
} catch {
showError("Clipboard permission failed");
}
});
});
mount.querySelector("#refreshAdmin").addEventListener("click", async () => {
try {
await loadAndRender();
} catch (error) {
showError(error.message);
}
});
if (state.phase === 3) {
mount.querySelector("#newRound").addEventListener("click", async () => {
if (busy) {
return;
}
try {
busy = true;
const info = computeRoundResult();
const winner = info.kind === "ok" ? info.result.winner.movie : "";
await apiPost("new_round", { winner });
await loadAndRender();
} catch (error) {
showError(error.message);
} finally {
busy = false;
}
});
}
}
function renderUserWaitingPhase(phase) {
const donePhase1 = state.doneUsersPhase1 || [];
const donePhase2 = state.doneUsersPhase2 || [];
const totalSteps = state.users.length * 2;
const completedSteps = donePhase1.length + donePhase2.length;
const progressPercent = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
const phaseTitle = phase === 1 ? "Phase 1: Movie Collection" : "Phase 2: Voting";
const waitingText = phase === 1
? "You are done with movie collection. Waiting for other users."
: "You are done with voting. Waiting for other users.";
mount.innerHTML = `
<h2>Waiting</h2>
<section class="card progressCard">
<h3>${escapeHtml(phaseTitle)}</h3>
<p class="hint">Overall progress: <strong>${completedSteps}/${totalSteps}</strong> user-steps (${progressPercent}%).</p>
<div class="progressTrack" role="progressbar" aria-label="Round progress" aria-valuemin="0" aria-valuemax="${totalSteps}" aria-valuenow="${completedSteps}">
<div class="progressFill" style="width: ${progressPercent}%;"></div>
</div>
</section>
<p class="hint">${escapeHtml(waitingText)}</p>
<div class="actions">
<button id="refreshUser" class="primary">Refresh</button>
</div>
<p id="error" class="error" hidden></p>
`;
mount.querySelector("#refreshUser").addEventListener("click", async () => {
await loadAndRender();
});
}
function buildPrevMoviesSection(remaining) {
const history = state.history || [];
if (history.length === 0) {
return "";
}
// normalize current titles (strip trophy if stored)
const currentTitles = new Set(state.movies.map((m) => (m.title || "").replace(/^🏆\s*/, "").toLowerCase()));
const wonMovies = [];
const regularMovies = new Set();
for (let i = history.length - 1; i >= 0; i--) {
const round = history[i];
if (round.winner) {
wonMovies.push(round.winner);
}
for (const m of (round.movies || [])) {
const raw = (m.title || "").replace(/^🏆\s*/, "");
regularMovies.add(raw);
}
}
const wonTitleSet = new Set(wonMovies.map((t) => (t || "").replace(/^🏆\s*/, "").toLowerCase()));
const regularList = Array.from(regularMovies)
.filter((title) => !wonTitleSet.has(title.toLowerCase()))
.filter((title) => !currentTitles.has(title.toLowerCase()))
.sort((a, b) => a.localeCompare(b));
const seenWon = new Set();
const wonList = [];
for (const w of wonMovies) {
const clean = (w || "").replace(/^🏆\s*/, "");
const key = clean.toLowerCase();
if (!clean || seenWon.has(key) || currentTitles.has(key)) {
continue;
}
seenWon.add(key);
wonList.push({ display: `🏆 ${clean}`, value: clean });
}
const items = [
...regularList.map((t) => ({ display: t, value: t })),
...wonList,
];
if (items.length === 0) {
return "";
}
const chips = items
.map((item) => {
const disabled = remaining <= 0 ? " disabled" : "";
return `<li><button class="chip prevMovie"${disabled} data-title="${escapeHtml(item.value)}">${escapeHtml(item.display)}</button></li>`;
})
.join("");
return `<h3>Available Movies</h3><ul class="chips">${chips}</ul>`;
}
function renderMoviePhase() {
const canAddMovies = user && state.users.includes(user);
const myMovieCount = canAddMovies ? state.movies.filter((m) => m.addedBy.toLowerCase() === user.toLowerCase()).length : 0;
const remaining = Math.max(0, 5 - myMovieCount);
const done = isUserDone(1, user);
if (done) {
renderUserWaitingPhase(1);
return;
}
mount.innerHTML = `
<h2>Add Movies</h2>
<p class="hint">Phase 1 of 3: add up to 5 movies, then press Done.</p>
${canAddMovies ? `<p class="hint">Your movie slots left: <strong>${remaining}</strong></p>` : `<p class="hint">Use your personal user link.</p>`}
${canAddMovies ? `<div class="row"><input id="movieInput" type="text" maxlength="60" placeholder="Movie title" aria-label="Movie title"><button id="addMovie">Add</button></div>` : ""}
${canAddMovies && myMovieCount > 0 ? `<h3>Your Selection (tap to remove)</h3>` : ""}
<ul class="chips" id="movieList"></ul>
${canAddMovies ? buildPrevMoviesSection(remaining) : ""}
${canAddMovies ? `<div class="actions"><button id="refreshUser">Refresh</button><button id="markDone" class="primary" ${done ? "disabled" : ""}>Done</button></div>` : ""}
<p id="error" class="error" hidden></p>
`;
mount.querySelector("#movieList").innerHTML = state.movies
.filter((m) => canAddMovies && m.addedBy.toLowerCase() === user.toLowerCase())
.map((m) => `<li><button class="chip userMovie" data-title="${escapeHtml(m.title.replace(/^🏆\s*/, ''))}">${escapeHtml(m.title.replace(/^🏆\s*/, ''))}</button></li>`)
.join("");
if (!canAddMovies) {
return;
}
const addMovie = async () => {
const input = mount.querySelector("#movieInput");
const title = input.value.trim();
if (!title || busy) {
return;
}
try {
busy = true;
await apiPost("add_movie", { user, title });
input.value = "";
await loadAndRender();
} catch (error) {
showError(error.message);
} finally {
busy = false;
}
};
mount.querySelector("#addMovie").addEventListener("click", addMovie);
mount.querySelector("#movieInput").focus();
mount.querySelector("#movieInput").addEventListener("keydown", async (event) => {
if (event.key !== "Enter") {
return;
}
event.preventDefault();
await addMovie();
});
mount.querySelectorAll(".prevMovie").forEach((btn) => {
btn.addEventListener("click", async () => {
if (busy || btn.disabled) {
return;
}
const title = btn.dataset.title;
try {
busy = true;
await apiPost("add_movie", { user, title });
await loadAndRender();
} catch (error) {
showError(error.message);
} finally {
busy = false;
}
});
});
mount.querySelectorAll(".userMovie").forEach((btn) => {
btn.addEventListener("click", async () => {
if (busy) {
return;
}
const title = btn.dataset.title;
try {
busy = true;
await apiPost("remove_movie", { user, title });
await loadAndRender();
} catch (error) {
showError(error.message);
} finally {
busy = false;
}
});
});
mount.querySelector("#markDone").addEventListener("click", async () => {
if (busy) {
return;
}
try {
busy = true;
await apiPost("mark_done", { user, phase: 1 });
await loadAndRender();
} catch (error) {
showError(error.message);
} finally {
busy = false;
}
});
mount.querySelector("#refreshUser").addEventListener("click", async () => {
await loadAndRender();
});
}
function renderVotingPhase() {
const movies = state.movies.map((m) => m.title);
if (!state.votes[user]) {
state.votes[user] = {};
}
const done = isUserDone(2, user);
if (done) {
renderUserWaitingPhase(2);
return;
}
const rows = movies
.map((title) => {
const current = draftRatings[title] ?? state.votes[user][title] ?? 3;
return `<tr><th scope="row">${escapeHtml(title)}</th><td><div class="ratingRow"><input class="rating" type="range" min="0" max="5" step="1" value="${current}" data-title="${escapeHtml(title)}" aria-label="Rate ${escapeHtml(title)}"><span class="ratingValue" data-title="${escapeHtml(title)}">${current}</span></div></td></tr>`;
})
.join("");
mount.innerHTML = `
<h2>Vote</h2>
<p class="hint">Phase 2 of 3: rate every movie, then press Done.</p>
<table>
<thead><tr><th>Movie</th><th>Your Rating</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<div class="actions">
<button id="refreshUser">Refresh</button>
<button id="saveVotes">Save Ratings</button>
<button id="doneVoting" class="primary" ${done ? "disabled" : ""}>Done</button>
</div>
<p id="error" class="error" hidden></p>
`;
mount.querySelectorAll(".rating").forEach((input) => {
input.addEventListener("input", () => {
const title = input.dataset.title;
const rating = Math.max(0, Math.min(5, Number.parseInt(input.value || "0", 10)));
input.value = String(rating);
draftRatings[title] = rating;
const valueLabel = input.closest(".ratingRow")?.querySelector(".ratingValue");
if (valueLabel) {
valueLabel.textContent = String(rating);
}
});
});
mount.querySelector("#saveVotes").addEventListener("click", async () => {
if (busy) {
return;
}
try {
busy = true;
const ratings = {};
mount.querySelectorAll(".rating").forEach((input) => {
const title = input.dataset.title;
ratings[title] = Math.max(0, Math.min(5, Number.parseInt(input.value || "0", 10)));
});
await apiPost("vote_many", { user, ratings });
draftRatings = {};
await loadAndRender();
} catch (error) {
showError(error.message);
} finally {
busy = false;
}
});
mount.querySelector("#doneVoting").addEventListener("click", async () => {
if (busy) {
return;
}
try {
busy = true;
const ratings = {};
mount.querySelectorAll(".rating").forEach((input) => {
const title = input.dataset.title;
ratings[title] = Math.max(0, Math.min(5, Number.parseInt(input.value || "0", 10)));
});
await apiPost("vote_many", { user, ratings });
await apiPost("mark_done", { user, phase: 2 });
draftRatings = {};
await loadAndRender();
} catch (error) {
showError(error.message);
} finally {
busy = false;
}
});
mount.querySelector("#refreshUser").addEventListener("click", async () => {
await loadAndRender();
});
}
function renderFinalPage() {
mount.innerHTML = `
<h2>Final Result</h2>
<p class="hint">Phase 3 of 3: voting is complete.</p>
${resultSection("Round Outcome")}
<div class="actions">
<button id="refreshUser">Refresh</button>
</div>
<p id="error" class="error" hidden></p>
`;
mount.querySelector("#refreshUser").addEventListener("click", async () => {
await loadAndRender();
});
}
async function loadAndRender() {
const data = await apiGetState();
state = data.state;
if (!state.setupDone && isAdmin) {
renderAdminSetup();
return;
}
if (!state.setupDone) {
mount.innerHTML = `<p class="hint">Admin is still setting up users. Open your personal link when ready.</p><div class="actions"><button id="refreshUser">Refresh</button></div>`;
mount.querySelector("#refreshUser").addEventListener("click", async () => {
await loadAndRender();
});
return;
}
if (isAdmin) {
renderAdminStatus();
return;
}
if (!user) {
askUserName();
return;
}
if (state.users.length > 0 && !state.users.includes(user)) {
mount.innerHTML = `<p class="error">Unknown user. Use one of the shared links from admin.</p>`;
return;
}
if (state.phase === 1) {
renderMoviePhase();
return;
}
if (state.phase === 2) {
renderVotingPhase();
return;
}
renderFinalPage();
}
function askUserName() {
mount.innerHTML = `
<h2>Who are you?</h2>
<p class="hint">Use the exact name from your invite link.</p>
<div class="row">
<input id="userInput" type="text" maxlength="30" placeholder="Your name" aria-label="Your name">
<button id="saveUser" class="primary">Continue</button>
</div>
`;
mount.querySelector("#userInput").focus();
mount.querySelector("#saveUser").addEventListener("click", () => {
const value = mount.querySelector("#userInput").value.trim();
if (!value) {
return;
}
params.set("user", value);
window.location.search = params.toString();
});
}
async function boot() {
if (!uuid) {
buildCreateScreen();
return;
}
if (!isValidUuid(uuid)) {
mount.innerHTML = `<p class="error">Invalid UUID in URL.</p>`;
return;
}
try {
await loadAndRender();
} catch (error) {
mount.innerHTML = `<p class="error">${escapeHtml(error.message)}</p>`;
}
}
boot();