552 lines
21 KiB
TypeScript
552 lines
21 KiB
TypeScript
import { decideMovie } from "./algorithm.ts";
|
||
import { collectCompleteRatings } from "./round-state.ts";
|
||
|
||
interface Movie {
|
||
title: string;
|
||
addedBy: string;
|
||
addedAt: string;
|
||
}
|
||
|
||
interface HistoryEntry {
|
||
movies: Movie[];
|
||
winner: string | null;
|
||
}
|
||
|
||
interface State {
|
||
uuid: string;
|
||
phase: 1 | 2 | 3;
|
||
setupDone: boolean;
|
||
users: string[];
|
||
doneUsersPhase1: string[];
|
||
doneUsersPhase2: string[];
|
||
movies: Movie[];
|
||
votes: Record<string, Record<string, number>>;
|
||
history: HistoryEntry[];
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
}
|
||
|
||
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: State | null = null;
|
||
let draftRatings: Record<string, number> = {};
|
||
let adminUserDraft = "";
|
||
let busy = false;
|
||
|
||
function isValidUuid(v: string): boolean {
|
||
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(v);
|
||
}
|
||
|
||
function apiUrl(action: string): string {
|
||
return `/movies/api?action=${encodeURIComponent(action)}&uuid=${encodeURIComponent(uuid)}`;
|
||
}
|
||
|
||
async function apiGetState(): Promise<{ ok: boolean; state: State }> {
|
||
const res = await fetch(apiUrl("state"));
|
||
return res.json() as Promise<{ ok: boolean; state: State }>;
|
||
}
|
||
|
||
async function apiPost(action: string, body: Record<string, unknown>): Promise<{ ok: boolean; state: State }> {
|
||
const res = await fetch(apiUrl(action), {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ ...body, action, uuid }),
|
||
});
|
||
const data = await res.json() as { ok: boolean; state: State; error?: string };
|
||
if (!res.ok || !data.ok) throw new Error(data.error ?? "Request failed");
|
||
return data;
|
||
}
|
||
|
||
function esc(text: string): string {
|
||
return text.replace(/[&<>"']/g, (c) => (
|
||
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c,
|
||
);
|
||
}
|
||
|
||
function showError(message: string): void {
|
||
const el = mount.querySelector<HTMLElement>("#error");
|
||
if (!el) return;
|
||
el.hidden = false;
|
||
el.textContent = message;
|
||
}
|
||
|
||
function doneForPhase(phase: 1 | 2): string[] {
|
||
if (!state) return [];
|
||
return phase === 1 ? (state.doneUsersPhase1 ?? []) : (state.doneUsersPhase2 ?? []);
|
||
}
|
||
|
||
function isUserDone(phase: 1 | 2, name: string): boolean {
|
||
return !!name && doneForPhase(phase).includes(name);
|
||
}
|
||
|
||
type RoundResult =
|
||
| { kind: "no_movies" }
|
||
| { kind: "no_votes" }
|
||
| { kind: "ok"; voterCount: number; totalUsers: number; result: ReturnType<typeof decideMovie> };
|
||
|
||
function computeRoundResult(): RoundResult {
|
||
if (!state) return { kind: "no_movies" };
|
||
const movies = state.movies.map((m) => m.title);
|
||
if (movies.length === 0) return { kind: "no_movies" };
|
||
const ratings = collectCompleteRatings(state.users, movies, state.votes ?? {});
|
||
const voters = Object.keys(ratings);
|
||
if (voters.length === 0) return { kind: "no_votes" };
|
||
return {
|
||
kind: "ok",
|
||
voterCount: voters.length,
|
||
totalUsers: state.users.length,
|
||
result: decideMovie({ movies, people: voters, ratings }),
|
||
};
|
||
}
|
||
|
||
function resultSection(title: string): string {
|
||
const info = computeRoundResult();
|
||
if (info.kind === "no_movies") {
|
||
return `<div class="card-inset"><p class="text-[13px] font-semibold uppercase tracking-wider text-slate-400 m-0">${esc(title)}</p><p class="hint">No movies were added.</p></div>`;
|
||
}
|
||
if (info.kind === "no_votes") {
|
||
return `<div class="card-inset"><p class="text-[13px] font-semibold uppercase tracking-wider text-slate-400 m-0">${esc(title)}</p><p class="hint">No complete votes yet.</p></div>`;
|
||
}
|
||
const { result, voterCount, totalUsers } = info;
|
||
const rows = result.ranking.map((r, i) => `
|
||
<div class="flex items-center gap-3 py-2.5 ${i > 0 ? "border-t border-slate-100" : ""}">
|
||
<span class="w-5 text-center text-[13px] text-slate-400 font-semibold shrink-0">${i + 1}</span>
|
||
<span class="flex-1 text-sm">${esc(r.movie)}</span>
|
||
<span class="text-[13px] text-slate-400 shrink-0">Nash ${r.nash.toFixed(1)}</span>
|
||
</div>`).join("");
|
||
return `<div class="card-inset">
|
||
<p class="text-[13px] font-semibold uppercase tracking-wider text-slate-400 m-0">${esc(title)}</p>
|
||
<p class="mt-3 text-lg font-bold text-slate-900">🏆 ${esc(result.winner.movie)}</p>
|
||
<p class="hint mt-1">Voters: ${voterCount} of ${totalUsers}</p>
|
||
<div class="mt-3">${rows}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function progressCard(phaseTitle: string, done: number, total: number): string {
|
||
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
||
return `<div class="card-progress">
|
||
<p class="m-0 text-sm font-semibold text-blue-800">${esc(phaseTitle)}</p>
|
||
<div class="mt-2 flex items-center gap-3">
|
||
<div class="flex-1 h-2 rounded-full bg-blue-100 overflow-hidden">
|
||
<div class="h-full rounded-full bg-blue-500 transition-all duration-300" style="width:${pct}%"></div>
|
||
</div>
|
||
<span class="text-sm font-semibold text-blue-700 shrink-0">${done}/${total}</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ─── Screens ───────────────────────────────────────────────────────────────
|
||
|
||
function buildCreateScreen(): void {
|
||
mount.innerHTML = `
|
||
<h2 class="screen-title">Movie Select</h2>
|
||
<p class="hint">Start a new round, add everyone, share links — pick a film.</p>
|
||
<div class="action-row">
|
||
<button id="createRound" class="btn btn-primary">New Round</button>
|
||
</div>`;
|
||
mount.querySelector("#createRound")!.addEventListener("click", () => {
|
||
window.location.href = `/movies/${crypto.randomUUID()}/admin`;
|
||
});
|
||
}
|
||
|
||
function renderAdminSetup(): void {
|
||
mount.innerHTML = `
|
||
<h2 class="screen-title">Add People</h2>
|
||
<p class="hint">Add everyone who'll vote, then tap Next.</p>
|
||
<div class="input-row">
|
||
<input id="userInput" type="text" maxlength="30" placeholder="Name" autocomplete="off" spellcheck="false"
|
||
aria-label="Person name" class="field flex-1">
|
||
<button id="addUser" class="btn">Add</button>
|
||
</div>
|
||
<ul id="userList" class="chips-list"></ul>
|
||
<div class="action-row">
|
||
<button id="nextSetup" class="btn btn-primary" ${(state?.users.length ?? 0) === 0 ? "disabled" : ""}>Next →</button>
|
||
</div>
|
||
<p id="error" class="error-msg" hidden></p>`;
|
||
|
||
const inp = mount.querySelector<HTMLInputElement>("#userInput")!;
|
||
inp.value = adminUserDraft;
|
||
inp.focus();
|
||
inp.addEventListener("input", () => { adminUserDraft = inp.value; });
|
||
|
||
mount.querySelector<HTMLElement>("#userList")!.innerHTML =
|
||
(state?.users ?? []).map((n) => `<li class="chip">${esc(n)}</li>`).join("");
|
||
|
||
const addUser = async () => {
|
||
const name = adminUserDraft.trim();
|
||
if (!name || busy) return;
|
||
try {
|
||
busy = true;
|
||
await apiPost("add_user", { user: name });
|
||
adminUserDraft = "";
|
||
await loadAndRender();
|
||
} catch (e) { showError((e as Error).message); }
|
||
finally { busy = false; }
|
||
};
|
||
|
||
mount.querySelector("#addUser")!.addEventListener("click", addUser);
|
||
inp.addEventListener("keydown", async (e) => { if (e.key === "Enter") { e.preventDefault(); await addUser(); } });
|
||
mount.querySelector("#nextSetup")!.addEventListener("click", async () => {
|
||
if (busy) return;
|
||
try {
|
||
busy = true;
|
||
await apiPost("finish_setup", {});
|
||
await loadAndRender();
|
||
} catch (e) { showError((e as Error).message); }
|
||
finally { busy = false; }
|
||
});
|
||
}
|
||
|
||
function renderAdminStatus(): void {
|
||
const s = state!;
|
||
const d1 = s.doneUsersPhase1 ?? [];
|
||
const d2 = s.doneUsersPhase2 ?? [];
|
||
const totalSteps = s.users.length * 2;
|
||
const completedSteps = d1.length + d2.length;
|
||
const base = window.location.origin;
|
||
const phaseTitle = s.phase === 1 ? "Phase 1 · Movie Collection" : s.phase === 2 ? "Phase 2 · Voting" : "Phase 3 · Results";
|
||
|
||
const shareRows = s.users.map((name) => {
|
||
const link = `${base}/movies/${s.uuid}?user=${encodeURIComponent(name)}`;
|
||
const steps = (d1.includes(name) ? 1 : 0) + (d2.includes(name) ? 1 : 0);
|
||
const badge = steps === 2
|
||
? `<span class="ml-1.5 rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-700">Done</span>`
|
||
: steps === 1
|
||
? `<span class="ml-1.5 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700">1/2</span>`
|
||
: `<span class="ml-1.5 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-500">0/2</span>`;
|
||
return `<li class="rounded-2xl border border-slate-200 bg-white p-3.5">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<span class="text-sm font-semibold">${esc(name)}</span>${badge}
|
||
</div>
|
||
<div class="flex gap-2 items-center">
|
||
<input class="field-mono flex-1 min-w-0" type="text" readonly value="${esc(link)}" aria-label="Invite link for ${esc(name)}">
|
||
<button class="btn btn-sm copyLink shrink-0" data-link="${esc(link)}" aria-label="Copy link for ${esc(name)}">Copy</button>
|
||
</div>
|
||
</li>`;
|
||
}).join("");
|
||
|
||
mount.innerHTML = `
|
||
<h2 class="screen-title">Admin</h2>
|
||
${progressCard(phaseTitle, completedSteps, totalSteps)}
|
||
<p class="section-title">Invite Links</p>
|
||
<ul class="links-list">${shareRows}</ul>
|
||
<div class="action-row">
|
||
<button id="refreshAdmin" class="btn">Refresh</button>
|
||
</div>
|
||
${s.phase === 3 ? resultSection("Round Result") : ""}
|
||
${s.phase === 3 ? `<div class="action-row"><button id="newRound" class="btn btn-primary">New Round →</button></div>` : ""}
|
||
<p id="error" class="error-msg" hidden></p>`;
|
||
|
||
mount.querySelectorAll<HTMLButtonElement>(".copyLink").forEach((btn) => {
|
||
btn.addEventListener("click", async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(btn.dataset["link"] ?? "");
|
||
btn.textContent = "✓ Copied";
|
||
setTimeout(() => { btn.textContent = "Copy"; }, 1400);
|
||
} catch { showError("Clipboard unavailable"); }
|
||
});
|
||
});
|
||
|
||
mount.querySelector("#refreshAdmin")!.addEventListener("click", async () => {
|
||
try { await loadAndRender(); } catch (e) { showError((e as Error).message); }
|
||
});
|
||
|
||
if (s.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 (e) { showError((e as Error).message); }
|
||
finally { busy = false; }
|
||
});
|
||
}
|
||
}
|
||
|
||
function renderWaiting(phase: 1 | 2): void {
|
||
const s = state!;
|
||
const d1 = s.doneUsersPhase1 ?? [];
|
||
const d2 = s.doneUsersPhase2 ?? [];
|
||
const total = s.users.length * 2;
|
||
const done = d1.length + d2.length;
|
||
const phaseTitle = phase === 1 ? "Phase 1 · Movie Collection" : "Phase 2 · Voting";
|
||
const waitMsg = phase === 1 ? "Your movies are in. Waiting for others…" : "Your votes are saved. Waiting for others…";
|
||
|
||
mount.innerHTML = `
|
||
<h2 class="screen-title">Waiting…</h2>
|
||
${progressCard(phaseTitle, done, total)}
|
||
<p class="hint mt-3">${esc(waitMsg)}</p>
|
||
<div class="action-row">
|
||
<button id="refreshUser" class="btn">Refresh</button>
|
||
</div>
|
||
<p id="error" class="error-msg" hidden></p>`;
|
||
mount.querySelector("#refreshUser")!.addEventListener("click", () => loadAndRender());
|
||
}
|
||
|
||
function prevMoviesSection(remaining: number): string {
|
||
const history = state?.history ?? [];
|
||
if (history.length === 0) return "";
|
||
|
||
const currentTitles = new Set((state?.movies ?? []).map((m) => m.title.replace(/^🏆\s*/, "").toLowerCase()));
|
||
const wonMovies: string[] = [];
|
||
const regularMovies = new Set<string>();
|
||
|
||
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 ?? []) regularMovies.add(m.title.replace(/^🏆\s*/, ""));
|
||
}
|
||
|
||
const wonSet = new Set(wonMovies.map((t) => t.replace(/^🏆\s*/, "").toLowerCase()));
|
||
const regularList = [...regularMovies]
|
||
.filter((t) => !wonSet.has(t.toLowerCase()) && !currentTitles.has(t.toLowerCase()))
|
||
.sort((a, b) => a.localeCompare(b));
|
||
|
||
const seenWon = new Set<string>();
|
||
const wonList: Array<{ display: string; value: string }> = [];
|
||
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(({ display, value }) => {
|
||
const off = remaining <= 0;
|
||
return `<li><button class="chip-btn ${off ? "opacity-40 cursor-default" : ""}" ${off ? "disabled" : ""} data-title="${esc(value)}">${esc(display)}</button></li>`;
|
||
}).join("");
|
||
|
||
return `<p class="section-title">Add from History</p><ul class="chips-list">${chips}</ul>`;
|
||
}
|
||
|
||
function renderMoviePhase(): void {
|
||
const s = state!;
|
||
const canAdd = !!user && s.users.includes(user);
|
||
const myCount = canAdd ? s.movies.filter((m) => m.addedBy.toLowerCase() === user.toLowerCase()).length : 0;
|
||
const remaining = Math.max(0, 5 - myCount);
|
||
if (isUserDone(1, user)) { renderWaiting(1); return; }
|
||
|
||
mount.innerHTML = `
|
||
<h2 class="screen-title">Add Movies</h2>
|
||
<p class="hint">Phase 1 of 3 · Up to 5 movies each, then tap Done.</p>
|
||
${canAdd ? `<p class="hint"><strong>${remaining}</strong> slot${remaining === 1 ? "" : "s"} left</p>` : `<p class="hint text-amber-600">Open your personal link to add movies.</p>`}
|
||
${canAdd ? `<div class="input-row">
|
||
<input id="movieInput" type="text" maxlength="60" placeholder="Movie title" autocomplete="off"
|
||
aria-label="Movie title" class="field flex-1">
|
||
<button id="addMovie" class="btn">Add</button>
|
||
</div>` : ""}
|
||
${canAdd && myCount > 0 ? `<p class="section-title">Your Picks (tap to remove)</p>` : ""}
|
||
<ul id="movieList" class="chips-list"></ul>
|
||
${canAdd ? prevMoviesSection(remaining) : ""}
|
||
${canAdd ? `<div class="action-row">
|
||
<button id="refreshUser" class="btn">Refresh</button>
|
||
<button id="markDone" class="btn btn-primary">Done ✓</button>
|
||
</div>` : ""}
|
||
<p id="error" class="error-msg" hidden></p>`;
|
||
|
||
mount.querySelector<HTMLElement>("#movieList")!.innerHTML = s.movies
|
||
.filter((m) => canAdd && m.addedBy.toLowerCase() === user.toLowerCase())
|
||
.map((m) => {
|
||
const clean = m.title.replace(/^🏆\s*/, "");
|
||
return `<li><button class="chip-user" data-title="${esc(clean)}">${esc(clean)} ×</button></li>`;
|
||
}).join("");
|
||
|
||
if (!canAdd) return;
|
||
|
||
const addMovie = async () => {
|
||
const inp = mount.querySelector<HTMLInputElement>("#movieInput")!;
|
||
const title = inp.value.trim();
|
||
if (!title || busy) return;
|
||
try {
|
||
busy = true;
|
||
await apiPost("add_movie", { user, title });
|
||
inp.value = "";
|
||
await loadAndRender();
|
||
} catch (e) { showError((e as Error).message); }
|
||
finally { busy = false; }
|
||
};
|
||
|
||
const inp = mount.querySelector<HTMLInputElement>("#movieInput")!;
|
||
inp.focus();
|
||
mount.querySelector("#addMovie")!.addEventListener("click", addMovie);
|
||
inp.addEventListener("keydown", async (e) => { if (e.key === "Enter") { e.preventDefault(); await addMovie(); } });
|
||
|
||
mount.querySelectorAll<HTMLButtonElement>(".chip-btn[data-title]").forEach((btn) => {
|
||
btn.addEventListener("click", async () => {
|
||
if (busy || btn.disabled) return;
|
||
try { busy = true; await apiPost("add_movie", { user, title: btn.dataset["title"] }); await loadAndRender(); }
|
||
catch (e) { showError((e as Error).message); }
|
||
finally { busy = false; }
|
||
});
|
||
});
|
||
|
||
mount.querySelectorAll<HTMLButtonElement>(".chip-user").forEach((btn) => {
|
||
btn.addEventListener("click", async () => {
|
||
if (busy) return;
|
||
try { busy = true; await apiPost("remove_movie", { user, title: btn.dataset["title"] }); await loadAndRender(); }
|
||
catch (e) { showError((e as 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 (e) { showError((e as Error).message); }
|
||
finally { busy = false; }
|
||
});
|
||
|
||
mount.querySelector("#refreshUser")!.addEventListener("click", () => loadAndRender());
|
||
}
|
||
|
||
function renderVotingPhase(): void {
|
||
const s = state!;
|
||
const movies = s.movies.map((m) => m.title);
|
||
if (!s.votes[user]) s.votes[user] = {};
|
||
if (isUserDone(2, user)) { renderWaiting(2); return; }
|
||
|
||
const cards = movies.map((title) => {
|
||
const current = draftRatings[title] ?? s.votes[user]?.[title] ?? 3;
|
||
return `<div class="rounded-2xl border border-slate-100 bg-slate-50 p-4">
|
||
<p class="m-0 text-[15px] font-medium text-slate-800 mb-3">${esc(title)}</p>
|
||
<div class="flex items-center gap-4">
|
||
<input class="rating flex-1" type="range" min="0" max="5" step="1" value="${current}"
|
||
data-title="${esc(title)}" aria-label="Rate ${esc(title)} (0–5)">
|
||
<span class="rating-value w-6 text-right text-lg font-bold text-blue-500 shrink-0">${current}</span>
|
||
</div>
|
||
<div class="mt-1.5 flex justify-between text-[11px] text-slate-400 px-0.5">
|
||
<span>Skip</span><span>Meh</span><span>OK</span><span>Good</span><span>Great</span><span>Love</span>
|
||
</div>
|
||
</div>`;
|
||
}).join("");
|
||
|
||
mount.innerHTML = `
|
||
<h2 class="screen-title">Rate Movies</h2>
|
||
<p class="hint">Phase 2 of 3 · Rate each film 0–5, then tap Done.</p>
|
||
<div class="mt-4 flex flex-col gap-3">${cards}</div>
|
||
<div class="action-row">
|
||
<button id="refreshUser" class="btn">Refresh</button>
|
||
<button id="saveVotes" class="btn">Save</button>
|
||
<button id="doneVoting" class="btn btn-primary">Done ✓</button>
|
||
</div>
|
||
<p id="error" class="error-msg" hidden></p>`;
|
||
|
||
mount.querySelectorAll<HTMLInputElement>(".rating").forEach((inp) => {
|
||
inp.addEventListener("input", () => {
|
||
const title = inp.dataset["title"] ?? "";
|
||
const rating = Math.max(0, Math.min(5, parseInt(inp.value || "0", 10)));
|
||
inp.value = String(rating);
|
||
draftRatings[title] = rating;
|
||
const valEl = inp.closest("div.flex")?.querySelector<HTMLElement>(".rating-value");
|
||
if (valEl) valEl.textContent = String(rating);
|
||
});
|
||
});
|
||
|
||
const collectRatings = (): Record<string, number> => {
|
||
const out: Record<string, number> = {};
|
||
mount.querySelectorAll<HTMLInputElement>(".rating").forEach((inp) => {
|
||
out[inp.dataset["title"] ?? ""] = Math.max(0, Math.min(5, parseInt(inp.value || "0", 10)));
|
||
});
|
||
return out;
|
||
};
|
||
|
||
mount.querySelector("#saveVotes")!.addEventListener("click", async () => {
|
||
if (busy) return;
|
||
try { busy = true; await apiPost("vote_many", { user, ratings: collectRatings() }); draftRatings = {}; await loadAndRender(); }
|
||
catch (e) { showError((e as Error).message); }
|
||
finally { busy = false; }
|
||
});
|
||
|
||
mount.querySelector("#doneVoting")!.addEventListener("click", async () => {
|
||
if (busy) return;
|
||
try {
|
||
busy = true;
|
||
await apiPost("vote_many", { user, ratings: collectRatings() });
|
||
await apiPost("mark_done", { user, phase: 2 });
|
||
draftRatings = {};
|
||
await loadAndRender();
|
||
} catch (e) { showError((e as Error).message); }
|
||
finally { busy = false; }
|
||
});
|
||
|
||
mount.querySelector("#refreshUser")!.addEventListener("click", () => loadAndRender());
|
||
}
|
||
|
||
function renderFinalPage(): void {
|
||
mount.innerHTML = `
|
||
<h2 class="screen-title">Result</h2>
|
||
<p class="hint">Phase 3 of 3 · Voting complete.</p>
|
||
${resultSection("Round Outcome")}
|
||
<div class="action-row">
|
||
<button id="refreshUser" class="btn">Refresh</button>
|
||
</div>
|
||
<p id="error" class="error-msg" hidden></p>`;
|
||
mount.querySelector("#refreshUser")!.addEventListener("click", () => loadAndRender());
|
||
}
|
||
|
||
function askUserName(): void {
|
||
mount.innerHTML = `
|
||
<h2 class="screen-title">Who are you?</h2>
|
||
<p class="hint">Enter the name from your invite link.</p>
|
||
<div class="input-row mt-4">
|
||
<input id="nameInput" type="text" maxlength="30" placeholder="Your name"
|
||
aria-label="Your name" class="field flex-1">
|
||
<button id="saveName" class="btn btn-primary">Go →</button>
|
||
</div>`;
|
||
const inp = mount.querySelector<HTMLInputElement>("#nameInput")!;
|
||
inp.focus();
|
||
const submit = () => {
|
||
const v = inp.value.trim();
|
||
if (!v) return;
|
||
params.set("user", v);
|
||
window.location.search = params.toString();
|
||
};
|
||
mount.querySelector("#saveName")!.addEventListener("click", submit);
|
||
inp.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
|
||
}
|
||
|
||
async function loadAndRender(): Promise<void> {
|
||
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. Open your link when ready.</p>
|
||
<div class="action-row"><button id="r" class="btn">Refresh</button></div>`;
|
||
mount.querySelector("#r")!.addEventListener("click", () => loadAndRender());
|
||
return;
|
||
}
|
||
if (isAdmin) { renderAdminStatus(); return; }
|
||
if (!user) { askUserName(); return; }
|
||
if (state.users.length > 0 && !state.users.includes(user)) {
|
||
mount.innerHTML = `<p class="error-msg">Unknown user — use your invite link.</p>`;
|
||
return;
|
||
}
|
||
if (state.phase === 1) { renderMoviePhase(); return; }
|
||
if (state.phase === 2) { renderVotingPhase(); return; }
|
||
renderFinalPage();
|
||
}
|
||
|
||
async function boot(): Promise<void> {
|
||
if (!uuid) { buildCreateScreen(); return; }
|
||
if (!isValidUuid(uuid)) {
|
||
mount.innerHTML = `<p class="error-msg">Invalid session URL.</p>`;
|
||
return;
|
||
}
|
||
try { await loadAndRender(); }
|
||
catch (e) { mount.innerHTML = `<p class="error-msg">${esc((e as Error).message)}</p>`; }
|
||
}
|
||
|
||
boot();
|