refactor to react/vite/hono/drizzle/postgresql stack

replace vanilla TS + Bun.serve() + MariaDB with:
- frontend: React 19, Vite 6, TanStack Router/Query, Tailwind v4
- backend: Hono + @hono/node-server, Drizzle ORM, PostgreSQL, Zod
- shared: algorithm, round-state, types in src/shared/
- tooling: Biome (lint/format), Vitest (tests)
- deploy: static files at /movie-select/, API at /movie-select/api

URL scheme changes from /movies/ to /movie-select/,
API from action-based to RESTful endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 15:03:35 +01:00
parent 1e65d8930d
commit 265cd329f6
58 changed files with 2573 additions and 2932 deletions

View File

@@ -1,6 +1,2 @@
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=serve
DB_PASS=yourpassword
DB_NAME=serve
DATABASE_URL=postgresql://serve:yourpassword@localhost:5432/movie_select
PORT=3001

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ dist/
.env
.DS_Store
data/
drizzle/meta/

View File

@@ -1,10 +1,10 @@
RewriteEngine On
RewriteBase /movies/
DirectoryIndex index.php
RewriteBase /movie-select/
# If the request is for an existing file or directory, serve it
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
RewriteRule ^api/?$ api.php [L,QSA]
RewriteRule ^ index.php [L,QSA]
# Otherwise, serve index.html (SPA fallback)
RewriteRule ^ index.html [L]

View File

@@ -1,67 +0,0 @@
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
View File

@@ -1,389 +0,0 @@
<?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
View File

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

25
biome.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
"files": {
"includes": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"]
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"formatter": {
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noNonNullAssertion": "off"
}
}
}
}

507
bun.lock
View File

@@ -4,15 +4,152 @@
"": {
"name": "movie-select",
"dependencies": {
"mysql2": "^3.11.3",
"@hono/node-server": "^1.13.0",
"@hono/zod-validator": "^0.7.6",
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.92.0",
"clsx": "^2.1.1",
"drizzle-orm": "^0.38.0",
"hono": "^4.6.0",
"postgres": "^3.4.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.5.0",
"zod": "^3.24.0",
},
"devDependencies": {
"@tailwindcss/cli": "^4.0.0",
"@types/bun": "latest",
"@biomejs/biome": "^2.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"drizzle-kit": "^0.30.0",
"tailwindcss": "^4.0.0",
"vite": "^6.0.0",
"vitest": "^3.0.0",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@biomejs/biome": ["@biomejs/biome@2.4.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.5", "@biomejs/cli-darwin-x64": "2.4.5", "@biomejs/cli-linux-arm64": "2.4.5", "@biomejs/cli-linux-arm64-musl": "2.4.5", "@biomejs/cli-linux-x64": "2.4.5", "@biomejs/cli-linux-x64-musl": "2.4.5", "@biomejs/cli-win32-arm64": "2.4.5", "@biomejs/cli-win32-x64": "2.4.5" }, "bin": { "biome": "bin/biome" } }, "sha512-OWNCyMS0Q011R6YifXNOg6qsOg64IVc7XX6SqGsrGszPbkVCoaO7Sr/lISFnXZ9hjQhDewwZ40789QmrG0GYgQ=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lGS4Nd5O3KQJ6TeWv10mElnx1phERhBxqGP/IKq0SvZl78kcWDFMaTtVK+w3v3lusRFxJY78n07PbKplirsU5g=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-6MoH4tyISIBNkZ2Q5T1R7dLd5BsITb2yhhhrU9jHZxnNSNMWl+s2Mxu7NBF8Y3a7JJcqq9nsk8i637z4gqkJxQ=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-U1GAG6FTjhAO04MyH4xn23wRNBkT6H7NentHh+8UxD6ShXKBm5SY4RedKJzkUThANxb9rUKIPc7B8ew9Xo/cWg=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-iqLDgpzobG7gpBF0fwEVS/LT8kmN7+S0E2YKFDtqliJfzNLnAiV2Nnyb+ehCDCJgAZBASkYHR2o60VQWikpqIg=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-NdODlSugMzTlENPTa4z0xB82dTUlCpsrOxc43///aNkTLblIYH4XpYflBbf5ySlQuP8AA4AZd1qXhV07IdrHdQ=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-NlKa7GpbQmNhZf9kakQeddqZyT7itN7jjWdakELeXyTU3pg/83fTysRRDPJD0akTfKDl6vZYNT9Zqn4MYZVBOA=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-EBfrTqRIWOFSd7CQb/0ttjHMR88zm3hGravnDwUA9wHAaCAYsULKDebWcN5RmrEo1KBtl/gDVJMrFjNR0pdGUw=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Pmhv9zT95YzECfjEHNl3mN9Vhusw9VA5KHY0ZvlGsxsjwS5cb7vpRnHzJIv0vG7jB0JI7xEaMH9ddfZm/RozBw=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="],
"@hono/node-server": ["@hono/node-server@1.19.10", "", { "peerDependencies": { "hono": "^4" } }, "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw=="],
"@hono/zod-validator": ["@hono/zod-validator@0.7.6", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="],
"@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=="],
@@ -23,35 +160,59 @@
"@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=="],
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
"@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
"@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
"@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
"@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
"@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
"@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
"@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
"@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
"@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
"@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
"@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=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
"@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=="],
@@ -81,34 +242,136 @@
"@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=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="],
"@types/node": ["@types/node@25.3.1", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw=="],
"@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="],
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"@tanstack/react-router": ["@tanstack/react-router@1.163.3", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.163.3", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-hheBbFVb+PbxtrWp8iy6+TTRTbhx3Pn6hKo8Tv/sWlG89ZMcD1xpQWzx8ukHN9K8YWbh5rdzt4kv6u8X4kB28Q=="],
"@tanstack/react-store": ["@tanstack/react-store@0.9.1", "", { "dependencies": { "@tanstack/store": "0.9.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA=="],
"@tanstack/router-core": ["@tanstack/router-core@1.163.3", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA=="],
"@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@22.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
"@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="],
"@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="],
"@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001776", "", {}, "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="],
"drizzle-orm": ["drizzle-orm@0.38.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/react", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "react", "sql.js", "sqlite3"] }, "sha512-s7/5BpLKO+WJRHspvpqTydxFob8i1vo2rEx4pY6TGY7QSMuUfWUuzaY0DIpXCkgHOo37BaFC+SJQb99dDUXT3Q=="],
"electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="],
"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=="],
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gel": ["gel@2.2.0", "", { "dependencies": { "@petamoriken/float16": "^3.8.7", "debug": "^4.3.4", "env-paths": "^3.0.0", "semver": "^7.6.2", "shell-quote": "^1.8.1", "which": "^4.0.0" }, "bin": { "gel": "dist/cli.mjs" } }, "sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
"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=="],
"hono": ["hono@4.12.4", "", {}, "sha512-ooiZW1Xy8rQ4oELQ++otI2T9DsKpV0M6c6cO6JGx4RTfav9poFFLlet9UMXHZnoM1yG0HWGlQLswBGX3RZmHtg=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="],
"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=="],
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"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=="],
@@ -133,35 +396,107 @@
"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=="],
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
"lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"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=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"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=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
"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=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
"seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"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=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
"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=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
"tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
"vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@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=="],
@@ -174,5 +509,101 @@
"@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=="],
"gel/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
}
}

View File

