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) => `
  1. ${escapeHtml(r.movie)} (Nash ${r.nash.toFixed(2)})
  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.

    - - - ${rows} -
    MovieYour Rating
    -
    - - - -
    - - `; - - 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

    - - -
    - -
    -

    Setup

    -
    - - -
    - -
    - - -
    - -

    Add at least 2 people and 2 movies.

    -
    - - - - -
    - +
    + 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 - - -
    -
    -

    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 `
    -

    ${esc(phaseTitle)}

    -
    -
    -
    -
    - ${done}/${total} -
    -
    `; -} - -// โ”€โ”€โ”€ 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

        ${chips}
      `; -} - -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.users.map((name) => { + const link = `${window.location.origin}/movie-select/${uuid}?user=${encodeURIComponent(name)}`; + const steps = + (d1.includes(name) ? 1 : 0) + (d2.includes(name) ? 1 : 0); + return ( +
        • +
          + {name} + +
          +
          + + +
          +
        • + ); + })} +
        +
        + +
        + {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 ( +
        +

        {title}

        +
        +
        +
        +
        + + {done}/{total} + +
        +
        + ); +} 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โ€ฆ +

        +
        +

        {phaseTitle}

        +
        +
        +
        +
        + + {done}/{total} + +
        +
        +

        {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 ( +
        +
        +
        +

        Movie Select

        +
        +
        + +
        +
        +
        + ); +} + +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 - - -
        -
        -

        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, + }, + }, +});