651 lines
20 KiB
JavaScript
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 = {
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'",
|
|
};
|
|
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();
|