@@ -2,43 +2,46 @@
set -euo pipefail
REMOTE_HOST="serve"
REMOTE_APP_DIR="/home/serve/services/movies"
REMOTE_PORT=3001
REMOTE_APP_DIR="/home/serve/services/movie-select"
REMOTE_STATIC_DIR="/var/www/virtual/serve/html/movie-select"
echo "==> Installing local dependencies..."
bun install
echo "==> Building client bundle + CSS + assets..."
echo "==> Building client (Vite)..."
bun run build
echo "==> Syncing to ${REMOTE_HOST}:${REMOTE_APP_DIR} ..."
echo "==> Syncing static files to ${REMOTE_HOST}:${REMOTE_STATIC_DIR} ..."
rsync -avz --delete \
--exclude='.DS_Store' \
dist/client/ "${REMOTE_HOST}:${REMOTE_STATIC_DIR}/"
echo "==> Copying .htaccess for SPA fallback..."
scp .htaccess "${REMOTE_HOST}:${REMOTE_STATIC_DIR}/.htaccess"
echo "==> Syncing server code 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='dist/' \
--exclude='tests/' \
--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' \
--exclude='drizzle/' \
--exclude='node_modules/' \
--exclude='.htaccess' \
--exclude='AI_AGENT_REPORT.md' \
--exclude='*.test.ts' \
--exclude='vite.config.ts' \
--exclude='drizzle.config.ts' \
--exclude='biome.json' \
./ "${REMOTE_HOST}:${REMOTE_APP_DIR}/"
echo "==> Installing server dependencies on remote..."
ssh "${REMOTE_HOST}" "cd ${REMOTE_APP_DIR} && bun install --production"
echo "==> Installing production dependencies on remote..."
ssh "${REMOTE_HOST}" "cd ${REMOTE_APP_DIR} && npm install --omit=dev"
echo "==> Restarting service..."
ssh "${REMOTE_HOST}" "systemctl --user restart movie-select 2>/dev/null || true"
echo "Done. Live at https://serve.uber.space/movies/"
echo "Done. Live at https://serve.uber.space/movie-select/"

10
drizzle.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/server/shared/db/schema/index.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View File

@@ -1,57 +1,17 @@
<!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">
<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="/movie-select/manifest.webmanifest" />
<link rel="icon" href="/movie-select/icon.svg" type="image/svg+xml" />
<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>
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

View File

@@ -1,23 +0,0 @@
<?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>

View File

@@ -1,2 +1,3 @@
[tools]
bun = "1.3.0"
node = "22"

View File

@@ -1,21 +1,45 @@
{
"name": "movie-select",
"version": "2026.02.26",
"version": "2026.03.03",
"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"
"dev": "vite",
"dev:server": "node --watch --env-file=.env src/server/index.ts",
"build": "vite build",
"build:server": "true",
"test": "vitest run",
"test:watch": "vitest",
"lint": "biome check .",
"lint:fix": "biome check . --write",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"mysql2": "^3.11.3"
"@hono/node-server": "^1.13.0",
"@hono/zod-validator": "^0.7.6",
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.92.0",
"clsx": "^2.1.1",
"drizzle-orm": "^0.38.0",
"hono": "^4.6.0",
"postgres": "^3.4.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.5.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@tailwindcss/cli": "^4.0.0",
"@types/bun": "latest"
"@biomejs/biome": "^2.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"drizzle-kit": "^0.30.0",
"tailwindcss": "^4.0.0",
"vite": "^6.0.0",
"vitest": "^3.0.0"
}
}

View File

Before

Width:  |  Height:  |  Size: 332 B

After

Width:  |  Height:  |  Size: 332 B

View File

@@ -1,14 +1,14 @@
{
"name": "Movie Select",
"short_name": "MovieSelect",
"start_url": "/movies/",
"scope": "/movies/",
"start_url": "/movie-select/",
"scope": "/movie-select/",
"display": "standalone",
"background_color": "#f8fafc",
"theme_color": "#0b0f14",
"icons": [
{
"src": "/movies/icon.svg",
"src": "/movie-select/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"

View File

@@ -1,35 +0,0 @@
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;
}

View File

@@ -1,6 +0,0 @@
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.");

104
scripts/migrate-data.ts Normal file
View File

@@ -0,0 +1,104 @@
/**
* One-time migration: MariaDB → PostgreSQL
*
* Reads all data from the existing MariaDB database and inserts into PostgreSQL.
* Requires both DATABASE_URL (postgres) and MARIA_* env vars.
*
* Usage: node --env-file=.env scripts/migrate-data.ts
*/
import mysql from "mysql2/promise";
import postgres from "postgres";
const MARIA_HOST = process.env.MARIA_HOST ?? "127.0.0.1";
const MARIA_PORT = Number(process.env.MARIA_PORT ?? 3306);
const MARIA_USER = process.env.MARIA_USER ?? "serve";
const MARIA_PASS = process.env.MARIA_PASS ?? "";
const MARIA_DB = process.env.MARIA_DB ?? "serve";
const PG_URL = process.env.DATABASE_URL;
if (!PG_URL) {
console.error("DATABASE_URL is required");
process.exit(1);
}
const maria = await mysql.createConnection({
host: MARIA_HOST,
port: MARIA_PORT,
user: MARIA_USER,
password: MARIA_PASS,
database: MARIA_DB,
timezone: "+00:00",
});
const pg = postgres(PG_URL);
// Order: rounds → round_users → movies → votes → round_history (FK constraint order)
console.log("Migrating rounds...");
const [roundRows] = await maria.execute("SELECT * FROM rounds");
for (const r of roundRows as mysql.RowDataPacket[]) {
await pg`
INSERT INTO rounds (uuid, phase, setup_done, created_at, updated_at)
VALUES (${r.uuid}, ${r.phase}, ${Boolean(r.setup_done)}, ${r.created_at}, ${r.updated_at})
ON CONFLICT (uuid) DO NOTHING
`;
}
console.log(` ${(roundRows as mysql.RowDataPacket[]).length} rounds`);
console.log("Migrating round_users...");
const [userRows] = await maria.execute("SELECT * FROM round_users");
for (const u of userRows as mysql.RowDataPacket[]) {
await pg`
INSERT INTO round_users (round_uuid, name, done_phase1, done_phase2, sort_order)
VALUES (${u.round_uuid}, ${u.name}, ${Boolean(u.done_phase1)}, ${Boolean(u.done_phase2)}, ${u.sort_order})
ON CONFLICT (round_uuid, name) DO NOTHING
`;
}
console.log(` ${(userRows as mysql.RowDataPacket[]).length} users`);
console.log("Migrating movies...");
const [movieRows] = await maria.execute("SELECT * FROM movies ORDER BY id");
for (const m of movieRows as mysql.RowDataPacket[]) {
await pg`
INSERT INTO movies (id, round_uuid, title, added_by, added_at)
VALUES (${m.id}, ${m.round_uuid}, ${m.title}, ${m.added_by}, ${m.added_at})
ON CONFLICT DO NOTHING
`;
}
console.log(` ${(movieRows as mysql.RowDataPacket[]).length} movies`);
// Reset the serial sequence for movies.id
await pg`SELECT setval('movies_id_seq', COALESCE((SELECT MAX(id) FROM movies), 0) + 1)`;
console.log("Migrating votes...");
const [voteRows] = await maria.execute("SELECT * FROM votes");
for (const v of voteRows as mysql.RowDataPacket[]) {
await pg`
INSERT INTO votes (round_uuid, user_name, movie_title, rating)
VALUES (${v.round_uuid}, ${v.user_name}, ${v.movie_title}, ${v.rating})
ON CONFLICT (round_uuid, user_name, movie_title) DO NOTHING
`;
}
console.log(` ${(voteRows as mysql.RowDataPacket[]).length} votes`);
console.log("Migrating round_history...");
const [historyRows] = await maria.execute(
"SELECT * FROM round_history ORDER BY id",
);
for (const h of historyRows as mysql.RowDataPacket[]) {
await pg`
INSERT INTO round_history (id, round_uuid, winner, movies_json, created_at)
VALUES (${h.id}, ${h.round_uuid}, ${h.winner}, ${h.movies_json}, ${h.created_at})
ON CONFLICT DO NOTHING
`;
}
console.log(
` ${(historyRows as mysql.RowDataPacket[]).length} history entries`,
);
// Reset the serial sequence for round_history.id
await pg`SELECT setval('round_history_id_seq', COALESCE((SELECT MAX(id) FROM round_history), 0) + 1)`;
console.log("Migration complete!");
await maria.end();
await pg.end();

View File

@@ -1,46 +1,48 @@
#!/usr/bin/env bash
# Run once to set up the server.
# Run once to set up the movie-select service on Uberspace 8.
# Usage: bash setup-server.sh
set -euo pipefail
REMOTE_HOST="serve"
REMOTE_APP_DIR="/home/serve/services/movies"
REMOTE_APP_DIR="/home/serve/services/movie-select"
REMOTE_STATIC_DIR="/var/www/virtual/serve/html/movie-select"
REMOTE_PORT=3001
DB_PASS="09a7d97c99aff8fd883d3bbe3da927e254ac507b"
DB_NAME="movie_select"
echo "==> Creating app directory on server..."
ssh "${REMOTE_HOST}" "mkdir -p ${REMOTE_APP_DIR}"
echo "==> Creating app + static directories on server..."
ssh "${REMOTE_HOST}" "mkdir -p ${REMOTE_APP_DIR} ${REMOTE_STATIC_DIR}"
echo "==> Creating PostgreSQL database (if not exists)..."
ssh "${REMOTE_HOST}" "createdb ${DB_NAME} 2>/dev/null || echo 'Database already exists'"
echo "==> Fetching PostgreSQL credentials..."
DB_PASS=$(ssh "${REMOTE_HOST}" "my_print_defaults client 2>/dev/null | grep -oP '(?<=--password=).*' || echo ''")
DB_USER="serve"
DB_HOST="localhost"
DB_PORT="5432"
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
DATABASE_URL=postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}
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 "==> Detecting node path on server..."
NODE_PATH=$(ssh "${REMOTE_HOST}" 'which node || echo "$HOME/.local/bin/node"')
echo " node at: ${NODE_PATH}"
echo "==> Creating systemd service..."
ssh "${REMOTE_HOST}" BUN_PATH="${BUN_PATH}" REMOTE_APP_DIR="${REMOTE_APP_DIR}" bash <<'ENDSSH'
ssh "${REMOTE_HOST}" NODE_PATH="${NODE_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
ExecStart=${NODE_PATH} --env-file=.env src/server/index.ts
WorkingDirectory=${REMOTE_APP_DIR}
Restart=on-failure
RestartSec=3
@@ -50,11 +52,11 @@ 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 "==> Configuring web backend for API..."
ssh "${REMOTE_HOST}" "uberspace web backend add /movie-select/api port ${REMOTE_PORT} --remove-prefix 2>/dev/null || uberspace web backend set /movie-select/api 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/"
echo "Done. Live at https://serve.uber.space/movie-select/"

View File

@@ -0,0 +1,82 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { State } from "@/shared/types.ts";
import { api } from "../lib/api-client.ts";
function useRoundMutation(uuid: string, mutationFn: () => Promise<State>) {
const qc = useQueryClient();
return useMutation({
mutationFn,
onSuccess: (state) => {
qc.setQueryData(["round", uuid], state);
},
});
}
export function useAddUser(uuid: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (user: string) => api.addUser(uuid, user),
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
});
}
export function useFinishSetup(uuid: string) {
return useRoundMutation(uuid, () => api.finishSetup(uuid));
}
export function useAddMovie(uuid: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ user, title }: { user: string; title: string }) =>
api.addMovie(uuid, user, title),
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
});
}
export function useRemoveMovie(uuid: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ user, title }: { user: string; title: string }) =>
api.removeMovie(uuid, user, title),
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
});
}
export function useVote(uuid: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
user,
ratings,
}: {
user: string;
ratings: Record<string, number>;
}) => api.vote(uuid, user, ratings),
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
});
}
export function useMarkDone(uuid: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ user, phase }: { user: string; phase: 1 | 2 }) =>
api.markDone(uuid, user, phase),
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
});
}
export function useSetPhase(uuid: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (phase: 1 | 2 | 3) => api.setPhase(uuid, phase),
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
});
}
export function useNewRound(uuid: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (winner: string) => api.newRound(uuid, winner),
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
});
}

