diff --git a/.env.example b/.env.example
index e100be9..15dfb8c 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/.gitignore b/.gitignore
index 5ab0775..a47c985 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ dist/
.env
.DS_Store
data/
+drizzle/meta/
diff --git a/.htaccess b/.htaccess
index ff97f4b..6f6ba78 100644
--- a/.htaccess
+++ b/.htaccess
@@ -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]
diff --git a/algorithm.js b/algorithm.js
deleted file mode 100644
index 1faf34f..0000000
--- a/algorithm.js
+++ /dev/null
@@ -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,
- };
-}
diff --git a/api.php b/api.php
deleted file mode 100644
index b72f675..0000000
--- a/api.php
+++ /dev/null
@@ -1,389 +0,0 @@
- 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');
diff --git a/app.js b/app.js
deleted file mode 100644
index 8734f61..0000000
--- a/app.js
+++ /dev/null
@@ -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 = {
- "&": "&",
- "<": "<",
- ">": ">",
- '"': """,
- "'": "'",
- };
- 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 `${escapeHtml(title)}
No movies were added.
`;
- }
- if (info.kind === "no_votes") {
- return `${escapeHtml(title)}
No complete votes yet.
`;
- }
- return `${escapeHtml(title)}
Winner: ${escapeHtml(info.result.winner.movie)}
Complete voters: ${info.voterCount}/${info.totalUsers}
${info.result.ranking.map((r) => `- ${escapeHtml(r.movie)} (Nash ${r.nash.toFixed(2)})
`).join("")}
`;
-}
-
-function buildCreateScreen() {
- mount.innerHTML = `
-
Create Round
- Create a new round, add users, then share each user link.
-
-
-
- `;
- mount.querySelector("#createRound").addEventListener("click", () => {
- const id = crypto.randomUUID();
- window.location.href = `/movies/${id}/admin`;
- });
-}
-
-function renderAdminSetup() {
- mount.innerHTML = `
- Add Users
- Add all users, then press Next to start the round.
-
-
-
-
-
-
-
-
-
- `;
- const userInput = mount.querySelector("#userInput");
- userInput.value = adminUserDraft;
- userInput.focus();
- userInput.addEventListener("input", () => {
- adminUserDraft = userInput.value;
- });
- mount.querySelector("#userList").innerHTML = state.users.map((name) => `${escapeHtml(name)}`).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 `${escapeHtml(name)} (${userSteps}/2)`;
- }).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 = `
-
- ${escapeHtml(phaseTitle)}
- Overall progress: ${completedSteps}/${totalSteps} user-steps (${progressPercent}%).
-
-
- `;
-
- mount.innerHTML = `
- Admin Status
- ${phasePanel}
- Invite Links
-
-
-
-
- ${state.phase === 3 ? resultSection("Final Result") : ""}
- ${state.phase === 3 ? `` : ""}
-
- `;
- 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 = `
- Waiting
-
- ${escapeHtml(phaseTitle)}
- Overall progress: ${completedSteps}/${totalSteps} user-steps (${progressPercent}%).
-
-
- ${escapeHtml(waitingText)}
-
-
-
-
- `;
- 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 ``;
- })
- .join("");
- return `Available Movies
`;
-}
-
-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 = `
- Add Movies
- Phase 1 of 3: add up to 5 movies, then press Done.
- ${canAddMovies ? `Your movie slots left: ${remaining}
` : `Use your personal user link.
`}
- ${canAddMovies ? `` : ""}
- ${canAddMovies && myMovieCount > 0 ? `Your Selection (tap to remove)
` : ""}
-
- ${canAddMovies ? buildPrevMoviesSection(remaining) : ""}
- ${canAddMovies ? `` : ""}
-
- `;
- mount.querySelector("#movieList").innerHTML = state.movies
- .filter((m) => canAddMovies && m.addedBy.toLowerCase() === user.toLowerCase())
- .map((m) => ``)
- .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 `| ${escapeHtml(title)} | ${current} |
`;
- })
- .join("");
-
- mount.innerHTML = `
- Vote
- Phase 2 of 3: rate every movie, then press Done.
-
- | Movie | Your Rating |
- ${rows}
-
-
-
-
-
-
-
- `;
-
- 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 = `
- Final Result
- Phase 3 of 3: voting is complete.
- ${resultSection("Round Outcome")}
-
-
-
-
- `;
- 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 = `Admin is still setting up users. Open your personal link when ready.
`;
- 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 = `Unknown user. Use one of the shared links from admin.
`;
- return;
- }
- if (state.phase === 1) {
- renderMoviePhase();
- return;
- }
- if (state.phase === 2) {
- renderVotingPhase();
- return;
- }
- renderFinalPage();
-}
-
-function askUserName() {
- mount.innerHTML = `
- Who are you?
- Use the exact name from your invite link.
-
-
-
-
- `;
- 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 = `Invalid UUID in URL.
`;
- return;
- }
- try {
- await loadAndRender();
- } catch (error) {
- mount.innerHTML = `${escapeHtml(error.message)}
`;
- }
-}
-
-boot();
diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000..629048a
--- /dev/null
+++ b/biome.json
@@ -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"
+ }
+ }
+ }
+}
diff --git a/bun.lock b/bun.lock
index f68575a..5386e36 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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=="],
}
}
diff --git a/deploy.sh b/deploy.sh
index e75577f..3650343 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -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/"
diff --git a/drizzle.config.ts b/drizzle.config.ts
new file mode 100644
index 0000000..aac2ba9
--- /dev/null
+++ b/drizzle.config.ts
@@ -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!,
+ },
+});
diff --git a/index.html b/index.html
index 45e7271..a3f26f2 100644
--- a/index.html
+++ b/index.html
@@ -1,57 +1,17 @@
-
-
-
-
-
+
+
+
+
+
+
+
Movie Select
-
-
- Movie Select
-
-
-
-
-
-
-
- Ratings
- Each person rates each movie from 0 (no) to 5 (yes).
-
-
-
-
-
-
-
- Result
-
-
- Method: Pareto filter + Nash social welfare (product of ratings+1). This avoids picks one person strongly dislikes.
-
-
-
+
+
diff --git a/index.php b/index.php
deleted file mode 100644
index 7d0fdf9..0000000
--- a/index.php
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
-
-
-
-
-
- Movie Select
-
-
-
-
-
-
-
-
-
diff --git a/mise.toml b/mise.toml
index fa27325..b406cd3 100644
--- a/mise.toml
+++ b/mise.toml
@@ -1,2 +1,3 @@
[tools]
bun = "1.3.0"
+node = "22"
diff --git a/package.json b/package.json
index 4794666..7d3d72e 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
}
diff --git a/icon.svg b/public/icon.svg
similarity index 100%
rename from icon.svg
rename to public/icon.svg
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
index fb00fdd..6810e59 100644
--- a/public/manifest.webmanifest
+++ b/public/manifest.webmanifest
@@ -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"
diff --git a/round-state.js b/round-state.js
deleted file mode 100644
index 51ad354..0000000
--- a/round-state.js
+++ /dev/null
@@ -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;
-}
diff --git a/scripts/copy-assets.ts b/scripts/copy-assets.ts
deleted file mode 100644
index f96890e..0000000
--- a/scripts/copy-assets.ts
+++ /dev/null
@@ -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.");
diff --git a/scripts/migrate-data.ts b/scripts/migrate-data.ts
new file mode 100644
index 0000000..dd14217
--- /dev/null
+++ b/scripts/migrate-data.ts
@@ -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();
diff --git a/setup-server.sh b/setup-server.sh
index 3e4b908..bd57235 100755
--- a/setup-server.sh
+++ b/setup-server.sh
@@ -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" < 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 < 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/"
diff --git a/src/client/hooks/use-round-mutation.ts b/src/client/hooks/use-round-mutation.ts
new file mode 100644
index 0000000..2812ac7
--- /dev/null
+++ b/src/client/hooks/use-round-mutation.ts
@@ -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) {
+ 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;
+ }) => 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),
+ });
+}
diff --git a/src/client/hooks/use-round.ts b/src/client/hooks/use-round.ts
new file mode 100644
index 0000000..913a8a3
--- /dev/null
+++ b/src/client/hooks/use-round.ts
@@ -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,
+ });
+}
diff --git a/src/client/index.css b/src/client/index.css
new file mode 100644
index 0000000..4b98750
--- /dev/null
+++ b/src/client/index.css
@@ -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));
+}
diff --git a/src/client/input.css b/src/client/input.css
deleted file mode 100644
index f4ce102..0000000
--- a/src/client/input.css
+++ /dev/null
@@ -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);
-}
diff --git a/src/client/lib/api-client.ts b/src/client/lib/api-client.ts
new file mode 100644
index 0000000..f9174c4
--- /dev/null
+++ b/src/client/lib/api-client.ts
@@ -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 {
+ 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) =>
+ 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 }),
+};
diff --git a/src/client/lib/utils.ts b/src/client/lib/utils.ts
new file mode 100644
index 0000000..ac680b3
--- /dev/null
+++ b/src/client/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/src/client/main.ts b/src/client/main.ts
deleted file mode 100644
index 3329417..0000000
--- a/src/client/main.ts
+++ /dev/null
@@ -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>;
- 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 = {};
-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): Promise<{ ok: boolean; state: State }> {
- const res = await fetch(apiUrl(action), {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ ...body, action, uuid }),
- });
- const data = await res.json() as { ok: boolean; state: State; error?: string };
- if (!res.ok || !data.ok) throw new Error(data.error ?? "Request failed");
- return data;
-}
-
-function esc(text: string): string {
- return text.replace(/[&<>"']/g, (c) => (
- { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c,
- );
-}
-
-function showError(message: string): void {
- const el = mount.querySelector("#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 };
-
-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 `${esc(title)}
No movies were added.
`;
- }
- if (info.kind === "no_votes") {
- return `${esc(title)}
No complete votes yet.
`;
- }
- const { result, voterCount, totalUsers } = info;
- const rows = result.ranking.map((r, i) => `
-
- ${i + 1}
- ${esc(r.movie)}
- Nash ${r.nash.toFixed(1)}
-
`).join("");
- return `
-
${esc(title)}
-
๐ ${esc(result.winner.movie)}
-
Voters: ${voterCount} of ${totalUsers}
-
${rows}
-
`;
-}
-
-function progressCard(phaseTitle: string, done: number, total: number): string {
- const pct = total > 0 ? Math.round((done / total) * 100) : 0;
- return ``;
-}
-
-// โโโ Screens โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-function buildCreateScreen(): void {
- mount.innerHTML = `
-Movie Select
-Start a new round, add everyone, share links โ pick a film.
-
-
-
`;
- mount.querySelector("#createRound")!.addEventListener("click", () => {
- window.location.href = `/movies/${crypto.randomUUID()}/admin`;
- });
-}
-
-function renderAdminSetup(): void {
- mount.innerHTML = `
-Add People
-Add everyone who'll vote, then tap Next.
-
-
-
-
-
-
-
-
-`;
-
- const inp = mount.querySelector("#userInput")!;
- inp.value = adminUserDraft;
- inp.focus();
- inp.addEventListener("input", () => { adminUserDraft = inp.value; });
-
- mount.querySelector("#userList")!.innerHTML =
- (state?.users ?? []).map((n) => `${esc(n)}`).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
- ? `Done`
- : steps === 1
- ? `1/2`
- : `0/2`;
- return `
-
- ${esc(name)}${badge}
-
-
-
-
-
-`;
- }).join("");
-
- mount.innerHTML = `
-Admin
-${progressCard(phaseTitle, completedSteps, totalSteps)}
-Invite Links
-
-
-
-
-${s.phase === 3 ? resultSection("Round Result") : ""}
-${s.phase === 3 ? `` : ""}
-`;
-
- mount.querySelectorAll(".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 = `
-Waitingโฆ
-${progressCard(phaseTitle, done, total)}
-${esc(waitMsg)}
-
-
-
-`;
- 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();
-
- 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();
- 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 ``;
- }).join("");
-
- return `Add from History
`;
-}
-
-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 = `
-Add Movies
-Phase 1 of 3 ยท Up to 5 movies each, then tap Done.
-${canAdd ? `${remaining} slot${remaining === 1 ? "" : "s"} left
` : `Open your personal link to add movies.
`}
-${canAdd ? `
-
-
-
` : ""}
-${canAdd && myCount > 0 ? `Your Picks (tap to remove)
` : ""}
-
-${canAdd ? prevMoviesSection(remaining) : ""}
-${canAdd ? `
-
-
-
` : ""}
-`;
-
- mount.querySelector("#movieList")!.innerHTML = s.movies
- .filter((m) => canAdd && m.addedBy.toLowerCase() === user.toLowerCase())
- .map((m) => {
- const clean = m.title.replace(/^๐\s*/, "");
- return ``;
- }).join("");
-
- if (!canAdd) return;
-
- const addMovie = async () => {
- const inp = mount.querySelector("#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("#movieInput")!;
- inp.focus();
- mount.querySelector("#addMovie")!.addEventListener("click", addMovie);
- inp.addEventListener("keydown", async (e) => { if (e.key === "Enter") { e.preventDefault(); await addMovie(); } });
-
- mount.querySelectorAll(".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(".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 `
-
${esc(title)}
-
-
- ${current}
-
-
- SkipMehOKGoodGreatLove
-
-
`;
- }).join("");
-
- mount.innerHTML = `
-Rate Movies
-Phase 2 of 3 ยท Rate each film 0โ5, then tap Done.
-${cards}
-
-
-
-
-
-`;
-
- mount.querySelectorAll(".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(".rating-value");
- if (valEl) valEl.textContent = String(rating);
- });
- });
-
- const collectRatings = (): Record => {
- const out: Record = {};
- mount.querySelectorAll(".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 = `
-Result
-Phase 3 of 3 ยท Voting complete.
-${resultSection("Round Outcome")}
-
-
-
-`;
- mount.querySelector("#refreshUser")!.addEventListener("click", () => loadAndRender());
-}
-
-function askUserName(): void {
- mount.innerHTML = `
-Who are you?
-Enter the name from your invite link.
-
-
-
-
`;
- const inp = mount.querySelector("#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 {
- const data = await apiGetState();
- state = data.state;
- if (!state.setupDone && isAdmin) { renderAdminSetup(); return; }
- if (!state.setupDone) {
- mount.innerHTML = `Admin is still setting up. Open your link when ready.
-`;
- 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 = `Unknown user โ use your invite link.
`;
- return;
- }
- if (state.phase === 1) { renderMoviePhase(); return; }
- if (state.phase === 2) { renderVotingPhase(); return; }
- renderFinalPage();
-}
-
-async function boot(): Promise {
- if (!uuid) { buildCreateScreen(); return; }
- if (!isValidUuid(uuid)) {
- mount.innerHTML = `Invalid session URL.
`;
- return;
- }
- try { await loadAndRender(); }
- catch (e) { mount.innerHTML = `${esc((e as Error).message)}
`; }
-}
-
-boot();
diff --git a/src/client/main.tsx b/src/client/main.tsx
new file mode 100644
index 0000000..8dd0e66
--- /dev/null
+++ b/src/client/main.tsx
@@ -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(
+
+
+ ,
+);
diff --git a/src/client/router.tsx b/src/client/router.tsx
new file mode 100644
index 0000000..4c95775
--- /dev/null
+++ b/src/client/router.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/src/client/routes/$uuid/admin.tsx b/src/client/routes/$uuid/admin.tsx
new file mode 100644
index 0000000..715ceea
--- /dev/null
+++ b/src/client/routes/$uuid/admin.tsx
@@ -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 Loadingโฆ
;
+ }
+
+ if (!state.setupDone) {
+ return ;
+ }
+ return ;
+}
+
+function AdminSetup({ uuid, state }: { uuid: string; state: State }) {
+ const [draft, setDraft] = useState("");
+ const inputRef = useRef(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 (
+ <>
+
+ Add People
+
+
+ Add everyone who'll vote, then tap Next.
+
+
+ setDraft(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleAdd();
+ }
+ }}
+ />
+
+
+
+ {state.users.map((name) => (
+ -
+ {name}
+
+ ))}
+
+
+
+
+ {(addUser.error || finishSetup.error) && (
+
+ {(addUser.error ?? finishSetup.error)?.message}
+
+ )}
+ >
+ );
+}
+
+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 (
+ <>
+
+ Admin
+
+
+
+ Invite Links
+
+
+
+
+
+ {state.phase === 3 && (
+ <>
+
+
+
+
+ >
+ )}
+ >
+ );
+}
+
+function StatusBadge({ steps }: { steps: number }) {
+ if (steps === 2)
+ return (
+
+ Done
+
+ );
+ if (steps === 1)
+ return (
+
+ 1/2
+
+ );
+ return (
+
+ 0/2
+
+ );
+}
+
+function ProgressCard({
+ title,
+ done,
+ total,
+}: {
+ title: string;
+ done: number;
+ total: number;
+}) {
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
+ return (
+
+ );
+}
diff --git a/src/client/routes/$uuid/route.tsx b/src/client/routes/$uuid/route.tsx
new file mode 100644
index 0000000..798ee99
--- /dev/null
+++ b/src/client/routes/$uuid/route.tsx
@@ -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;
+ const userName = (search.user ?? "").trim();
+ const { data: state, refetch } = useRound(uuid);
+
+ if (!state) {
+ return Loadingโฆ
;
+ }
+
+ if (!state.setupDone) {
+ return (
+ <>
+
+ Admin is still setting up. Open your link when ready.
+
+
+
+
+ >
+ );
+ }
+
+ if (!userName) {
+ return ;
+ }
+
+ if (state.users.length > 0 && !state.users.includes(userName)) {
+ return (
+
+ Unknown user โ use your invite link.
+
+ );
+ }
+
+ if (state.phase === 1) {
+ const isDone = state.doneUsersPhase1?.includes(userName);
+ if (isDone) {
+ return ;
+ }
+ return (
+
+ );
+ }
+ if (state.phase === 2) {
+ const isDone = state.doneUsersPhase2?.includes(userName);
+ if (isDone) {
+ return ;
+ }
+ return (
+
+ );
+ }
+ return ;
+}
+
+function AskUserName() {
+ const inputRef = useRef(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 (
+ <>
+
+ Who are you?
+
+
+ Enter the name from your invite link.
+
+
+ {
+ if (e.key === "Enter") handleSubmit();
+ }}
+ />
+
+
+ >
+ );
+}
+
+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 (
+ <>
+
+ Waitingโฆ
+
+
+ {waitMsg}
+
+
+
+ >
+ );
+}
+
+function MoviePhase({
+ uuid,
+ user,
+ state,
+ onRefresh,
+}: {
+ uuid: string;
+ user: string;
+ state: State;
+ onRefresh: () => void;
+}) {
+ const [movieDraft, setMovieDraft] = useState("");
+ const inputRef = useRef(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 (
+ <>
+
+ Add Movies
+
+
+ Phase 1 of 3 ยท Up to 5 movies each, then tap Done.
+
+
+ {remaining} slot{remaining === 1 ? "" : "s"} left
+
+
+ setMovieDraft(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleAdd();
+ }
+ }}
+ />
+
+
+ {myMovies.length > 0 && (
+ <>
+
+ Your Picks (tap to remove)
+
+
+ {myMovies.map((m) => {
+ const clean = m.title.replace(/^(?:\u{1F3C6}\s*)+/u, "");
+ return (
+ -
+
+
+ );
+ })}
+
+ >
+ )}
+ {historyItems.length > 0 && (
+ <>
+
+ Add from History
+
+
+ {historyItems.map(({ display, value }) => (
+ -
+
+
+ ))}
+
+ >
+ )}
+
+
+
+
+
+ >
+ );
+}
+
+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>(() => {
+ const initial: Record = {};
+ 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 (
+ <>
+
+ Rate Movies
+
+
+ Phase 2 of 3 ยท Rate each film 0โ5, then tap Done.
+
+
+ {movies.map((title) => (
+
+
+ {title}
+
+
+
+ handleRatingChange(title, Number.parseInt(e.target.value, 10))
+ }
+ aria-label={`Rate ${title} (0โ5)`}
+ className="flex-1"
+ />
+
+ {ratings[title] ?? 3}
+
+
+
+ Skip
+ Meh
+ OK
+ Good
+ Great
+ Love
+
+
+ ))}
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function FinalPage({
+ state,
+ onRefresh,
+}: {
+ state: State;
+ onRefresh: () => void;
+}) {
+ return (
+ <>
+
+ Result
+
+
+ Phase 3 of 3 ยท Voting complete.
+
+
+
+
+
+ >
+ );
+}
+
+export function ResultSection({
+ state,
+ title,
+}: {
+ state: State;
+ title: string;
+}) {
+ const movieTitles = state.movies.map((m) => m.title);
+ if (movieTitles.length === 0) {
+ return (
+
+
+ {title}
+
+
+ No movies were added.
+
+
+ );
+ }
+
+ const ratings = collectCompleteRatings(
+ state.users,
+ movieTitles,
+ state.votes ?? {},
+ );
+ const voters = Object.keys(ratings);
+ if (voters.length === 0) {
+ return (
+
+
+ {title}
+
+
+ No complete votes yet.
+
+
+ );
+ }
+
+ const result = decideMovie({
+ movies: movieTitles,
+ people: voters,
+ ratings,
+ });
+
+ return (
+
+
+ {title}
+
+
+ ๐ {result.winner.movie}
+
+
+ Voters: {voters.length} of {state.users.length}
+
+
+ {result.ranking.map((r, i) => (
+
0 ? "border-t border-slate-100" : ""}`}
+ >
+
+ {i + 1}
+
+ {r.movie}
+
+ Nash {r.nash.toFixed(1)}
+
+
+ ))}
+
+
+ );
+}
+
+function RefreshButton({ onRefresh }: { onRefresh: () => void }) {
+ return (
+
+ );
+}
+
+function ErrorMsg({ error }: { error: Error | null | undefined }) {
+ if (!error) return null;
+ return (
+ {error.message}
+ );
+}
+
+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();
+
+ 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();
+ 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];
+}
diff --git a/src/client/routes/__root.tsx b/src/client/routes/__root.tsx
new file mode 100644
index 0000000..921f997
--- /dev/null
+++ b/src/client/routes/__root.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+}
+
+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,
+]);
diff --git a/src/client/routes/index.tsx b/src/client/routes/index.tsx
new file mode 100644
index 0000000..a071b2d
--- /dev/null
+++ b/src/client/routes/index.tsx
@@ -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 (
+ <>
+
+ Movie Select
+
+
+ Start a new round, add everyone, share links โ pick a film.
+
+
+
+
+ >
+ );
+}
diff --git a/src/server/api.ts b/src/server/api.ts
deleted file mode 100644
index 167f18f..0000000
--- a/src/server/api.ts
+++ /dev/null
@@ -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,
-): Promise {
- 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;
- 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");
-}
diff --git a/src/server/app.ts b/src/server/app.ts
new file mode 100644
index 0000000..65dbf41
--- /dev/null
+++ b/src/server/app.ts
@@ -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;
diff --git a/src/server/db.ts b/src/server/db.ts
deleted file mode 100644
index e504d93..0000000
--- a/src/server/db.ts
+++ /dev/null
@@ -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 {
- 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(
- "SELECT phase, setup_done, created_at, updated_at FROM rounds WHERE uuid = ?",
- [uuid],
- );
-
- const [userRows] = await conn.execute(
- "SELECT name, done_phase1, done_phase2 FROM round_users WHERE round_uuid = ? ORDER BY sort_order, name",
- [uuid],
- );
-
- const [movieRows] = await conn.execute(
- "SELECT title, added_by, added_at FROM movies WHERE round_uuid = ? ORDER BY id",
- [uuid],
- );
-
- const [voteRows] = await conn.execute(
- "SELECT user_name, movie_title, rating FROM votes WHERE round_uuid = ?",
- [uuid],
- );
-
- const [historyRows] = await conn.execute(
- "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> = {};
- 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 {
- await pool.execute("UPDATE rounds SET phase = ? WHERE uuid = ?", [phase, uuid]);
-}
-
-export async function setSetupDoneInDb(uuid: string): Promise {
- await pool.execute("UPDATE rounds SET setup_done = 1 WHERE uuid = ?", [uuid]);
-}
-
-export async function addUserInDb(uuid: string, name: string, sortOrder: number): Promise {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- const col = phase === 1 ? "done_phase1" : "done_phase2";
- await pool.execute(
- `UPDATE round_users SET ${col} = 0 WHERE round_uuid = ? AND name = ?`,
- [uuid, name],
- );
-}
diff --git a/src/server/features/rounds/router.ts b/src/server/features/rounds/router.ts
new file mode 100644
index 0000000..da8030e
--- /dev/null
+++ b/src/server/features/rounds/router.ts
@@ -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 });
+ },
+ );
diff --git a/src/server/features/rounds/schema.ts b/src/server/features/rounds/schema.ts
new file mode 100644
index 0000000..bafc74e
--- /dev/null
+++ b/src/server/features/rounds/schema.ts
@@ -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(""),
+});
diff --git a/src/server/features/rounds/service.ts b/src/server/features/rounds/service.ts
new file mode 100644
index 0000000..83810b5
--- /dev/null
+++ b/src/server/features/rounds/service.ts
@@ -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 {
+ // 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> = {};
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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,
+): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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);
+}
diff --git a/src/server/index.ts b/src/server/index.ts
index 88bd8b5..006c569 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -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 = {
- ".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 `
-
-
-
-
-
-
-
-
-
-Movie Select
-
-
-
-
-
-
-
-
-`;
-}
-
-async function serveStatic(path: string): Promise {
- 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 = {};
-
- if (method === "POST") {
- try {
- const raw = await req.text();
- if (raw) body = JSON.parse(raw) as Record;
- } 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}`);
diff --git a/src/server/shared/db/index.ts b/src/server/shared/db/index.ts
new file mode 100644
index 0000000..a8a8521
--- /dev/null
+++ b/src/server/shared/db/index.ts
@@ -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 });
diff --git a/src/server/shared/db/schema/index.ts b/src/server/shared/db/schema/index.ts
new file mode 100644
index 0000000..820fe3d
--- /dev/null
+++ b/src/server/shared/db/schema/index.ts
@@ -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";
diff --git a/src/server/shared/db/schema/movies.ts b/src/server/shared/db/schema/movies.ts
new file mode 100644
index 0000000..10e3cd3
--- /dev/null
+++ b/src/server/shared/db/schema/movies.ts
@@ -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)],
+);
diff --git a/src/server/shared/db/schema/round-history.ts b/src/server/shared/db/schema/round-history.ts
new file mode 100644
index 0000000..aa5a294
--- /dev/null
+++ b/src/server/shared/db/schema/round-history.ts
@@ -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(),
+});
diff --git a/src/server/shared/db/schema/round-users.ts b/src/server/shared/db/schema/round-users.ts
new file mode 100644
index 0000000..3fa8528
--- /dev/null
+++ b/src/server/shared/db/schema/round-users.ts
@@ -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] })],
+);
diff --git a/src/server/shared/db/schema/rounds.ts b/src/server/shared/db/schema/rounds.ts
new file mode 100644
index 0000000..047fcb0
--- /dev/null
+++ b/src/server/shared/db/schema/rounds.ts
@@ -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()),
+});
diff --git a/src/server/shared/db/schema/votes.ts b/src/server/shared/db/schema/votes.ts
new file mode 100644
index 0000000..199ce18
--- /dev/null
+++ b/src/server/shared/db/schema/votes.ts
@@ -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] })],
+);
diff --git a/src/server/shared/lib/env.ts b/src/server/shared/lib/env.ts
new file mode 100644
index 0000000..dc5a367
--- /dev/null
+++ b/src/server/shared/lib/env.ts
@@ -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);
diff --git a/src/client/algorithm.ts b/src/shared/algorithm.ts
similarity index 96%
rename from src/client/algorithm.ts
rename to src/shared/algorithm.ts
index 477fd49..ba39991 100644
--- a/src/client/algorithm.ts
+++ b/src/shared/algorithm.ts
@@ -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;
diff --git a/src/client/round-state.ts b/src/shared/round-state.ts
similarity index 78%
rename from src/client/round-state.ts
rename to src/shared/round-state.ts
index 8bf3d57..289e002 100644
--- a/src/client/round-state.ts
+++ b/src/shared/round-state.ts
@@ -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 | 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>,
): Record> {
const output: Record> = {};
- 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) {
diff --git a/src/server/types.ts b/src/shared/types.ts
similarity index 100%
rename from src/server/types.ts
rename to src/shared/types.ts
diff --git a/styles.css b/styles.css
deleted file mode 100644
index 3fafd53..0000000
--- a/styles.css
+++ /dev/null
@@ -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;
- }
-}
diff --git a/tests/algorithm.test.mjs b/tests/algorithm.test.mjs
deleted file mode 100644
index 954b952..0000000
--- a/tests/algorithm.test.mjs
+++ /dev/null
@@ -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();
diff --git a/tests/round-state.test.mjs b/tests/round-state.test.mjs
deleted file mode 100644
index 52de633..0000000
--- a/tests/round-state.test.mjs
+++ /dev/null
@@ -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();
diff --git a/tests/algorithm.test.ts b/tests/shared/algorithm.test.ts
similarity index 81%
rename from tests/algorithm.test.ts
rename to tests/shared/algorithm.test.ts
index 5ce20b1..42b1f98 100644
--- a/tests/algorithm.test.ts
+++ b/tests/shared/algorithm.test.ts
@@ -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");
});
diff --git a/tests/round-state.test.ts b/tests/shared/round-state.test.ts
similarity index 67%
rename from tests/round-state.test.ts
rename to tests/shared/round-state.test.ts
index becfd92..e8d34e7 100644
--- a/tests/round-state.test.ts
+++ b/tests/shared/round-state.test.ts
@@ -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 } });
});
diff --git a/tsconfig.json b/tsconfig.json
index 661df49..92b0a74 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -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"]
}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..b30db88
--- /dev/null
+++ b/vite.config.ts
@@ -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,
+ },
+ },
+});