sync current state
This commit is contained in:
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_USER=serve
|
||||
DB_PASS=yourpassword
|
||||
DB_NAME=serve
|
||||
PORT=3001
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.DS_Store
|
||||
data/
|
||||
10
.htaccess
Normal file
10
.htaccess
Normal file
@@ -0,0 +1,10 @@
|
||||
RewriteEngine On
|
||||
RewriteBase /movies/
|
||||
DirectoryIndex index.php
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -d
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
RewriteRule ^api/?$ api.php [L,QSA]
|
||||
RewriteRule ^ index.php [L,QSA]
|
||||
67
algorithm.js
Normal file
67
algorithm.js
Normal file
@@ -0,0 +1,67 @@
|
||||
export function paretoFilter(movies, people, ratings) {
|
||||
return movies.filter((movieA) => {
|
||||
return !movies.some((movieB) => {
|
||||
if (movieA === movieB) {
|
||||
return false;
|
||||
}
|
||||
let allAtLeastAsGood = true;
|
||||
let strictlyBetter = false;
|
||||
for (const person of people) {
|
||||
const a = ratings[person][movieA];
|
||||
const b = ratings[person][movieB];
|
||||
if (b < a) {
|
||||
allAtLeastAsGood = false;
|
||||
break;
|
||||
}
|
||||
if (b > a) {
|
||||
strictlyBetter = true;
|
||||
}
|
||||
}
|
||||
return allAtLeastAsGood && strictlyBetter;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function nashScore(movie, people, ratings) {
|
||||
let product = 1;
|
||||
for (const person of people) {
|
||||
product *= ratings[person][movie] + 1;
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
export function averageScore(movie, people, ratings) {
|
||||
let total = 0;
|
||||
for (const person of people) {
|
||||
total += ratings[person][movie];
|
||||
}
|
||||
return total / people.length;
|
||||
}
|
||||
|
||||
export function decideMovie({ movies, people, ratings }) {
|
||||
if (movies.length < 1 || people.length < 1) {
|
||||
throw new Error("Need at least one movie and one person");
|
||||
}
|
||||
const remaining = paretoFilter(movies, people, ratings);
|
||||
const scored = remaining.map((movie) => {
|
||||
return {
|
||||
movie,
|
||||
nash: nashScore(movie, people, ratings),
|
||||
avg: averageScore(movie, people, ratings),
|
||||
};
|
||||
});
|
||||
scored.sort((a, b) => {
|
||||
if (b.nash !== a.nash) {
|
||||
return b.nash - a.nash;
|
||||
}
|
||||
if (b.avg !== a.avg) {
|
||||
return b.avg - a.avg;
|
||||
}
|
||||
return a.movie.localeCompare(b.movie);
|
||||
});
|
||||
return {
|
||||
winner: scored[0],
|
||||
remaining,
|
||||
ranking: scored,
|
||||
};
|
||||
}
|
||||
389
api.php
Normal file
389
api.php
Normal file
@@ -0,0 +1,389 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
const DATA_DIR = __DIR__ . '/data';
|
||||
const UUID_PATTERN = '/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/';
|
||||
|
||||
if (!is_dir(DATA_DIR)) {
|
||||
mkdir(DATA_DIR, 0775, true);
|
||||
}
|
||||
|
||||
function fail(int $status, string $message): void {
|
||||
http_response_code($status);
|
||||
echo json_encode(['ok' => false, 'error' => $message], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
function clean_user(string $value): string {
|
||||
$trimmed = trim($value);
|
||||
if ($trimmed === '' || mb_strlen($trimmed) > 30) {
|
||||
fail(400, 'Invalid user');
|
||||
}
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
function clean_movie(string $value): string {
|
||||
$trimmed = trim($value);
|
||||
// Strip leading trophy emoji if present (e.g., "🏆 Matrix" -> "Matrix")
|
||||
$trimmed = preg_replace('/^(?:\x{1F3C6}\s*)+/u', '', $trimmed);
|
||||
if ($trimmed === '' || mb_strlen($trimmed) > 60) {
|
||||
fail(400, 'Invalid movie title');
|
||||
}
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
function normalize_users(array $users): array {
|
||||
$normalized = [];
|
||||
foreach ($users as $user) {
|
||||
if (!is_string($user)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim($user);
|
||||
if ($name === '' || mb_strlen($name) > 30) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($name, $normalized, true)) {
|
||||
$normalized[] = $name;
|
||||
}
|
||||
}
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
function normalize_done_users(array $doneUsers, array $users): array {
|
||||
$normalized = [];
|
||||
foreach ($doneUsers as $name) {
|
||||
if (!is_string($name)) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($name, $users, true)) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($name, $normalized, true)) {
|
||||
$normalized[] = $name;
|
||||
}
|
||||
}
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
function all_users_done(array $users, array $doneUsers): bool {
|
||||
if (count($users) === 0) {
|
||||
return false;
|
||||
}
|
||||
foreach ($users as $name) {
|
||||
if (!in_array($name, $doneUsers, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function load_state(string $uuid): array {
|
||||
$path = DATA_DIR . '/' . $uuid . '.json';
|
||||
if (!file_exists($path)) {
|
||||
return [
|
||||
'uuid' => $uuid,
|
||||
'phase' => 1,
|
||||
'setupDone' => false,
|
||||
'users' => [],
|
||||
'doneUsersPhase1' => [],
|
||||
'doneUsersPhase2' => [],
|
||||
'movies' => [],
|
||||
'votes' => new stdClass(),
|
||||
'updatedAt' => gmdate('c'),
|
||||
'createdAt' => gmdate('c'),
|
||||
];
|
||||
}
|
||||
$raw = file_get_contents($path);
|
||||
if ($raw === false) {
|
||||
fail(500, 'Could not read state');
|
||||
}
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
fail(500, 'Corrupt state file');
|
||||
}
|
||||
$decoded['setupDone'] = (bool)($decoded['setupDone'] ?? false);
|
||||
$decoded['users'] = normalize_users(is_array($decoded['users'] ?? null) ? $decoded['users'] : []);
|
||||
$decoded['doneUsersPhase1'] = normalize_done_users(is_array($decoded['doneUsersPhase1'] ?? null) ? $decoded['doneUsersPhase1'] : [], $decoded['users']);
|
||||
$decoded['doneUsersPhase2'] = normalize_done_users(is_array($decoded['doneUsersPhase2'] ?? null) ? $decoded['doneUsersPhase2'] : [], $decoded['users']);
|
||||
$decoded['movies'] = is_array($decoded['movies'] ?? null) ? $decoded['movies'] : [];
|
||||
$decoded['votes'] = is_array($decoded['votes'] ?? null) ? $decoded['votes'] : [];
|
||||
$decoded['phase'] = (int)($decoded['phase'] ?? 1);
|
||||
if ($decoded['phase'] !== 1 && $decoded['phase'] !== 2 && $decoded['phase'] !== 3) {
|
||||
$decoded['phase'] = 1;
|
||||
}
|
||||
$decoded['history'] = is_array($decoded['history'] ?? null) ? $decoded['history'] : [];
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
function save_state(string $uuid, array $state): void {
|
||||
$state['updatedAt'] = gmdate('c');
|
||||
$path = DATA_DIR . '/' . $uuid . '.json';
|
||||
$json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
if ($json === false) {
|
||||
fail(500, 'Could not encode state');
|
||||
}
|
||||
if (file_put_contents($path, $json, LOCK_EX) === false) {
|
||||
fail(500, 'Could not save state');
|
||||
}
|
||||
}
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
$body = [];
|
||||
if ($method === 'POST') {
|
||||
$rawBody = file_get_contents('php://input');
|
||||
if ($rawBody !== false && $rawBody !== '') {
|
||||
$parsed = json_decode($rawBody, true);
|
||||
if (is_array($parsed)) {
|
||||
$body = $parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$action = $_GET['action'] ?? ($body['action'] ?? 'state');
|
||||
$uuid = $_GET['uuid'] ?? ($body['uuid'] ?? '');
|
||||
|
||||
if (!preg_match(UUID_PATTERN, $uuid)) {
|
||||
fail(400, 'Invalid UUID');
|
||||
}
|
||||
|
||||
$state = load_state($uuid);
|
||||
|
||||
if ($action === 'state') {
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($method !== 'POST') {
|
||||
fail(405, 'Use POST for mutations');
|
||||
}
|
||||
|
||||
if ($action === 'add_user') {
|
||||
if ((int)$state['phase'] !== 1 || $state['setupDone']) {
|
||||
fail(409, 'Setup is closed');
|
||||
}
|
||||
$user = clean_user((string)($body['user'] ?? ''));
|
||||
if (!in_array($user, $state['users'], true)) {
|
||||
$state['users'][] = $user;
|
||||
}
|
||||
save_state($uuid, $state);
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'finish_setup') {
|
||||
if (count($state['users']) < 1) {
|
||||
fail(409, 'Add at least one user first');
|
||||
}
|
||||
$state['setupDone'] = true;
|
||||
$state['phase'] = 1;
|
||||
$state['doneUsersPhase1'] = [];
|
||||
$state['doneUsersPhase2'] = [];
|
||||
save_state($uuid, $state);
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'add_movie') {
|
||||
if (!$state['setupDone']) {
|
||||
fail(409, 'Setup is not finished');
|
||||
}
|
||||
if ((int)$state['phase'] !== 1) {
|
||||
fail(409, 'Movie phase is closed');
|
||||
}
|
||||
$user = clean_user((string)($body['user'] ?? ''));
|
||||
if (count($state['users']) > 0 && !in_array($user, $state['users'], true)) {
|
||||
fail(403, 'Unknown user');
|
||||
}
|
||||
$title = clean_movie((string)($body['title'] ?? ''));
|
||||
$userMovieCount = 0;
|
||||
foreach ($state['movies'] as $movie) {
|
||||
if (!is_array($movie)) {
|
||||
continue;
|
||||
}
|
||||
if (mb_strtolower((string)($movie['addedBy'] ?? '')) === mb_strtolower($user)) {
|
||||
$userMovieCount++;
|
||||
}
|
||||
}
|
||||
if ($userMovieCount >= 5) {
|
||||
fail(409, 'Movie limit reached for this user (5)');
|
||||
}
|
||||
foreach ($state['movies'] as $existing) {
|
||||
if (mb_strtolower((string)($existing['title'] ?? '')) === mb_strtolower($title)) {
|
||||
fail(409, 'Movie already exists');
|
||||
}
|
||||
}
|
||||
$state['movies'][] = [
|
||||
'title' => $title,
|
||||
'addedBy' => $user,
|
||||
'addedAt' => gmdate('c'),
|
||||
];
|
||||
$state['doneUsersPhase1'] = array_values(array_filter(
|
||||
$state['doneUsersPhase1'],
|
||||
static fn(string $name): bool => $name !== $user,
|
||||
));
|
||||
save_state($uuid, $state);
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'remove_movie') {
|
||||
if (!$state['setupDone']) {
|
||||
fail(409, 'Setup is not finished');
|
||||
}
|
||||
if ((int)$state['phase'] !== 1) {
|
||||
fail(409, 'Movie phase is closed');
|
||||
}
|
||||
$user = clean_user((string)($body['user'] ?? ''));
|
||||
if (count($state['users']) > 0 && !in_array($user, $state['users'], true)) {
|
||||
fail(403, 'Unknown user');
|
||||
}
|
||||
$title = clean_movie((string)($body['title'] ?? ''));
|
||||
$state['movies'] = array_values(array_filter(
|
||||
$state['movies'],
|
||||
static fn(array $m): bool => !(mb_strtolower((string)($m['title'] ?? '')) === mb_strtolower($title) && mb_strtolower((string)($m['addedBy'] ?? '')) === mb_strtolower($user)),
|
||||
));
|
||||
$state['doneUsersPhase1'] = array_values(array_filter(
|
||||
$state['doneUsersPhase1'],
|
||||
static fn(string $name): bool => $name !== $user,
|
||||
));
|
||||
save_state($uuid, $state);
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'vote_many') {
|
||||
if ((int)$state['phase'] !== 2) {
|
||||
fail(409, 'Voting phase is not active');
|
||||
}
|
||||
$user = clean_user((string)($body['user'] ?? ''));
|
||||
if (count($state['users']) > 0 && !in_array($user, $state['users'], true)) {
|
||||
fail(403, 'Unknown user');
|
||||
}
|
||||
$ratings = $body['ratings'] ?? null;
|
||||
if (!is_array($ratings)) {
|
||||
fail(400, 'Ratings payload missing');
|
||||
}
|
||||
$movieTitles = array_map(static fn(array $movie): string => (string)$movie['title'], $state['movies']);
|
||||
if (!isset($state['votes'][$user]) || !is_array($state['votes'][$user])) {
|
||||
$state['votes'][$user] = [];
|
||||
}
|
||||
foreach ($movieTitles as $title) {
|
||||
$rawRating = $ratings[$title] ?? null;
|
||||
if (!is_int($rawRating)) {
|
||||
if (is_numeric($rawRating)) {
|
||||
$rawRating = (int)$rawRating;
|
||||
} else {
|
||||
fail(400, 'Each movie needs a rating');
|
||||
}
|
||||
}
|
||||
if ($rawRating < 0 || $rawRating > 5) {
|
||||
fail(400, 'Rating must be 0 to 5');
|
||||
}
|
||||
$state['votes'][$user][$title] = $rawRating;
|
||||
}
|
||||
$state['doneUsersPhase2'] = array_values(array_filter(
|
||||
$state['doneUsersPhase2'],
|
||||
static fn(string $name): bool => $name !== $user,
|
||||
));
|
||||
save_state($uuid, $state);
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'mark_done') {
|
||||
if (!$state['setupDone']) {
|
||||
fail(409, 'Setup is not finished');
|
||||
}
|
||||
$user = clean_user((string)($body['user'] ?? ''));
|
||||
if (count($state['users']) > 0 && !in_array($user, $state['users'], true)) {
|
||||
fail(403, 'Unknown user');
|
||||
}
|
||||
$phase = (int)($body['phase'] ?? $state['phase']);
|
||||
if ($phase === 1) {
|
||||
if ((int)$state['phase'] !== 1) {
|
||||
fail(409, 'Movie phase is not active');
|
||||
}
|
||||
if (!in_array($user, $state['doneUsersPhase1'], true)) {
|
||||
$state['doneUsersPhase1'][] = $user;
|
||||
}
|
||||
if (all_users_done($state['users'], $state['doneUsersPhase1'])) {
|
||||
$state['phase'] = 2;
|
||||
$state['doneUsersPhase2'] = [];
|
||||
}
|
||||
save_state($uuid, $state);
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
if ($phase === 2) {
|
||||
if ((int)$state['phase'] !== 2) {
|
||||
fail(409, 'Voting phase is not active');
|
||||
}
|
||||
$movieTitles = array_map(static fn(array $movie): string => (string)$movie['title'], $state['movies']);
|
||||
if (!isset($state['votes'][$user]) || !is_array($state['votes'][$user])) {
|
||||
fail(409, 'Save your ratings first');
|
||||
}
|
||||
foreach ($movieTitles as $title) {
|
||||
$value = $state['votes'][$user][$title] ?? null;
|
||||
if (!is_int($value)) {
|
||||
fail(409, 'Rate every movie before finishing');
|
||||
}
|
||||
}
|
||||
if (!in_array($user, $state['doneUsersPhase2'], true)) {
|
||||
$state['doneUsersPhase2'][] = $user;
|
||||
}
|
||||
if (all_users_done($state['users'], $state['doneUsersPhase2'])) {
|
||||
$state['phase'] = 3;
|
||||
}
|
||||
save_state($uuid, $state);
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
fail(400, 'Invalid done phase');
|
||||
}
|
||||
|
||||
if ($action === 'set_phase') {
|
||||
if (!$state['setupDone']) {
|
||||
fail(409, 'Finish setup first');
|
||||
}
|
||||
$phase = (int)($body['phase'] ?? 0);
|
||||
if ($phase !== 1 && $phase !== 2 && $phase !== 3) {
|
||||
fail(400, 'Invalid phase');
|
||||
}
|
||||
$state['phase'] = $phase;
|
||||
if ($phase === 1) {
|
||||
$state['doneUsersPhase1'] = [];
|
||||
$state['doneUsersPhase2'] = [];
|
||||
}
|
||||
if ($phase === 2) {
|
||||
$state['doneUsersPhase2'] = [];
|
||||
}
|
||||
save_state($uuid, $state);
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'new_round') {
|
||||
if ((int)$state['phase'] !== 3) {
|
||||
fail(409, 'Round is not finished yet');
|
||||
}
|
||||
$winner = trim((string)($body['winner'] ?? ''));
|
||||
// strip trophy emoji if present
|
||||
$winner = preg_replace('/^(?:\x{1F3C6}\s*)+/u', '', $winner);
|
||||
$state['history'][] = [
|
||||
'movies' => $state['movies'],
|
||||
'winner' => $winner !== '' ? $winner : null,
|
||||
];
|
||||
$state['phase'] = 1;
|
||||
$state['movies'] = [];
|
||||
$state['votes'] = new stdClass();
|
||||
$state['doneUsersPhase1'] = [];
|
||||
$state['doneUsersPhase2'] = [];
|
||||
save_state($uuid, $state);
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
fail(404, 'Unknown action');
|
||||
650
app.js
Normal file
650
app.js
Normal file
@@ -0,0 +1,650 @@
|
||||
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();
|
||||
178
bun.lock
Normal file
178
bun.lock
Normal file
@@ -0,0 +1,178 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "movie-select",
|
||||
"dependencies": {
|
||||
"mysql2": "^3.11.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.0.0",
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
|
||||
|
||||
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],
|
||||
|
||||
"@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="],
|
||||
|
||||
"@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="],
|
||||
|
||||
"@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="],
|
||||
|
||||
"@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="],
|
||||
|
||||
"@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="],
|
||||
|
||||
"@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="],
|
||||
|
||||
"@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="],
|
||||
|
||||
"@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="],
|
||||
|
||||
"@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="],
|
||||
|
||||
"@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="],
|
||||
|
||||
"@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="],
|
||||
|
||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
|
||||
|
||||
"@tailwindcss/cli": ["@tailwindcss/cli@4.2.1", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "enhanced-resolve": "^5.19.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.2.1" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-b7MGn51IA80oSG+7fuAgzfQ+7pZBgjzbqwmiv6NO7/+a1sev32cGqnwhscT7h0EcAvMa9r7gjRylqOH8Xhc4DA=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||
|
||||
"@types/node": ["@types/node@25.3.1", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw=="],
|
||||
|
||||
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
|
||||
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
||||
|
||||
"generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"mysql2": ["mysql2@3.18.1", "", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.2", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.3.3" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-Mz3yJ+YqppZUFr6Q9tP0g9v3RTWGfLQ/J4RlnQ6CNrWz436+FDtFDICecsbWYwjupgfPpj8ZtNVMsCX79VKpLg=="],
|
||||
|
||||
"named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="],
|
||||
|
||||
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"sql-escaper": ["sql-escaper@1.3.3", "", {}, "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
|
||||
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
}
|
||||
}
|
||||
44
deploy.sh
Executable file
44
deploy.sh
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REMOTE_HOST="serve"
|
||||
REMOTE_APP_DIR="/home/serve/services/movies"
|
||||
REMOTE_PORT=3001
|
||||
|
||||
echo "==> Installing local dependencies..."
|
||||
bun install
|
||||
|
||||
echo "==> Building client bundle + CSS + assets..."
|
||||
bun run build
|
||||
|
||||
echo "==> Syncing to ${REMOTE_HOST}:${REMOTE_APP_DIR} ..."
|
||||
rsync -avz --delete \
|
||||
--exclude='.DS_Store' \
|
||||
--exclude='.git/' \
|
||||
--exclude='AI_AGENT_REPORT.md' \
|
||||
--exclude='LEARNINGS.md' \
|
||||
--exclude='tests/' \
|
||||
--exclude='.env' \
|
||||
--exclude='.env.example' \
|
||||
--exclude='deploy.sh' \
|
||||
--exclude='scripts/' \
|
||||
--exclude='memory/' \
|
||||
--exclude='setup-db.sql' \
|
||||
--exclude='data/' \
|
||||
--exclude='/index.html' \
|
||||
--exclude='/index.php' \
|
||||
--exclude='/api.php' \
|
||||
--exclude='/styles.css' \
|
||||
--exclude='/app.js' \
|
||||
--exclude='/algorithm.js' \
|
||||
--exclude='/round-state.js' \
|
||||
--exclude='/tsconfig.json' \
|
||||
./ "${REMOTE_HOST}:${REMOTE_APP_DIR}/"
|
||||
|
||||
echo "==> Installing server dependencies on remote..."
|
||||
ssh "${REMOTE_HOST}" "cd ${REMOTE_APP_DIR} && bun install --production"
|
||||
|
||||
echo "==> Restarting service..."
|
||||
ssh "${REMOTE_HOST}" "systemctl --user restart movie-select 2>/dev/null || true"
|
||||
|
||||
echo "Done. Live at https://serve.uber.space/movies/"
|
||||
7
icon.svg
Normal file
7
icon.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<rect width="256" height="256" rx="52" fill="#0a84ff"/>
|
||||
<path d="M64 84h128v88H64z" fill="#fff"/>
|
||||
<path d="M96 64h16v32H96zM144 64h16v32h-16z" fill="#fff"/>
|
||||
<circle cx="104" cy="128" r="10" fill="#0a84ff"/>
|
||||
<circle cx="152" cy="128" r="10" fill="#0a84ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 332 B |
57
index.html
Normal file
57
index.html
Normal file
@@ -0,0 +1,57 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#0b0f14">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<title>Movie Select</title>
|
||||
</head>
|
||||
<body>
|
||||
<main class="app" aria-live="polite">
|
||||
<header class="topbar">
|
||||
<h1 data-i18n="title">Movie Select</h1>
|
||||
<label for="language" class="sr-only" data-i18n="language">Language</label>
|
||||
<select id="language" aria-label="Language selector">
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="fr">Français</option>
|
||||
</select>
|
||||
</header>
|
||||
|
||||
<section class="card">
|
||||
<h2 data-i18n="setup">Setup</h2>
|
||||
<div class="row">
|
||||
<input id="participantInput" type="text" data-i18n-placeholder="participantPlaceholder" placeholder="Add participant" maxlength="30">
|
||||
<button id="addParticipant" data-i18n="add">Add</button>
|
||||
</div>
|
||||
<ul id="participantList" class="chips" aria-label="Participants"></ul>
|
||||
<div class="row">
|
||||
<input id="movieInput" type="text" data-i18n-placeholder="moviePlaceholder" placeholder="Add movie" maxlength="60">
|
||||
<button id="addMovie" data-i18n="add">Add</button>
|
||||
</div>
|
||||
<ul id="movieList" class="chips" aria-label="Movies"></ul>
|
||||
<p class="hint" data-i18n="setupHint">Add at least 2 people and 2 movies.</p>
|
||||
</section>
|
||||
|
||||
<section class="card" id="ratingsCard" hidden>
|
||||
<h2 data-i18n="ratings">Ratings</h2>
|
||||
<p class="hint" data-i18n="ratingsHint">Each person rates each movie from 0 (no) to 5 (yes).</p>
|
||||
<div id="ratingsGrid" class="ratings-grid" role="table" aria-label="Ratings grid"></div>
|
||||
<div class="actions">
|
||||
<button id="decide" class="primary" data-i18n="decide">Pick a movie</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="resultCard" hidden>
|
||||
<h2 data-i18n="result">Result</h2>
|
||||
<p id="winnerText" class="winner"></p>
|
||||
<div id="resultList"></div>
|
||||
<p class="hint" data-i18n="algoNote">Method: Pareto filter + Nash social welfare (product of ratings+1). This avoids picks one person strongly dislikes.</p>
|
||||
</section>
|
||||
</main>
|
||||
<script src="app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
index.php
Normal file
23
index.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
$css_v = filemtime(__DIR__ . '/styles.css');
|
||||
$js_v = filemtime(__DIR__ . '/app.js');
|
||||
?><!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#0b0f14">
|
||||
<link rel="manifest" href="/movies/manifest.webmanifest">
|
||||
<link rel="stylesheet" href="/movies/styles.css?v=<?= $css_v ?>">
|
||||
<title>Movie Select</title>
|
||||
</head>
|
||||
<body>
|
||||
<main class="app" aria-live="polite">
|
||||
<header class="topbar">
|
||||
<h1>Movie Select</h1>
|
||||
</header>
|
||||
<section class="card" id="app"></section>
|
||||
</main>
|
||||
<script type="module" src="/movies/app.js?v=<?= $js_v ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
17
manifest.webmanifest
Normal file
17
manifest.webmanifest
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Movie Select",
|
||||
"short_name": "MovieSelect",
|
||||
"start_url": "/movies/",
|
||||
"scope": "/movies/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f3f5f7",
|
||||
"theme_color": "#0b0f14",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/movies/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "movie-select",
|
||||
"version": "2026.02.26",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run src/server/index.ts",
|
||||
"build:client": "bun build src/client/main.ts --outfile dist/main.js --minify --sourcemap=none",
|
||||
"build:css": "tailwindcss -i src/client/input.css -o dist/styles.css --minify",
|
||||
"build:assets": "bun run scripts/copy-assets.ts",
|
||||
"build": "bun run build:client && bun run build:css && bun run build:assets",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"mysql2": "^3.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.0.0",
|
||||
"@types/bun": "latest"
|
||||
}
|
||||
}
|
||||
17
public/manifest.webmanifest
Normal file
17
public/manifest.webmanifest
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Movie Select",
|
||||
"short_name": "MovieSelect",
|
||||
"start_url": "/movies/",
|
||||
"scope": "/movies/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f8fafc",
|
||||
"theme_color": "#0b0f14",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/movies/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
35
round-state.js
Normal file
35
round-state.js
Normal file
@@ -0,0 +1,35 @@
|
||||
export function allUsersDone(users, doneUsers) {
|
||||
if (!Array.isArray(users) || users.length === 0 || !Array.isArray(doneUsers)) {
|
||||
return false;
|
||||
}
|
||||
return users.every((name) => doneUsers.includes(name));
|
||||
}
|
||||
|
||||
export function hasCompleteRatings(movieTitles, votesForUser) {
|
||||
if (!Array.isArray(movieTitles) || !votesForUser || typeof votesForUser !== "object") {
|
||||
return false;
|
||||
}
|
||||
for (const title of movieTitles) {
|
||||
if (!Number.isInteger(votesForUser[title])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function collectCompleteRatings(users, movieTitles, votes) {
|
||||
const output = {};
|
||||
if (!Array.isArray(users) || !Array.isArray(movieTitles) || !votes || typeof votes !== "object") {
|
||||
return output;
|
||||
}
|
||||
for (const name of users) {
|
||||
if (!hasCompleteRatings(movieTitles, votes[name])) {
|
||||
continue;
|
||||
}
|
||||
output[name] = {};
|
||||
for (const title of movieTitles) {
|
||||
output[name][title] = votes[name][title];
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
6
scripts/copy-assets.ts
Normal file
6
scripts/copy-assets.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { copyFileSync, mkdirSync } from "fs";
|
||||
|
||||
mkdirSync("dist", { recursive: true });
|
||||
copyFileSync("public/manifest.webmanifest", "dist/manifest.webmanifest");
|
||||
copyFileSync("icon.svg", "dist/icon.svg");
|
||||
console.log("Assets copied.");
|
||||
48
setup-db.sql
Normal file
48
setup-db.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
-- Movie Select schema
|
||||
-- Run once: mysql -u serve -p serve < setup-db.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rounds (
|
||||
uuid CHAR(36) PRIMARY KEY,
|
||||
phase TINYINT NOT NULL DEFAULT 1,
|
||||
setup_done TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT NOW(),
|
||||
updated_at DATETIME NOT NULL DEFAULT NOW() ON UPDATE NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS round_users (
|
||||
round_uuid CHAR(36) NOT NULL,
|
||||
name VARCHAR(30) NOT NULL,
|
||||
done_phase1 TINYINT(1) NOT NULL DEFAULT 0,
|
||||
done_phase2 TINYINT(1) NOT NULL DEFAULT 0,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (round_uuid, name),
|
||||
FOREIGN KEY (round_uuid) REFERENCES rounds(uuid) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS movies (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
round_uuid CHAR(36) NOT NULL,
|
||||
title VARCHAR(60) NOT NULL,
|
||||
added_by VARCHAR(30) NOT NULL,
|
||||
added_at DATETIME NOT NULL DEFAULT NOW(),
|
||||
UNIQUE KEY uq_round_title (round_uuid, title),
|
||||
FOREIGN KEY (round_uuid) REFERENCES rounds(uuid) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS votes (
|
||||
round_uuid CHAR(36) NOT NULL,
|
||||
user_name VARCHAR(30) NOT NULL,
|
||||
movie_title VARCHAR(60) NOT NULL,
|
||||
rating TINYINT NOT NULL,
|
||||
PRIMARY KEY (round_uuid, user_name, movie_title),
|
||||
FOREIGN KEY (round_uuid) REFERENCES rounds(uuid) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS round_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
round_uuid CHAR(36) NOT NULL,
|
||||
winner VARCHAR(60),
|
||||
movies_json TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT NOW(),
|
||||
FOREIGN KEY (round_uuid) REFERENCES rounds(uuid) ON DELETE CASCADE
|
||||
);
|
||||
60
setup-server.sh
Executable file
60
setup-server.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run once to set up the server.
|
||||
# Usage: bash setup-server.sh
|
||||
set -euo pipefail
|
||||
|
||||
REMOTE_HOST="serve"
|
||||
REMOTE_APP_DIR="/home/serve/services/movies"
|
||||
REMOTE_PORT=3001
|
||||
DB_PASS="09a7d97c99aff8fd883d3bbe3da927e254ac507b"
|
||||
|
||||
echo "==> Creating app directory on server..."
|
||||
ssh "${REMOTE_HOST}" "mkdir -p ${REMOTE_APP_DIR}"
|
||||
|
||||
echo "==> Creating .env on server..."
|
||||
ssh "${REMOTE_HOST}" "cat > ${REMOTE_APP_DIR}/.env" <<EOF
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_USER=serve
|
||||
DB_PASS=${DB_PASS}
|
||||
DB_NAME=serve
|
||||
PORT=${REMOTE_PORT}
|
||||
EOF
|
||||
|
||||
echo "==> Initialising database schema..."
|
||||
scp setup-db.sql "${REMOTE_HOST}:/tmp/movie-select-setup.sql"
|
||||
ssh "${REMOTE_HOST}" "mysql -u serve -p'${DB_PASS}' serve < /tmp/movie-select-setup.sql && rm /tmp/movie-select-setup.sql"
|
||||
|
||||
echo "==> Deploying app files..."
|
||||
bash deploy.sh
|
||||
|
||||
echo "==> Detecting bun path on server..."
|
||||
BUN_PATH=$(ssh "${REMOTE_HOST}" 'which bun || echo "$HOME/.bun/bin/bun"')
|
||||
echo " bun at: ${BUN_PATH}"
|
||||
|
||||
echo "==> Creating systemd service..."
|
||||
ssh "${REMOTE_HOST}" BUN_PATH="${BUN_PATH}" REMOTE_APP_DIR="${REMOTE_APP_DIR}" bash <<'ENDSSH'
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cat > ~/.config/systemd/user/movie-select.service <<SERVICE
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
[Service]
|
||||
ExecStart=${BUN_PATH} run src/server/index.ts
|
||||
WorkingDirectory=${REMOTE_APP_DIR}
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
SERVICE
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable movie-select
|
||||
systemctl --user start movie-select
|
||||
ENDSSH
|
||||
|
||||
echo "==> Configuring web backend..."
|
||||
ssh "${REMOTE_HOST}" "uberspace web backend add /movies port ${REMOTE_PORT} --remove-prefix 2>/dev/null || uberspace web backend set /movies port ${REMOTE_PORT} --remove-prefix"
|
||||
|
||||
echo "==> Service status:"
|
||||
ssh "${REMOTE_HOST}" "systemctl --user status movie-select --no-pager"
|
||||
|
||||
echo ""
|
||||
echo "Done. Live at https://serve.uber.space/movies/"
|
||||
79
src/client/algorithm.ts
Normal file
79
src/client/algorithm.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export function paretoFilter(
|
||||
movies: string[],
|
||||
people: string[],
|
||||
ratings: Record<string, Record<string, number>>,
|
||||
): string[] {
|
||||
return movies.filter((movieA) => {
|
||||
return !movies.some((movieB) => {
|
||||
if (movieA === movieB) return false;
|
||||
let allAtLeastAsGood = true;
|
||||
let strictlyBetter = false;
|
||||
for (const person of people) {
|
||||
const a = ratings[person]?.[movieA] ?? 0;
|
||||
const b = ratings[person]?.[movieB] ?? 0;
|
||||
if (b < a) { allAtLeastAsGood = false; break; }
|
||||
if (b > a) strictlyBetter = true;
|
||||
}
|
||||
return allAtLeastAsGood && strictlyBetter;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function nashScore(
|
||||
movie: string,
|
||||
people: string[],
|
||||
ratings: Record<string, Record<string, number>>,
|
||||
): number {
|
||||
let product = 1;
|
||||
for (const person of people) {
|
||||
product *= (ratings[person]?.[movie] ?? 0) + 1;
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
export function averageScore(
|
||||
movie: string,
|
||||
people: string[],
|
||||
ratings: Record<string, Record<string, number>>,
|
||||
): number {
|
||||
let total = 0;
|
||||
for (const person of people) {
|
||||
total += ratings[person]?.[movie] ?? 0;
|
||||
}
|
||||
return total / people.length;
|
||||
}
|
||||
|
||||
export interface RankedMovie {
|
||||
movie: string;
|
||||
nash: number;
|
||||
avg: number;
|
||||
}
|
||||
|
||||
export interface DecisionResult {
|
||||
winner: RankedMovie;
|
||||
remaining: string[];
|
||||
ranking: RankedMovie[];
|
||||
}
|
||||
|
||||
export function decideMovie(opts: {
|
||||
movies: string[];
|
||||
people: string[];
|
||||
ratings: Record<string, Record<string, number>>;
|
||||
}): DecisionResult {
|
||||
const { movies, people, ratings } = opts;
|
||||
if (movies.length < 1 || people.length < 1) {
|
||||
throw new Error("Need at least one movie and one person");
|
||||
}
|
||||
const remaining = paretoFilter(movies, people, ratings);
|
||||
const scored: RankedMovie[] = remaining.map((movie) => ({
|
||||
movie,
|
||||
nash: nashScore(movie, people, ratings),
|
||||
avg: averageScore(movie, people, ratings),
|
||||
}));
|
||||
scored.sort((a, b) => {
|
||||
if (b.nash !== a.nash) return b.nash - a.nash;
|
||||
if (b.avg !== a.avg) return b.avg - a.avg;
|
||||
return a.movie.localeCompare(b.movie);
|
||||
});
|
||||
return { winner: scored[0]!, remaining, ranking: scored };
|
||||
}
|
||||
161
src/client/input.css
Normal file
161
src/client/input.css
Normal file
@@ -0,0 +1,161 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@source "../server/index.ts";
|
||||
@source "../client/main.ts";
|
||||
|
||||
@theme {
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* ── Base ── */
|
||||
@layer base {
|
||||
html {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
button, input, select, textarea {
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Components ── */
|
||||
@layer components {
|
||||
/* Buttons — 44 px min tap target */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center min-h-[44px] px-5 py-2.5
|
||||
rounded-xl border border-slate-200 bg-white
|
||||
text-[15px] font-medium text-slate-800
|
||||
whitespace-nowrap select-none
|
||||
transition-transform duration-100 active:scale-[0.97];
|
||||
}
|
||||
.btn-primary {
|
||||
@apply bg-blue-500 border-blue-500 text-white;
|
||||
}
|
||||
.btn-sm {
|
||||
@apply min-h-[36px] px-4 py-1.5 text-sm;
|
||||
}
|
||||
|
||||
/* Inputs — 44 px min tap target */
|
||||
.field {
|
||||
@apply min-h-[44px] w-full px-4 py-2.5
|
||||
border border-slate-300 rounded-xl bg-white text-base
|
||||
outline-none placeholder:text-slate-400
|
||||
transition-[border-color,box-shadow] duration-150;
|
||||
}
|
||||
.field-mono {
|
||||
@apply min-h-[36px] w-full px-3 py-1.5
|
||||
border border-slate-300 rounded-xl bg-white
|
||||
font-mono text-sm
|
||||
outline-none
|
||||
transition-[border-color,box-shadow] duration-150;
|
||||
}
|
||||
|
||||
/* Chips */
|
||||
.chip {
|
||||
@apply inline-flex items-center rounded-full
|
||||
bg-slate-100 px-3 py-1.5
|
||||
text-[13px] font-medium text-slate-700;
|
||||
}
|
||||
.chip-btn {
|
||||
@apply inline-flex items-center rounded-full border-0
|
||||
bg-slate-100 px-3 py-1.5
|
||||
text-[13px] font-medium text-slate-700
|
||||
cursor-pointer select-none
|
||||
transition-[background-color,transform] duration-100
|
||||
hover:bg-slate-200 active:scale-[0.96];
|
||||
}
|
||||
.chip-user {
|
||||
@apply inline-flex items-center rounded-full border-2 border-blue-400
|
||||
bg-blue-100 px-3 py-1.5
|
||||
text-[13px] font-medium text-blue-900
|
||||
cursor-pointer select-none
|
||||
transition-[background-color,transform] duration-100
|
||||
hover:bg-blue-200 active:scale-[0.96];
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card-inset {
|
||||
@apply mt-4 rounded-2xl border border-slate-100 bg-slate-50 p-4;
|
||||
}
|
||||
.card-progress {
|
||||
@apply mt-3 rounded-2xl border border-blue-200 bg-blue-50 p-4;
|
||||
}
|
||||
|
||||
/* Layout helpers */
|
||||
.chips-list {
|
||||
@apply mt-3 flex flex-wrap gap-2 list-none p-0 m-0;
|
||||
}
|
||||
.links-list {
|
||||
@apply mt-3 flex flex-col gap-2 list-none p-0 m-0;
|
||||
}
|
||||
.input-row {
|
||||
@apply mt-4 flex gap-2;
|
||||
}
|
||||
.action-row {
|
||||
@apply mt-5 flex flex-wrap justify-end gap-2.5;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
.screen-title {
|
||||
@apply m-0 text-xl font-bold tracking-tight text-slate-900;
|
||||
}
|
||||
.section-title {
|
||||
@apply m-0 mt-5 mb-2 text-[13px] font-semibold uppercase tracking-wider text-slate-400;
|
||||
}
|
||||
.hint {
|
||||
@apply mt-2 text-sm leading-relaxed text-slate-500;
|
||||
}
|
||||
.error-msg {
|
||||
@apply mt-3 text-sm font-medium text-red-500;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus states (outside @layer so they can reference component classes) */
|
||||
.field:focus,
|
||||
.field-mono:focus {
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
/* Disabled button state */
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.btn:disabled:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Safe-area bottom padding for notched phones */
|
||||
.safe-b {
|
||||
padding-bottom: max(2.5rem, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* ── Range slider ── */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: #dbeafe;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
551
src/client/main.ts
Normal file
551
src/client/main.ts
Normal file
@@ -0,0 +1,551 @@
|
||||
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();
|
||||
34
src/client/round-state.ts
Normal file
34
src/client/round-state.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export function allUsersDone(users: string[], doneUsers: string[]): boolean {
|
||||
if (!Array.isArray(users) || users.length === 0 || !Array.isArray(doneUsers)) return false;
|
||||
return users.every((name) => doneUsers.includes(name));
|
||||
}
|
||||
|
||||
export function hasCompleteRatings(
|
||||
movieTitles: string[],
|
||||
votesForUser: Record<string, number> | null | undefined,
|
||||
): boolean {
|
||||
if (!Array.isArray(movieTitles) || !votesForUser || typeof votesForUser !== "object") return false;
|
||||
for (const title of movieTitles) {
|
||||
if (!Number.isInteger(votesForUser[title])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function collectCompleteRatings(
|
||||
users: string[],
|
||||
movieTitles: string[],
|
||||
votes: Record<string, Record<string, number>>,
|
||||
): Record<string, Record<string, number>> {
|
||||
const output: Record<string, Record<string, number>> = {};
|
||||
if (!Array.isArray(users) || !Array.isArray(movieTitles) || !votes || typeof votes !== "object") {
|
||||
return output;
|
||||
}
|
||||
for (const name of users) {
|
||||
if (!hasCompleteRatings(movieTitles, votes[name])) continue;
|
||||
output[name] = {};
|
||||
for (const title of movieTitles) {
|
||||
output[name][title] = votes[name]![title]!;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
197
src/server/api.ts
Normal file
197
src/server/api.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
loadState,
|
||||
addUserInDb,
|
||||
setSetupDoneInDb,
|
||||
addMovieInDb,
|
||||
removeMovieInDb,
|
||||
upsertVoteInDb,
|
||||
setUserDoneInDb,
|
||||
setPhaseInDb,
|
||||
resetDoneUsersInDb,
|
||||
clearMoviesAndVotesInDb,
|
||||
addHistoryEntryInDb,
|
||||
resetUserDoneForUser,
|
||||
} from "./db.ts";
|
||||
import type { State } from "./types.ts";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const TROPHY_RE = /^(?:\u{1F3C6}\s*)+/u;
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function fail(status: number, message: string): never {
|
||||
throw new ApiError(status, message);
|
||||
}
|
||||
|
||||
function cleanUser(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "" || trimmed.length > 30) fail(400, "Invalid user");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function cleanMovie(value: string): string {
|
||||
let trimmed = value.trim().replace(TROPHY_RE, "");
|
||||
if (trimmed === "" || trimmed.length > 60) fail(400, "Invalid movie title");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function allUsersDone(users: string[], doneUsers: string[]): boolean {
|
||||
if (users.length === 0) return false;
|
||||
return users.every((name) => doneUsers.includes(name));
|
||||
}
|
||||
|
||||
export async function handleAction(
|
||||
action: string,
|
||||
uuid: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<State> {
|
||||
if (!UUID_RE.test(uuid)) fail(400, "Invalid UUID");
|
||||
|
||||
if (action === "state") {
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
// All mutating actions below
|
||||
const state = await loadState(uuid);
|
||||
|
||||
if (action === "add_user") {
|
||||
if (state.phase !== 1 || state.setupDone) fail(409, "Setup is closed");
|
||||
const user = cleanUser(String(body["user"] ?? ""));
|
||||
if (!state.users.includes(user)) {
|
||||
await addUserInDb(uuid, user, state.users.length);
|
||||
}
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
if (action === "finish_setup") {
|
||||
if (state.users.length < 1) fail(409, "Add at least one user first");
|
||||
await setSetupDoneInDb(uuid);
|
||||
await resetDoneUsersInDb(uuid, "both");
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
if (action === "add_movie") {
|
||||
if (!state.setupDone) fail(409, "Setup is not finished");
|
||||
if (state.phase !== 1) fail(409, "Movie phase is closed");
|
||||
const user = cleanUser(String(body["user"] ?? ""));
|
||||
if (state.users.length > 0 && !state.users.includes(user)) fail(403, "Unknown user");
|
||||
const title = cleanMovie(String(body["title"] ?? ""));
|
||||
const myCount = state.movies.filter(
|
||||
(m) => m.addedBy.toLowerCase() === user.toLowerCase(),
|
||||
).length;
|
||||
if (myCount >= 5) fail(409, "Movie limit reached for this user (5)");
|
||||
const duplicate = state.movies.some(
|
||||
(m) => m.title.toLowerCase() === title.toLowerCase(),
|
||||
);
|
||||
if (duplicate) fail(409, "Movie already exists");
|
||||
await addMovieInDb(uuid, title, user);
|
||||
// Un-mark done for this user when they add a movie
|
||||
await resetUserDoneForUser(uuid, user, 1);
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
if (action === "remove_movie") {
|
||||
if (!state.setupDone) fail(409, "Setup is not finished");
|
||||
if (state.phase !== 1) fail(409, "Movie phase is closed");
|
||||
const user = cleanUser(String(body["user"] ?? ""));
|
||||
if (state.users.length > 0 && !state.users.includes(user)) fail(403, "Unknown user");
|
||||
const title = cleanMovie(String(body["title"] ?? ""));
|
||||
await removeMovieInDb(uuid, title, user);
|
||||
await resetUserDoneForUser(uuid, user, 1);
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
if (action === "vote_many") {
|
||||
if (state.phase !== 2) fail(409, "Voting phase is not active");
|
||||
const user = cleanUser(String(body["user"] ?? ""));
|
||||
if (state.users.length > 0 && !state.users.includes(user)) fail(403, "Unknown user");
|
||||
const ratings = body["ratings"];
|
||||
if (!ratings || typeof ratings !== "object" || Array.isArray(ratings)) {
|
||||
fail(400, "Ratings payload missing");
|
||||
}
|
||||
const ratingsMap = ratings as Record<string, unknown>;
|
||||
for (const movie of state.movies) {
|
||||
const raw = ratingsMap[movie.title];
|
||||
let rating: number;
|
||||
if (typeof raw === "number" && Number.isInteger(raw)) {
|
||||
rating = raw;
|
||||
} else if (typeof raw === "string" && /^\d+$/.test(raw)) {
|
||||
rating = parseInt(raw, 10);
|
||||
} else {
|
||||
fail(400, "Each movie needs a rating");
|
||||
}
|
||||
if (rating < 0 || rating > 5) fail(400, "Rating must be 0 to 5");
|
||||
await upsertVoteInDb(uuid, user, movie.title, rating);
|
||||
}
|
||||
// Un-mark done for this user when they update votes
|
||||
await resetUserDoneForUser(uuid, user, 2);
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
if (action === "mark_done") {
|
||||
if (!state.setupDone) fail(409, "Setup is not finished");
|
||||
const user = cleanUser(String(body["user"] ?? ""));
|
||||
if (state.users.length > 0 && !state.users.includes(user)) fail(403, "Unknown user");
|
||||
const phase = Number(body["phase"] ?? state.phase);
|
||||
|
||||
if (phase === 1) {
|
||||
if (state.phase !== 1) fail(409, "Movie phase is not active");
|
||||
await setUserDoneInDb(uuid, user, 1);
|
||||
const updated = await loadState(uuid);
|
||||
if (allUsersDone(updated.users, updated.doneUsersPhase1)) {
|
||||
await setPhaseInDb(uuid, 2);
|
||||
await resetDoneUsersInDb(uuid, 2);
|
||||
}
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
if (phase === 2) {
|
||||
if (state.phase !== 2) fail(409, "Voting phase is not active");
|
||||
// Verify all movies rated
|
||||
const movieTitles = state.movies.map((m) => m.title);
|
||||
const userVotes = state.votes[user] ?? {};
|
||||
for (const title of movieTitles) {
|
||||
const v = userVotes[title];
|
||||
if (typeof v !== "number") fail(409, "Rate every movie before finishing");
|
||||
}
|
||||
await setUserDoneInDb(uuid, user, 2);
|
||||
const updated = await loadState(uuid);
|
||||
if (allUsersDone(updated.users, updated.doneUsersPhase2)) {
|
||||
await setPhaseInDb(uuid, 3);
|
||||
}
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
fail(400, "Invalid done phase");
|
||||
}
|
||||
|
||||
if (action === "set_phase") {
|
||||
if (!state.setupDone) fail(409, "Finish setup first");
|
||||
const phase = Number(body["phase"] ?? 0);
|
||||
if (phase !== 1 && phase !== 2 && phase !== 3) fail(400, "Invalid phase");
|
||||
await setPhaseInDb(uuid, phase);
|
||||
if (phase === 1) await resetDoneUsersInDb(uuid, "both");
|
||||
if (phase === 2) await resetDoneUsersInDb(uuid, 2);
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
if (action === "new_round") {
|
||||
if (state.phase !== 3) fail(409, "Round is not finished yet");
|
||||
let winner = String(body["winner"] ?? "").trim().replace(TROPHY_RE, "");
|
||||
if (winner === "") winner = "";
|
||||
await addHistoryEntryInDb(uuid, winner || null, state.movies);
|
||||
await clearMoviesAndVotesInDb(uuid);
|
||||
await setPhaseInDb(uuid, 1);
|
||||
await resetDoneUsersInDb(uuid, "both");
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
fail(404, "Unknown action");
|
||||
}
|
||||
182
src/server/db.ts
Normal file
182
src/server/db.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import mysql from "mysql2/promise";
|
||||
import type { RowDataPacket } from "mysql2/promise";
|
||||
import type { State, Movie } from "./types.ts";
|
||||
|
||||
export const pool = mysql.createPool({
|
||||
host: process.env["DB_HOST"] ?? "127.0.0.1",
|
||||
port: Number(process.env["DB_PORT"] ?? 3306),
|
||||
user: process.env["DB_USER"] ?? "serve",
|
||||
password: process.env["DB_PASS"] ?? "",
|
||||
database: process.env["DB_NAME"] ?? "serve",
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
decimalNumbers: true,
|
||||
timezone: "+00:00",
|
||||
});
|
||||
|
||||
function fmtDate(d: unknown): string {
|
||||
if (d instanceof Date) return d.toISOString();
|
||||
if (typeof d === "string") return d;
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export async function loadState(uuid: string): Promise<State> {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
// Ensure round row exists
|
||||
await conn.execute(
|
||||
"INSERT IGNORE INTO rounds (uuid) VALUES (?)",
|
||||
[uuid],
|
||||
);
|
||||
|
||||
const [[round]] = await conn.execute<RowDataPacket[]>(
|
||||
"SELECT phase, setup_done, created_at, updated_at FROM rounds WHERE uuid = ?",
|
||||
[uuid],
|
||||
);
|
||||
|
||||
const [userRows] = await conn.execute<RowDataPacket[]>(
|
||||
"SELECT name, done_phase1, done_phase2 FROM round_users WHERE round_uuid = ? ORDER BY sort_order, name",
|
||||
[uuid],
|
||||
);
|
||||
|
||||
const [movieRows] = await conn.execute<RowDataPacket[]>(
|
||||
"SELECT title, added_by, added_at FROM movies WHERE round_uuid = ? ORDER BY id",
|
||||
[uuid],
|
||||
);
|
||||
|
||||
const [voteRows] = await conn.execute<RowDataPacket[]>(
|
||||
"SELECT user_name, movie_title, rating FROM votes WHERE round_uuid = ?",
|
||||
[uuid],
|
||||
);
|
||||
|
||||
const [historyRows] = await conn.execute<RowDataPacket[]>(
|
||||
"SELECT winner, movies_json FROM round_history WHERE round_uuid = ? ORDER BY id",
|
||||
[uuid],
|
||||
);
|
||||
|
||||
const users: string[] = userRows.map((u) => String(u["name"]));
|
||||
const donePhase1 = userRows.filter((u) => u["done_phase1"]).map((u) => String(u["name"]));
|
||||
const donePhase2 = userRows.filter((u) => u["done_phase2"]).map((u) => String(u["name"]));
|
||||
|
||||
const votes: Record<string, Record<string, number>> = {};
|
||||
for (const v of voteRows) {
|
||||
const uname = String(v["user_name"]);
|
||||
const mtitle = String(v["movie_title"]);
|
||||
if (!votes[uname]) votes[uname] = {};
|
||||
votes[uname][mtitle] = Number(v["rating"]);
|
||||
}
|
||||
|
||||
const history = historyRows.map((h) => ({
|
||||
winner: h["winner"] ? String(h["winner"]) : null,
|
||||
movies: JSON.parse(String(h["movies_json"])) as Movie[],
|
||||
}));
|
||||
|
||||
const phase = Number(round?.["phase"] ?? 1);
|
||||
|
||||
return {
|
||||
uuid,
|
||||
phase: (phase === 1 || phase === 2 || phase === 3 ? phase : 1) as 1 | 2 | 3,
|
||||
setupDone: Boolean(round?.["setup_done"]),
|
||||
users,
|
||||
doneUsersPhase1: donePhase1,
|
||||
doneUsersPhase2: donePhase2,
|
||||
movies: movieRows.map((m) => ({
|
||||
title: String(m["title"]),
|
||||
addedBy: String(m["added_by"]),
|
||||
addedAt: fmtDate(m["added_at"]),
|
||||
})),
|
||||
votes,
|
||||
history,
|
||||
createdAt: fmtDate(round?.["created_at"]),
|
||||
updatedAt: fmtDate(round?.["updated_at"]),
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function setPhaseInDb(uuid: string, phase: number): Promise<void> {
|
||||
await pool.execute("UPDATE rounds SET phase = ? WHERE uuid = ?", [phase, uuid]);
|
||||
}
|
||||
|
||||
export async function setSetupDoneInDb(uuid: string): Promise<void> {
|
||||
await pool.execute("UPDATE rounds SET setup_done = 1 WHERE uuid = ?", [uuid]);
|
||||
}
|
||||
|
||||
export async function addUserInDb(uuid: string, name: string, sortOrder: number): Promise<void> {
|
||||
await pool.execute(
|
||||
"INSERT IGNORE INTO round_users (round_uuid, name, sort_order) VALUES (?, ?, ?)",
|
||||
[uuid, name, sortOrder],
|
||||
);
|
||||
}
|
||||
|
||||
export async function addMovieInDb(uuid: string, title: string, addedBy: string): Promise<void> {
|
||||
await pool.execute(
|
||||
"INSERT INTO movies (round_uuid, title, added_by) VALUES (?, ?, ?)",
|
||||
[uuid, title, addedBy],
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeMovieInDb(uuid: string, title: string, addedBy: string): Promise<void> {
|
||||
await pool.execute(
|
||||
"DELETE FROM movies WHERE round_uuid = ? AND title = ? AND added_by = ?",
|
||||
[uuid, title, addedBy],
|
||||
);
|
||||
}
|
||||
|
||||
export async function upsertVoteInDb(
|
||||
uuid: string,
|
||||
userName: string,
|
||||
movieTitle: string,
|
||||
rating: number,
|
||||
): Promise<void> {
|
||||
await pool.execute(
|
||||
"INSERT INTO votes (round_uuid, user_name, movie_title, rating) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE rating = ?",
|
||||
[uuid, userName, movieTitle, rating, rating],
|
||||
);
|
||||
}
|
||||
|
||||
export async function setUserDoneInDb(
|
||||
uuid: string,
|
||||
name: string,
|
||||
phase: 1 | 2,
|
||||
): Promise<void> {
|
||||
const col = phase === 1 ? "done_phase1" : "done_phase2";
|
||||
await pool.execute(
|
||||
`UPDATE round_users SET ${col} = 1 WHERE round_uuid = ? AND name = ?`,
|
||||
[uuid, name],
|
||||
);
|
||||
}
|
||||
|
||||
export async function resetDoneUsersInDb(uuid: string, phase: 1 | 2 | "both"): Promise<void> {
|
||||
if (phase === "both" || phase === 1) {
|
||||
await pool.execute("UPDATE round_users SET done_phase1 = 0 WHERE round_uuid = ?", [uuid]);
|
||||
}
|
||||
if (phase === "both" || phase === 2) {
|
||||
await pool.execute("UPDATE round_users SET done_phase2 = 0 WHERE round_uuid = ?", [uuid]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearMoviesAndVotesInDb(uuid: string): Promise<void> {
|
||||
await pool.execute("DELETE FROM movies WHERE round_uuid = ?", [uuid]);
|
||||
await pool.execute("DELETE FROM votes WHERE round_uuid = ?", [uuid]);
|
||||
}
|
||||
|
||||
export async function addHistoryEntryInDb(
|
||||
uuid: string,
|
||||
winner: string | null,
|
||||
movies: Movie[],
|
||||
): Promise<void> {
|
||||
await pool.execute(
|
||||
"INSERT INTO round_history (round_uuid, winner, movies_json) VALUES (?, ?, ?)",
|
||||
[uuid, winner, JSON.stringify(movies)],
|
||||
);
|
||||
}
|
||||
|
||||
export async function resetUserDoneForUser(uuid: string, name: string, phase: 1 | 2): Promise<void> {
|
||||
const col = phase === 1 ? "done_phase1" : "done_phase2";
|
||||
await pool.execute(
|
||||
`UPDATE round_users SET ${col} = 0 WHERE round_uuid = ? AND name = ?`,
|
||||
[uuid, name],
|
||||
);
|
||||
}
|
||||
128
src/server/index.ts
Normal file
128
src/server/index.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { statSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { handleAction, ApiError } from "./api.ts";
|
||||
|
||||
const PORT = Number(process.env["PORT"] ?? 3001);
|
||||
const DIST_DIR = join(import.meta.dir, "../../dist");
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".webmanifest": "application/manifest+json; charset=utf-8",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
|
||||
function fileVersion(rel: string): number {
|
||||
try {
|
||||
return statSync(join(DIST_DIR, rel)).mtimeMs;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function htmlShell(): string {
|
||||
const jsV = fileVersion("main.js");
|
||||
const cssV = fileVersion("styles.css");
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#f8fafc">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<link rel="manifest" href="/movies/manifest.webmanifest">
|
||||
<link rel="stylesheet" href="/movies/styles.css?v=${cssV}">
|
||||
<title>Movie Select</title>
|
||||
</head>
|
||||
<body class="min-h-dvh bg-slate-50 text-slate-900 font-sans antialiased">
|
||||
<main class="mx-auto max-w-2xl px-4 pt-6 pb-10 flex flex-col gap-5 safe-b" aria-live="polite">
|
||||
<header>
|
||||
<h1 class="text-2xl font-bold tracking-tight">Movie Select</h1>
|
||||
</header>
|
||||
<section class="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm" id="app"></section>
|
||||
</main>
|
||||
<script type="module" src="/movies/main.js?v=${jsV}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function serveStatic(path: string): Promise<Response | null> {
|
||||
const ext = path.substring(path.lastIndexOf("."));
|
||||
const contentType = MIME[ext];
|
||||
if (!contentType) return null;
|
||||
const file = Bun.file(join(DIST_DIR, path));
|
||||
if (!(await file.exists())) return null;
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function jsonOk(data: unknown): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
function jsonErr(status: number, message: string): Response {
|
||||
return new Response(JSON.stringify({ ok: false, error: message }), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
port: PORT,
|
||||
hostname: "::",
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
const pathname = url.pathname;
|
||||
const method = req.method;
|
||||
|
||||
// Static assets
|
||||
if (pathname === "/main.js" || pathname === "/styles.css" || pathname === "/icon.svg" || pathname === "/manifest.webmanifest") {
|
||||
const resp = await serveStatic(pathname.slice(1));
|
||||
if (resp) return resp;
|
||||
}
|
||||
|
||||
// API
|
||||
if (pathname === "/api") {
|
||||
const uuidParam = url.searchParams.get("uuid") ?? "";
|
||||
let action = url.searchParams.get("action") ?? "state";
|
||||
let body: Record<string, unknown> = {};
|
||||
|
||||
if (method === "POST") {
|
||||
try {
|
||||
const raw = await req.text();
|
||||
if (raw) body = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonErr(400, "Invalid JSON body");
|
||||
}
|
||||
action = String(body["action"] ?? action);
|
||||
} else if (method !== "GET") {
|
||||
return jsonErr(405, "Use POST for mutations");
|
||||
}
|
||||
|
||||
const uuid = uuidParam || String(body["uuid"] ?? "");
|
||||
try {
|
||||
const state = await handleAction(action, uuid, body);
|
||||
return jsonOk({ ok: true, state });
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) return jsonErr(e.status, e.message);
|
||||
console.error(e);
|
||||
return jsonErr(500, "Internal server error");
|
||||
}
|
||||
}
|
||||
|
||||
// SPA fallback — serve HTML shell for all other paths
|
||||
return new Response(htmlShell(), {
|
||||
headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Movie Select listening on port ${server.port}`);
|
||||
24
src/server/types.ts
Normal file
24
src/server/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface Movie {
|
||||
title: string;
|
||||
addedBy: string;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
movies: Movie[];
|
||||
winner: string | null;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
296
styles.css
Normal file
296
styles.css
Normal file
@@ -0,0 +1,296 @@
|
||||
:root {
|
||||
--bg: #f3f5f7;
|
||||
--card: #ffffff;
|
||||
--text: #101418;
|
||||
--muted: #4e5968;
|
||||
--line: #d4dbe3;
|
||||
--primary: #0a84ff;
|
||||
--chip: #eef2f7;
|
||||
--error: #b00020;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: linear-gradient(180deg, #f9fbfd 0%, var(--bg) 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 0.75rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.6rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card.inset {
|
||||
margin-top: 1rem;
|
||||
background: #fafcff;
|
||||
}
|
||||
|
||||
.progressCard {
|
||||
border-color: #9ec8ff;
|
||||
background: #f7fbff;
|
||||
}
|
||||
|
||||
.progressTrack {
|
||||
width: 100%;
|
||||
height: 0.85rem;
|
||||
background: #dce9f8;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 0.7rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
padding: 0;
|
||||
margin: 0.75rem 0 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.chips li {
|
||||
background: var(--chip);
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
button.chip {
|
||||
background: var(--chip);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button.chip.userMovie {
|
||||
background: #c7e9ff;
|
||||
border: 2px solid var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
button.chip.userMovie:hover {
|
||||
background: #9ed4ff;
|
||||
}
|
||||
|
||||
button.chip:hover:not(:disabled) {
|
||||
background: #dce9f8;
|
||||
}
|
||||
|
||||
button.chip:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.links {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.8rem 0 0;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.links li {
|
||||
display: block;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.shareName {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.shareControls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shareInput {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.copyLink {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.statusList li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 0.93rem;
|
||||
margin: 0.65rem 0 0;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.55rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.rating {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ratingRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ratingValue {
|
||||
min-width: 1.25rem;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.winner {
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 0.8rem;
|
||||
color: var(--error);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.app {
|
||||
max-width: 760px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.shareControls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
45
tests/algorithm.test.mjs
Normal file
45
tests/algorithm.test.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { decideMovie, paretoFilter } from "../algorithm.js";
|
||||
|
||||
function testParetoFilter() {
|
||||
const movies = ["A", "B"];
|
||||
const people = ["P1", "P2"];
|
||||
const ratings = {
|
||||
P1: { A: 1, B: 3 },
|
||||
P2: { A: 2, B: 4 },
|
||||
};
|
||||
const remaining = paretoFilter(movies, people, ratings);
|
||||
assert.deepEqual(remaining, ["B"]);
|
||||
}
|
||||
|
||||
function testNashProtectsAgainstHardNo() {
|
||||
const movies = ["Consensus", "Polarizing"];
|
||||
const people = ["A", "B", "C"];
|
||||
const ratings = {
|
||||
A: { Consensus: 3, Polarizing: 5 },
|
||||
B: { Consensus: 3, Polarizing: 5 },
|
||||
C: { Consensus: 3, Polarizing: 0 },
|
||||
};
|
||||
const result = decideMovie({ movies, people, ratings });
|
||||
assert.equal(result.winner.movie, "Consensus");
|
||||
}
|
||||
|
||||
function testTieBreaker() {
|
||||
const movies = ["Alpha", "Beta"];
|
||||
const people = ["A", "B"];
|
||||
const ratings = {
|
||||
A: { Alpha: 2, Beta: 2 },
|
||||
B: { Alpha: 2, Beta: 2 },
|
||||
};
|
||||
const result = decideMovie({ movies, people, ratings });
|
||||
assert.equal(result.winner.movie, "Alpha");
|
||||
}
|
||||
|
||||
function run() {
|
||||
testParetoFilter();
|
||||
testNashProtectsAgainstHardNo();
|
||||
testTieBreaker();
|
||||
console.log("All tests passed");
|
||||
}
|
||||
|
||||
run();
|
||||
29
tests/algorithm.test.ts
Normal file
29
tests/algorithm.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { decideMovie, paretoFilter } from "../src/client/algorithm.ts";
|
||||
|
||||
test("paretoFilter removes dominated movies", () => {
|
||||
const movies = ["A", "B"];
|
||||
const people = ["P1", "P2"];
|
||||
const ratings = { P1: { A: 1, B: 3 }, P2: { A: 2, B: 4 } };
|
||||
expect(paretoFilter(movies, people, ratings)).toEqual(["B"]);
|
||||
});
|
||||
|
||||
test("nash protects against hard no", () => {
|
||||
const movies = ["Consensus", "Polarizing"];
|
||||
const people = ["A", "B", "C"];
|
||||
const ratings = {
|
||||
A: { Consensus: 3, Polarizing: 5 },
|
||||
B: { Consensus: 3, Polarizing: 5 },
|
||||
C: { Consensus: 3, Polarizing: 0 },
|
||||
};
|
||||
const result = decideMovie({ movies, people, ratings });
|
||||
expect(result.winner.movie).toBe("Consensus");
|
||||
});
|
||||
|
||||
test("tie-breaker uses alphabetical order", () => {
|
||||
const movies = ["Alpha", "Beta"];
|
||||
const people = ["A", "B"];
|
||||
const ratings = { A: { Alpha: 2, Beta: 2 }, B: { Alpha: 2, Beta: 2 } };
|
||||
const result = decideMovie({ movies, people, ratings });
|
||||
expect(result.winner.movie).toBe("Alpha");
|
||||
});
|
||||
34
tests/round-state.test.mjs
Normal file
34
tests/round-state.test.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { allUsersDone, hasCompleteRatings, collectCompleteRatings } from "../round-state.js";
|
||||
|
||||
function testAllUsersDone() {
|
||||
assert.equal(allUsersDone(["A", "B"], ["A", "B"]), true);
|
||||
assert.equal(allUsersDone(["A", "B"], ["A"]), false);
|
||||
assert.equal(allUsersDone([], []), false);
|
||||
}
|
||||
|
||||
function testCompleteRatings() {
|
||||
assert.equal(hasCompleteRatings(["M1", "M2"], { M1: 2, M2: 5 }), true);
|
||||
assert.equal(hasCompleteRatings(["M1", "M2"], { M1: 2 }), false);
|
||||
}
|
||||
|
||||
function testCollectCompleteRatings() {
|
||||
const users = ["A", "B"];
|
||||
const movieTitles = ["M1", "M2"];
|
||||
const votes = {
|
||||
A: { M1: 1, M2: 2 },
|
||||
B: { M1: 3 },
|
||||
};
|
||||
assert.deepEqual(collectCompleteRatings(users, movieTitles, votes), {
|
||||
A: { M1: 1, M2: 2 },
|
||||
});
|
||||
}
|
||||
|
||||
function run() {
|
||||
testAllUsersDone();
|
||||
testCompleteRatings();
|
||||
testCollectCompleteRatings();
|
||||
console.log("Round state tests passed");
|
||||
}
|
||||
|
||||
run();
|
||||
22
tests/round-state.test.ts
Normal file
22
tests/round-state.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { allUsersDone, hasCompleteRatings, collectCompleteRatings } from "../src/client/round-state.ts";
|
||||
|
||||
test("allUsersDone returns true when all done", () => {
|
||||
expect(allUsersDone(["A", "B"], ["A", "B"])).toBe(true);
|
||||
expect(allUsersDone(["A", "B"], ["A"])).toBe(false);
|
||||
expect(allUsersDone([], [])).toBe(false);
|
||||
});
|
||||
|
||||
test("hasCompleteRatings detects missing ratings", () => {
|
||||
expect(hasCompleteRatings(["M1", "M2"], { M1: 2, M2: 5 })).toBe(true);
|
||||
expect(hasCompleteRatings(["M1", "M2"], { M1: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
test("collectCompleteRatings filters incomplete voters", () => {
|
||||
const result = collectCompleteRatings(
|
||||
["A", "B"],
|
||||
["M1", "M2"],
|
||||
{ A: { M1: 1, M2: 2 }, B: { M1: 3 } },
|
||||
);
|
||||
expect(result).toEqual({ A: { M1: 1, M2: 2 } });
|
||||
});
|
||||
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*", "scripts/**/*", "tests/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user