Files
movie-select/src/client/main.ts
2026-03-01 11:44:21 +01:00

552 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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)} (05)">
<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 05, 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();