View File

@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api-client.ts";
export function useRound(uuid: string) {
return useQuery({
queryKey: ["round", uuid],
queryFn: () => api.getState(uuid),
enabled: uuid.length > 0,
refetchInterval: 5000,
});
}

53
src/client/index.css Normal file
View File

@@ -0,0 +1,53 @@
@import "tailwindcss";
@theme {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
}
@layer base {
html {
-webkit-tap-highlight-color: transparent;
}
button,
input,
select,
textarea {
font: inherit;
}
}
/* 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);
}
/* Safe-area bottom padding for notched phones */
.safe-b {
padding-bottom: max(2.5rem, env(safe-area-inset-bottom));
}

View File

@@ -1,161 +0,0 @@
@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);
}

View File

@@ -0,0 +1,43 @@
import type { State } from "@/shared/types.ts";
const BASE = "/movie-select/api/rounds";
interface ApiResponse {
ok: boolean;
state: State;
error?: string;
}
async function request(
method: string,
path: string,
body?: unknown,
): Promise<State> {
const res = await fetch(`${BASE}${path}`, {
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const data = (await res.json()) as ApiResponse;
if (!res.ok || !data.ok) throw new Error(data.error ?? "Request failed");
return data.state;
}
export const api = {
getState: (uuid: string) => request("GET", `/${uuid}`),
addUser: (uuid: string, user: string) =>
request("POST", `/${uuid}/users`, { user }),
finishSetup: (uuid: string) => request("POST", `/${uuid}/setup`),
addMovie: (uuid: string, user: string, title: string) =>
request("POST", `/${uuid}/movies`, { user, title }),
removeMovie: (uuid: string, user: string, title: string) =>
request("DELETE", `/${uuid}/movies`, { user, title }),
vote: (uuid: string, user: string, ratings: Record<string, number>) =>
request("POST", `/${uuid}/votes`, { user, ratings }),
markDone: (uuid: string, user: string, phase: 1 | 2) =>
request("POST", `/${uuid}/done`, { user, phase }),
setPhase: (uuid: string, phase: 1 | 2 | 3) =>
request("POST", `/${uuid}/phase`, { phase }),
newRound: (uuid: string, winner: string) =>
request("POST", `/${uuid}/new-round`, { winner }),
};

6
src/client/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

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

10
src/client/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./router.tsx";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

24
src/client/router.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { routeTree } from "./routes/__root.tsx";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 2000,
},
},
});
const router = createRouter({
routeTree,
basepath: "/movie-select",
});
export function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,276 @@
import { useParams } from "@tanstack/react-router";
import { useRef, useState } from "react";
import { decideMovie } from "@/shared/algorithm.ts";
import { collectCompleteRatings } from "@/shared/round-state.ts";
import type { State } from "@/shared/types.ts";
import { useRound } from "../../hooks/use-round.ts";
import {
useAddUser,
useFinishSetup,
useNewRound,
} from "../../hooks/use-round-mutation.ts";
import { ResultSection } from "./route.tsx";
export function AdminRoute() {
const { uuid } = useParams({ strict: false }) as { uuid: string };
const { data: state, refetch } = useRound(uuid);
if (!state) {
return <p className="mt-2 text-sm text-slate-500">Loading</p>;
}
if (!state.setupDone) {
return <AdminSetup uuid={uuid} state={state} />;
}
return <AdminStatus uuid={uuid} state={state} onRefresh={refetch} />;
}
function AdminSetup({ uuid, state }: { uuid: string; state: State }) {
const [draft, setDraft] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const addUser = useAddUser(uuid);
const finishSetup = useFinishSetup(uuid);
async function handleAdd() {
const name = draft.trim();
if (!name) return;
await addUser.mutateAsync(name);
setDraft("");
inputRef.current?.focus();
}
return (
<>
<h2 className="m-0 text-xl font-bold tracking-tight text-slate-900">
Add People
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-500">
Add everyone who'll vote, then tap Next.
</p>
<div className="mt-4 flex gap-2">
<input
ref={inputRef}
type="text"
maxLength={30}
placeholder="Name"
autoComplete="off"
spellCheck={false}
aria-label="Person name"
className="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 focus:border-blue-400 focus:shadow-[0_0_0_3px_rgba(96,165,250,0.2)] flex-1"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAdd();
}
}}
/>
<button
type="button"
onClick={handleAdd}
disabled={addUser.isPending}
className="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] disabled:opacity-40"
>
Add
</button>
</div>
<ul className="mt-3 flex flex-wrap gap-2 list-none p-0 m-0">
{state.users.map((name) => (
<li
key={name}
className="inline-flex items-center rounded-full bg-slate-100 px-3 py-1.5 text-[13px] font-medium text-slate-700"
>
{name}
</li>
))}
</ul>
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
<button
type="button"
onClick={() => finishSetup.mutate()}
disabled={state.users.length === 0 || finishSetup.isPending}
className="inline-flex items-center justify-center min-h-[44px] px-5 py-2.5 rounded-xl border border-blue-500 bg-blue-500 text-[15px] font-medium text-white whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97] disabled:opacity-40"
>
Next
</button>
</div>
{(addUser.error || finishSetup.error) && (
<p className="mt-3 text-sm font-medium text-red-500">
{(addUser.error ?? finishSetup.error)?.message}
</p>
)}
</>
);
}
function AdminStatus({
uuid,
state,
onRefresh,
}: {
uuid: string;
state: State;
onRefresh: () => void;
}) {
const newRound = useNewRound(uuid);
const d1 = state.doneUsersPhase1 ?? [];
const d2 = state.doneUsersPhase2 ?? [];
const totalSteps = state.users.length * 2;
const completedSteps = d1.length + d2.length;
const phaseTitle =
state.phase === 1
? "Phase 1 · Movie Collection"
: state.phase === 2
? "Phase 2 · Voting"
: "Phase 3 · Results";
function computeWinner(): string {
const movieTitles = state.movies.map((m) => m.title);
if (movieTitles.length === 0) return "";
const ratings = collectCompleteRatings(
state.users,
movieTitles,
state.votes ?? {},
);
const voters = Object.keys(ratings);
if (voters.length === 0) return "";
return decideMovie({
movies: movieTitles,
people: voters,
ratings,
}).winner.movie;
}
function handleCopy(link: string, btn: HTMLButtonElement) {
navigator.clipboard.writeText(link).then(() => {
btn.textContent = "✓ Copied";
setTimeout(() => {
btn.textContent = "Copy";
}, 1400);
});
}
return (
<>
<h2 className="m-0 text-xl font-bold tracking-tight text-slate-900">
Admin
</h2>
<ProgressCard
title={phaseTitle}
done={completedSteps}
total={totalSteps}
/>
<p className="m-0 mt-5 mb-2 text-[13px] font-semibold uppercase tracking-wider text-slate-400">
Invite Links
</p>
<ul className="mt-3 flex flex-col gap-2 list-none p-0 m-0">
{state.users.map((name) => {
const link = `${window.location.origin}/movie-select/${uuid}?user=${encodeURIComponent(name)}`;
const steps =
(d1.includes(name) ? 1 : 0) + (d2.includes(name) ? 1 : 0);
return (
<li
key={name}
className="rounded-2xl border border-slate-200 bg-white p-3.5"
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold">{name}</span>
<StatusBadge steps={steps} />
</div>
<div className="flex gap-2 items-center">
<input
className="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 flex-1 min-w-0"
type="text"
readOnly
value={link}
aria-label={`Invite link for ${name}`}
/>
<button
type="button"
className="inline-flex items-center justify-center min-h-[36px] px-4 py-1.5 rounded-xl border border-slate-200 bg-white text-sm font-medium text-slate-800 whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97] shrink-0"
onClick={(e) => handleCopy(link, e.currentTarget)}
aria-label={`Copy link for ${name}`}
>
Copy
</button>
</div>
</li>
);
})}
</ul>
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
<button
type="button"
onClick={onRefresh}
className="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]"
>
Refresh
</button>
</div>
{state.phase === 3 && (
<>
<ResultSection state={state} title="Round Result" />
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
<button
type="button"
onClick={() => newRound.mutate(computeWinner())}
disabled={newRound.isPending}
className="inline-flex items-center justify-center min-h-[44px] px-5 py-2.5 rounded-xl border border-blue-500 bg-blue-500 text-[15px] font-medium text-white whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97] disabled:opacity-40"
>
New Round
</button>
</div>
</>
)}
</>
);
}
function StatusBadge({ steps }: { steps: number }) {
if (steps === 2)
return (
<span className="ml-1.5 rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-700">
Done
</span>
);
if (steps === 1)
return (
<span className="ml-1.5 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700">
1/2
</span>
);
return (
<span className="ml-1.5 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-500">
0/2
</span>
);
}
function ProgressCard({
title,
done,
total,
}: {
title: string;
done: number;
total: number;
}) {
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
return (
<div className="mt-3 rounded-2xl border border-blue-200 bg-blue-50 p-4">
<p className="m-0 text-sm font-semibold text-blue-800">{title}</p>
<div className="mt-2 flex items-center gap-3">
<div className="flex-1 h-2 rounded-full bg-blue-100 overflow-hidden">
<div
className="h-full rounded-full bg-blue-500 transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-sm font-semibold text-blue-700 shrink-0">
{done}/{total}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,572 @@
import { useParams, useSearch } from "@tanstack/react-router";
import { useRef, useState } from "react";
import { decideMovie } from "@/shared/algorithm.ts";
import { collectCompleteRatings } from "@/shared/round-state.ts";
import type { State } from "@/shared/types.ts";
import { useRound } from "../../hooks/use-round.ts";
import {
useAddMovie,
useMarkDone,
useRemoveMovie,
useVote,
} from "../../hooks/use-round-mutation.ts";
export function UserRoute() {
const { uuid } = useParams({ strict: false }) as { uuid: string };
const search = useSearch({ strict: false }) as Record<string, string>;
const userName = (search.user ?? "").trim();
const { data: state, refetch } = useRound(uuid);
if (!state) {
return <p className="mt-2 text-sm text-slate-500">Loading</p>;
}
if (!state.setupDone) {
return (
<>
<p className="mt-2 text-sm text-slate-500">
Admin is still setting up. Open your link when ready.
</p>
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
<RefreshButton onRefresh={refetch} />
</div>
</>
);
}
if (!userName) {
return <AskUserName />;
}
if (state.users.length > 0 && !state.users.includes(userName)) {
return (
<p className="mt-3 text-sm font-medium text-red-500">
Unknown user use your invite link.
</p>
);
}
if (state.phase === 1) {
const isDone = state.doneUsersPhase1?.includes(userName);
if (isDone) {
return <WaitingScreen state={state} phase={1} onRefresh={refetch} />;
}
return (
<MoviePhase
uuid={uuid}
user={userName}
state={state}
onRefresh={refetch}
/>
);
}
if (state.phase === 2) {
const isDone = state.doneUsersPhase2?.includes(userName);
if (isDone) {
return <WaitingScreen state={state} phase={2} onRefresh={refetch} />;
}
return (
<VotingPhase
uuid={uuid}
user={userName}
state={state}
onRefresh={refetch}
/>
);
}
return <FinalPage state={state} onRefresh={refetch} />;
}
function AskUserName() {
const inputRef = useRef<HTMLInputElement>(null);
function handleSubmit() {
const v = inputRef.current?.value.trim();
if (!v) return;
const params = new URLSearchParams(window.location.search);
params.set("user", v);
window.location.search = params.toString();
}
return (
<>
<h2 className="m-0 text-xl font-bold tracking-tight text-slate-900">
Who are you?
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-500">
Enter the name from your invite link.
</p>
<div className="mt-4 flex gap-2">
<input
ref={inputRef}
type="text"
maxLength={30}
placeholder="Your name"
aria-label="Your name"
className="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 focus:border-blue-400 focus:shadow-[0_0_0_3px_rgba(96,165,250,0.2)] flex-1"
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
}}
/>
<button
type="button"
onClick={handleSubmit}
className="inline-flex items-center justify-center min-h-[44px] px-5 py-2.5 rounded-xl border border-blue-500 bg-blue-500 text-[15px] font-medium text-white whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97]"
>
Go
</button>
</div>
</>
);
}
function WaitingScreen({
state,
phase,
onRefresh,
}: {
state: State;
phase: 1 | 2;
onRefresh: () => void;
}) {
const d1 = state.doneUsersPhase1?.length ?? 0;
const d2 = state.doneUsersPhase2?.length ?? 0;
const total = state.users.length * 2;
const done = d1 + d2;
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…";
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
return (
<>
<h2 className="m-0 text-xl font-bold tracking-tight text-slate-900">
Waiting
</h2>
<div className="mt-3 rounded-2xl border border-blue-200 bg-blue-50 p-4">
<p className="m-0 text-sm font-semibold text-blue-800">{phaseTitle}</p>
<div className="mt-2 flex items-center gap-3">
<div className="flex-1 h-2 rounded-full bg-blue-100 overflow-hidden">
<div
className="h-full rounded-full bg-blue-500 transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-sm font-semibold text-blue-700 shrink-0">
{done}/{total}
</span>
</div>
</div>
<p className="mt-3 text-sm leading-relaxed text-slate-500">{waitMsg}</p>
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
<RefreshButton onRefresh={onRefresh} />
</div>
</>
);
}
function MoviePhase({
uuid,
user,
state,
onRefresh,
}: {
uuid: string;
user: string;
state: State;
onRefresh: () => void;
}) {
const [movieDraft, setMovieDraft] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const addMovie = useAddMovie(uuid);
const removeMovie = useRemoveMovie(uuid);
const markDone = useMarkDone(uuid);
const myMovies = state.movies.filter(
(m) => m.addedBy.toLowerCase() === user.toLowerCase(),
);
const remaining = Math.max(0, 5 - myMovies.length);
async function handleAdd(title?: string) {
const t = (title ?? movieDraft).trim();
if (!t) return;
await addMovie.mutateAsync({ user, title: t });
setMovieDraft("");
inputRef.current?.focus();
}
async function handleRemove(title: string) {
await removeMovie.mutateAsync({ user, title });
}
const historyItems = getHistoryItems(state);
return (
<>
<h2 className="m-0 text-xl font-bold tracking-tight text-slate-900">
Add Movies
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-500">
Phase 1 of 3 · Up to 5 movies each, then tap Done.
</p>
<p className="mt-2 text-sm leading-relaxed text-slate-500">
<strong>{remaining}</strong> slot{remaining === 1 ? "" : "s"} left
</p>
<div className="mt-4 flex gap-2">
<input
ref={inputRef}
type="text"
maxLength={60}
placeholder="Movie title"
autoComplete="off"
aria-label="Movie title"
className="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 focus:border-blue-400 focus:shadow-[0_0_0_3px_rgba(96,165,250,0.2)] flex-1"
value={movieDraft}
onChange={(e) => setMovieDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAdd();
}
}}
/>
<button
type="button"
onClick={() => handleAdd()}
disabled={addMovie.isPending}
className="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] disabled:opacity-40"
>
Add
</button>
</div>
{myMovies.length > 0 && (
<>
<p className="m-0 mt-5 mb-2 text-[13px] font-semibold uppercase tracking-wider text-slate-400">
Your Picks (tap to remove)
</p>
<ul className="mt-3 flex flex-wrap gap-2 list-none p-0 m-0">
{myMovies.map((m) => {
const clean = m.title.replace(/^(?:\u{1F3C6}\s*)+/u, "");
return (
<li key={m.title}>
<button
type="button"
onClick={() => handleRemove(clean)}
disabled={removeMovie.isPending}
className="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]"
>
{clean} ×
</button>
</li>
);
})}
</ul>
</>
)}
{historyItems.length > 0 && (
<>
<p className="m-0 mt-5 mb-2 text-[13px] font-semibold uppercase tracking-wider text-slate-400">
Add from History
</p>
<ul className="mt-3 flex flex-wrap gap-2 list-none p-0 m-0">
{historyItems.map(({ display, value }) => (
<li key={value}>
<button
type="button"
onClick={() => handleAdd(value)}
disabled={remaining <= 0 || addMovie.isPending}
className="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] disabled:opacity-40 disabled:cursor-default"
>
{display}
</button>
</li>
))}
</ul>
</>
)}
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
<RefreshButton onRefresh={onRefresh} />
<button
type="button"
onClick={() => markDone.mutate({ user, phase: 1 })}
disabled={markDone.isPending}
className="inline-flex items-center justify-center min-h-[44px] px-5 py-2.5 rounded-xl border border-blue-500 bg-blue-500 text-[15px] font-medium text-white whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97] disabled:opacity-40"
>
Done
</button>
</div>
<ErrorMsg error={addMovie.error ?? removeMovie.error ?? markDone.error} />
</>
);
}
function VotingPhase({
uuid,
user,
state,
onRefresh,
}: {
uuid: string;
user: string;
state: State;
onRefresh: () => void;
}) {
const movies = state.movies.map((m) => m.title);
const existingVotes = state.votes[user] ?? {};
const [ratings, setRatings] = useState<Record<string, number>>(() => {
const initial: Record<string, number> = {};
for (const title of movies) {
initial[title] = existingVotes[title] ?? 3;
}
return initial;
});
const vote = useVote(uuid);
const markDone = useMarkDone(uuid);
function handleRatingChange(title: string, value: number) {
setRatings((prev) => ({ ...prev, [title]: value }));
}
async function handleSave() {
await vote.mutateAsync({ user, ratings });
}
async function handleDone() {
await vote.mutateAsync({ user, ratings });
await markDone.mutateAsync({ user, phase: 2 });
}
return (
<>
<h2 className="m-0 text-xl font-bold tracking-tight text-slate-900">
Rate Movies
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-500">
Phase 2 of 3 · Rate each film 05, then tap Done.
</p>
<div className="mt-4 flex flex-col gap-3">
{movies.map((title) => (
<div
key={title}
className="rounded-2xl border border-slate-100 bg-slate-50 p-4"
>
<p className="m-0 text-[15px] font-medium text-slate-800 mb-3">
{title}
</p>
<div className="flex items-center gap-4">
<input
type="range"
min={0}
max={5}
step={1}
value={ratings[title] ?? 3}
onChange={(e) =>
handleRatingChange(title, Number.parseInt(e.target.value, 10))
}
aria-label={`Rate ${title} (05)`}
className="flex-1"
/>
<span className="w-6 text-right text-lg font-bold text-blue-500 shrink-0">
{ratings[title] ?? 3}
</span>
</div>
<div className="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>
))}
</div>
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
<RefreshButton onRefresh={onRefresh} />
<button
type="button"
onClick={handleSave}
disabled={vote.isPending}
className="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] disabled:opacity-40"
>
Save
</button>
<button
type="button"
onClick={handleDone}
disabled={vote.isPending || markDone.isPending}
className="inline-flex items-center justify-center min-h-[44px] px-5 py-2.5 rounded-xl border border-blue-500 bg-blue-500 text-[15px] font-medium text-white whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97] disabled:opacity-40"
>
Done
</button>
</div>
<ErrorMsg error={vote.error ?? markDone.error} />
</>
);
}
function FinalPage({
state,
onRefresh,
}: {
state: State;
onRefresh: () => void;
}) {
return (
<>
<h2 className="m-0 text-xl font-bold tracking-tight text-slate-900">
Result
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-500">
Phase 3 of 3 · Voting complete.
</p>
<ResultSection state={state} title="Round Outcome" />
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
<RefreshButton onRefresh={onRefresh} />
</div>
</>
);
}
export function ResultSection({
state,
title,
}: {
state: State;
title: string;
}) {
const movieTitles = state.movies.map((m) => m.title);
if (movieTitles.length === 0) {
return (
<div className="mt-4 rounded-2xl border border-slate-100 bg-slate-50 p-4">
<p className="text-[13px] font-semibold uppercase tracking-wider text-slate-400 m-0">
{title}
</p>
<p className="mt-2 text-sm leading-relaxed text-slate-500">
No movies were added.
</p>
</div>
);
}
const ratings = collectCompleteRatings(
state.users,
movieTitles,
state.votes ?? {},
);
const voters = Object.keys(ratings);
if (voters.length === 0) {
return (
<div className="mt-4 rounded-2xl border border-slate-100 bg-slate-50 p-4">
<p className="text-[13px] font-semibold uppercase tracking-wider text-slate-400 m-0">
{title}
</p>
<p className="mt-2 text-sm leading-relaxed text-slate-500">
No complete votes yet.
</p>
</div>
);
}
const result = decideMovie({
movies: movieTitles,
people: voters,
ratings,
});
return (
<div className="mt-4 rounded-2xl border border-slate-100 bg-slate-50 p-4">
<p className="text-[13px] font-semibold uppercase tracking-wider text-slate-400 m-0">
{title}
</p>
<p className="mt-3 text-lg font-bold text-slate-900">
🏆 {result.winner.movie}
</p>
<p className="mt-1 text-sm leading-relaxed text-slate-500">
Voters: {voters.length} of {state.users.length}
</p>
<div className="mt-3">
{result.ranking.map((r, i) => (
<div
key={r.movie}
className={`flex items-center gap-3 py-2.5 ${i > 0 ? "border-t border-slate-100" : ""}`}
>
<span className="w-5 text-center text-[13px] text-slate-400 font-semibold shrink-0">
{i + 1}
</span>
<span className="flex-1 text-sm">{r.movie}</span>
<span className="text-[13px] text-slate-400 shrink-0">
Nash {r.nash.toFixed(1)}
</span>
</div>
))}
</div>
</div>
);
}
function RefreshButton({ onRefresh }: { onRefresh: () => void }) {
return (
<button
type="button"
onClick={onRefresh}
className="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]"
>
Refresh
</button>
);
}
function ErrorMsg({ error }: { error: Error | null | undefined }) {
if (!error) return null;
return (
<p className="mt-3 text-sm font-medium text-red-500">{error.message}</p>
);
}
function getHistoryItems(
state: State,
): Array<{ display: string; value: string }> {
const history = state.history ?? [];
if (history.length === 0) return [];
const currentTitles = new Set(
state.movies.map((m) =>
m.title.replace(/^(?:\u{1F3C6}\s*)+/u, "").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(/^(?:\u{1F3C6}\s*)+/u, ""));
}
}
const wonSet = new Set(
wonMovies.map((t) => t.replace(/^(?:\u{1F3C6}\s*)+/u, "").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(/^(?:\u{1F3C6}\s*)+/u, "");
const key = clean.toLowerCase();
if (!clean || seenWon.has(key) || currentTitles.has(key)) continue;
seenWon.add(key);
wonList.push({ display: `🏆 ${clean}`, value: clean });
}
return [...regularList.map((t) => ({ display: t, value: t })), ...wonList];
}

View File

@@ -0,0 +1,47 @@
import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router";
import { AdminRoute } from "./$uuid/admin.tsx";
import { UserRoute } from "./$uuid/route.tsx";
import { CreateScreen } from "./index.tsx";
function RootLayout() {
return (
<div className="min-h-dvh bg-slate-50 text-slate-900 font-sans antialiased">
<main className="mx-auto max-w-2xl px-4 pt-6 pb-10 flex flex-col gap-5 safe-b">
<header>
<h1 className="text-2xl font-bold tracking-tight">Movie Select</h1>
</header>
<section className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm">
<Outlet />
</section>
</main>
</div>
);
}
const rootRoute = createRootRoute({
component: RootLayout,
});
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: CreateScreen,
});
const uuidAdminRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/$uuid/admin",
component: AdminRoute,
});
const uuidUserRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/$uuid",
component: UserRoute,
});
export const routeTree = rootRoute.addChildren([
indexRoute,
uuidAdminRoute,
uuidUserRoute,
]);

View File

@@ -0,0 +1,30 @@
import { useNavigate } from "@tanstack/react-router";
export function CreateScreen() {
const navigate = useNavigate();
function handleCreate() {
const uuid = crypto.randomUUID();
navigate({ to: "/$uuid/admin", params: { uuid } });
}
return (
<>
<h2 className="m-0 text-xl font-bold tracking-tight text-slate-900">
Movie Select
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-500">
Start a new round, add everyone, share links pick a film.
</p>
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
<button
type="button"
onClick={handleCreate}
className="inline-flex items-center justify-center min-h-[44px] px-5 py-2.5 rounded-xl border border-blue-500 bg-blue-500 text-[15px] font-medium text-white whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97]"
>
New Round
</button>
</div>
</>
);
}

View File

@@ -1,197 +0,0 @@
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");
}

20
src/server/app.ts Normal file
View File

@@ -0,0 +1,20 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { roundsRouter } from "./features/rounds/router.ts";
import { ApiError } from "./features/rounds/service.ts";
const app = new Hono();
app.use("*", cors());
app.route("/api/rounds", roundsRouter);
app.onError((err, c) => {
if (err instanceof ApiError) {
return c.json({ ok: false, error: err.message }, err.status as 400);
}
console.error(err);
return c.json({ ok: false, error: "Internal server error" }, 500);
});
export default app;

View File

@@ -1,182 +0,0 @@
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],
);
}

View File

@@ -0,0 +1,110 @@
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import {
addMovieBody,
addUserBody,
markDoneBody,
newRoundBody,
removeMovieBody,
setPhaseBody,
uuidParam,
voteBody,
} from "./schema.ts";
import * as service from "./service.ts";
export const roundsRouter = new Hono()
.get("/:uuid", zValidator("param", uuidParam), async (c) => {
const { uuid } = c.req.valid("param");
const state = await service.loadState(uuid);
return c.json({ ok: true, state });
})
.post(
"/:uuid/users",
zValidator("param", uuidParam),
zValidator("json", addUserBody),
async (c) => {
const { uuid } = c.req.valid("param");
const { user } = c.req.valid("json");
const state = await service.addUser(uuid, user);
return c.json({ ok: true, state });
},
)
.post("/:uuid/setup", zValidator("param", uuidParam), async (c) => {
const { uuid } = c.req.valid("param");
const state = await service.finishSetup(uuid);
return c.json({ ok: true, state });
})
.post(
"/:uuid/movies",
zValidator("param", uuidParam),
zValidator("json", addMovieBody),
async (c) => {
const { uuid } = c.req.valid("param");
const { user, title } = c.req.valid("json");
const state = await service.addMovie(uuid, user, title);
return c.json({ ok: true, state });
},
)
.delete(
"/:uuid/movies",
zValidator("param", uuidParam),
zValidator("json", removeMovieBody),
async (c) => {
const { uuid } = c.req.valid("param");
const { user, title } = c.req.valid("json");
const state = await service.removeMovie(uuid, user, title);
return c.json({ ok: true, state });
},
)
.post(
"/:uuid/votes",
zValidator("param", uuidParam),
zValidator("json", voteBody),
async (c) => {
const { uuid } = c.req.valid("param");
const { user, ratings } = c.req.valid("json");
const state = await service.voteMany(uuid, user, ratings);
return c.json({ ok: true, state });
},
)
.post(
"/:uuid/done",
zValidator("param", uuidParam),
zValidator("json", markDoneBody),
async (c) => {
const { uuid } = c.req.valid("param");
const { user, phase } = c.req.valid("json");
const state = await service.markDone(uuid, user, phase);
return c.json({ ok: true, state });
},
)
.post(
"/:uuid/phase",
zValidator("param", uuidParam),
zValidator("json", setPhaseBody),
async (c) => {
const { uuid } = c.req.valid("param");
const { phase } = c.req.valid("json");
const state = await service.setPhase(uuid, phase);
return c.json({ ok: true, state });
},
)
.post(
"/:uuid/new-round",
zValidator("param", uuidParam),
zValidator("json", newRoundBody),
async (c) => {
const { uuid } = c.req.valid("param");
const { winner } = c.req.valid("json");
const state = await service.newRound(uuid, winner);
return c.json({ ok: true, state });
},
);

View File

@@ -0,0 +1,42 @@
import { z } from "zod";
export const uuidParam = z.object({
uuid: z
.string()
.regex(
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
"Invalid UUID",
),
});
export const addUserBody = z.object({
user: z.string().trim().min(1, "Name required").max(30, "Name too long"),
});
export const addMovieBody = z.object({
user: z.string().trim().min(1, "Name required").max(30, "Name too long"),
title: z.string().trim().min(1, "Title required").max(60, "Title too long"),
});
export const removeMovieBody = z.object({
user: z.string().trim().min(1, "Name required").max(30, "Name too long"),
title: z.string().trim().min(1, "Title required").max(60, "Title too long"),
});
export const voteBody = z.object({
user: z.string().trim().min(1, "Name required").max(30, "Name too long"),
ratings: z.record(z.string(), z.number().int().min(0).max(5)),
});
export const markDoneBody = z.object({
user: z.string().trim().min(1, "Name required").max(30, "Name too long"),
phase: z.union([z.literal(1), z.literal(2)]),
});
export const setPhaseBody = z.object({
phase: z.union([z.literal(1), z.literal(2), z.literal(3)]),
});
export const newRoundBody = z.object({
winner: z.string().default(""),
});

View File

@@ -0,0 +1,333 @@
import { and, asc, eq } from "drizzle-orm";
import type { Movie, State } from "@/shared/types.ts";
import { db } from "../../shared/db/index.ts";
import {
movies,
roundHistory,
rounds,
roundUsers,
votes,
} from "../../shared/db/schema/index.ts";
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 cleanTitle(value: string): string {
const trimmed = value.trim().replace(TROPHY_RE, "");
if (trimmed === "" || trimmed.length > 60) fail(400, "Invalid movie title");
return trimmed;
}
export async function loadState(uuid: string): Promise<State> {
// Ensure round row exists (upsert)
await db
.insert(rounds)
.values({ uuid })
.onConflictDoNothing({ target: rounds.uuid });
const [round] = await db
.select({
phase: rounds.phase,
setupDone: rounds.setupDone,
createdAt: rounds.createdAt,
updatedAt: rounds.updatedAt,
})
.from(rounds)
.where(eq(rounds.uuid, uuid));
const userRows = await db
.select({
name: roundUsers.name,
donePhase1: roundUsers.donePhase1,
donePhase2: roundUsers.donePhase2,
})
.from(roundUsers)
.where(eq(roundUsers.roundUuid, uuid))
.orderBy(asc(roundUsers.sortOrder), asc(roundUsers.name));
const movieRows = await db
.select({
title: movies.title,
addedBy: movies.addedBy,
addedAt: movies.addedAt,
})
.from(movies)
.where(eq(movies.roundUuid, uuid))
.orderBy(asc(movies.id));
const voteRows = await db
.select({
userName: votes.userName,
movieTitle: votes.movieTitle,
rating: votes.rating,
})
.from(votes)
.where(eq(votes.roundUuid, uuid));
const historyRows = await db
.select({
winner: roundHistory.winner,
moviesJson: roundHistory.moviesJson,
})
.from(roundHistory)
.where(eq(roundHistory.roundUuid, uuid))
.orderBy(asc(roundHistory.id));
const users = userRows.map((u) => u.name);
const donePhase1 = userRows.filter((u) => u.donePhase1).map((u) => u.name);
const donePhase2 = userRows.filter((u) => u.donePhase2).map((u) => u.name);
const votesMap: Record<string, Record<string, number>> = {};
for (const v of voteRows) {
if (!votesMap[v.userName]) votesMap[v.userName] = {};
votesMap[v.userName][v.movieTitle] = v.rating;
}
const history = historyRows.map((h) => ({
winner: h.winner,
movies: JSON.parse(h.moviesJson) 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?.setupDone),
users,
doneUsersPhase1: donePhase1,
doneUsersPhase2: donePhase2,
movies: movieRows.map((m) => ({
title: m.title,
addedBy: m.addedBy,
addedAt: m.addedAt.toISOString(),
})),
votes: votesMap,
history,
createdAt: round?.createdAt?.toISOString() ?? new Date().toISOString(),
updatedAt: round?.updatedAt?.toISOString() ?? new Date().toISOString(),
};
}
export async function addUser(uuid: string, user: string): Promise<State> {
const state = await loadState(uuid);
if (state.phase !== 1 || state.setupDone) fail(409, "Setup is closed");
if (!state.users.includes(user)) {
await db
.insert(roundUsers)
.values({ roundUuid: uuid, name: user, sortOrder: state.users.length })
.onConflictDoNothing();
}
return loadState(uuid);
}
export async function finishSetup(uuid: string): Promise<State> {
const state = await loadState(uuid);
if (state.users.length < 1) fail(409, "Add at least one user first");
await db.update(rounds).set({ setupDone: true }).where(eq(rounds.uuid, uuid));
await db
.update(roundUsers)
.set({ donePhase1: false, donePhase2: false })
.where(eq(roundUsers.roundUuid, uuid));
return loadState(uuid);
}
export async function addMovie(
uuid: string,
user: string,
rawTitle: string,
): Promise<State> {
const state = await loadState(uuid);
if (!state.setupDone) fail(409, "Setup is not finished");
if (state.phase !== 1) fail(409, "Movie phase is closed");
if (state.users.length > 0 && !state.users.includes(user))
fail(403, "Unknown user");
const title = cleanTitle(rawTitle);
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 db.insert(movies).values({ roundUuid: uuid, title, addedBy: user });
await db
.update(roundUsers)
.set({ donePhase1: false })
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)));
return loadState(uuid);
}
export async function removeMovie(
uuid: string,
user: string,
rawTitle: string,
): Promise<State> {
const state = await loadState(uuid);
if (!state.setupDone) fail(409, "Setup is not finished");
if (state.phase !== 1) fail(409, "Movie phase is closed");
if (state.users.length > 0 && !state.users.includes(user))
fail(403, "Unknown user");
const title = cleanTitle(rawTitle);
await db
.delete(movies)
.where(
and(
eq(movies.roundUuid, uuid),
eq(movies.title, title),
eq(movies.addedBy, user),
),
);
await db
.update(roundUsers)
.set({ donePhase1: false })
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)));
return loadState(uuid);
}
export async function voteMany(
uuid: string,
user: string,
ratings: Record<string, number>,
): Promise<State> {
const state = await loadState(uuid);
if (state.phase !== 2) fail(409, "Voting phase is not active");
if (state.users.length > 0 && !state.users.includes(user))
fail(403, "Unknown user");
for (const movie of state.movies) {
const rating = ratings[movie.title];
if (typeof rating !== "number" || !Number.isInteger(rating))
fail(400, "Each movie needs a rating");
if (rating < 0 || rating > 5) fail(400, "Rating must be 0 to 5");
await db
.insert(votes)
.values({
roundUuid: uuid,
userName: user,
movieTitle: movie.title,
rating,
})
.onConflictDoUpdate({
target: [votes.roundUuid, votes.userName, votes.movieTitle],
set: { rating },
});
}
await db
.update(roundUsers)
.set({ donePhase2: false })
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)));
return loadState(uuid);
}
function allDone(users: string[], doneUsers: string[]): boolean {
if (users.length === 0) return false;
return users.every((name) => doneUsers.includes(name));
}
export async function markDone(
uuid: string,
user: string,
phase: 1 | 2,
): Promise<State> {
const state = await loadState(uuid);
if (!state.setupDone) fail(409, "Setup is not finished");
if (state.users.length > 0 && !state.users.includes(user))
fail(403, "Unknown user");
if (phase === 1) {
if (state.phase !== 1) fail(409, "Movie phase is not active");
await db
.update(roundUsers)
.set({ donePhase1: true })
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)));
const updated = await loadState(uuid);
if (allDone(updated.users, updated.doneUsersPhase1)) {
await db.update(rounds).set({ phase: 2 }).where(eq(rounds.uuid, uuid));
await db
.update(roundUsers)
.set({ donePhase2: false })
.where(eq(roundUsers.roundUuid, uuid));
}
return loadState(uuid);
}
if (phase === 2) {
if (state.phase !== 2) fail(409, "Voting phase is not active");
const movieTitles = state.movies.map((m) => m.title);
const userVotes = state.votes[user] ?? {};
for (const title of movieTitles) {
if (typeof userVotes[title] !== "number")
fail(409, "Rate every movie before finishing");
}
await db
.update(roundUsers)
.set({ donePhase2: true })
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)));
const updated = await loadState(uuid);
if (allDone(updated.users, updated.doneUsersPhase2)) {
await db.update(rounds).set({ phase: 3 }).where(eq(rounds.uuid, uuid));
}
return loadState(uuid);
}
fail(400, "Invalid done phase");
}
export async function setPhase(uuid: string, phase: 1 | 2 | 3): Promise<State> {
const state = await loadState(uuid);
if (!state.setupDone) fail(409, "Finish setup first");
await db.update(rounds).set({ phase }).where(eq(rounds.uuid, uuid));
if (phase === 1) {
await db
.update(roundUsers)
.set({ donePhase1: false, donePhase2: false })
.where(eq(roundUsers.roundUuid, uuid));
}
if (phase === 2) {
await db
.update(roundUsers)
.set({ donePhase2: false })
.where(eq(roundUsers.roundUuid, uuid));
}
return loadState(uuid);
}
export async function newRound(
uuid: string,
rawWinner: string,
): Promise<State> {
const state = await loadState(uuid);
if (state.phase !== 3) fail(409, "Round is not finished yet");
const winner = rawWinner.trim().replace(TROPHY_RE, "") || null;
await db.insert(roundHistory).values({
roundUuid: uuid,
winner,
moviesJson: JSON.stringify(state.movies),
});
await db.delete(movies).where(eq(movies.roundUuid, uuid));
await db.delete(votes).where(eq(votes.roundUuid, uuid));
await db.update(rounds).set({ phase: 1 }).where(eq(rounds.uuid, uuid));
await db
.update(roundUsers)
.set({ donePhase1: false, donePhase2: false })
.where(eq(roundUsers.roundUuid, uuid));
return loadState(uuid);
}

View File

@@ -1,128 +1,7 @@
import { statSync } from "fs";
import { join } from "path";
import { handleAction, ApiError } from "./api.ts";
import { serve } from "@hono/node-server";
import app from "./app.ts";
import { env } from "./shared/lib/env.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" },
});
},
serve({ fetch: app.fetch, port: env.PORT }, (info) => {
console.log(`Movie Select API listening on port ${info.port}`);
});
console.log(`Movie Select listening on port ${server.port}`);

View File

@@ -0,0 +1,8 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { env } from "../lib/env.ts";
import * as schema from "./schema/index.ts";
const client = postgres(env.DATABASE_URL);
export const db = drizzle(client, { schema });

View File

@@ -0,0 +1,5 @@
export { movies } from "./movies.ts";
export { roundHistory } from "./round-history.ts";
export { roundUsers } from "./round-users.ts";
export { rounds } from "./rounds.ts";
export { votes } from "./votes.ts";

View File

@@ -0,0 +1,25 @@
import {
char,
pgTable,
serial,
timestamp,
uniqueIndex,
varchar,
} from "drizzle-orm/pg-core";
import { rounds } from "./rounds.ts";
export const movies = pgTable(
"movies",
{
id: serial("id").primaryKey(),
roundUuid: char("round_uuid", { length: 36 })
.notNull()
.references(() => rounds.uuid, { onDelete: "cascade" }),
title: varchar("title", { length: 60 }).notNull(),
addedBy: varchar("added_by", { length: 30 }).notNull(),
addedAt: timestamp("added_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [uniqueIndex("uq_round_title").on(t.roundUuid, t.title)],
);

View File

@@ -0,0 +1,21 @@
import {
char,
pgTable,
serial,
text,
timestamp,
varchar,
} from "drizzle-orm/pg-core";
import { rounds } from "./rounds.ts";
export const roundHistory = pgTable("round_history", {
id: serial("id").primaryKey(),
roundUuid: char("round_uuid", { length: 36 })
.notNull()
.references(() => rounds.uuid, { onDelete: "cascade" }),
winner: varchar("winner", { length: 60 }),
moviesJson: text("movies_json").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
});

View File

@@ -0,0 +1,23 @@
import {
boolean,
char,
integer,
pgTable,
primaryKey,
varchar,
} from "drizzle-orm/pg-core";
import { rounds } from "./rounds.ts";
export const roundUsers = pgTable(
"round_users",
{
roundUuid: char("round_uuid", { length: 36 })
.notNull()
.references(() => rounds.uuid, { onDelete: "cascade" }),
name: varchar("name", { length: 30 }).notNull(),
donePhase1: boolean("done_phase1").notNull().default(false),
donePhase2: boolean("done_phase2").notNull().default(false),
sortOrder: integer("sort_order").notNull().default(0),
},
(t) => [primaryKey({ columns: [t.roundUuid, t.name] })],
);

View File

@@ -0,0 +1,20 @@
import {
boolean,
char,
pgTable,
smallint,
timestamp,
} from "drizzle-orm/pg-core";
export const rounds = pgTable("rounds", {
uuid: char("uuid", { length: 36 }).primaryKey(),
phase: smallint("phase").notNull().default(1),
setupDone: boolean("setup_done").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
});

View File

@@ -0,0 +1,21 @@
import {
char,
pgTable,
primaryKey,
smallint,
varchar,
} from "drizzle-orm/pg-core";
import { rounds } from "./rounds.ts";
export const votes = pgTable(
"votes",
{
roundUuid: char("round_uuid", { length: 36 })
.notNull()
.references(() => rounds.uuid, { onDelete: "cascade" }),
userName: varchar("user_name", { length: 30 }).notNull(),
movieTitle: varchar("movie_title", { length: 60 }).notNull(),
rating: smallint("rating").notNull(),
},
(t) => [primaryKey({ columns: [t.roundUuid, t.userName, t.movieTitle] })],
);

View File

@@ -0,0 +1,8 @@
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().default(3001),
});
export const env = envSchema.parse(process.env);

View File

@@ -11,7 +11,10 @@ export function paretoFilter(
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) {
allAtLeastAsGood = false;
break;
}
if (b > a) strictlyBetter = true;
}
return allAtLeastAsGood && strictlyBetter;

View File

@@ -1,5 +1,6 @@
export function allUsersDone(users: string[], doneUsers: string[]): boolean {
if (!Array.isArray(users) || users.length === 0 || !Array.isArray(doneUsers)) return false;
if (!Array.isArray(users) || users.length === 0 || !Array.isArray(doneUsers))
return false;
return users.every((name) => doneUsers.includes(name));
}
@@ -7,7 +8,12 @@ export function hasCompleteRatings(
movieTitles: string[],
votesForUser: Record<string, number> | null | undefined,
): boolean {
if (!Array.isArray(movieTitles) || !votesForUser || typeof votesForUser !== "object") return false;
if (
!Array.isArray(movieTitles) ||
!votesForUser ||
typeof votesForUser !== "object"
)
return false;
for (const title of movieTitles) {
if (!Number.isInteger(votesForUser[title])) return false;
}
@@ -20,7 +26,12 @@ export function collectCompleteRatings(
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") {
if (
!Array.isArray(users) ||
!Array.isArray(movieTitles) ||
!votes ||
typeof votes !== "object"
) {
return output;
}
for (const name of users) {

View File

@@ -1,296 +0,0 @@
: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;
}
}

View File

@@ -1,45 +0,0 @@
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();

View File

@@ -1,34 +0,0 @@
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();

View File

@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
import { decideMovie, paretoFilter } from "../src/client/algorithm.ts";
import { expect, test } from "vitest";
import { decideMovie, paretoFilter } from "../../src/shared/algorithm.ts";
test("paretoFilter removes dominated movies", () => {
const movies = ["A", "B"];
@@ -23,7 +23,10 @@ test("nash protects against hard no", () => {
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 ratings = {
A: { Alpha: 2, Beta: 2 },
B: { Alpha: 2, Beta: 2 },
};
const result = decideMovie({ movies, people, ratings });
expect(result.winner.movie).toBe("Alpha");
});

View File

@@ -1,5 +1,9 @@
import { expect, test } from "bun:test";
import { allUsersDone, hasCompleteRatings, collectCompleteRatings } from "../src/client/round-state.ts";
import { expect, test } from "vitest";
import {
allUsersDone,
collectCompleteRatings,
hasCompleteRatings,
} from "../../src/shared/round-state.ts";
test("allUsersDone returns true when all done", () => {
expect(allUsersDone(["A", "B"], ["A", "B"])).toBe(true);
@@ -13,10 +17,9 @@ test("hasCompleteRatings detects missing ratings", () => {
});
test("collectCompleteRatings filters incomplete voters", () => {
const result = collectCompleteRatings(
["A", "B"],
["M1", "M2"],
{ A: { M1: 1, M2: 2 }, B: { M1: 3 } },
);
const result = collectCompleteRatings(["A", "B"], ["M1", "M2"], {
A: { M1: 1, M2: 2 },
B: { M1: 3 },
});
expect(result).toEqual({ A: { M1: 1, M2: 2 } });
});

View File

@@ -1,12 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"types": ["bun-types"]
"allowImportingTsExtensions": true,
"noEmit": true,
"jsx": "react-jsx",
"esModuleInterop": true,
"resolveJsonModule": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "scripts/**/*", "tests/**/*"]
"include": ["src/**/*", "tests/**/*", "vite.config.ts", "drizzle.config.ts"]
}

24
vite.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
base: "/movie-select/",
plugins: [react(), tailwindcss()],
build: {
outDir: "dist/client",
},
server: {
proxy: {
"/movie-select/api": {
target: "http://localhost:3001",
rewrite: (path) => path.replace(/^\/movie-select/, ""),
},
},
},
resolve: {
alias: {
"@": new URL("./src", import.meta.url).pathname,
},
},
});