refactor to react/vite/hono/drizzle/postgresql stack
replace vanilla TS + Bun.serve() + MariaDB with: - frontend: React 19, Vite 6, TanStack Router/Query, Tailwind v4 - backend: Hono + @hono/node-server, Drizzle ORM, PostgreSQL, Zod - shared: algorithm, round-state, types in src/shared/ - tooling: Biome (lint/format), Vitest (tests) - deploy: static files at /movie-select/, API at /movie-select/api URL scheme changes from /movies/ to /movie-select/, API from action-based to RESTful endpoints. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,2 @@
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_USER=serve
|
||||
DB_PASS=yourpassword
|
||||
DB_NAME=serve
|
||||
DATABASE_URL=postgresql://serve:yourpassword@localhost:5432/movie_select
|
||||
PORT=3001
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ dist/
|
||||
.env
|
||||
.DS_Store
|
||||
data/
|
||||
drizzle/meta/
|
||||
|
||||
@@ -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]
|
||||
|
||||
67
algorithm.js
67
algorithm.js
@@ -1,67 +0,0 @@
|
||||
export function paretoFilter(movies, people, ratings) {
|
||||
return movies.filter((movieA) => {
|
||||
return !movies.some((movieB) => {
|
||||
if (movieA === movieB) {
|
||||
return false;
|
||||
}
|
||||
let allAtLeastAsGood = true;
|
||||
let strictlyBetter = false;
|
||||
for (const person of people) {
|
||||
const a = ratings[person][movieA];
|
||||
const b = ratings[person][movieB];
|
||||
if (b < a) {
|
||||
allAtLeastAsGood = false;
|
||||
break;
|
||||
}
|
||||
if (b > a) {
|
||||
strictlyBetter = true;
|
||||
}
|
||||
}
|
||||
return allAtLeastAsGood && strictlyBetter;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function nashScore(movie, people, ratings) {
|
||||
let product = 1;
|
||||
for (const person of people) {
|
||||
product *= ratings[person][movie] + 1;
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
export function averageScore(movie, people, ratings) {
|
||||
let total = 0;
|
||||
for (const person of people) {
|
||||
total += ratings[person][movie];
|
||||
}
|
||||
return total / people.length;
|
||||
}
|
||||
|
||||
export function decideMovie({ movies, people, ratings }) {
|
||||
if (movies.length < 1 || people.length < 1) {
|
||||
throw new Error("Need at least one movie and one person");
|
||||
}
|
||||
const remaining = paretoFilter(movies, people, ratings);
|
||||
const scored = remaining.map((movie) => {
|
||||
return {
|
||||
movie,
|
||||
nash: nashScore(movie, people, ratings),
|
||||
avg: averageScore(movie, people, ratings),
|
||||
};
|
||||
});
|
||||
scored.sort((a, b) => {
|
||||
if (b.nash !== a.nash) {
|
||||
return b.nash - a.nash;
|
||||
}
|
||||
if (b.avg !== a.avg) {
|
||||
return b.avg - a.avg;
|
||||
}
|
||||
return a.movie.localeCompare(b.movie);
|
||||
});
|
||||
return {
|
||||
winner: scored[0],
|
||||
remaining,
|
||||
ranking: scored,
|
||||
};
|
||||
}
|
||||
389
api.php
389
api.php
@@ -1,389 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
const DATA_DIR = __DIR__ . '/data';
|
||||
const UUID_PATTERN = '/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/';
|
||||
|
||||
if (!is_dir(DATA_DIR)) {
|
||||
mkdir(DATA_DIR, 0775, true);
|
||||
}
|
||||
|
||||
function fail(int $status, string $message): void {
|
||||
http_response_code($status);
|
||||
echo json_encode(['ok' => false, 'error' => $message], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
function clean_user(string $value): string {
|
||||
$trimmed = trim($value);
|
||||
if ($trimmed === '' || mb_strlen($trimmed) > 30) {
|
||||
fail(400, 'Invalid user');
|
||||
}
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
function clean_movie(string $value): string {
|
||||
$trimmed = trim($value);
|
||||
// Strip leading trophy emoji if present (e.g., "🏆 Matrix" -> "Matrix")
|
||||
$trimmed = preg_replace('/^(?:\x{1F3C6}\s*)+/u', '', $trimmed);
|
||||
if ($trimmed === '' || mb_strlen($trimmed) > 60) {
|
||||
fail(400, 'Invalid movie title');
|
||||
}
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
function normalize_users(array $users): array {
|
||||
$normalized = [];
|
||||
foreach ($users as $user) {
|
||||
if (!is_string($user)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim($user);
|
||||
if ($name === '' || mb_strlen($name) > 30) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($name, $normalized, true)) {
|
||||
$normalized[] = $name;
|
||||
}
|
||||
}
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
function normalize_done_users(array $doneUsers, array $users): array {
|
||||
$normalized = [];
|
||||
foreach ($doneUsers as $name) {
|
||||
if (!is_string($name)) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($name, $users, true)) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($name, $normalized, true)) {
|
||||
$normalized[] = $name;
|
||||
}
|
||||
}
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
function all_users_done(array $users, array $doneUsers): bool {
|
||||
if (count($users) === 0) {
|
||||
return false;
|
||||
}
|
||||
foreach ($users as $name) {
|
||||
if (!in_array($name, $doneUsers, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function load_state(string $uuid): array {
|
||||
$path = DATA_DIR . '/' . $uuid . '.json';
|
||||
if (!file_exists($path)) {
|
||||
return [
|
||||
'uuid' => $uuid,
|
||||
'phase' => 1,
|
||||
'setupDone' => false,
|
||||
'users' => [],
|
||||
'doneUsersPhase1' => [],
|
||||
'doneUsersPhase2' => [],
|
||||
'movies' => [],
|
||||
'votes' => new stdClass(),
|
||||
'updatedAt' => gmdate('c'),
|
||||
'createdAt' => gmdate('c'),
|
||||
];
|
||||
}
|
||||
$raw = file_get_contents($path);
|
||||
if ($raw === false) {
|
||||
fail(500, 'Could not read state');
|
||||
}
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
fail(500, 'Corrupt state file');
|
||||
}
|
||||
$decoded['setupDone'] = (bool)($decoded['setupDone'] ?? false);
|
||||
$decoded['users'] = normalize_users(is_array($decoded['users'] ?? null) ? $decoded['users'] : []);
|
||||
$decoded['doneUsersPhase1'] = normalize_done_users(is_array($decoded['doneUsersPhase1'] ?? null) ? $decoded['doneUsersPhase1'] : [], $decoded['users']);
|
||||
$decoded['doneUsersPhase2'] = normalize_done_users(is_array($decoded['doneUsersPhase2'] ?? null) ? $decoded['doneUsersPhase2'] : [], $decoded['users']);
|
||||
$decoded['movies'] = is_array($decoded['movies'] ?? null) ? $decoded['movies'] : [];
|
||||
$decoded['votes'] = is_array($decoded['votes'] ?? null) ? $decoded['votes'] : [];
|
||||
$decoded['phase'] = (int)($decoded['phase'] ?? 1);
|
||||
if ($decoded['phase'] !== 1 && $decoded['phase'] !== 2 && $decoded['phase'] !== 3) {
|
||||
$decoded['phase'] = 1;
|
||||
}
|
||||
$decoded['history'] = is_array($decoded['history'] ?? null) ? $decoded['history'] : [];
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
function save_state(string $uuid, array $state): void {
|
||||
$state['updatedAt'] = gmdate('c');
|
||||
$path = DATA_DIR . '/' . $uuid . '.json';
|
||||
$json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
if ($json === false) {
|
||||
fail(500, 'Could not encode state');
|
||||
}
|
||||
if (file_put_contents($path, $json, LOCK_EX) === false) {
|
||||
fail(500, 'Could not save state');
|
||||
}
|
||||
}
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
$body = [];
|
||||
if ($method === 'POST') {
|
||||
$rawBody = file_get_contents('php://input');
|
||||
if ($rawBody !== false && $rawBody !== '') {
|
||||
$parsed = json_decode($rawBody, true);
|
||||
if (is_array($parsed)) {
|
||||
$body = $parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$action = $_GET['action'] ?? ($body['action'] ?? 'state');
|
||||
$uuid = $_GET['uuid'] ?? ($body['uuid'] ?? '');
|
||||
|
||||
if (!preg_match(UUID_PATTERN, $uuid)) {
|
||||
fail(400, 'Invalid UUID');
|
||||
}
|
||||
|
||||
$state = load_state($uuid);
|
||||
|
||||
if ($action === 'state') {
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($method !== 'POST') {
|
||||
fail(405, 'Use POST for mutations');
|
||||
}
|
||||
|
||||
if ($action === 'add_user') {
|
||||
if ((int)$state['phase'] !== 1 || $state['setupDone']) {
|
||||
fail(409, 'Setup is closed');
|
||||
}
|
||||
$user = clean_user((string)($body['user'] ?? ''));
|
||||
if (!in_array($user, $state['users'], true)) {
|
||||
$state['users'][] = $user;
|
||||
}
|
||||
save_state($uuid, $state);
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'finish_setup') {
|
||||
if (count($state['users']) < 1) {
|
||||
fail(409, 'Add at least one user first');
|
||||
}
|
||||
$state['setupDone'] = true;
|
||||
$state['phase'] = 1;
|
||||
$state['doneUsersPhase1'] = [];
|
||||
$state['doneUsersPhase2'] = [];
|
||||
save_state($uuid, $state);
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'add_movie') {
|
||||
if (!$state['setupDone']) {
|
||||
fail(409, 'Setup is not finished');
|
||||
}
|
||||
if ((int)$state['phase'] !== 1) {
|
||||
fail(409, 'Movie phase is closed');
|
||||
}
|
||||
$user = clean_user((string)($body['user'] ?? ''));
|
||||
if (count($state['users']) > 0 && !in_array($user, $state['users'], true)) {
|
||||
fail(403, 'Unknown user');
|
||||
}
|
||||
$title = clean_movie((string)($body['title'] ?? ''));
|
||||
$userMovieCount = 0;
|
||||
foreach ($state['movies'] as $movie) {
|
||||
if (!is_array($movie)) {
|
||||
continue;
|
||||
}
|
||||
if (mb_strtolower((string)($movie['addedBy'] ?? '')) === mb_strtolower($user)) {
|
||||
$userMovieCount++;
|
||||
}
|
||||
}
|
||||
if ($userMovieCount >= 5) {
|
||||
fail(409, 'Movie limit reached for this user (5)');
|
||||
}
|
||||
foreach ($state['movies'] as $existing) {
|
||||
if (mb_strtolower((string)($existing['title'] ?? '')) === mb_strtolower($title)) {
|
||||
fail(409, 'Movie already exists');
|
||||
}
|
||||
}
|
||||
$state['movies'][] = [
|
||||
'title' => $title,
|
||||
'addedBy' => $user,
|
||||
'addedAt' => gmdate('c'),
|
||||
];
|
||||
$state['doneUsersPhase1'] = array_values(array_filter(
|
||||
$state['doneUsersPhase1'],
|
||||
static fn(string $name): bool => $name !== $user,
|
||||
));
|
||||
save_state($uuid, $state);
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'remove_movie') {
|
||||
if (!$state['setupDone']) {
|
||||
fail(409, 'Setup is not finished');
|
||||
}
|
||||
if ((int)$state['phase'] !== 1) {
|
||||
fail(409, 'Movie phase is closed');
|
||||
}
|
||||
$user = clean_user((string)($body['user'] ?? ''));
|
||||
if (count($state['users']) > 0 && !in_array($user, $state['users'], true)) {
|
||||
fail(403, 'Unknown user');
|
||||
}
|
||||
$title = clean_movie((string)($body['title'] ?? ''));
|
||||
$state['movies'] = array_values(array_filter(
|
||||
$state['movies'],
|
||||
static fn(array $m): bool => !(mb_strtolower((string)($m['title'] ?? '')) === mb_strtolower($title) && mb_strtolower((string)($m['addedBy'] ?? '')) === mb_strtolower($user)),
|
||||
));
|
||||
$state['doneUsersPhase1'] = array_values(array_filter(
|
||||
$state['doneUsersPhase1'],
|
||||
static fn(string $name): bool => $name !== $user,
|
||||
));
|
||||
save_state($uuid, $state);
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'vote_many') {
|
||||
if ((int)$state['phase'] !== 2) {
|
||||
fail(409, 'Voting phase is not active');
|
||||
}
|
||||
$user = clean_user((string)($body['user'] ?? ''));
|
||||
if (count($state['users']) > 0 && !in_array($user, $state['users'], true)) {
|
||||
fail(403, 'Unknown user');
|
||||
}
|
||||
$ratings = $body['ratings'] ?? null;
|
||||
if (!is_array($ratings)) {
|
||||
fail(400, 'Ratings payload missing');
|
||||
}
|
||||
$movieTitles = array_map(static fn(array $movie): string => (string)$movie['title'], $state['movies']);
|
||||
if (!isset($state['votes'][$user]) || !is_array($state['votes'][$user])) {
|
||||
$state['votes'][$user] = [];
|
||||
}
|
||||
foreach ($movieTitles as $title) {
|
||||
$rawRating = $ratings[$title] ?? null;
|
||||
if (!is_int($rawRating)) {
|
||||
if (is_numeric($rawRating)) {
|
||||
$rawRating = (int)$rawRating;
|
||||
} else {
|
||||
fail(400, 'Each movie needs a rating');
|
||||
}
|
||||
}
|
||||
if ($rawRating < 0 || $rawRating > 5) {
|
||||
fail(400, 'Rating must be 0 to 5');
|
||||
}
|
||||
$state['votes'][$user][$title] = $rawRating;
|
||||
}
|
||||
$state['doneUsersPhase2'] = array_values(array_filter(
|
||||
$state['doneUsersPhase2'],
|
||||
static fn(string $name): bool => $name !== $user,
|
||||
));
|
||||
save_state($uuid, $state);
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'mark_done') {
|
||||
if (!$state['setupDone']) {
|
||||
fail(409, 'Setup is not finished');
|
||||
}
|
||||
$user = clean_user((string)($body['user'] ?? ''));
|
||||
if (count($state['users']) > 0 && !in_array($user, $state['users'], true)) {
|
||||
fail(403, 'Unknown user');
|
||||
}
|
||||
$phase = (int)($body['phase'] ?? $state['phase']);
|
||||
if ($phase === 1) {
|
||||
if ((int)$state['phase'] !== 1) {
|
||||
fail(409, 'Movie phase is not active');
|
||||
}
|
||||
if (!in_array($user, $state['doneUsersPhase1'], true)) {
|
||||
$state['doneUsersPhase1'][] = $user;
|
||||
}
|
||||
if (all_users_done($state['users'], $state['doneUsersPhase1'])) {
|
||||
$state['phase'] = 2;
|
||||
$state['doneUsersPhase2'] = [];
|
||||
}
|
||||
save_state($uuid, $state);
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
if ($phase === 2) {
|
||||
if ((int)$state['phase'] !== 2) {
|
||||
fail(409, 'Voting phase is not active');
|
||||
}
|
||||
$movieTitles = array_map(static fn(array $movie): string => (string)$movie['title'], $state['movies']);
|
||||
if (!isset($state['votes'][$user]) || !is_array($state['votes'][$user])) {
|
||||
fail(409, 'Save your ratings first');
|
||||
}
|
||||
foreach ($movieTitles as $title) {
|
||||
$value = $state['votes'][$user][$title] ?? null;
|
||||
if (!is_int($value)) {
|
||||
fail(409, 'Rate every movie before finishing');
|
||||
}
|
||||
}
|
||||
if (!in_array($user, $state['doneUsersPhase2'], true)) {
|
||||
$state['doneUsersPhase2'][] = $user;
|
||||
}
|
||||
if (all_users_done($state['users'], $state['doneUsersPhase2'])) {
|
||||
$state['phase'] = 3;
|
||||
}
|
||||
save_state($uuid, $state);
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
fail(400, 'Invalid done phase');
|
||||
}
|
||||
|
||||
if ($action === 'set_phase') {
|
||||
if (!$state['setupDone']) {
|
||||
fail(409, 'Finish setup first');
|
||||
}
|
||||
$phase = (int)($body['phase'] ?? 0);
|
||||
if ($phase !== 1 && $phase !== 2 && $phase !== 3) {
|
||||
fail(400, 'Invalid phase');
|
||||
}
|
||||
$state['phase'] = $phase;
|
||||
if ($phase === 1) {
|
||||
$state['doneUsersPhase1'] = [];
|
||||
$state['doneUsersPhase2'] = [];
|
||||
}
|
||||
if ($phase === 2) {
|
||||
$state['doneUsersPhase2'] = [];
|
||||
}
|
||||
save_state($uuid, $state);
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'new_round') {
|
||||
if ((int)$state['phase'] !== 3) {
|
||||
fail(409, 'Round is not finished yet');
|
||||
}
|
||||
$winner = trim((string)($body['winner'] ?? ''));
|
||||
// strip trophy emoji if present
|
||||
$winner = preg_replace('/^(?:\x{1F3C6}\s*)+/u', '', $winner);
|
||||
$state['history'][] = [
|
||||
'movies' => $state['movies'],
|
||||
'winner' => $winner !== '' ? $winner : null,
|
||||
];
|
||||
$state['phase'] = 1;
|
||||
$state['movies'] = [];
|
||||
$state['votes'] = new stdClass();
|
||||
$state['doneUsersPhase1'] = [];
|
||||
$state['doneUsersPhase2'] = [];
|
||||
save_state($uuid, $state);
|
||||
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
fail(404, 'Unknown action');
|
||||
650
app.js
650
app.js
@@ -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 `<section class="card inset"><h3>${escapeHtml(title)}</h3><p class="hint">No movies were added.</p></section>`;
|
||||
}
|
||||
if (info.kind === "no_votes") {
|
||||
return `<section class="card inset"><h3>${escapeHtml(title)}</h3><p class="hint">No complete votes yet.</p></section>`;
|
||||
}
|
||||
return `<section class="card inset"><h3>${escapeHtml(title)}</h3><p class="winner">Winner: ${escapeHtml(info.result.winner.movie)}</p><p class="hint">Complete voters: ${info.voterCount}/${info.totalUsers}</p><ol>${info.result.ranking.map((r) => `<li>${escapeHtml(r.movie)} (Nash ${r.nash.toFixed(2)})</li>`).join("")}</ol></section>`;
|
||||
}
|
||||
|
||||
function buildCreateScreen() {
|
||||
mount.innerHTML = `
|
||||
<h2>Create Round</h2>
|
||||
<p class="hint">Create a new round, add users, then share each user link.</p>
|
||||
<div class="actions">
|
||||
<button id="createRound" class="primary">Create New Round</button>
|
||||
</div>
|
||||
`;
|
||||
mount.querySelector("#createRound").addEventListener("click", () => {
|
||||
const id = crypto.randomUUID();
|
||||
window.location.href = `/movies/${id}/admin`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderAdminSetup() {
|
||||
mount.innerHTML = `
|
||||
<h2>Add Users</h2>
|
||||
<p class="hint">Add all users, then press Next to start the round.</p>
|
||||
<div class="row">
|
||||
<input id="userInput" type="text" maxlength="30" placeholder="User name" aria-label="User name">
|
||||
<button id="addUser">Add</button>
|
||||
</div>
|
||||
<ul class="chips" id="userList"></ul>
|
||||
<div class="actions">
|
||||
<button id="nextSetup" class="primary" ${state.users.length === 0 ? "disabled" : ""}>Next</button>
|
||||
</div>
|
||||
<p id="error" class="error" hidden></p>
|
||||
`;
|
||||
const userInput = mount.querySelector("#userInput");
|
||||
userInput.value = adminUserDraft;
|
||||
userInput.focus();
|
||||
userInput.addEventListener("input", () => {
|
||||
adminUserDraft = userInput.value;
|
||||
});
|
||||
mount.querySelector("#userList").innerHTML = state.users.map((name) => `<li>${escapeHtml(name)}</li>`).join("");
|
||||
const addUser = async () => {
|
||||
const name = adminUserDraft.trim();
|
||||
if (!name || busy) {
|
||||
return;
|
||||
}
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
busy = true;
|
||||
await apiPost("add_user", { user: name });
|
||||
adminUserDraft = "";
|
||||
draftRatings = {};
|
||||
await loadAndRender();
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
mount.querySelector("#addUser").addEventListener("click", addUser);
|
||||
userInput.addEventListener("keydown", async (event) => {
|
||||
if (event.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
await addUser();
|
||||
});
|
||||
mount.querySelector("#nextSetup").addEventListener("click", async () => {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
busy = true;
|
||||
await apiPost("finish_setup", {});
|
||||
await loadAndRender();
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderAdminStatus() {
|
||||
const donePhase1 = state.doneUsersPhase1 || [];
|
||||
const donePhase2 = state.doneUsersPhase2 || [];
|
||||
const totalSteps = state.users.length * 2;
|
||||
const completedSteps = donePhase1.length + donePhase2.length;
|
||||
const progressPercent = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
|
||||
const base = window.location.origin;
|
||||
const shareRows = state.users.map((name) => {
|
||||
const link = `${base}/movies/${uuid}?user=${encodeURIComponent(name)}`;
|
||||
const userSteps = (donePhase1.includes(name) ? 1 : 0) + (donePhase2.includes(name) ? 1 : 0);
|
||||
return `<li class="shareRow"><strong class="shareName">${escapeHtml(name)} <span class="muted">(${userSteps}/2)</span></strong><div class="shareControls"><input class="shareInput" type="text" readonly value="${escapeHtml(link)}" aria-label="Invite link for ${escapeHtml(name)}"><button class="copyLink" data-link="${escapeHtml(link)}" aria-label="Copy invite link for ${escapeHtml(name)}">Copy</button></div></li>`;
|
||||
}).join("");
|
||||
|
||||
let phaseTitle = "";
|
||||
if (state.phase === 1) {
|
||||
phaseTitle = "Phase 1: Movie Collection";
|
||||
} else if (state.phase === 2) {
|
||||
phaseTitle = "Phase 2: Voting";
|
||||
} else {
|
||||
phaseTitle = "Phase 3: Result";
|
||||
}
|
||||
const phasePanel = `
|
||||
<section class="card progressCard">
|
||||
<h3>${escapeHtml(phaseTitle)}</h3>
|
||||
<p class="hint">Overall progress: <strong>${completedSteps}/${totalSteps}</strong> user-steps (${progressPercent}%).</p>
|
||||
<div class="progressTrack" role="progressbar" aria-label="Round progress" aria-valuemin="0" aria-valuemax="${totalSteps}" aria-valuenow="${completedSteps}">
|
||||
<div class="progressFill" style="width: ${progressPercent}%;"></div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
mount.innerHTML = `
|
||||
<h2>Admin Status</h2>
|
||||
${phasePanel}
|
||||
<h3>Invite Links</h3>
|
||||
<ul class="links">${shareRows}</ul>
|
||||
<div class="actions">
|
||||
<button id="refreshAdmin" class="primary">Refresh</button>
|
||||
</div>
|
||||
${state.phase === 3 ? resultSection("Final Result") : ""}
|
||||
${state.phase === 3 ? `<div class="actions"><button id="newRound" class="primary">New Round</button></div>` : ""}
|
||||
<p id="error" class="error" hidden></p>
|
||||
`;
|
||||
mount.querySelectorAll(".copyLink").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const link = button.dataset.link;
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
button.textContent = "Copied";
|
||||
window.setTimeout(() => {
|
||||
button.textContent = "Copy";
|
||||
}, 1200);
|
||||
} catch {
|
||||
showError("Clipboard permission failed");
|
||||
}
|
||||
});
|
||||
});
|
||||
mount.querySelector("#refreshAdmin").addEventListener("click", async () => {
|
||||
try {
|
||||
await loadAndRender();
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
});
|
||||
if (state.phase === 3) {
|
||||
mount.querySelector("#newRound").addEventListener("click", async () => {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
busy = true;
|
||||
const info = computeRoundResult();
|
||||
const winner = info.kind === "ok" ? info.result.winner.movie : "";
|
||||
await apiPost("new_round", { winner });
|
||||
await loadAndRender();
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderUserWaitingPhase(phase) {
|
||||
const donePhase1 = state.doneUsersPhase1 || [];
|
||||
const donePhase2 = state.doneUsersPhase2 || [];
|
||||
const totalSteps = state.users.length * 2;
|
||||
const completedSteps = donePhase1.length + donePhase2.length;
|
||||
const progressPercent = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
|
||||
const phaseTitle = phase === 1 ? "Phase 1: Movie Collection" : "Phase 2: Voting";
|
||||
const waitingText = phase === 1
|
||||
? "You are done with movie collection. Waiting for other users."
|
||||
: "You are done with voting. Waiting for other users.";
|
||||
mount.innerHTML = `
|
||||
<h2>Waiting</h2>
|
||||
<section class="card progressCard">
|
||||
<h3>${escapeHtml(phaseTitle)}</h3>
|
||||
<p class="hint">Overall progress: <strong>${completedSteps}/${totalSteps}</strong> user-steps (${progressPercent}%).</p>
|
||||
<div class="progressTrack" role="progressbar" aria-label="Round progress" aria-valuemin="0" aria-valuemax="${totalSteps}" aria-valuenow="${completedSteps}">
|
||||
<div class="progressFill" style="width: ${progressPercent}%;"></div>
|
||||
</div>
|
||||
</section>
|
||||
<p class="hint">${escapeHtml(waitingText)}</p>
|
||||
<div class="actions">
|
||||
<button id="refreshUser" class="primary">Refresh</button>
|
||||
</div>
|
||||
<p id="error" class="error" hidden></p>
|
||||
`;
|
||||
mount.querySelector("#refreshUser").addEventListener("click", async () => {
|
||||
await loadAndRender();
|
||||
});
|
||||
}
|
||||
|
||||
function buildPrevMoviesSection(remaining) {
|
||||
const history = state.history || [];
|
||||
if (history.length === 0) {
|
||||
return "";
|
||||
}
|
||||
// normalize current titles (strip trophy if stored)
|
||||
const currentTitles = new Set(state.movies.map((m) => (m.title || "").replace(/^🏆\s*/, "").toLowerCase()));
|
||||
|
||||
const wonMovies = [];
|
||||
const regularMovies = new Set();
|
||||
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const round = history[i];
|
||||
if (round.winner) {
|
||||
wonMovies.push(round.winner);
|
||||
}
|
||||
for (const m of (round.movies || [])) {
|
||||
const raw = (m.title || "").replace(/^🏆\s*/, "");
|
||||
regularMovies.add(raw);
|
||||
}
|
||||
}
|
||||
|
||||
const wonTitleSet = new Set(wonMovies.map((t) => (t || "").replace(/^🏆\s*/, "").toLowerCase()));
|
||||
|
||||
const regularList = Array.from(regularMovies)
|
||||
.filter((title) => !wonTitleSet.has(title.toLowerCase()))
|
||||
.filter((title) => !currentTitles.has(title.toLowerCase()))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const seenWon = new Set();
|
||||
const wonList = [];
|
||||
for (const w of wonMovies) {
|
||||
const clean = (w || "").replace(/^🏆\s*/, "");
|
||||
const key = clean.toLowerCase();
|
||||
if (!clean || seenWon.has(key) || currentTitles.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seenWon.add(key);
|
||||
wonList.push({ display: `🏆 ${clean}`, value: clean });
|
||||
}
|
||||
|
||||
const items = [
|
||||
...regularList.map((t) => ({ display: t, value: t })),
|
||||
...wonList,
|
||||
];
|
||||
|
||||
if (items.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const chips = items
|
||||
.map((item) => {
|
||||
const disabled = remaining <= 0 ? " disabled" : "";
|
||||
return `<li><button class="chip prevMovie"${disabled} data-title="${escapeHtml(item.value)}">${escapeHtml(item.display)}</button></li>`;
|
||||
})
|
||||
.join("");
|
||||
return `<h3>Available Movies</h3><ul class="chips">${chips}</ul>`;
|
||||
}
|
||||
|
||||
function renderMoviePhase() {
|
||||
const canAddMovies = user && state.users.includes(user);
|
||||
const myMovieCount = canAddMovies ? state.movies.filter((m) => m.addedBy.toLowerCase() === user.toLowerCase()).length : 0;
|
||||
const remaining = Math.max(0, 5 - myMovieCount);
|
||||
const done = isUserDone(1, user);
|
||||
if (done) {
|
||||
renderUserWaitingPhase(1);
|
||||
return;
|
||||
}
|
||||
mount.innerHTML = `
|
||||
<h2>Add Movies</h2>
|
||||
<p class="hint">Phase 1 of 3: add up to 5 movies, then press Done.</p>
|
||||
${canAddMovies ? `<p class="hint">Your movie slots left: <strong>${remaining}</strong></p>` : `<p class="hint">Use your personal user link.</p>`}
|
||||
${canAddMovies ? `<div class="row"><input id="movieInput" type="text" maxlength="60" placeholder="Movie title" aria-label="Movie title"><button id="addMovie">Add</button></div>` : ""}
|
||||
${canAddMovies && myMovieCount > 0 ? `<h3>Your Selection (tap to remove)</h3>` : ""}
|
||||
<ul class="chips" id="movieList"></ul>
|
||||
${canAddMovies ? buildPrevMoviesSection(remaining) : ""}
|
||||
${canAddMovies ? `<div class="actions"><button id="refreshUser">Refresh</button><button id="markDone" class="primary" ${done ? "disabled" : ""}>Done</button></div>` : ""}
|
||||
<p id="error" class="error" hidden></p>
|
||||
`;
|
||||
mount.querySelector("#movieList").innerHTML = state.movies
|
||||
.filter((m) => canAddMovies && m.addedBy.toLowerCase() === user.toLowerCase())
|
||||
.map((m) => `<li><button class="chip userMovie" data-title="${escapeHtml(m.title.replace(/^🏆\s*/, ''))}">${escapeHtml(m.title.replace(/^🏆\s*/, ''))}</button></li>`)
|
||||
.join("");
|
||||
if (!canAddMovies) {
|
||||
return;
|
||||
}
|
||||
const addMovie = async () => {
|
||||
const input = mount.querySelector("#movieInput");
|
||||
const title = input.value.trim();
|
||||
if (!title || busy) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
busy = true;
|
||||
await apiPost("add_movie", { user, title });
|
||||
input.value = "";
|
||||
await loadAndRender();
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
mount.querySelector("#addMovie").addEventListener("click", addMovie);
|
||||
mount.querySelector("#movieInput").focus();
|
||||
mount.querySelector("#movieInput").addEventListener("keydown", async (event) => {
|
||||
if (event.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
await addMovie();
|
||||
});
|
||||
mount.querySelectorAll(".prevMovie").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
if (busy || btn.disabled) {
|
||||
return;
|
||||
}
|
||||
const title = btn.dataset.title;
|
||||
try {
|
||||
busy = true;
|
||||
await apiPost("add_movie", { user, title });
|
||||
await loadAndRender();
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
mount.querySelectorAll(".userMovie").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
const title = btn.dataset.title;
|
||||
try {
|
||||
busy = true;
|
||||
await apiPost("remove_movie", { user, title });
|
||||
await loadAndRender();
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
mount.querySelector("#markDone").addEventListener("click", async () => {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
busy = true;
|
||||
await apiPost("mark_done", { user, phase: 1 });
|
||||
await loadAndRender();
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
});
|
||||
mount.querySelector("#refreshUser").addEventListener("click", async () => {
|
||||
await loadAndRender();
|
||||
});
|
||||
}
|
||||
|
||||
function renderVotingPhase() {
|
||||
const movies = state.movies.map((m) => m.title);
|
||||
if (!state.votes[user]) {
|
||||
state.votes[user] = {};
|
||||
}
|
||||
const done = isUserDone(2, user);
|
||||
if (done) {
|
||||
renderUserWaitingPhase(2);
|
||||
return;
|
||||
}
|
||||
const rows = movies
|
||||
.map((title) => {
|
||||
const current = draftRatings[title] ?? state.votes[user][title] ?? 3;
|
||||
return `<tr><th scope="row">${escapeHtml(title)}</th><td><div class="ratingRow"><input class="rating" type="range" min="0" max="5" step="1" value="${current}" data-title="${escapeHtml(title)}" aria-label="Rate ${escapeHtml(title)}"><span class="ratingValue" data-title="${escapeHtml(title)}">${current}</span></div></td></tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
mount.innerHTML = `
|
||||
<h2>Vote</h2>
|
||||
<p class="hint">Phase 2 of 3: rate every movie, then press Done.</p>
|
||||
<table>
|
||||
<thead><tr><th>Movie</th><th>Your Rating</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
<div class="actions">
|
||||
<button id="refreshUser">Refresh</button>
|
||||
<button id="saveVotes">Save Ratings</button>
|
||||
<button id="doneVoting" class="primary" ${done ? "disabled" : ""}>Done</button>
|
||||
</div>
|
||||
<p id="error" class="error" hidden></p>
|
||||
`;
|
||||
|
||||
mount.querySelectorAll(".rating").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
const title = input.dataset.title;
|
||||
const rating = Math.max(0, Math.min(5, Number.parseInt(input.value || "0", 10)));
|
||||
input.value = String(rating);
|
||||
draftRatings[title] = rating;
|
||||
const valueLabel = input.closest(".ratingRow")?.querySelector(".ratingValue");
|
||||
if (valueLabel) {
|
||||
valueLabel.textContent = String(rating);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
mount.querySelector("#saveVotes").addEventListener("click", async () => {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
busy = true;
|
||||
const ratings = {};
|
||||
mount.querySelectorAll(".rating").forEach((input) => {
|
||||
const title = input.dataset.title;
|
||||
ratings[title] = Math.max(0, Math.min(5, Number.parseInt(input.value || "0", 10)));
|
||||
});
|
||||
await apiPost("vote_many", { user, ratings });
|
||||
draftRatings = {};
|
||||
await loadAndRender();
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
});
|
||||
|
||||
mount.querySelector("#doneVoting").addEventListener("click", async () => {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
busy = true;
|
||||
const ratings = {};
|
||||
mount.querySelectorAll(".rating").forEach((input) => {
|
||||
const title = input.dataset.title;
|
||||
ratings[title] = Math.max(0, Math.min(5, Number.parseInt(input.value || "0", 10)));
|
||||
});
|
||||
await apiPost("vote_many", { user, ratings });
|
||||
await apiPost("mark_done", { user, phase: 2 });
|
||||
draftRatings = {};
|
||||
await loadAndRender();
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
});
|
||||
|
||||
mount.querySelector("#refreshUser").addEventListener("click", async () => {
|
||||
await loadAndRender();
|
||||
});
|
||||
}
|
||||
|
||||
function renderFinalPage() {
|
||||
mount.innerHTML = `
|
||||
<h2>Final Result</h2>
|
||||
<p class="hint">Phase 3 of 3: voting is complete.</p>
|
||||
${resultSection("Round Outcome")}
|
||||
<div class="actions">
|
||||
<button id="refreshUser">Refresh</button>
|
||||
</div>
|
||||
<p id="error" class="error" hidden></p>
|
||||
`;
|
||||
mount.querySelector("#refreshUser").addEventListener("click", async () => {
|
||||
await loadAndRender();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAndRender() {
|
||||
const data = await apiGetState();
|
||||
state = data.state;
|
||||
if (!state.setupDone && isAdmin) {
|
||||
renderAdminSetup();
|
||||
return;
|
||||
}
|
||||
if (!state.setupDone) {
|
||||
mount.innerHTML = `<p class="hint">Admin is still setting up users. Open your personal link when ready.</p><div class="actions"><button id="refreshUser">Refresh</button></div>`;
|
||||
mount.querySelector("#refreshUser").addEventListener("click", async () => {
|
||||
await loadAndRender();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isAdmin) {
|
||||
renderAdminStatus();
|
||||
return;
|
||||
}
|
||||
if (!user) {
|
||||
askUserName();
|
||||
return;
|
||||
}
|
||||
if (state.users.length > 0 && !state.users.includes(user)) {
|
||||
mount.innerHTML = `<p class="error">Unknown user. Use one of the shared links from admin.</p>`;
|
||||
return;
|
||||
}
|
||||
if (state.phase === 1) {
|
||||
renderMoviePhase();
|
||||
return;
|
||||
}
|
||||
if (state.phase === 2) {
|
||||
renderVotingPhase();
|
||||
return;
|
||||
}
|
||||
renderFinalPage();
|
||||
}
|
||||
|
||||
function askUserName() {
|
||||
mount.innerHTML = `
|
||||
<h2>Who are you?</h2>
|
||||
<p class="hint">Use the exact name from your invite link.</p>
|
||||
<div class="row">
|
||||
<input id="userInput" type="text" maxlength="30" placeholder="Your name" aria-label="Your name">
|
||||
<button id="saveUser" class="primary">Continue</button>
|
||||
</div>
|
||||
`;
|
||||
mount.querySelector("#userInput").focus();
|
||||
mount.querySelector("#saveUser").addEventListener("click", () => {
|
||||
const value = mount.querySelector("#userInput").value.trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
params.set("user", value);
|
||||
window.location.search = params.toString();
|
||||
});
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
if (!uuid) {
|
||||
buildCreateScreen();
|
||||
return;
|
||||
}
|
||||
if (!isValidUuid(uuid)) {
|
||||
mount.innerHTML = `<p class="error">Invalid UUID in URL.</p>`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await loadAndRender();
|
||||
} catch (error) {
|
||||
mount.innerHTML = `<p class="error">${escapeHtml(error.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
boot();
|
||||
25
biome.json
Normal file
25
biome.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||
"files": {
|
||||
"includes": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"]
|
||||
},
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"indentStyle": "tab"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
507
bun.lock
507
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=="],
|
||||
}
|
||||
}
|
||||
|
||||
47
deploy.sh
47
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/"
|
||||
|
||||
10
drizzle.config.ts
Normal file
10
drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/server/shared/db/schema/index.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
58
index.html
58
index.html
@@ -1,57 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#0b0f14">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f8fafc" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<link rel="manifest" href="/movie-select/manifest.webmanifest" />
|
||||
<link rel="icon" href="/movie-select/icon.svg" type="image/svg+xml" />
|
||||
<title>Movie Select</title>
|
||||
</head>
|
||||
<body>
|
||||
<main class="app" aria-live="polite">
|
||||
<header class="topbar">
|
||||
<h1 data-i18n="title">Movie Select</h1>
|
||||
<label for="language" class="sr-only" data-i18n="language">Language</label>
|
||||
<select id="language" aria-label="Language selector">
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="fr">Français</option>
|
||||
</select>
|
||||
</header>
|
||||
|
||||
<section class="card">
|
||||
<h2 data-i18n="setup">Setup</h2>
|
||||
<div class="row">
|
||||
<input id="participantInput" type="text" data-i18n-placeholder="participantPlaceholder" placeholder="Add participant" maxlength="30">
|
||||
<button id="addParticipant" data-i18n="add">Add</button>
|
||||
</div>
|
||||
<ul id="participantList" class="chips" aria-label="Participants"></ul>
|
||||
<div class="row">
|
||||
<input id="movieInput" type="text" data-i18n-placeholder="moviePlaceholder" placeholder="Add movie" maxlength="60">
|
||||
<button id="addMovie" data-i18n="add">Add</button>
|
||||
</div>
|
||||
<ul id="movieList" class="chips" aria-label="Movies"></ul>
|
||||
<p class="hint" data-i18n="setupHint">Add at least 2 people and 2 movies.</p>
|
||||
</section>
|
||||
|
||||
<section class="card" id="ratingsCard" hidden>
|
||||
<h2 data-i18n="ratings">Ratings</h2>
|
||||
<p class="hint" data-i18n="ratingsHint">Each person rates each movie from 0 (no) to 5 (yes).</p>
|
||||
<div id="ratingsGrid" class="ratings-grid" role="table" aria-label="Ratings grid"></div>
|
||||
<div class="actions">
|
||||
<button id="decide" class="primary" data-i18n="decide">Pick a movie</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="resultCard" hidden>
|
||||
<h2 data-i18n="result">Result</h2>
|
||||
<p id="winnerText" class="winner"></p>
|
||||
<div id="resultList"></div>
|
||||
<p class="hint" data-i18n="algoNote">Method: Pareto filter + Nash social welfare (product of ratings+1). This avoids picks one person strongly dislikes.</p>
|
||||
</section>
|
||||
</main>
|
||||
<script src="app.js" type="module"></script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
23
index.php
23
index.php
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
$css_v = filemtime(__DIR__ . '/styles.css');
|
||||
$js_v = filemtime(__DIR__ . '/app.js');
|
||||
?><!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#0b0f14">
|
||||
<link rel="manifest" href="/movies/manifest.webmanifest">
|
||||
<link rel="stylesheet" href="/movies/styles.css?v=<?= $css_v ?>">
|
||||
<title>Movie Select</title>
|
||||
</head>
|
||||
<body>
|
||||
<main class="app" aria-live="polite">
|
||||
<header class="topbar">
|
||||
<h1>Movie Select</h1>
|
||||
</header>
|
||||
<section class="card" id="app"></section>
|
||||
</main>
|
||||
<script type="module" src="/movies/app.js?v=<?= $js_v ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
44
package.json
44
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"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 332 B After Width: | Height: | Size: 332 B |
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { copyFileSync, mkdirSync } from "fs";
|
||||
|
||||
mkdirSync("dist", { recursive: true });
|
||||
copyFileSync("public/manifest.webmanifest", "dist/manifest.webmanifest");
|
||||
copyFileSync("icon.svg", "dist/icon.svg");
|
||||
console.log("Assets copied.");
|
||||
104
scripts/migrate-data.ts
Normal file
104
scripts/migrate-data.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* One-time migration: MariaDB → PostgreSQL
|
||||
*
|
||||
* Reads all data from the existing MariaDB database and inserts into PostgreSQL.
|
||||
* Requires both DATABASE_URL (postgres) and MARIA_* env vars.
|
||||
*
|
||||
* Usage: node --env-file=.env scripts/migrate-data.ts
|
||||
*/
|
||||
import mysql from "mysql2/promise";
|
||||
import postgres from "postgres";
|
||||
|
||||
const MARIA_HOST = process.env.MARIA_HOST ?? "127.0.0.1";
|
||||
const MARIA_PORT = Number(process.env.MARIA_PORT ?? 3306);
|
||||
const MARIA_USER = process.env.MARIA_USER ?? "serve";
|
||||
const MARIA_PASS = process.env.MARIA_PASS ?? "";
|
||||
const MARIA_DB = process.env.MARIA_DB ?? "serve";
|
||||
const PG_URL = process.env.DATABASE_URL;
|
||||
|
||||
if (!PG_URL) {
|
||||
console.error("DATABASE_URL is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const maria = await mysql.createConnection({
|
||||
host: MARIA_HOST,
|
||||
port: MARIA_PORT,
|
||||
user: MARIA_USER,
|
||||
password: MARIA_PASS,
|
||||
database: MARIA_DB,
|
||||
timezone: "+00:00",
|
||||
});
|
||||
|
||||
const pg = postgres(PG_URL);
|
||||
|
||||
// Order: rounds → round_users → movies → votes → round_history (FK constraint order)
|
||||
|
||||
console.log("Migrating rounds...");
|
||||
const [roundRows] = await maria.execute("SELECT * FROM rounds");
|
||||
for (const r of roundRows as mysql.RowDataPacket[]) {
|
||||
await pg`
|
||||
INSERT INTO rounds (uuid, phase, setup_done, created_at, updated_at)
|
||||
VALUES (${r.uuid}, ${r.phase}, ${Boolean(r.setup_done)}, ${r.created_at}, ${r.updated_at})
|
||||
ON CONFLICT (uuid) DO NOTHING
|
||||
`;
|
||||
}
|
||||
console.log(` ${(roundRows as mysql.RowDataPacket[]).length} rounds`);
|
||||
|
||||
console.log("Migrating round_users...");
|
||||
const [userRows] = await maria.execute("SELECT * FROM round_users");
|
||||
for (const u of userRows as mysql.RowDataPacket[]) {
|
||||
await pg`
|
||||
INSERT INTO round_users (round_uuid, name, done_phase1, done_phase2, sort_order)
|
||||
VALUES (${u.round_uuid}, ${u.name}, ${Boolean(u.done_phase1)}, ${Boolean(u.done_phase2)}, ${u.sort_order})
|
||||
ON CONFLICT (round_uuid, name) DO NOTHING
|
||||
`;
|
||||
}
|
||||
console.log(` ${(userRows as mysql.RowDataPacket[]).length} users`);
|
||||
|
||||
console.log("Migrating movies...");
|
||||
const [movieRows] = await maria.execute("SELECT * FROM movies ORDER BY id");
|
||||
for (const m of movieRows as mysql.RowDataPacket[]) {
|
||||
await pg`
|
||||
INSERT INTO movies (id, round_uuid, title, added_by, added_at)
|
||||
VALUES (${m.id}, ${m.round_uuid}, ${m.title}, ${m.added_by}, ${m.added_at})
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
}
|
||||
console.log(` ${(movieRows as mysql.RowDataPacket[]).length} movies`);
|
||||
|
||||
// Reset the serial sequence for movies.id
|
||||
await pg`SELECT setval('movies_id_seq', COALESCE((SELECT MAX(id) FROM movies), 0) + 1)`;
|
||||
|
||||
console.log("Migrating votes...");
|
||||
const [voteRows] = await maria.execute("SELECT * FROM votes");
|
||||
for (const v of voteRows as mysql.RowDataPacket[]) {
|
||||
await pg`
|
||||
INSERT INTO votes (round_uuid, user_name, movie_title, rating)
|
||||
VALUES (${v.round_uuid}, ${v.user_name}, ${v.movie_title}, ${v.rating})
|
||||
ON CONFLICT (round_uuid, user_name, movie_title) DO NOTHING
|
||||
`;
|
||||
}
|
||||
console.log(` ${(voteRows as mysql.RowDataPacket[]).length} votes`);
|
||||
|
||||
console.log("Migrating round_history...");
|
||||
const [historyRows] = await maria.execute(
|
||||
"SELECT * FROM round_history ORDER BY id",
|
||||
);
|
||||
for (const h of historyRows as mysql.RowDataPacket[]) {
|
||||
await pg`
|
||||
INSERT INTO round_history (id, round_uuid, winner, movies_json, created_at)
|
||||
VALUES (${h.id}, ${h.round_uuid}, ${h.winner}, ${h.movies_json}, ${h.created_at})
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
}
|
||||
console.log(
|
||||
` ${(historyRows as mysql.RowDataPacket[]).length} history entries`,
|
||||
);
|
||||
|
||||
// Reset the serial sequence for round_history.id
|
||||
await pg`SELECT setval('round_history_id_seq', COALESCE((SELECT MAX(id) FROM round_history), 0) + 1)`;
|
||||
|
||||
console.log("Migration complete!");
|
||||
await maria.end();
|
||||
await pg.end();
|
||||
@@ -1,46 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run once to set up the server.
|
||||
# Run once to set up the movie-select service on Uberspace 8.
|
||||
# Usage: bash setup-server.sh
|
||||
set -euo pipefail
|
||||
|
||||
REMOTE_HOST="serve"
|
||||
REMOTE_APP_DIR="/home/serve/services/movies"
|
||||
REMOTE_APP_DIR="/home/serve/services/movie-select"
|
||||
REMOTE_STATIC_DIR="/var/www/virtual/serve/html/movie-select"
|
||||
REMOTE_PORT=3001
|
||||
DB_PASS="09a7d97c99aff8fd883d3bbe3da927e254ac507b"
|
||||
DB_NAME="movie_select"
|
||||
|
||||
echo "==> Creating app directory on server..."
|
||||
ssh "${REMOTE_HOST}" "mkdir -p ${REMOTE_APP_DIR}"
|
||||
echo "==> Creating app + static directories on server..."
|
||||
ssh "${REMOTE_HOST}" "mkdir -p ${REMOTE_APP_DIR} ${REMOTE_STATIC_DIR}"
|
||||
|
||||
echo "==> Creating PostgreSQL database (if not exists)..."
|
||||
ssh "${REMOTE_HOST}" "createdb ${DB_NAME} 2>/dev/null || echo 'Database already exists'"
|
||||
|
||||
echo "==> Fetching PostgreSQL credentials..."
|
||||
DB_PASS=$(ssh "${REMOTE_HOST}" "my_print_defaults client 2>/dev/null | grep -oP '(?<=--password=).*' || echo ''")
|
||||
DB_USER="serve"
|
||||
DB_HOST="localhost"
|
||||
DB_PORT="5432"
|
||||
|
||||
echo "==> Creating .env on server..."
|
||||
ssh "${REMOTE_HOST}" "cat > ${REMOTE_APP_DIR}/.env" <<EOF
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_USER=serve
|
||||
DB_PASS=${DB_PASS}
|
||||
DB_NAME=serve
|
||||
DATABASE_URL=postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}
|
||||
PORT=${REMOTE_PORT}
|
||||
EOF
|
||||
|
||||
echo "==> Initialising database schema..."
|
||||
scp setup-db.sql "${REMOTE_HOST}:/tmp/movie-select-setup.sql"
|
||||
ssh "${REMOTE_HOST}" "mysql -u serve -p'${DB_PASS}' serve < /tmp/movie-select-setup.sql && rm /tmp/movie-select-setup.sql"
|
||||
|
||||
echo "==> Deploying app files..."
|
||||
bash deploy.sh
|
||||
|
||||
echo "==> Detecting bun path on server..."
|
||||
BUN_PATH=$(ssh "${REMOTE_HOST}" 'which bun || echo "$HOME/.bun/bin/bun"')
|
||||
echo " bun at: ${BUN_PATH}"
|
||||
echo "==> Detecting node path on server..."
|
||||
NODE_PATH=$(ssh "${REMOTE_HOST}" 'which node || echo "$HOME/.local/bin/node"')
|
||||
echo " node at: ${NODE_PATH}"
|
||||
|
||||
echo "==> Creating systemd service..."
|
||||
ssh "${REMOTE_HOST}" BUN_PATH="${BUN_PATH}" REMOTE_APP_DIR="${REMOTE_APP_DIR}" bash <<'ENDSSH'
|
||||
ssh "${REMOTE_HOST}" NODE_PATH="${NODE_PATH}" REMOTE_APP_DIR="${REMOTE_APP_DIR}" bash <<'ENDSSH'
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cat > ~/.config/systemd/user/movie-select.service <<SERVICE
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
[Service]
|
||||
ExecStart=${BUN_PATH} run src/server/index.ts
|
||||
ExecStart=${NODE_PATH} --env-file=.env src/server/index.ts
|
||||
WorkingDirectory=${REMOTE_APP_DIR}
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
@@ -50,11 +52,11 @@ systemctl --user enable movie-select
|
||||
systemctl --user start movie-select
|
||||
ENDSSH
|
||||
|
||||
echo "==> Configuring web backend..."
|
||||
ssh "${REMOTE_HOST}" "uberspace web backend add /movies port ${REMOTE_PORT} --remove-prefix 2>/dev/null || uberspace web backend set /movies port ${REMOTE_PORT} --remove-prefix"
|
||||
echo "==> Configuring web backend for API..."
|
||||
ssh "${REMOTE_HOST}" "uberspace web backend add /movie-select/api port ${REMOTE_PORT} --remove-prefix 2>/dev/null || uberspace web backend set /movie-select/api port ${REMOTE_PORT} --remove-prefix"
|
||||
|
||||
echo "==> Service status:"
|
||||
ssh "${REMOTE_HOST}" "systemctl --user status movie-select --no-pager"
|
||||
|
||||
echo ""
|
||||
echo "Done. Live at https://serve.uber.space/movies/"
|
||||
echo "Done. Live at https://serve.uber.space/movie-select/"
|
||||
|
||||
82
src/client/hooks/use-round-mutation.ts
Normal file
82
src/client/hooks/use-round-mutation.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { State } from "@/shared/types.ts";
|
||||
import { api } from "../lib/api-client.ts";
|
||||
|
||||
function useRoundMutation(uuid: string, mutationFn: () => Promise<State>) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
onSuccess: (state) => {
|
||||
qc.setQueryData(["round", uuid], state);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddUser(uuid: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (user: string) => api.addUser(uuid, user),
|
||||
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFinishSetup(uuid: string) {
|
||||
return useRoundMutation(uuid, () => api.finishSetup(uuid));
|
||||
}
|
||||
|
||||
export function useAddMovie(uuid: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ user, title }: { user: string; title: string }) =>
|
||||
api.addMovie(uuid, user, title),
|
||||
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveMovie(uuid: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ user, title }: { user: string; title: string }) =>
|
||||
api.removeMovie(uuid, user, title),
|
||||
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
||||
});
|
||||
}
|
||||
|
||||
export function useVote(uuid: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
user,
|
||||
ratings,
|
||||
}: {
|
||||
user: string;
|
||||
ratings: Record<string, number>;
|
||||
}) => api.vote(uuid, user, ratings),
|
||||
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkDone(uuid: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ user, phase }: { user: string; phase: 1 | 2 }) =>
|
||||
api.markDone(uuid, user, phase),
|
||||
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetPhase(uuid: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (phase: 1 | 2 | 3) => api.setPhase(uuid, phase),
|
||||
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
||||
});
|
||||
}
|
||||
|
||||
export function useNewRound(uuid: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (winner: string) => api.newRound(uuid, winner),
|
||||
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
||||
});
|
||||
}
|
||||
11
src/client/hooks/use-round.ts
Normal file
11
src/client/hooks/use-round.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../lib/api-client.ts";
|
||||
|
||||
export function useRound(uuid: string) {
|
||||
return useQuery({
|
||||
queryKey: ["round", uuid],
|
||||
queryFn: () => api.getState(uuid),
|
||||
enabled: uuid.length > 0,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
}
|
||||
53
src/client/index.css
Normal file
53
src/client/index.css
Normal file
@@ -0,0 +1,53 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
/* Range slider */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: #dbeafe;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* Safe-area bottom padding for notched phones */
|
||||
.safe-b {
|
||||
padding-bottom: max(2.5rem, env(safe-area-inset-bottom));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
43
src/client/lib/api-client.ts
Normal file
43
src/client/lib/api-client.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { State } from "@/shared/types.ts";
|
||||
|
||||
const BASE = "/movie-select/api/rounds";
|
||||
|
||||
interface ApiResponse {
|
||||
ok: boolean;
|
||||
state: State;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function request(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<State> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const data = (await res.json()) as ApiResponse;
|
||||
if (!res.ok || !data.ok) throw new Error(data.error ?? "Request failed");
|
||||
return data.state;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getState: (uuid: string) => request("GET", `/${uuid}`),
|
||||
addUser: (uuid: string, user: string) =>
|
||||
request("POST", `/${uuid}/users`, { user }),
|
||||
finishSetup: (uuid: string) => request("POST", `/${uuid}/setup`),
|
||||
addMovie: (uuid: string, user: string, title: string) =>
|
||||
request("POST", `/${uuid}/movies`, { user, title }),
|
||||
removeMovie: (uuid: string, user: string, title: string) =>
|
||||
request("DELETE", `/${uuid}/movies`, { user, title }),
|
||||
vote: (uuid: string, user: string, ratings: Record<string, number>) =>
|
||||
request("POST", `/${uuid}/votes`, { user, ratings }),
|
||||
markDone: (uuid: string, user: string, phase: 1 | 2) =>
|
||||
request("POST", `/${uuid}/done`, { user, phase }),
|
||||
setPhase: (uuid: string, phase: 1 | 2 | 3) =>
|
||||
request("POST", `/${uuid}/phase`, { phase }),
|
||||
newRound: (uuid: string, winner: string) =>
|
||||
request("POST", `/${uuid}/new-round`, { winner }),
|
||||
};
|
||||
6
src/client/lib/utils.ts
Normal file
6
src/client/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,551 +0,0 @@
|
||||
import { decideMovie } from "./algorithm.ts";
|
||||
import { collectCompleteRatings } from "./round-state.ts";
|
||||
|
||||
interface Movie {
|
||||
title: string;
|
||||
addedBy: string;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
movies: Movie[];
|
||||
winner: string | null;
|
||||
}
|
||||
|
||||
interface State {
|
||||
uuid: string;
|
||||
phase: 1 | 2 | 3;
|
||||
setupDone: boolean;
|
||||
users: string[];
|
||||
doneUsersPhase1: string[];
|
||||
doneUsersPhase2: string[];
|
||||
movies: Movie[];
|
||||
votes: Record<string, Record<string, number>>;
|
||||
history: HistoryEntry[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const mount = document.getElementById("app")!;
|
||||
const path = window.location.pathname.replace(/^\/movies\/?/, "");
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
const uuid = segments[0] ?? "";
|
||||
const isAdmin = segments[1] === "admin";
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let user = (params.get("user") ?? "").trim();
|
||||
let state: State | null = null;
|
||||
let draftRatings: Record<string, number> = {};
|
||||
let adminUserDraft = "";
|
||||
let busy = false;
|
||||
|
||||
function isValidUuid(v: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v);
|
||||
}
|
||||
|
||||
function apiUrl(action: string): string {
|
||||
return `/movies/api?action=${encodeURIComponent(action)}&uuid=${encodeURIComponent(uuid)}`;
|
||||
}
|
||||
|
||||
async function apiGetState(): Promise<{ ok: boolean; state: State }> {
|
||||
const res = await fetch(apiUrl("state"));
|
||||
return res.json() as Promise<{ ok: boolean; state: State }>;
|
||||
}
|
||||
|
||||
async function apiPost(action: string, body: Record<string, unknown>): Promise<{ ok: boolean; state: State }> {
|
||||
const res = await fetch(apiUrl(action), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...body, action, uuid }),
|
||||
});
|
||||
const data = await res.json() as { ok: boolean; state: State; error?: string };
|
||||
if (!res.ok || !data.ok) throw new Error(data.error ?? "Request failed");
|
||||
return data;
|
||||
}
|
||||
|
||||
function esc(text: string): string {
|
||||
return text.replace(/[&<>"']/g, (c) => (
|
||||
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c,
|
||||
);
|
||||
}
|
||||
|
||||
function showError(message: string): void {
|
||||
const el = mount.querySelector<HTMLElement>("#error");
|
||||
if (!el) return;
|
||||
el.hidden = false;
|
||||
el.textContent = message;
|
||||
}
|
||||
|
||||
function doneForPhase(phase: 1 | 2): string[] {
|
||||
if (!state) return [];
|
||||
return phase === 1 ? (state.doneUsersPhase1 ?? []) : (state.doneUsersPhase2 ?? []);
|
||||
}
|
||||
|
||||
function isUserDone(phase: 1 | 2, name: string): boolean {
|
||||
return !!name && doneForPhase(phase).includes(name);
|
||||
}
|
||||
|
||||
type RoundResult =
|
||||
| { kind: "no_movies" }
|
||||
| { kind: "no_votes" }
|
||||
| { kind: "ok"; voterCount: number; totalUsers: number; result: ReturnType<typeof decideMovie> };
|
||||
|
||||
function computeRoundResult(): RoundResult {
|
||||
if (!state) return { kind: "no_movies" };
|
||||
const movies = state.movies.map((m) => m.title);
|
||||
if (movies.length === 0) return { kind: "no_movies" };
|
||||
const ratings = collectCompleteRatings(state.users, movies, state.votes ?? {});
|
||||
const voters = Object.keys(ratings);
|
||||
if (voters.length === 0) return { kind: "no_votes" };
|
||||
return {
|
||||
kind: "ok",
|
||||
voterCount: voters.length,
|
||||
totalUsers: state.users.length,
|
||||
result: decideMovie({ movies, people: voters, ratings }),
|
||||
};
|
||||
}
|
||||
|
||||
function resultSection(title: string): string {
|
||||
const info = computeRoundResult();
|
||||
if (info.kind === "no_movies") {
|
||||
return `<div class="card-inset"><p class="text-[13px] font-semibold uppercase tracking-wider text-slate-400 m-0">${esc(title)}</p><p class="hint">No movies were added.</p></div>`;
|
||||
}
|
||||
if (info.kind === "no_votes") {
|
||||
return `<div class="card-inset"><p class="text-[13px] font-semibold uppercase tracking-wider text-slate-400 m-0">${esc(title)}</p><p class="hint">No complete votes yet.</p></div>`;
|
||||
}
|
||||
const { result, voterCount, totalUsers } = info;
|
||||
const rows = result.ranking.map((r, i) => `
|
||||
<div class="flex items-center gap-3 py-2.5 ${i > 0 ? "border-t border-slate-100" : ""}">
|
||||
<span class="w-5 text-center text-[13px] text-slate-400 font-semibold shrink-0">${i + 1}</span>
|
||||
<span class="flex-1 text-sm">${esc(r.movie)}</span>
|
||||
<span class="text-[13px] text-slate-400 shrink-0">Nash ${r.nash.toFixed(1)}</span>
|
||||
</div>`).join("");
|
||||
return `<div class="card-inset">
|
||||
<p class="text-[13px] font-semibold uppercase tracking-wider text-slate-400 m-0">${esc(title)}</p>
|
||||
<p class="mt-3 text-lg font-bold text-slate-900">🏆 ${esc(result.winner.movie)}</p>
|
||||
<p class="hint mt-1">Voters: ${voterCount} of ${totalUsers}</p>
|
||||
<div class="mt-3">${rows}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function progressCard(phaseTitle: string, done: number, total: number): string {
|
||||
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
||||
return `<div class="card-progress">
|
||||
<p class="m-0 text-sm font-semibold text-blue-800">${esc(phaseTitle)}</p>
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
<div class="flex-1 h-2 rounded-full bg-blue-100 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-blue-500 transition-all duration-300" style="width:${pct}%"></div>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-blue-700 shrink-0">${done}/${total}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ─── Screens ───────────────────────────────────────────────────────────────
|
||||
|
||||
function buildCreateScreen(): void {
|
||||
mount.innerHTML = `
|
||||
<h2 class="screen-title">Movie Select</h2>
|
||||
<p class="hint">Start a new round, add everyone, share links — pick a film.</p>
|
||||
<div class="action-row">
|
||||
<button id="createRound" class="btn btn-primary">New Round</button>
|
||||
</div>`;
|
||||
mount.querySelector("#createRound")!.addEventListener("click", () => {
|
||||
window.location.href = `/movies/${crypto.randomUUID()}/admin`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderAdminSetup(): void {
|
||||
mount.innerHTML = `
|
||||
<h2 class="screen-title">Add People</h2>
|
||||
<p class="hint">Add everyone who'll vote, then tap Next.</p>
|
||||
<div class="input-row">
|
||||
<input id="userInput" type="text" maxlength="30" placeholder="Name" autocomplete="off" spellcheck="false"
|
||||
aria-label="Person name" class="field flex-1">
|
||||
<button id="addUser" class="btn">Add</button>
|
||||
</div>
|
||||
<ul id="userList" class="chips-list"></ul>
|
||||
<div class="action-row">
|
||||
<button id="nextSetup" class="btn btn-primary" ${(state?.users.length ?? 0) === 0 ? "disabled" : ""}>Next →</button>
|
||||
</div>
|
||||
<p id="error" class="error-msg" hidden></p>`;
|
||||
|
||||
const inp = mount.querySelector<HTMLInputElement>("#userInput")!;
|
||||
inp.value = adminUserDraft;
|
||||
inp.focus();
|
||||
inp.addEventListener("input", () => { adminUserDraft = inp.value; });
|
||||
|
||||
mount.querySelector<HTMLElement>("#userList")!.innerHTML =
|
||||
(state?.users ?? []).map((n) => `<li class="chip">${esc(n)}</li>`).join("");
|
||||
|
||||
const addUser = async () => {
|
||||
const name = adminUserDraft.trim();
|
||||
if (!name || busy) return;
|
||||
try {
|
||||
busy = true;
|
||||
await apiPost("add_user", { user: name });
|
||||
adminUserDraft = "";
|
||||
await loadAndRender();
|
||||
} catch (e) { showError((e as Error).message); }
|
||||
finally { busy = false; }
|
||||
};
|
||||
|
||||
mount.querySelector("#addUser")!.addEventListener("click", addUser);
|
||||
inp.addEventListener("keydown", async (e) => { if (e.key === "Enter") { e.preventDefault(); await addUser(); } });
|
||||
mount.querySelector("#nextSetup")!.addEventListener("click", async () => {
|
||||
if (busy) return;
|
||||
try {
|
||||
busy = true;
|
||||
await apiPost("finish_setup", {});
|
||||
await loadAndRender();
|
||||
} catch (e) { showError((e as Error).message); }
|
||||
finally { busy = false; }
|
||||
});
|
||||
}
|
||||
|
||||
function renderAdminStatus(): void {
|
||||
const s = state!;
|
||||
const d1 = s.doneUsersPhase1 ?? [];
|
||||
const d2 = s.doneUsersPhase2 ?? [];
|
||||
const totalSteps = s.users.length * 2;
|
||||
const completedSteps = d1.length + d2.length;
|
||||
const base = window.location.origin;
|
||||
const phaseTitle = s.phase === 1 ? "Phase 1 · Movie Collection" : s.phase === 2 ? "Phase 2 · Voting" : "Phase 3 · Results";
|
||||
|
||||
const shareRows = s.users.map((name) => {
|
||||
const link = `${base}/movies/${s.uuid}?user=${encodeURIComponent(name)}`;
|
||||
const steps = (d1.includes(name) ? 1 : 0) + (d2.includes(name) ? 1 : 0);
|
||||
const badge = steps === 2
|
||||
? `<span class="ml-1.5 rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-700">Done</span>`
|
||||
: steps === 1
|
||||
? `<span class="ml-1.5 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700">1/2</span>`
|
||||
: `<span class="ml-1.5 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-500">0/2</span>`;
|
||||
return `<li class="rounded-2xl border border-slate-200 bg-white p-3.5">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-semibold">${esc(name)}</span>${badge}
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input class="field-mono flex-1 min-w-0" type="text" readonly value="${esc(link)}" aria-label="Invite link for ${esc(name)}">
|
||||
<button class="btn btn-sm copyLink shrink-0" data-link="${esc(link)}" aria-label="Copy link for ${esc(name)}">Copy</button>
|
||||
</div>
|
||||
</li>`;
|
||||
}).join("");
|
||||
|
||||
mount.innerHTML = `
|
||||
<h2 class="screen-title">Admin</h2>
|
||||
${progressCard(phaseTitle, completedSteps, totalSteps)}
|
||||
<p class="section-title">Invite Links</p>
|
||||
<ul class="links-list">${shareRows}</ul>
|
||||
<div class="action-row">
|
||||
<button id="refreshAdmin" class="btn">Refresh</button>
|
||||
</div>
|
||||
${s.phase === 3 ? resultSection("Round Result") : ""}
|
||||
${s.phase === 3 ? `<div class="action-row"><button id="newRound" class="btn btn-primary">New Round →</button></div>` : ""}
|
||||
<p id="error" class="error-msg" hidden></p>`;
|
||||
|
||||
mount.querySelectorAll<HTMLButtonElement>(".copyLink").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(btn.dataset["link"] ?? "");
|
||||
btn.textContent = "✓ Copied";
|
||||
setTimeout(() => { btn.textContent = "Copy"; }, 1400);
|
||||
} catch { showError("Clipboard unavailable"); }
|
||||
});
|
||||
});
|
||||
|
||||
mount.querySelector("#refreshAdmin")!.addEventListener("click", async () => {
|
||||
try { await loadAndRender(); } catch (e) { showError((e as Error).message); }
|
||||
});
|
||||
|
||||
if (s.phase === 3) {
|
||||
mount.querySelector("#newRound")!.addEventListener("click", async () => {
|
||||
if (busy) return;
|
||||
try {
|
||||
busy = true;
|
||||
const info = computeRoundResult();
|
||||
const winner = info.kind === "ok" ? info.result.winner.movie : "";
|
||||
await apiPost("new_round", { winner });
|
||||
await loadAndRender();
|
||||
} catch (e) { showError((e as Error).message); }
|
||||
finally { busy = false; }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderWaiting(phase: 1 | 2): void {
|
||||
const s = state!;
|
||||
const d1 = s.doneUsersPhase1 ?? [];
|
||||
const d2 = s.doneUsersPhase2 ?? [];
|
||||
const total = s.users.length * 2;
|
||||
const done = d1.length + d2.length;
|
||||
const phaseTitle = phase === 1 ? "Phase 1 · Movie Collection" : "Phase 2 · Voting";
|
||||
const waitMsg = phase === 1 ? "Your movies are in. Waiting for others…" : "Your votes are saved. Waiting for others…";
|
||||
|
||||
mount.innerHTML = `
|
||||
<h2 class="screen-title">Waiting…</h2>
|
||||
${progressCard(phaseTitle, done, total)}
|
||||
<p class="hint mt-3">${esc(waitMsg)}</p>
|
||||
<div class="action-row">
|
||||
<button id="refreshUser" class="btn">Refresh</button>
|
||||
</div>
|
||||
<p id="error" class="error-msg" hidden></p>`;
|
||||
mount.querySelector("#refreshUser")!.addEventListener("click", () => loadAndRender());
|
||||
}
|
||||
|
||||
function prevMoviesSection(remaining: number): string {
|
||||
const history = state?.history ?? [];
|
||||
if (history.length === 0) return "";
|
||||
|
||||
const currentTitles = new Set((state?.movies ?? []).map((m) => m.title.replace(/^🏆\s*/, "").toLowerCase()));
|
||||
const wonMovies: string[] = [];
|
||||
const regularMovies = new Set<string>();
|
||||
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const round = history[i]!;
|
||||
if (round.winner) wonMovies.push(round.winner);
|
||||
for (const m of round.movies ?? []) regularMovies.add(m.title.replace(/^🏆\s*/, ""));
|
||||
}
|
||||
|
||||
const wonSet = new Set(wonMovies.map((t) => t.replace(/^🏆\s*/, "").toLowerCase()));
|
||||
const regularList = [...regularMovies]
|
||||
.filter((t) => !wonSet.has(t.toLowerCase()) && !currentTitles.has(t.toLowerCase()))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const seenWon = new Set<string>();
|
||||
const wonList: Array<{ display: string; value: string }> = [];
|
||||
for (const w of wonMovies) {
|
||||
const clean = w.replace(/^🏆\s*/, "");
|
||||
const key = clean.toLowerCase();
|
||||
if (!clean || seenWon.has(key) || currentTitles.has(key)) continue;
|
||||
seenWon.add(key);
|
||||
wonList.push({ display: `🏆 ${clean}`, value: clean });
|
||||
}
|
||||
|
||||
const items = [...regularList.map((t) => ({ display: t, value: t })), ...wonList];
|
||||
if (items.length === 0) return "";
|
||||
|
||||
const chips = items.map(({ display, value }) => {
|
||||
const off = remaining <= 0;
|
||||
return `<li><button class="chip-btn ${off ? "opacity-40 cursor-default" : ""}" ${off ? "disabled" : ""} data-title="${esc(value)}">${esc(display)}</button></li>`;
|
||||
}).join("");
|
||||
|
||||
return `<p class="section-title">Add from History</p><ul class="chips-list">${chips}</ul>`;
|
||||
}
|
||||
|
||||
function renderMoviePhase(): void {
|
||||
const s = state!;
|
||||
const canAdd = !!user && s.users.includes(user);
|
||||
const myCount = canAdd ? s.movies.filter((m) => m.addedBy.toLowerCase() === user.toLowerCase()).length : 0;
|
||||
const remaining = Math.max(0, 5 - myCount);
|
||||
if (isUserDone(1, user)) { renderWaiting(1); return; }
|
||||
|
||||
mount.innerHTML = `
|
||||
<h2 class="screen-title">Add Movies</h2>
|
||||
<p class="hint">Phase 1 of 3 · Up to 5 movies each, then tap Done.</p>
|
||||
${canAdd ? `<p class="hint"><strong>${remaining}</strong> slot${remaining === 1 ? "" : "s"} left</p>` : `<p class="hint text-amber-600">Open your personal link to add movies.</p>`}
|
||||
${canAdd ? `<div class="input-row">
|
||||
<input id="movieInput" type="text" maxlength="60" placeholder="Movie title" autocomplete="off"
|
||||
aria-label="Movie title" class="field flex-1">
|
||||
<button id="addMovie" class="btn">Add</button>
|
||||
</div>` : ""}
|
||||
${canAdd && myCount > 0 ? `<p class="section-title">Your Picks (tap to remove)</p>` : ""}
|
||||
<ul id="movieList" class="chips-list"></ul>
|
||||
${canAdd ? prevMoviesSection(remaining) : ""}
|
||||
${canAdd ? `<div class="action-row">
|
||||
<button id="refreshUser" class="btn">Refresh</button>
|
||||
<button id="markDone" class="btn btn-primary">Done ✓</button>
|
||||
</div>` : ""}
|
||||
<p id="error" class="error-msg" hidden></p>`;
|
||||
|
||||
mount.querySelector<HTMLElement>("#movieList")!.innerHTML = s.movies
|
||||
.filter((m) => canAdd && m.addedBy.toLowerCase() === user.toLowerCase())
|
||||
.map((m) => {
|
||||
const clean = m.title.replace(/^🏆\s*/, "");
|
||||
return `<li><button class="chip-user" data-title="${esc(clean)}">${esc(clean)} ×</button></li>`;
|
||||
}).join("");
|
||||
|
||||
if (!canAdd) return;
|
||||
|
||||
const addMovie = async () => {
|
||||
const inp = mount.querySelector<HTMLInputElement>("#movieInput")!;
|
||||
const title = inp.value.trim();
|
||||
if (!title || busy) return;
|
||||
try {
|
||||
busy = true;
|
||||
await apiPost("add_movie", { user, title });
|
||||
inp.value = "";
|
||||
await loadAndRender();
|
||||
} catch (e) { showError((e as Error).message); }
|
||||
finally { busy = false; }
|
||||
};
|
||||
|
||||
const inp = mount.querySelector<HTMLInputElement>("#movieInput")!;
|
||||
inp.focus();
|
||||
mount.querySelector("#addMovie")!.addEventListener("click", addMovie);
|
||||
inp.addEventListener("keydown", async (e) => { if (e.key === "Enter") { e.preventDefault(); await addMovie(); } });
|
||||
|
||||
mount.querySelectorAll<HTMLButtonElement>(".chip-btn[data-title]").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
if (busy || btn.disabled) return;
|
||||
try { busy = true; await apiPost("add_movie", { user, title: btn.dataset["title"] }); await loadAndRender(); }
|
||||
catch (e) { showError((e as Error).message); }
|
||||
finally { busy = false; }
|
||||
});
|
||||
});
|
||||
|
||||
mount.querySelectorAll<HTMLButtonElement>(".chip-user").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
if (busy) return;
|
||||
try { busy = true; await apiPost("remove_movie", { user, title: btn.dataset["title"] }); await loadAndRender(); }
|
||||
catch (e) { showError((e as Error).message); }
|
||||
finally { busy = false; }
|
||||
});
|
||||
});
|
||||
|
||||
mount.querySelector("#markDone")!.addEventListener("click", async () => {
|
||||
if (busy) return;
|
||||
try { busy = true; await apiPost("mark_done", { user, phase: 1 }); await loadAndRender(); }
|
||||
catch (e) { showError((e as Error).message); }
|
||||
finally { busy = false; }
|
||||
});
|
||||
|
||||
mount.querySelector("#refreshUser")!.addEventListener("click", () => loadAndRender());
|
||||
}
|
||||
|
||||
function renderVotingPhase(): void {
|
||||
const s = state!;
|
||||
const movies = s.movies.map((m) => m.title);
|
||||
if (!s.votes[user]) s.votes[user] = {};
|
||||
if (isUserDone(2, user)) { renderWaiting(2); return; }
|
||||
|
||||
const cards = movies.map((title) => {
|
||||
const current = draftRatings[title] ?? s.votes[user]?.[title] ?? 3;
|
||||
return `<div class="rounded-2xl border border-slate-100 bg-slate-50 p-4">
|
||||
<p class="m-0 text-[15px] font-medium text-slate-800 mb-3">${esc(title)}</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<input class="rating flex-1" type="range" min="0" max="5" step="1" value="${current}"
|
||||
data-title="${esc(title)}" aria-label="Rate ${esc(title)} (0–5)">
|
||||
<span class="rating-value w-6 text-right text-lg font-bold text-blue-500 shrink-0">${current}</span>
|
||||
</div>
|
||||
<div class="mt-1.5 flex justify-between text-[11px] text-slate-400 px-0.5">
|
||||
<span>Skip</span><span>Meh</span><span>OK</span><span>Good</span><span>Great</span><span>Love</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
mount.innerHTML = `
|
||||
<h2 class="screen-title">Rate Movies</h2>
|
||||
<p class="hint">Phase 2 of 3 · Rate each film 0–5, then tap Done.</p>
|
||||
<div class="mt-4 flex flex-col gap-3">${cards}</div>
|
||||
<div class="action-row">
|
||||
<button id="refreshUser" class="btn">Refresh</button>
|
||||
<button id="saveVotes" class="btn">Save</button>
|
||||
<button id="doneVoting" class="btn btn-primary">Done ✓</button>
|
||||
</div>
|
||||
<p id="error" class="error-msg" hidden></p>`;
|
||||
|
||||
mount.querySelectorAll<HTMLInputElement>(".rating").forEach((inp) => {
|
||||
inp.addEventListener("input", () => {
|
||||
const title = inp.dataset["title"] ?? "";
|
||||
const rating = Math.max(0, Math.min(5, parseInt(inp.value || "0", 10)));
|
||||
inp.value = String(rating);
|
||||
draftRatings[title] = rating;
|
||||
const valEl = inp.closest("div.flex")?.querySelector<HTMLElement>(".rating-value");
|
||||
if (valEl) valEl.textContent = String(rating);
|
||||
});
|
||||
});
|
||||
|
||||
const collectRatings = (): Record<string, number> => {
|
||||
const out: Record<string, number> = {};
|
||||
mount.querySelectorAll<HTMLInputElement>(".rating").forEach((inp) => {
|
||||
out[inp.dataset["title"] ?? ""] = Math.max(0, Math.min(5, parseInt(inp.value || "0", 10)));
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
mount.querySelector("#saveVotes")!.addEventListener("click", async () => {
|
||||
if (busy) return;
|
||||
try { busy = true; await apiPost("vote_many", { user, ratings: collectRatings() }); draftRatings = {}; await loadAndRender(); }
|
||||
catch (e) { showError((e as Error).message); }
|
||||
finally { busy = false; }
|
||||
});
|
||||
|
||||
mount.querySelector("#doneVoting")!.addEventListener("click", async () => {
|
||||
if (busy) return;
|
||||
try {
|
||||
busy = true;
|
||||
await apiPost("vote_many", { user, ratings: collectRatings() });
|
||||
await apiPost("mark_done", { user, phase: 2 });
|
||||
draftRatings = {};
|
||||
await loadAndRender();
|
||||
} catch (e) { showError((e as Error).message); }
|
||||
finally { busy = false; }
|
||||
});
|
||||
|
||||
mount.querySelector("#refreshUser")!.addEventListener("click", () => loadAndRender());
|
||||
}
|
||||
|
||||
function renderFinalPage(): void {
|
||||
mount.innerHTML = `
|
||||
<h2 class="screen-title">Result</h2>
|
||||
<p class="hint">Phase 3 of 3 · Voting complete.</p>
|
||||
${resultSection("Round Outcome")}
|
||||
<div class="action-row">
|
||||
<button id="refreshUser" class="btn">Refresh</button>
|
||||
</div>
|
||||
<p id="error" class="error-msg" hidden></p>`;
|
||||
mount.querySelector("#refreshUser")!.addEventListener("click", () => loadAndRender());
|
||||
}
|
||||
|
||||
function askUserName(): void {
|
||||
mount.innerHTML = `
|
||||
<h2 class="screen-title">Who are you?</h2>
|
||||
<p class="hint">Enter the name from your invite link.</p>
|
||||
<div class="input-row mt-4">
|
||||
<input id="nameInput" type="text" maxlength="30" placeholder="Your name"
|
||||
aria-label="Your name" class="field flex-1">
|
||||
<button id="saveName" class="btn btn-primary">Go →</button>
|
||||
</div>`;
|
||||
const inp = mount.querySelector<HTMLInputElement>("#nameInput")!;
|
||||
inp.focus();
|
||||
const submit = () => {
|
||||
const v = inp.value.trim();
|
||||
if (!v) return;
|
||||
params.set("user", v);
|
||||
window.location.search = params.toString();
|
||||
};
|
||||
mount.querySelector("#saveName")!.addEventListener("click", submit);
|
||||
inp.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
|
||||
}
|
||||
|
||||
async function loadAndRender(): Promise<void> {
|
||||
const data = await apiGetState();
|
||||
state = data.state;
|
||||
if (!state.setupDone && isAdmin) { renderAdminSetup(); return; }
|
||||
if (!state.setupDone) {
|
||||
mount.innerHTML = `<p class="hint">Admin is still setting up. Open your link when ready.</p>
|
||||
<div class="action-row"><button id="r" class="btn">Refresh</button></div>`;
|
||||
mount.querySelector("#r")!.addEventListener("click", () => loadAndRender());
|
||||
return;
|
||||
}
|
||||
if (isAdmin) { renderAdminStatus(); return; }
|
||||
if (!user) { askUserName(); return; }
|
||||
if (state.users.length > 0 && !state.users.includes(user)) {
|
||||
mount.innerHTML = `<p class="error-msg">Unknown user — use your invite link.</p>`;
|
||||
return;
|
||||
}
|
||||
if (state.phase === 1) { renderMoviePhase(); return; }
|
||||
if (state.phase === 2) { renderVotingPhase(); return; }
|
||||
renderFinalPage();
|
||||
}
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
if (!uuid) { buildCreateScreen(); return; }
|
||||
if (!isValidUuid(uuid)) {
|
||||
mount.innerHTML = `<p class="error-msg">Invalid session URL.</p>`;
|
||||
return;
|
||||
}
|
||||
try { await loadAndRender(); }
|
||||
catch (e) { mount.innerHTML = `<p class="error-msg">${esc((e as Error).message)}</p>`; }
|
||||
}
|
||||
|
||||
boot();
|
||||
10
src/client/main.tsx
Normal file
10
src/client/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./router.tsx";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
24
src/client/router.tsx
Normal file
24
src/client/router.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRouter, RouterProvider } from "@tanstack/react-router";
|
||||
import { routeTree } from "./routes/__root.tsx";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 2000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
basepath: "/movie-select",
|
||||
});
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
276
src/client/routes/$uuid/admin.tsx
Normal file
276
src/client/routes/$uuid/admin.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
import { useRef, useState } from "react";
|
||||
import { decideMovie } from "@/shared/algorithm.ts";
|
||||
import { collectCompleteRatings } from "@/shared/round-state.ts";
|
||||
import type { State } from "@/shared/types.ts";
|
||||
import { useRound } from "../../hooks/use-round.ts";
|
||||
import {
|
||||
useAddUser,
|
||||
useFinishSetup,
|
||||
useNewRound,
|
||||
} from "../../hooks/use-round-mutation.ts";
|
||||
import { ResultSection } from "./route.tsx";
|
||||
|
||||
export function AdminRoute() {
|
||||
const { uuid } = useParams({ strict: false }) as { uuid: string };
|
||||
const { data: state, refetch } = useRound(uuid);
|
||||
|
||||
if (!state) {
|
||||
return <p className="mt-2 text-sm text-slate-500">Loading…</p>;
|
||||
}
|
||||
|
||||
if (!state.setupDone) {
|
||||
return <AdminSetup uuid={uuid} state={state} />;
|
||||
}
|
||||
return <AdminStatus uuid={uuid} state={state} onRefresh={refetch} />;
|
||||
}
|
||||
|
||||
function AdminSetup({ uuid, state }: { uuid: string; state: State }) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const addUser = useAddUser(uuid);
|
||||
const finishSetup = useFinishSetup(uuid);
|
||||
|
||||
async function handleAdd() {
|
||||
const name = draft.trim();
|
||||
if (!name) return;
|
||||
await addUser.mutateAsync(name);
|
||||
setDraft("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="m-0 text-xl font-bold tracking-tight text-slate-900">
|
||||
Add People
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-slate-500">
|
||||
Add everyone who'll vote, then tap Next.
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
maxLength={30}
|
||||
placeholder="Name"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-label="Person name"
|
||||
className="min-h-[44px] w-full px-4 py-2.5 border border-slate-300 rounded-xl bg-white text-base outline-none placeholder:text-slate-400 transition-[border-color,box-shadow] duration-150 focus:border-blue-400 focus:shadow-[0_0_0_3px_rgba(96,165,250,0.2)] flex-1"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAdd();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={addUser.isPending}
|
||||
className="inline-flex items-center justify-center min-h-[44px] px-5 py-2.5 rounded-xl border border-slate-200 bg-white text-[15px] font-medium text-slate-800 whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97] disabled:opacity-40"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<ul className="mt-3 flex flex-wrap gap-2 list-none p-0 m-0">
|
||||
{state.users.map((name) => (
|
||||
<li
|
||||
key={name}
|
||||
className="inline-flex items-center rounded-full bg-slate-100 px-3 py-1.5 text-[13px] font-medium text-slate-700"
|
||||
>
|
||||
{name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => finishSetup.mutate()}
|
||||
disabled={state.users.length === 0 || finishSetup.isPending}
|
||||
className="inline-flex items-center justify-center min-h-[44px] px-5 py-2.5 rounded-xl border border-blue-500 bg-blue-500 text-[15px] font-medium text-white whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97] disabled:opacity-40"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
{(addUser.error || finishSetup.error) && (
|
||||
<p className="mt-3 text-sm font-medium text-red-500">
|
||||
{(addUser.error ?? finishSetup.error)?.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminStatus({
|
||||
uuid,
|
||||
state,
|
||||
onRefresh,
|
||||
}: {
|
||||
uuid: string;
|
||||
state: State;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const newRound = useNewRound(uuid);
|
||||
const d1 = state.doneUsersPhase1 ?? [];
|
||||
const d2 = state.doneUsersPhase2 ?? [];
|
||||
const totalSteps = state.users.length * 2;
|
||||
const completedSteps = d1.length + d2.length;
|
||||
const phaseTitle =
|
||||
state.phase === 1
|
||||
? "Phase 1 · Movie Collection"
|
||||
: state.phase === 2
|
||||
? "Phase 2 · Voting"
|
||||
: "Phase 3 · Results";
|
||||
|
||||
function computeWinner(): string {
|
||||
const movieTitles = state.movies.map((m) => m.title);
|
||||
if (movieTitles.length === 0) return "";
|
||||
const ratings = collectCompleteRatings(
|
||||
state.users,
|
||||
movieTitles,
|
||||
state.votes ?? {},
|
||||
);
|
||||
const voters = Object.keys(ratings);
|
||||
if (voters.length === 0) return "";
|
||||
return decideMovie({
|
||||
movies: movieTitles,
|
||||
people: voters,
|
||||
ratings,
|
||||
}).winner.movie;
|
||||
}
|
||||
|
||||
function handleCopy(link: string, btn: HTMLButtonElement) {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
btn.textContent = "✓ Copied";
|
||||
setTimeout(() => {
|
||||
btn.textContent = "Copy";
|
||||
}, 1400);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="m-0 text-xl font-bold tracking-tight text-slate-900">
|
||||
Admin
|
||||
</h2>
|
||||
<ProgressCard
|
||||
title={phaseTitle}
|
||||
done={completedSteps}
|
||||
total={totalSteps}
|
||||
/>
|
||||
<p className="m-0 mt-5 mb-2 text-[13px] font-semibold uppercase tracking-wider text-slate-400">
|
||||
Invite Links
|
||||
</p>
|
||||
<ul className="mt-3 flex flex-col gap-2 list-none p-0 m-0">
|
||||
{state.users.map((name) => {
|
||||
const link = `${window.location.origin}/movie-select/${uuid}?user=${encodeURIComponent(name)}`;
|
||||
const steps =
|
||||
(d1.includes(name) ? 1 : 0) + (d2.includes(name) ? 1 : 0);
|
||||
return (
|
||||
<li
|
||||
key={name}
|
||||
className="rounded-2xl border border-slate-200 bg-white p-3.5"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-semibold">{name}</span>
|
||||
<StatusBadge steps={steps} />
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
className="min-h-[36px] w-full px-3 py-1.5 border border-slate-300 rounded-xl bg-white font-mono text-sm outline-none transition-[border-color,box-shadow] duration-150 flex-1 min-w-0"
|
||||
type="text"
|
||||
readOnly
|
||||
value={link}
|
||||
aria-label={`Invite link for ${name}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center min-h-[36px] px-4 py-1.5 rounded-xl border border-slate-200 bg-white text-sm font-medium text-slate-800 whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97] shrink-0"
|
||||
onClick={(e) => handleCopy(link, e.currentTarget)}
|
||||
aria-label={`Copy link for ${name}`}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
className="inline-flex items-center justify-center min-h-[44px] px-5 py-2.5 rounded-xl border border-slate-200 bg-white text-[15px] font-medium text-slate-800 whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97]"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{state.phase === 3 && (
|
||||
<>
|
||||
<ResultSection state={state} title="Round Result" />
|
||||
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => newRound.mutate(computeWinner())}
|
||||
disabled={newRound.isPending}
|
||||
className="inline-flex items-center justify-center min-h-[44px] px-5 py-2.5 rounded-xl border border-blue-500 bg-blue-500 text-[15px] font-medium text-white whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97] disabled:opacity-40"
|
||||
>
|
||||
New Round →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ steps }: { steps: number }) {
|
||||
if (steps === 2)
|
||||
return (
|
||||
<span className="ml-1.5 rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-700">
|
||||
Done
|
||||
</span>
|
||||
);
|
||||
if (steps === 1)
|
||||
return (
|
||||
<span className="ml-1.5 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700">
|
||||
1/2
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="ml-1.5 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-500">
|
||||
0/2
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressCard({
|
||||
title,
|
||||
done,
|
||||
total,
|
||||
}: {
|
||||
title: string;
|
||||
done: number;
|
||||
total: number;
|
||||
}) {
|
||||
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
||||
return (
|
||||
<div className="mt-3 rounded-2xl border border-blue-200 bg-blue-50 p-4">
|
||||
<p className="m-0 text-sm font-semibold text-blue-800">{title}</p>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<div className="flex-1 h-2 rounded-full bg-blue-100 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500 transition-all duration-300"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-blue-700 shrink-0">
|
||||
{done}/{total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
572
src/client/routes/$uuid/route.tsx
Normal file
572
src/client/routes/$uuid/route.tsx
Normal file
@@ -0,0 +1,572 @@
|
||||
import { useParams, useSearch } from "@tanstack/react-router";
|
||||
import { useRef, useState } from "react";
|
||||
import { decideMovie } from "@/shared/algorithm.ts";
|
||||
import { collectCompleteRatings } from "@/shared/round-state.ts";
|
||||
import type { State } from "@/shared/types.ts";
|
||||
import { useRound } from "../../hooks/use-round.ts";
|
||||
import {
|
||||
useAddMovie,
|
||||
useMarkDone,
|
||||
useRemoveMovie,
|
||||
useVote,
|
||||
} from "../../hooks/use-round-mutation.ts";
|
||||
|
||||
export function UserRoute() {
|
||||
const { uuid } = useParams({ strict: false }) as { uuid: string };
|
||||
const search = useSearch({ strict: false }) as Record<string, string>;
|
||||
const userName = (search.user ?? "").trim();
|
||||
const { data: state, refetch } = useRound(uuid);
|
||||
|
||||
if (!state) {
|
||||
return <p className="mt-2 text-sm text-slate-500">Loading…</p>;
|
||||
}
|
||||
|
||||
if (!state.setupDone) {
|
||||
return (
|
||||
<>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
Admin is still setting up. Open your link when ready.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
|
||||
<RefreshButton onRefresh={refetch} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!userName) {
|
||||
return <AskUserName />;
|
||||
}
|
||||
|
||||
if (state.users.length > 0 && !state.users.includes(userName)) {
|
||||
return (
|
||||
<p className="mt-3 text-sm font-medium text-red-500">
|
||||
Unknown user — use your invite link.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === 1) {
|
||||
const isDone = state.doneUsersPhase1?.includes(userName);
|
||||
if (isDone) {
|
||||
return <WaitingScreen state={state} phase={1} onRefresh={refetch} />;
|
||||
}
|
||||
return (
|
||||
<MoviePhase
|
||||
uuid={uuid}
|
||||
user={userName}
|
||||
state={state}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (state.phase === 2) {
|
||||
const isDone = state.doneUsersPhase2?.includes(userName);
|
||||
if (isDone) {
|
||||
return <WaitingScreen state={state} phase={2} onRefresh={refetch} />;
|
||||
}
|
||||
return (
|
||||
<VotingPhase
|
||||
uuid={uuid}
|
||||
user={userName}
|
||||
state={state}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <FinalPage state={state} onRefresh={refetch} />;
|
||||
}
|
||||
|
||||
function AskUserName() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
function handleSubmit() {
|
||||
const v = inputRef.current?.value.trim();
|
||||
if (!v) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("user", v);
|
||||
window.location.search = params.toString();
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h2 className="m-0 text-xl font-bold tracking-tight text-slate-900">
|
||||
Who are you?
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-slate-500">
|
||||
Enter the name from your invite link.
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
maxLength={30}
|
||||
placeholder="Your name"
|
||||
aria-label="Your name"
|
||||
className="min-h-[44px] w-full px-4 py-2.5 border border-slate-300 rounded-xl bg-white text-base outline-none placeholder:text-slate-400 transition-[border-color,box-shadow] duration-150 focus:border-blue-400 focus:shadow-[0_0_0_3px_rgba(96,165,250,0.2)] flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSubmit();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className="inline-flex items-center justify-center min-h-[44px] px-5 py-2.5 rounded-xl border border-blue-500 bg-blue-500 text-[15px] font-medium text-white whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97]"
|
||||
>
|
||||
Go →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WaitingScreen({
|
||||
state,
|
||||
phase,
|
||||
onRefresh,
|
||||
}: {
|
||||
state: State;
|
||||
phase: 1 | 2;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const d1 = state.doneUsersPhase1?.length ?? 0;
|
||||
const d2 = state.doneUsersPhase2?.length ?? 0;
|
||||
const total = state.users.length * 2;
|
||||
const done = d1 + d2;
|
||||
const phaseTitle =
|
||||
phase === 1 ? "Phase 1 · Movie Collection" : "Phase 2 · Voting";
|
||||
const waitMsg =
|
||||
phase === 1
|
||||
? "Your movies are in. Waiting for others…"
|
||||
: "Your votes are saved. Waiting for others…";
|
||||
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="m-0 text-xl font-bold tracking-tight text-slate-900">
|
||||
Waiting…
|
||||
</h2>
|
||||
<div className="mt-3 rounded-2xl border border-blue-200 bg-blue-50 p-4">
|
||||
<p className="m-0 text-sm font-semibold text-blue-800">{phaseTitle}</p>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<div className="flex-1 h-2 rounded-full bg-blue-100 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500 transition-all duration-300"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-blue-700 shrink-0">
|
||||
{done}/{total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-slate-500">{waitMsg}</p>
|
||||
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
|
||||
<RefreshButton onRefresh={onRefresh} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MoviePhase({
|
||||
uuid,
|
||||
user,
|
||||
state,
|
||||
onRefresh,
|
||||
}: {
|
||||
uuid: string;
|
||||
user: string;
|
||||
state: State;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [movieDraft, setMovieDraft] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const addMovie = useAddMovie(uuid);
|
||||
const removeMovie = useRemoveMovie(uuid);
|
||||
const markDone = useMarkDone(uuid);
|
||||
|
||||
const myMovies = state.movies.filter(
|
||||
(m) => m.addedBy.toLowerCase() === user.toLowerCase(),
|
||||
);
|
||||
const remaining = Math.max(0, 5 - myMovies.length);
|
||||
|
||||
async function handleAdd(title?: string) {
|
||||
const t = (title ?? movieDraft).trim();
|
||||
if (!t) return;
|
||||
await addMovie.mutateAsync({ user, title: t });
|
||||
setMovieDraft("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
async function handleRemove(title: string) {
|
||||
await removeMovie.mutateAsync({ user, title });
|
||||
}
|
||||
|
||||
const historyItems = getHistoryItems(state);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="m-0 text-xl font-bold tracking-tight text-slate-900">
|
||||
Add Movies
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-slate-500">
|
||||
Phase 1 of 3 · Up to 5 movies each, then tap Done.
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-relaxed text-slate-500">
|
||||
<strong>{remaining}</strong> slot{remaining === 1 ? "" : "s"} left
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
maxLength={60}
|
||||
placeholder="Movie title"
|
||||
autoComplete="off"
|
||||
aria-label="Movie title"
|
||||
className="min-h-[44px] w-full px-4 py-2.5 border border-slate-300 rounded-xl bg-white text-base outline-none placeholder:text-slate-400 transition-[border-color,box-shadow] duration-150 focus:border-blue-400 focus:shadow-[0_0_0_3px_rgba(96,165,250,0.2)] flex-1"
|
||||
value={movieDraft}
|
||||
onChange={(e) => setMovieDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAdd();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAdd()}
|
||||
disabled={addMovie.isPending}
|
||||
className="inline-flex items-center justify-center min-h-[44px] px-5 py-2.5 rounded-xl border border-slate-200 bg-white text-[15px] font-medium text-slate-800 whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97] disabled:opacity-40"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{myMovies.length > 0 && (
|
||||
<>
|
||||
<p className="m-0 mt-5 mb-2 text-[13px] font-semibold uppercase tracking-wider text-slate-400">
|
||||
Your Picks (tap to remove)
|
||||
</p>
|
||||
<ul className="mt-3 flex flex-wrap gap-2 list-none p-0 m-0">
|
||||
{myMovies.map((m) => {
|
||||
const clean = m.title.replace(/^(?:\u{1F3C6}\s*)+/u, "");
|
||||
return (
|
||||
<li key={m.title}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(clean)}
|
||||
disabled={removeMovie.isPending}
|
||||
className="inline-flex items-center rounded-full border-2 border-blue-400 bg-blue-100 px-3 py-1.5 text-[13px] font-medium text-blue-900 cursor-pointer select-none transition-[background-color,transform] duration-100 hover:bg-blue-200 active:scale-[0.96]"
|
||||
>
|
||||
{clean} ×
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{historyItems.length > 0 && (
|
||||
<>
|
||||
<p className="m-0 mt-5 mb-2 text-[13px] font-semibold uppercase tracking-wider text-slate-400">
|
||||
Add from History
|
||||
</p>
|
||||
<ul className="mt-3 flex flex-wrap gap-2 list-none p-0 m-0">
|
||||
{historyItems.map(({ display, value }) => (
|
||||
<li key={value}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAdd(value)}
|
||||
disabled={remaining <= 0 || addMovie.isPending}
|
||||
className="inline-flex items-center rounded-full border-0 bg-slate-100 px-3 py-1.5 text-[13px] font-medium text-slate-700 cursor-pointer select-none transition-[background-color,transform] duration-100 hover:bg-slate-200 active:scale-[0.96] disabled:opacity-40 disabled:cursor-default"
|
||||
>
|
||||
{display}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
|
||||
<RefreshButton onRefresh={onRefresh} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => markDone.mutate({ user, phase: 1 })}
|
||||
disabled={markDone.isPending}
|
||||
className="inline-flex items-center justify-center min-h-[44px] px-5 py-2.5 rounded-xl border border-blue-500 bg-blue-500 text-[15px] font-medium text-white whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97] disabled:opacity-40"
|
||||
>
|
||||
Done ✓
|
||||
</button>
|
||||
</div>
|
||||
<ErrorMsg error={addMovie.error ?? removeMovie.error ?? markDone.error} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VotingPhase({
|
||||
uuid,
|
||||
user,
|
||||
state,
|
||||
onRefresh,
|
||||
}: {
|
||||
uuid: string;
|
||||
user: string;
|
||||
state: State;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const movies = state.movies.map((m) => m.title);
|
||||
const existingVotes = state.votes[user] ?? {};
|
||||
const [ratings, setRatings] = useState<Record<string, number>>(() => {
|
||||
const initial: Record<string, number> = {};
|
||||
for (const title of movies) {
|
||||
initial[title] = existingVotes[title] ?? 3;
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
const vote = useVote(uuid);
|
||||
const markDone = useMarkDone(uuid);
|
||||
|
||||
function handleRatingChange(title: string, value: number) {
|
||||
setRatings((prev) => ({ ...prev, [title]: value }));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
await vote.mutateAsync({ user, ratings });
|
||||
}
|
||||
|
||||
async function handleDone() {
|
||||
await vote.mutateAsync({ user, ratings });
|
||||
await markDone.mutateAsync({ user, phase: 2 });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="m-0 text-xl font-bold tracking-tight text-slate-900">
|
||||
Rate Movies
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-slate-500">
|
||||
Phase 2 of 3 · Rate each film 0–5, then tap Done.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{movies.map((title) => (
|
||||
<div
|
||||
key={title}
|
||||
className="rounded-2xl border border-slate-100 bg-slate-50 p-4"
|
||||
>
|
||||
<p className="m-0 text-[15px] font-medium text-slate-800 mb-3">
|
||||
{title}
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={5}
|
||||
step={1}
|
||||
value={ratings[title] ?? 3}
|
||||
onChange={(e) =>
|
||||
handleRatingChange(title, Number.parseInt(e.target.value, 10))
|
||||
}
|
||||
aria-label={`Rate ${title} (0–5)`}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="w-6 text-right text-lg font-bold text-blue-500 shrink-0">
|
||||
{ratings[title] ?? 3}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-between text-[11px] text-slate-400 px-0.5">
|
||||
<span>Skip</span>
|
||||
<span>Meh</span>
|
||||
<span>OK</span>
|
||||
<span>Good</span>
|
||||
<span>Great</span>
|
||||
<span>Love</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
|
||||
<RefreshButton onRefresh={onRefresh} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={vote.isPending}
|
||||
className="inline-flex items-center justify-center min-h-[44px] px-5 py-2.5 rounded-xl border border-slate-200 bg-white text-[15px] font-medium text-slate-800 whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97] disabled:opacity-40"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDone}
|
||||
disabled={vote.isPending || markDone.isPending}
|
||||
className="inline-flex items-center justify-center min-h-[44px] px-5 py-2.5 rounded-xl border border-blue-500 bg-blue-500 text-[15px] font-medium text-white whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97] disabled:opacity-40"
|
||||
>
|
||||
Done ✓
|
||||
</button>
|
||||
</div>
|
||||
<ErrorMsg error={vote.error ?? markDone.error} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FinalPage({
|
||||
state,
|
||||
onRefresh,
|
||||
}: {
|
||||
state: State;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<h2 className="m-0 text-xl font-bold tracking-tight text-slate-900">
|
||||
Result
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-slate-500">
|
||||
Phase 3 of 3 · Voting complete.
|
||||
</p>
|
||||
<ResultSection state={state} title="Round Outcome" />
|
||||
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
|
||||
<RefreshButton onRefresh={onRefresh} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResultSection({
|
||||
state,
|
||||
title,
|
||||
}: {
|
||||
state: State;
|
||||
title: string;
|
||||
}) {
|
||||
const movieTitles = state.movies.map((m) => m.title);
|
||||
if (movieTitles.length === 0) {
|
||||
return (
|
||||
<div className="mt-4 rounded-2xl border border-slate-100 bg-slate-50 p-4">
|
||||
<p className="text-[13px] font-semibold uppercase tracking-wider text-slate-400 m-0">
|
||||
{title}
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-relaxed text-slate-500">
|
||||
No movies were added.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ratings = collectCompleteRatings(
|
||||
state.users,
|
||||
movieTitles,
|
||||
state.votes ?? {},
|
||||
);
|
||||
const voters = Object.keys(ratings);
|
||||
if (voters.length === 0) {
|
||||
return (
|
||||
<div className="mt-4 rounded-2xl border border-slate-100 bg-slate-50 p-4">
|
||||
<p className="text-[13px] font-semibold uppercase tracking-wider text-slate-400 m-0">
|
||||
{title}
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-relaxed text-slate-500">
|
||||
No complete votes yet.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const result = decideMovie({
|
||||
movies: movieTitles,
|
||||
people: voters,
|
||||
ratings,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-2xl border border-slate-100 bg-slate-50 p-4">
|
||||
<p className="text-[13px] font-semibold uppercase tracking-wider text-slate-400 m-0">
|
||||
{title}
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-bold text-slate-900">
|
||||
🏆 {result.winner.movie}
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-relaxed text-slate-500">
|
||||
Voters: {voters.length} of {state.users.length}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
{result.ranking.map((r, i) => (
|
||||
<div
|
||||
key={r.movie}
|
||||
className={`flex items-center gap-3 py-2.5 ${i > 0 ? "border-t border-slate-100" : ""}`}
|
||||
>
|
||||
<span className="w-5 text-center text-[13px] text-slate-400 font-semibold shrink-0">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="flex-1 text-sm">{r.movie}</span>
|
||||
<span className="text-[13px] text-slate-400 shrink-0">
|
||||
Nash {r.nash.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshButton({ onRefresh }: { onRefresh: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
className="inline-flex items-center justify-center min-h-[44px] px-5 py-2.5 rounded-xl border border-slate-200 bg-white text-[15px] font-medium text-slate-800 whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97]"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorMsg({ error }: { error: Error | null | undefined }) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<p className="mt-3 text-sm font-medium text-red-500">{error.message}</p>
|
||||
);
|
||||
}
|
||||
|
||||
function getHistoryItems(
|
||||
state: State,
|
||||
): Array<{ display: string; value: string }> {
|
||||
const history = state.history ?? [];
|
||||
if (history.length === 0) return [];
|
||||
|
||||
const currentTitles = new Set(
|
||||
state.movies.map((m) =>
|
||||
m.title.replace(/^(?:\u{1F3C6}\s*)+/u, "").toLowerCase(),
|
||||
),
|
||||
);
|
||||
const wonMovies: string[] = [];
|
||||
const regularMovies = new Set<string>();
|
||||
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const round = history[i]!;
|
||||
if (round.winner) wonMovies.push(round.winner);
|
||||
for (const m of round.movies ?? []) {
|
||||
regularMovies.add(m.title.replace(/^(?:\u{1F3C6}\s*)+/u, ""));
|
||||
}
|
||||
}
|
||||
|
||||
const wonSet = new Set(
|
||||
wonMovies.map((t) => t.replace(/^(?:\u{1F3C6}\s*)+/u, "").toLowerCase()),
|
||||
);
|
||||
const regularList = [...regularMovies]
|
||||
.filter(
|
||||
(t) =>
|
||||
!wonSet.has(t.toLowerCase()) && !currentTitles.has(t.toLowerCase()),
|
||||
)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const seenWon = new Set<string>();
|
||||
const wonList: Array<{ display: string; value: string }> = [];
|
||||
for (const w of wonMovies) {
|
||||
const clean = w.replace(/^(?:\u{1F3C6}\s*)+/u, "");
|
||||
const key = clean.toLowerCase();
|
||||
if (!clean || seenWon.has(key) || currentTitles.has(key)) continue;
|
||||
seenWon.add(key);
|
||||
wonList.push({ display: `🏆 ${clean}`, value: clean });
|
||||
}
|
||||
|
||||
return [...regularList.map((t) => ({ display: t, value: t })), ...wonList];
|
||||
}
|
||||
47
src/client/routes/__root.tsx
Normal file
47
src/client/routes/__root.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router";
|
||||
import { AdminRoute } from "./$uuid/admin.tsx";
|
||||
import { UserRoute } from "./$uuid/route.tsx";
|
||||
import { CreateScreen } from "./index.tsx";
|
||||
|
||||
function RootLayout() {
|
||||
return (
|
||||
<div className="min-h-dvh bg-slate-50 text-slate-900 font-sans antialiased">
|
||||
<main className="mx-auto max-w-2xl px-4 pt-6 pb-10 flex flex-col gap-5 safe-b">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Movie Select</h1>
|
||||
</header>
|
||||
<section className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<Outlet />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: RootLayout,
|
||||
});
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
component: CreateScreen,
|
||||
});
|
||||
|
||||
const uuidAdminRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/$uuid/admin",
|
||||
component: AdminRoute,
|
||||
});
|
||||
|
||||
const uuidUserRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/$uuid",
|
||||
component: UserRoute,
|
||||
});
|
||||
|
||||
export const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
uuidAdminRoute,
|
||||
uuidUserRoute,
|
||||
]);
|
||||
30
src/client/routes/index.tsx
Normal file
30
src/client/routes/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
export function CreateScreen() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
function handleCreate() {
|
||||
const uuid = crypto.randomUUID();
|
||||
navigate({ to: "/$uuid/admin", params: { uuid } });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="m-0 text-xl font-bold tracking-tight text-slate-900">
|
||||
Movie Select
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-slate-500">
|
||||
Start a new round, add everyone, share links — pick a film.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
className="inline-flex items-center justify-center min-h-[44px] px-5 py-2.5 rounded-xl border border-blue-500 bg-blue-500 text-[15px] font-medium text-white whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97]"
|
||||
>
|
||||
New Round
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
import {
|
||||
loadState,
|
||||
addUserInDb,
|
||||
setSetupDoneInDb,
|
||||
addMovieInDb,
|
||||
removeMovieInDb,
|
||||
upsertVoteInDb,
|
||||
setUserDoneInDb,
|
||||
setPhaseInDb,
|
||||
resetDoneUsersInDb,
|
||||
clearMoviesAndVotesInDb,
|
||||
addHistoryEntryInDb,
|
||||
resetUserDoneForUser,
|
||||
} from "./db.ts";
|
||||
import type { State } from "./types.ts";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const TROPHY_RE = /^(?:\u{1F3C6}\s*)+/u;
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function fail(status: number, message: string): never {
|
||||
throw new ApiError(status, message);
|
||||
}
|
||||
|
||||
function cleanUser(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "" || trimmed.length > 30) fail(400, "Invalid user");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function cleanMovie(value: string): string {
|
||||
let trimmed = value.trim().replace(TROPHY_RE, "");
|
||||
if (trimmed === "" || trimmed.length > 60) fail(400, "Invalid movie title");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function allUsersDone(users: string[], doneUsers: string[]): boolean {
|
||||
if (users.length === 0) return false;
|
||||
return users.every((name) => doneUsers.includes(name));
|
||||
}
|
||||
|
||||
export async function handleAction(
|
||||
action: string,
|
||||
uuid: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<State> {
|
||||
if (!UUID_RE.test(uuid)) fail(400, "Invalid UUID");
|
||||
|
||||
if (action === "state") {
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
// All mutating actions below
|
||||
const state = await loadState(uuid);
|
||||
|
||||
if (action === "add_user") {
|
||||
if (state.phase !== 1 || state.setupDone) fail(409, "Setup is closed");
|
||||
const user = cleanUser(String(body["user"] ?? ""));
|
||||
if (!state.users.includes(user)) {
|
||||
await addUserInDb(uuid, user, state.users.length);
|
||||
}
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
if (action === "finish_setup") {
|
||||
if (state.users.length < 1) fail(409, "Add at least one user first");
|
||||
await setSetupDoneInDb(uuid);
|
||||
await resetDoneUsersInDb(uuid, "both");
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
if (action === "add_movie") {
|
||||
if (!state.setupDone) fail(409, "Setup is not finished");
|
||||
if (state.phase !== 1) fail(409, "Movie phase is closed");
|
||||
const user = cleanUser(String(body["user"] ?? ""));
|
||||
if (state.users.length > 0 && !state.users.includes(user)) fail(403, "Unknown user");
|
||||
const title = cleanMovie(String(body["title"] ?? ""));
|
||||
const myCount = state.movies.filter(
|
||||
(m) => m.addedBy.toLowerCase() === user.toLowerCase(),
|
||||
).length;
|
||||
if (myCount >= 5) fail(409, "Movie limit reached for this user (5)");
|
||||
const duplicate = state.movies.some(
|
||||
(m) => m.title.toLowerCase() === title.toLowerCase(),
|
||||
);
|
||||
if (duplicate) fail(409, "Movie already exists");
|
||||
await addMovieInDb(uuid, title, user);
|
||||
// Un-mark done for this user when they add a movie
|
||||
await resetUserDoneForUser(uuid, user, 1);
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
if (action === "remove_movie") {
|
||||
if (!state.setupDone) fail(409, "Setup is not finished");
|
||||
if (state.phase !== 1) fail(409, "Movie phase is closed");
|
||||
const user = cleanUser(String(body["user"] ?? ""));
|
||||
if (state.users.length > 0 && !state.users.includes(user)) fail(403, "Unknown user");
|
||||
const title = cleanMovie(String(body["title"] ?? ""));
|
||||
await removeMovieInDb(uuid, title, user);
|
||||
await resetUserDoneForUser(uuid, user, 1);
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
if (action === "vote_many") {
|
||||
if (state.phase !== 2) fail(409, "Voting phase is not active");
|
||||
const user = cleanUser(String(body["user"] ?? ""));
|
||||
if (state.users.length > 0 && !state.users.includes(user)) fail(403, "Unknown user");
|
||||
const ratings = body["ratings"];
|
||||
if (!ratings || typeof ratings !== "object" || Array.isArray(ratings)) {
|
||||
fail(400, "Ratings payload missing");
|
||||
}
|
||||
const ratingsMap = ratings as Record<string, unknown>;
|
||||
for (const movie of state.movies) {
|
||||
const raw = ratingsMap[movie.title];
|
||||
let rating: number;
|
||||
if (typeof raw === "number" && Number.isInteger(raw)) {
|
||||
rating = raw;
|
||||
} else if (typeof raw === "string" && /^\d+$/.test(raw)) {
|
||||
rating = parseInt(raw, 10);
|
||||
} else {
|
||||
fail(400, "Each movie needs a rating");
|
||||
}
|
||||
if (rating < 0 || rating > 5) fail(400, "Rating must be 0 to 5");
|
||||
await upsertVoteInDb(uuid, user, movie.title, rating);
|
||||
}
|
||||
// Un-mark done for this user when they update votes
|
||||
await resetUserDoneForUser(uuid, user, 2);
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
if (action === "mark_done") {
|
||||
if (!state.setupDone) fail(409, "Setup is not finished");
|
||||
const user = cleanUser(String(body["user"] ?? ""));
|
||||
if (state.users.length > 0 && !state.users.includes(user)) fail(403, "Unknown user");
|
||||
const phase = Number(body["phase"] ?? state.phase);
|
||||
|
||||
if (phase === 1) {
|
||||
if (state.phase !== 1) fail(409, "Movie phase is not active");
|
||||
await setUserDoneInDb(uuid, user, 1);
|
||||
const updated = await loadState(uuid);
|
||||
if (allUsersDone(updated.users, updated.doneUsersPhase1)) {
|
||||
await setPhaseInDb(uuid, 2);
|
||||
await resetDoneUsersInDb(uuid, 2);
|
||||
}
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
if (phase === 2) {
|
||||
if (state.phase !== 2) fail(409, "Voting phase is not active");
|
||||
// Verify all movies rated
|
||||
const movieTitles = state.movies.map((m) => m.title);
|
||||
const userVotes = state.votes[user] ?? {};
|
||||
for (const title of movieTitles) {
|
||||
const v = userVotes[title];
|
||||
if (typeof v !== "number") fail(409, "Rate every movie before finishing");
|
||||
}
|
||||
await setUserDoneInDb(uuid, user, 2);
|
||||
const updated = await loadState(uuid);
|
||||
if (allUsersDone(updated.users, updated.doneUsersPhase2)) {
|
||||
await setPhaseInDb(uuid, 3);
|
||||
}
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
fail(400, "Invalid done phase");
|
||||
}
|
||||
|
||||
if (action === "set_phase") {
|
||||
if (!state.setupDone) fail(409, "Finish setup first");
|
||||
const phase = Number(body["phase"] ?? 0);
|
||||
if (phase !== 1 && phase !== 2 && phase !== 3) fail(400, "Invalid phase");
|
||||
await setPhaseInDb(uuid, phase);
|
||||
if (phase === 1) await resetDoneUsersInDb(uuid, "both");
|
||||
if (phase === 2) await resetDoneUsersInDb(uuid, 2);
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
if (action === "new_round") {
|
||||
if (state.phase !== 3) fail(409, "Round is not finished yet");
|
||||
let winner = String(body["winner"] ?? "").trim().replace(TROPHY_RE, "");
|
||||
if (winner === "") winner = "";
|
||||
await addHistoryEntryInDb(uuid, winner || null, state.movies);
|
||||
await clearMoviesAndVotesInDb(uuid);
|
||||
await setPhaseInDb(uuid, 1);
|
||||
await resetDoneUsersInDb(uuid, "both");
|
||||
return await loadState(uuid);
|
||||
}
|
||||
|
||||
fail(404, "Unknown action");
|
||||
}
|
||||
20
src/server/app.ts
Normal file
20
src/server/app.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { roundsRouter } from "./features/rounds/router.ts";
|
||||
import { ApiError } from "./features/rounds/service.ts";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.use("*", cors());
|
||||
|
||||
app.route("/api/rounds", roundsRouter);
|
||||
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof ApiError) {
|
||||
return c.json({ ok: false, error: err.message }, err.status as 400);
|
||||
}
|
||||
console.error(err);
|
||||
return c.json({ ok: false, error: "Internal server error" }, 500);
|
||||
});
|
||||
|
||||
export default app;
|
||||
182
src/server/db.ts
182
src/server/db.ts
@@ -1,182 +0,0 @@
|
||||
import mysql from "mysql2/promise";
|
||||
import type { RowDataPacket } from "mysql2/promise";
|
||||
import type { State, Movie } from "./types.ts";
|
||||
|
||||
export const pool = mysql.createPool({
|
||||
host: process.env["DB_HOST"] ?? "127.0.0.1",
|
||||
port: Number(process.env["DB_PORT"] ?? 3306),
|
||||
user: process.env["DB_USER"] ?? "serve",
|
||||
password: process.env["DB_PASS"] ?? "",
|
||||
database: process.env["DB_NAME"] ?? "serve",
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
decimalNumbers: true,
|
||||
timezone: "+00:00",
|
||||
});
|
||||
|
||||
function fmtDate(d: unknown): string {
|
||||
if (d instanceof Date) return d.toISOString();
|
||||
if (typeof d === "string") return d;
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export async function loadState(uuid: string): Promise<State> {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
// Ensure round row exists
|
||||
await conn.execute(
|
||||
"INSERT IGNORE INTO rounds (uuid) VALUES (?)",
|
||||
[uuid],
|
||||
);
|
||||
|
||||
const [[round]] = await conn.execute<RowDataPacket[]>(
|
||||
"SELECT phase, setup_done, created_at, updated_at FROM rounds WHERE uuid = ?",
|
||||
[uuid],
|
||||
);
|
||||
|
||||
const [userRows] = await conn.execute<RowDataPacket[]>(
|
||||
"SELECT name, done_phase1, done_phase2 FROM round_users WHERE round_uuid = ? ORDER BY sort_order, name",
|
||||
[uuid],
|
||||
);
|
||||
|
||||
const [movieRows] = await conn.execute<RowDataPacket[]>(
|
||||
"SELECT title, added_by, added_at FROM movies WHERE round_uuid = ? ORDER BY id",
|
||||
[uuid],
|
||||
);
|
||||
|
||||
const [voteRows] = await conn.execute<RowDataPacket[]>(
|
||||
"SELECT user_name, movie_title, rating FROM votes WHERE round_uuid = ?",
|
||||
[uuid],
|
||||
);
|
||||
|
||||
const [historyRows] = await conn.execute<RowDataPacket[]>(
|
||||
"SELECT winner, movies_json FROM round_history WHERE round_uuid = ? ORDER BY id",
|
||||
[uuid],
|
||||
);
|
||||
|
||||
const users: string[] = userRows.map((u) => String(u["name"]));
|
||||
const donePhase1 = userRows.filter((u) => u["done_phase1"]).map((u) => String(u["name"]));
|
||||
const donePhase2 = userRows.filter((u) => u["done_phase2"]).map((u) => String(u["name"]));
|
||||
|
||||
const votes: Record<string, Record<string, number>> = {};
|
||||
for (const v of voteRows) {
|
||||
const uname = String(v["user_name"]);
|
||||
const mtitle = String(v["movie_title"]);
|
||||
if (!votes[uname]) votes[uname] = {};
|
||||
votes[uname][mtitle] = Number(v["rating"]);
|
||||
}
|
||||
|
||||
const history = historyRows.map((h) => ({
|
||||
winner: h["winner"] ? String(h["winner"]) : null,
|
||||
movies: JSON.parse(String(h["movies_json"])) as Movie[],
|
||||
}));
|
||||
|
||||
const phase = Number(round?.["phase"] ?? 1);
|
||||
|
||||
return {
|
||||
uuid,
|
||||
phase: (phase === 1 || phase === 2 || phase === 3 ? phase : 1) as 1 | 2 | 3,
|
||||
setupDone: Boolean(round?.["setup_done"]),
|
||||
users,
|
||||
doneUsersPhase1: donePhase1,
|
||||
doneUsersPhase2: donePhase2,
|
||||
movies: movieRows.map((m) => ({
|
||||
title: String(m["title"]),
|
||||
addedBy: String(m["added_by"]),
|
||||
addedAt: fmtDate(m["added_at"]),
|
||||
})),
|
||||
votes,
|
||||
history,
|
||||
createdAt: fmtDate(round?.["created_at"]),
|
||||
updatedAt: fmtDate(round?.["updated_at"]),
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function setPhaseInDb(uuid: string, phase: number): Promise<void> {
|
||||
await pool.execute("UPDATE rounds SET phase = ? WHERE uuid = ?", [phase, uuid]);
|
||||
}
|
||||
|
||||
export async function setSetupDoneInDb(uuid: string): Promise<void> {
|
||||
await pool.execute("UPDATE rounds SET setup_done = 1 WHERE uuid = ?", [uuid]);
|
||||
}
|
||||
|
||||
export async function addUserInDb(uuid: string, name: string, sortOrder: number): Promise<void> {
|
||||
await pool.execute(
|
||||
"INSERT IGNORE INTO round_users (round_uuid, name, sort_order) VALUES (?, ?, ?)",
|
||||
[uuid, name, sortOrder],
|
||||
);
|
||||
}
|
||||
|
||||
export async function addMovieInDb(uuid: string, title: string, addedBy: string): Promise<void> {
|
||||
await pool.execute(
|
||||
"INSERT INTO movies (round_uuid, title, added_by) VALUES (?, ?, ?)",
|
||||
[uuid, title, addedBy],
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeMovieInDb(uuid: string, title: string, addedBy: string): Promise<void> {
|
||||
await pool.execute(
|
||||
"DELETE FROM movies WHERE round_uuid = ? AND title = ? AND added_by = ?",
|
||||
[uuid, title, addedBy],
|
||||
);
|
||||
}
|
||||
|
||||
export async function upsertVoteInDb(
|
||||
uuid: string,
|
||||
userName: string,
|
||||
movieTitle: string,
|
||||
rating: number,
|
||||
): Promise<void> {
|
||||
await pool.execute(
|
||||
"INSERT INTO votes (round_uuid, user_name, movie_title, rating) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE rating = ?",
|
||||
[uuid, userName, movieTitle, rating, rating],
|
||||
);
|
||||
}
|
||||
|
||||
export async function setUserDoneInDb(
|
||||
uuid: string,
|
||||
name: string,
|
||||
phase: 1 | 2,
|
||||
): Promise<void> {
|
||||
const col = phase === 1 ? "done_phase1" : "done_phase2";
|
||||
await pool.execute(
|
||||
`UPDATE round_users SET ${col} = 1 WHERE round_uuid = ? AND name = ?`,
|
||||
[uuid, name],
|
||||
);
|
||||
}
|
||||
|
||||
export async function resetDoneUsersInDb(uuid: string, phase: 1 | 2 | "both"): Promise<void> {
|
||||
if (phase === "both" || phase === 1) {
|
||||
await pool.execute("UPDATE round_users SET done_phase1 = 0 WHERE round_uuid = ?", [uuid]);
|
||||
}
|
||||
if (phase === "both" || phase === 2) {
|
||||
await pool.execute("UPDATE round_users SET done_phase2 = 0 WHERE round_uuid = ?", [uuid]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearMoviesAndVotesInDb(uuid: string): Promise<void> {
|
||||
await pool.execute("DELETE FROM movies WHERE round_uuid = ?", [uuid]);
|
||||
await pool.execute("DELETE FROM votes WHERE round_uuid = ?", [uuid]);
|
||||
}
|
||||
|
||||
export async function addHistoryEntryInDb(
|
||||
uuid: string,
|
||||
winner: string | null,
|
||||
movies: Movie[],
|
||||
): Promise<void> {
|
||||
await pool.execute(
|
||||
"INSERT INTO round_history (round_uuid, winner, movies_json) VALUES (?, ?, ?)",
|
||||
[uuid, winner, JSON.stringify(movies)],
|
||||
);
|
||||
}
|
||||
|
||||
export async function resetUserDoneForUser(uuid: string, name: string, phase: 1 | 2): Promise<void> {
|
||||
const col = phase === 1 ? "done_phase1" : "done_phase2";
|
||||
await pool.execute(
|
||||
`UPDATE round_users SET ${col} = 0 WHERE round_uuid = ? AND name = ?`,
|
||||
[uuid, name],
|
||||
);
|
||||
}
|
||||
110
src/server/features/rounds/router.ts
Normal file
110
src/server/features/rounds/router.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
addMovieBody,
|
||||
addUserBody,
|
||||
markDoneBody,
|
||||
newRoundBody,
|
||||
removeMovieBody,
|
||||
setPhaseBody,
|
||||
uuidParam,
|
||||
voteBody,
|
||||
} from "./schema.ts";
|
||||
import * as service from "./service.ts";
|
||||
|
||||
export const roundsRouter = new Hono()
|
||||
.get("/:uuid", zValidator("param", uuidParam), async (c) => {
|
||||
const { uuid } = c.req.valid("param");
|
||||
const state = await service.loadState(uuid);
|
||||
return c.json({ ok: true, state });
|
||||
})
|
||||
|
||||
.post(
|
||||
"/:uuid/users",
|
||||
zValidator("param", uuidParam),
|
||||
zValidator("json", addUserBody),
|
||||
async (c) => {
|
||||
const { uuid } = c.req.valid("param");
|
||||
const { user } = c.req.valid("json");
|
||||
const state = await service.addUser(uuid, user);
|
||||
return c.json({ ok: true, state });
|
||||
},
|
||||
)
|
||||
|
||||
.post("/:uuid/setup", zValidator("param", uuidParam), async (c) => {
|
||||
const { uuid } = c.req.valid("param");
|
||||
const state = await service.finishSetup(uuid);
|
||||
return c.json({ ok: true, state });
|
||||
})
|
||||
|
||||
.post(
|
||||
"/:uuid/movies",
|
||||
zValidator("param", uuidParam),
|
||||
zValidator("json", addMovieBody),
|
||||
async (c) => {
|
||||
const { uuid } = c.req.valid("param");
|
||||
const { user, title } = c.req.valid("json");
|
||||
const state = await service.addMovie(uuid, user, title);
|
||||
return c.json({ ok: true, state });
|
||||
},
|
||||
)
|
||||
|
||||
.delete(
|
||||
"/:uuid/movies",
|
||||
zValidator("param", uuidParam),
|
||||
zValidator("json", removeMovieBody),
|
||||
async (c) => {
|
||||
const { uuid } = c.req.valid("param");
|
||||
const { user, title } = c.req.valid("json");
|
||||
const state = await service.removeMovie(uuid, user, title);
|
||||
return c.json({ ok: true, state });
|
||||
},
|
||||
)
|
||||
|
||||
.post(
|
||||
"/:uuid/votes",
|
||||
zValidator("param", uuidParam),
|
||||
zValidator("json", voteBody),
|
||||
async (c) => {
|
||||
const { uuid } = c.req.valid("param");
|
||||
const { user, ratings } = c.req.valid("json");
|
||||
const state = await service.voteMany(uuid, user, ratings);
|
||||
return c.json({ ok: true, state });
|
||||
},
|
||||
)
|
||||
|
||||
.post(
|
||||
"/:uuid/done",
|
||||
zValidator("param", uuidParam),
|
||||
zValidator("json", markDoneBody),
|
||||
async (c) => {
|
||||
const { uuid } = c.req.valid("param");
|
||||
const { user, phase } = c.req.valid("json");
|
||||
const state = await service.markDone(uuid, user, phase);
|
||||
return c.json({ ok: true, state });
|
||||
},
|
||||
)
|
||||
|
||||
.post(
|
||||
"/:uuid/phase",
|
||||
zValidator("param", uuidParam),
|
||||
zValidator("json", setPhaseBody),
|
||||
async (c) => {
|
||||
const { uuid } = c.req.valid("param");
|
||||
const { phase } = c.req.valid("json");
|
||||
const state = await service.setPhase(uuid, phase);
|
||||
return c.json({ ok: true, state });
|
||||
},
|
||||
)
|
||||
|
||||
.post(
|
||||
"/:uuid/new-round",
|
||||
zValidator("param", uuidParam),
|
||||
zValidator("json", newRoundBody),
|
||||
async (c) => {
|
||||
const { uuid } = c.req.valid("param");
|
||||
const { winner } = c.req.valid("json");
|
||||
const state = await service.newRound(uuid, winner);
|
||||
return c.json({ ok: true, state });
|
||||
},
|
||||
);
|
||||
42
src/server/features/rounds/schema.ts
Normal file
42
src/server/features/rounds/schema.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const uuidParam = z.object({
|
||||
uuid: z
|
||||
.string()
|
||||
.regex(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
"Invalid UUID",
|
||||
),
|
||||
});
|
||||
|
||||
export const addUserBody = z.object({
|
||||
user: z.string().trim().min(1, "Name required").max(30, "Name too long"),
|
||||
});
|
||||
|
||||
export const addMovieBody = z.object({
|
||||
user: z.string().trim().min(1, "Name required").max(30, "Name too long"),
|
||||
title: z.string().trim().min(1, "Title required").max(60, "Title too long"),
|
||||
});
|
||||
|
||||
export const removeMovieBody = z.object({
|
||||
user: z.string().trim().min(1, "Name required").max(30, "Name too long"),
|
||||
title: z.string().trim().min(1, "Title required").max(60, "Title too long"),
|
||||
});
|
||||
|
||||
export const voteBody = z.object({
|
||||
user: z.string().trim().min(1, "Name required").max(30, "Name too long"),
|
||||
ratings: z.record(z.string(), z.number().int().min(0).max(5)),
|
||||
});
|
||||
|
||||
export const markDoneBody = z.object({
|
||||
user: z.string().trim().min(1, "Name required").max(30, "Name too long"),
|
||||
phase: z.union([z.literal(1), z.literal(2)]),
|
||||
});
|
||||
|
||||
export const setPhaseBody = z.object({
|
||||
phase: z.union([z.literal(1), z.literal(2), z.literal(3)]),
|
||||
});
|
||||
|
||||
export const newRoundBody = z.object({
|
||||
winner: z.string().default(""),
|
||||
});
|
||||
333
src/server/features/rounds/service.ts
Normal file
333
src/server/features/rounds/service.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import type { Movie, State } from "@/shared/types.ts";
|
||||
import { db } from "../../shared/db/index.ts";
|
||||
import {
|
||||
movies,
|
||||
roundHistory,
|
||||
rounds,
|
||||
roundUsers,
|
||||
votes,
|
||||
} from "../../shared/db/schema/index.ts";
|
||||
|
||||
const TROPHY_RE = /^(?:\u{1F3C6}\s*)+/u;
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function fail(status: number, message: string): never {
|
||||
throw new ApiError(status, message);
|
||||
}
|
||||
|
||||
function cleanTitle(value: string): string {
|
||||
const trimmed = value.trim().replace(TROPHY_RE, "");
|
||||
if (trimmed === "" || trimmed.length > 60) fail(400, "Invalid movie title");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export async function loadState(uuid: string): Promise<State> {
|
||||
// Ensure round row exists (upsert)
|
||||
await db
|
||||
.insert(rounds)
|
||||
.values({ uuid })
|
||||
.onConflictDoNothing({ target: rounds.uuid });
|
||||
|
||||
const [round] = await db
|
||||
.select({
|
||||
phase: rounds.phase,
|
||||
setupDone: rounds.setupDone,
|
||||
createdAt: rounds.createdAt,
|
||||
updatedAt: rounds.updatedAt,
|
||||
})
|
||||
.from(rounds)
|
||||
.where(eq(rounds.uuid, uuid));
|
||||
|
||||
const userRows = await db
|
||||
.select({
|
||||
name: roundUsers.name,
|
||||
donePhase1: roundUsers.donePhase1,
|
||||
donePhase2: roundUsers.donePhase2,
|
||||
})
|
||||
.from(roundUsers)
|
||||
.where(eq(roundUsers.roundUuid, uuid))
|
||||
.orderBy(asc(roundUsers.sortOrder), asc(roundUsers.name));
|
||||
|
||||
const movieRows = await db
|
||||
.select({
|
||||
title: movies.title,
|
||||
addedBy: movies.addedBy,
|
||||
addedAt: movies.addedAt,
|
||||
})
|
||||
.from(movies)
|
||||
.where(eq(movies.roundUuid, uuid))
|
||||
.orderBy(asc(movies.id));
|
||||
|
||||
const voteRows = await db
|
||||
.select({
|
||||
userName: votes.userName,
|
||||
movieTitle: votes.movieTitle,
|
||||
rating: votes.rating,
|
||||
})
|
||||
.from(votes)
|
||||
.where(eq(votes.roundUuid, uuid));
|
||||
|
||||
const historyRows = await db
|
||||
.select({
|
||||
winner: roundHistory.winner,
|
||||
moviesJson: roundHistory.moviesJson,
|
||||
})
|
||||
.from(roundHistory)
|
||||
.where(eq(roundHistory.roundUuid, uuid))
|
||||
.orderBy(asc(roundHistory.id));
|
||||
|
||||
const users = userRows.map((u) => u.name);
|
||||
const donePhase1 = userRows.filter((u) => u.donePhase1).map((u) => u.name);
|
||||
const donePhase2 = userRows.filter((u) => u.donePhase2).map((u) => u.name);
|
||||
|
||||
const votesMap: Record<string, Record<string, number>> = {};
|
||||
for (const v of voteRows) {
|
||||
if (!votesMap[v.userName]) votesMap[v.userName] = {};
|
||||
votesMap[v.userName][v.movieTitle] = v.rating;
|
||||
}
|
||||
|
||||
const history = historyRows.map((h) => ({
|
||||
winner: h.winner,
|
||||
movies: JSON.parse(h.moviesJson) as Movie[],
|
||||
}));
|
||||
|
||||
const phase = Number(round?.phase ?? 1);
|
||||
|
||||
return {
|
||||
uuid,
|
||||
phase: (phase === 1 || phase === 2 || phase === 3 ? phase : 1) as 1 | 2 | 3,
|
||||
setupDone: Boolean(round?.setupDone),
|
||||
users,
|
||||
doneUsersPhase1: donePhase1,
|
||||
doneUsersPhase2: donePhase2,
|
||||
movies: movieRows.map((m) => ({
|
||||
title: m.title,
|
||||
addedBy: m.addedBy,
|
||||
addedAt: m.addedAt.toISOString(),
|
||||
})),
|
||||
votes: votesMap,
|
||||
history,
|
||||
createdAt: round?.createdAt?.toISOString() ?? new Date().toISOString(),
|
||||
updatedAt: round?.updatedAt?.toISOString() ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function addUser(uuid: string, user: string): Promise<State> {
|
||||
const state = await loadState(uuid);
|
||||
if (state.phase !== 1 || state.setupDone) fail(409, "Setup is closed");
|
||||
if (!state.users.includes(user)) {
|
||||
await db
|
||||
.insert(roundUsers)
|
||||
.values({ roundUuid: uuid, name: user, sortOrder: state.users.length })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
return loadState(uuid);
|
||||
}
|
||||
|
||||
export async function finishSetup(uuid: string): Promise<State> {
|
||||
const state = await loadState(uuid);
|
||||
if (state.users.length < 1) fail(409, "Add at least one user first");
|
||||
await db.update(rounds).set({ setupDone: true }).where(eq(rounds.uuid, uuid));
|
||||
await db
|
||||
.update(roundUsers)
|
||||
.set({ donePhase1: false, donePhase2: false })
|
||||
.where(eq(roundUsers.roundUuid, uuid));
|
||||
return loadState(uuid);
|
||||
}
|
||||
|
||||
export async function addMovie(
|
||||
uuid: string,
|
||||
user: string,
|
||||
rawTitle: string,
|
||||
): Promise<State> {
|
||||
const state = await loadState(uuid);
|
||||
if (!state.setupDone) fail(409, "Setup is not finished");
|
||||
if (state.phase !== 1) fail(409, "Movie phase is closed");
|
||||
if (state.users.length > 0 && !state.users.includes(user))
|
||||
fail(403, "Unknown user");
|
||||
|
||||
const title = cleanTitle(rawTitle);
|
||||
const myCount = state.movies.filter(
|
||||
(m) => m.addedBy.toLowerCase() === user.toLowerCase(),
|
||||
).length;
|
||||
if (myCount >= 5) fail(409, "Movie limit reached for this user (5)");
|
||||
|
||||
const duplicate = state.movies.some(
|
||||
(m) => m.title.toLowerCase() === title.toLowerCase(),
|
||||
);
|
||||
if (duplicate) fail(409, "Movie already exists");
|
||||
|
||||
await db.insert(movies).values({ roundUuid: uuid, title, addedBy: user });
|
||||
await db
|
||||
.update(roundUsers)
|
||||
.set({ donePhase1: false })
|
||||
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)));
|
||||
return loadState(uuid);
|
||||
}
|
||||
|
||||
export async function removeMovie(
|
||||
uuid: string,
|
||||
user: string,
|
||||
rawTitle: string,
|
||||
): Promise<State> {
|
||||
const state = await loadState(uuid);
|
||||
if (!state.setupDone) fail(409, "Setup is not finished");
|
||||
if (state.phase !== 1) fail(409, "Movie phase is closed");
|
||||
if (state.users.length > 0 && !state.users.includes(user))
|
||||
fail(403, "Unknown user");
|
||||
|
||||
const title = cleanTitle(rawTitle);
|
||||
await db
|
||||
.delete(movies)
|
||||
.where(
|
||||
and(
|
||||
eq(movies.roundUuid, uuid),
|
||||
eq(movies.title, title),
|
||||
eq(movies.addedBy, user),
|
||||
),
|
||||
);
|
||||
await db
|
||||
.update(roundUsers)
|
||||
.set({ donePhase1: false })
|
||||
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)));
|
||||
return loadState(uuid);
|
||||
}
|
||||
|
||||
export async function voteMany(
|
||||
uuid: string,
|
||||
user: string,
|
||||
ratings: Record<string, number>,
|
||||
): Promise<State> {
|
||||
const state = await loadState(uuid);
|
||||
if (state.phase !== 2) fail(409, "Voting phase is not active");
|
||||
if (state.users.length > 0 && !state.users.includes(user))
|
||||
fail(403, "Unknown user");
|
||||
|
||||
for (const movie of state.movies) {
|
||||
const rating = ratings[movie.title];
|
||||
if (typeof rating !== "number" || !Number.isInteger(rating))
|
||||
fail(400, "Each movie needs a rating");
|
||||
if (rating < 0 || rating > 5) fail(400, "Rating must be 0 to 5");
|
||||
await db
|
||||
.insert(votes)
|
||||
.values({
|
||||
roundUuid: uuid,
|
||||
userName: user,
|
||||
movieTitle: movie.title,
|
||||
rating,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [votes.roundUuid, votes.userName, votes.movieTitle],
|
||||
set: { rating },
|
||||
});
|
||||
}
|
||||
await db
|
||||
.update(roundUsers)
|
||||
.set({ donePhase2: false })
|
||||
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)));
|
||||
return loadState(uuid);
|
||||
}
|
||||
|
||||
function allDone(users: string[], doneUsers: string[]): boolean {
|
||||
if (users.length === 0) return false;
|
||||
return users.every((name) => doneUsers.includes(name));
|
||||
}
|
||||
|
||||
export async function markDone(
|
||||
uuid: string,
|
||||
user: string,
|
||||
phase: 1 | 2,
|
||||
): Promise<State> {
|
||||
const state = await loadState(uuid);
|
||||
if (!state.setupDone) fail(409, "Setup is not finished");
|
||||
if (state.users.length > 0 && !state.users.includes(user))
|
||||
fail(403, "Unknown user");
|
||||
|
||||
if (phase === 1) {
|
||||
if (state.phase !== 1) fail(409, "Movie phase is not active");
|
||||
await db
|
||||
.update(roundUsers)
|
||||
.set({ donePhase1: true })
|
||||
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)));
|
||||
const updated = await loadState(uuid);
|
||||
if (allDone(updated.users, updated.doneUsersPhase1)) {
|
||||
await db.update(rounds).set({ phase: 2 }).where(eq(rounds.uuid, uuid));
|
||||
await db
|
||||
.update(roundUsers)
|
||||
.set({ donePhase2: false })
|
||||
.where(eq(roundUsers.roundUuid, uuid));
|
||||
}
|
||||
return loadState(uuid);
|
||||
}
|
||||
|
||||
if (phase === 2) {
|
||||
if (state.phase !== 2) fail(409, "Voting phase is not active");
|
||||
const movieTitles = state.movies.map((m) => m.title);
|
||||
const userVotes = state.votes[user] ?? {};
|
||||
for (const title of movieTitles) {
|
||||
if (typeof userVotes[title] !== "number")
|
||||
fail(409, "Rate every movie before finishing");
|
||||
}
|
||||
await db
|
||||
.update(roundUsers)
|
||||
.set({ donePhase2: true })
|
||||
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)));
|
||||
const updated = await loadState(uuid);
|
||||
if (allDone(updated.users, updated.doneUsersPhase2)) {
|
||||
await db.update(rounds).set({ phase: 3 }).where(eq(rounds.uuid, uuid));
|
||||
}
|
||||
return loadState(uuid);
|
||||
}
|
||||
|
||||
fail(400, "Invalid done phase");
|
||||
}
|
||||
|
||||
export async function setPhase(uuid: string, phase: 1 | 2 | 3): Promise<State> {
|
||||
const state = await loadState(uuid);
|
||||
if (!state.setupDone) fail(409, "Finish setup first");
|
||||
await db.update(rounds).set({ phase }).where(eq(rounds.uuid, uuid));
|
||||
if (phase === 1) {
|
||||
await db
|
||||
.update(roundUsers)
|
||||
.set({ donePhase1: false, donePhase2: false })
|
||||
.where(eq(roundUsers.roundUuid, uuid));
|
||||
}
|
||||
if (phase === 2) {
|
||||
await db
|
||||
.update(roundUsers)
|
||||
.set({ donePhase2: false })
|
||||
.where(eq(roundUsers.roundUuid, uuid));
|
||||
}
|
||||
return loadState(uuid);
|
||||
}
|
||||
|
||||
export async function newRound(
|
||||
uuid: string,
|
||||
rawWinner: string,
|
||||
): Promise<State> {
|
||||
const state = await loadState(uuid);
|
||||
if (state.phase !== 3) fail(409, "Round is not finished yet");
|
||||
const winner = rawWinner.trim().replace(TROPHY_RE, "") || null;
|
||||
await db.insert(roundHistory).values({
|
||||
roundUuid: uuid,
|
||||
winner,
|
||||
moviesJson: JSON.stringify(state.movies),
|
||||
});
|
||||
await db.delete(movies).where(eq(movies.roundUuid, uuid));
|
||||
await db.delete(votes).where(eq(votes.roundUuid, uuid));
|
||||
await db.update(rounds).set({ phase: 1 }).where(eq(rounds.uuid, uuid));
|
||||
await db
|
||||
.update(roundUsers)
|
||||
.set({ donePhase1: false, donePhase2: false })
|
||||
.where(eq(roundUsers.roundUuid, uuid));
|
||||
return loadState(uuid);
|
||||
}
|
||||
@@ -1,128 +1,7 @@
|
||||
import { statSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { handleAction, ApiError } from "./api.ts";
|
||||
import { serve } from "@hono/node-server";
|
||||
import app from "./app.ts";
|
||||
import { env } from "./shared/lib/env.ts";
|
||||
|
||||
const PORT = Number(process.env["PORT"] ?? 3001);
|
||||
const DIST_DIR = join(import.meta.dir, "../../dist");
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".webmanifest": "application/manifest+json; charset=utf-8",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
|
||||
function fileVersion(rel: string): number {
|
||||
try {
|
||||
return statSync(join(DIST_DIR, rel)).mtimeMs;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function htmlShell(): string {
|
||||
const jsV = fileVersion("main.js");
|
||||
const cssV = fileVersion("styles.css");
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#f8fafc">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<link rel="manifest" href="/movies/manifest.webmanifest">
|
||||
<link rel="stylesheet" href="/movies/styles.css?v=${cssV}">
|
||||
<title>Movie Select</title>
|
||||
</head>
|
||||
<body class="min-h-dvh bg-slate-50 text-slate-900 font-sans antialiased">
|
||||
<main class="mx-auto max-w-2xl px-4 pt-6 pb-10 flex flex-col gap-5 safe-b" aria-live="polite">
|
||||
<header>
|
||||
<h1 class="text-2xl font-bold tracking-tight">Movie Select</h1>
|
||||
</header>
|
||||
<section class="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm" id="app"></section>
|
||||
</main>
|
||||
<script type="module" src="/movies/main.js?v=${jsV}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function serveStatic(path: string): Promise<Response | null> {
|
||||
const ext = path.substring(path.lastIndexOf("."));
|
||||
const contentType = MIME[ext];
|
||||
if (!contentType) return null;
|
||||
const file = Bun.file(join(DIST_DIR, path));
|
||||
if (!(await file.exists())) return null;
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function jsonOk(data: unknown): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
function jsonErr(status: number, message: string): Response {
|
||||
return new Response(JSON.stringify({ ok: false, error: message }), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
port: PORT,
|
||||
hostname: "::",
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
const pathname = url.pathname;
|
||||
const method = req.method;
|
||||
|
||||
// Static assets
|
||||
if (pathname === "/main.js" || pathname === "/styles.css" || pathname === "/icon.svg" || pathname === "/manifest.webmanifest") {
|
||||
const resp = await serveStatic(pathname.slice(1));
|
||||
if (resp) return resp;
|
||||
}
|
||||
|
||||
// API
|
||||
if (pathname === "/api") {
|
||||
const uuidParam = url.searchParams.get("uuid") ?? "";
|
||||
let action = url.searchParams.get("action") ?? "state";
|
||||
let body: Record<string, unknown> = {};
|
||||
|
||||
if (method === "POST") {
|
||||
try {
|
||||
const raw = await req.text();
|
||||
if (raw) body = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonErr(400, "Invalid JSON body");
|
||||
}
|
||||
action = String(body["action"] ?? action);
|
||||
} else if (method !== "GET") {
|
||||
return jsonErr(405, "Use POST for mutations");
|
||||
}
|
||||
|
||||
const uuid = uuidParam || String(body["uuid"] ?? "");
|
||||
try {
|
||||
const state = await handleAction(action, uuid, body);
|
||||
return jsonOk({ ok: true, state });
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) return jsonErr(e.status, e.message);
|
||||
console.error(e);
|
||||
return jsonErr(500, "Internal server error");
|
||||
}
|
||||
}
|
||||
|
||||
// SPA fallback — serve HTML shell for all other paths
|
||||
return new Response(htmlShell(), {
|
||||
headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" },
|
||||
});
|
||||
},
|
||||
serve({ fetch: app.fetch, port: env.PORT }, (info) => {
|
||||
console.log(`Movie Select API listening on port ${info.port}`);
|
||||
});
|
||||
|
||||
console.log(`Movie Select listening on port ${server.port}`);
|
||||
|
||||
8
src/server/shared/db/index.ts
Normal file
8
src/server/shared/db/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import { env } from "../lib/env.ts";
|
||||
import * as schema from "./schema/index.ts";
|
||||
|
||||
const client = postgres(env.DATABASE_URL);
|
||||
|
||||
export const db = drizzle(client, { schema });
|
||||
5
src/server/shared/db/schema/index.ts
Normal file
5
src/server/shared/db/schema/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { movies } from "./movies.ts";
|
||||
export { roundHistory } from "./round-history.ts";
|
||||
export { roundUsers } from "./round-users.ts";
|
||||
export { rounds } from "./rounds.ts";
|
||||
export { votes } from "./votes.ts";
|
||||
25
src/server/shared/db/schema/movies.ts
Normal file
25
src/server/shared/db/schema/movies.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
char,
|
||||
pgTable,
|
||||
serial,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { rounds } from "./rounds.ts";
|
||||
|
||||
export const movies = pgTable(
|
||||
"movies",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
roundUuid: char("round_uuid", { length: 36 })
|
||||
.notNull()
|
||||
.references(() => rounds.uuid, { onDelete: "cascade" }),
|
||||
title: varchar("title", { length: 60 }).notNull(),
|
||||
addedBy: varchar("added_by", { length: 30 }).notNull(),
|
||||
addedAt: timestamp("added_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => [uniqueIndex("uq_round_title").on(t.roundUuid, t.title)],
|
||||
);
|
||||
21
src/server/shared/db/schema/round-history.ts
Normal file
21
src/server/shared/db/schema/round-history.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
char,
|
||||
pgTable,
|
||||
serial,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { rounds } from "./rounds.ts";
|
||||
|
||||
export const roundHistory = pgTable("round_history", {
|
||||
id: serial("id").primaryKey(),
|
||||
roundUuid: char("round_uuid", { length: 36 })
|
||||
.notNull()
|
||||
.references(() => rounds.uuid, { onDelete: "cascade" }),
|
||||
winner: varchar("winner", { length: 60 }),
|
||||
moviesJson: text("movies_json").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
});
|
||||
23
src/server/shared/db/schema/round-users.ts
Normal file
23
src/server/shared/db/schema/round-users.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
boolean,
|
||||
char,
|
||||
integer,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { rounds } from "./rounds.ts";
|
||||
|
||||
export const roundUsers = pgTable(
|
||||
"round_users",
|
||||
{
|
||||
roundUuid: char("round_uuid", { length: 36 })
|
||||
.notNull()
|
||||
.references(() => rounds.uuid, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 30 }).notNull(),
|
||||
donePhase1: boolean("done_phase1").notNull().default(false),
|
||||
donePhase2: boolean("done_phase2").notNull().default(false),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.roundUuid, t.name] })],
|
||||
);
|
||||
20
src/server/shared/db/schema/rounds.ts
Normal file
20
src/server/shared/db/schema/rounds.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
boolean,
|
||||
char,
|
||||
pgTable,
|
||||
smallint,
|
||||
timestamp,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const rounds = pgTable("rounds", {
|
||||
uuid: char("uuid", { length: 36 }).primaryKey(),
|
||||
phase: smallint("phase").notNull().default(1),
|
||||
setupDone: boolean("setup_done").notNull().default(false),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
});
|
||||
21
src/server/shared/db/schema/votes.ts
Normal file
21
src/server/shared/db/schema/votes.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
char,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
smallint,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { rounds } from "./rounds.ts";
|
||||
|
||||
export const votes = pgTable(
|
||||
"votes",
|
||||
{
|
||||
roundUuid: char("round_uuid", { length: 36 })
|
||||
.notNull()
|
||||
.references(() => rounds.uuid, { onDelete: "cascade" }),
|
||||
userName: varchar("user_name", { length: 30 }).notNull(),
|
||||
movieTitle: varchar("movie_title", { length: 60 }).notNull(),
|
||||
rating: smallint("rating").notNull(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.roundUuid, t.userName, t.movieTitle] })],
|
||||
);
|
||||
8
src/server/shared/lib/env.ts
Normal file
8
src/server/shared/lib/env.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const envSchema = z.object({
|
||||
DATABASE_URL: z.string().url(),
|
||||
PORT: z.coerce.number().default(3001),
|
||||
});
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
@@ -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;
|
||||
@@ -1,5 +1,6 @@
|
||||
export function allUsersDone(users: string[], doneUsers: string[]): boolean {
|
||||
if (!Array.isArray(users) || users.length === 0 || !Array.isArray(doneUsers)) return false;
|
||||
if (!Array.isArray(users) || users.length === 0 || !Array.isArray(doneUsers))
|
||||
return false;
|
||||
return users.every((name) => doneUsers.includes(name));
|
||||
}
|
||||
|
||||
@@ -7,7 +8,12 @@ export function hasCompleteRatings(
|
||||
movieTitles: string[],
|
||||
votesForUser: Record<string, number> | null | undefined,
|
||||
): boolean {
|
||||
if (!Array.isArray(movieTitles) || !votesForUser || typeof votesForUser !== "object") return false;
|
||||
if (
|
||||
!Array.isArray(movieTitles) ||
|
||||
!votesForUser ||
|
||||
typeof votesForUser !== "object"
|
||||
)
|
||||
return false;
|
||||
for (const title of movieTitles) {
|
||||
if (!Number.isInteger(votesForUser[title])) return false;
|
||||
}
|
||||
@@ -20,7 +26,12 @@ export function collectCompleteRatings(
|
||||
votes: Record<string, Record<string, number>>,
|
||||
): Record<string, Record<string, number>> {
|
||||
const output: Record<string, Record<string, number>> = {};
|
||||
if (!Array.isArray(users) || !Array.isArray(movieTitles) || !votes || typeof votes !== "object") {
|
||||
if (
|
||||
!Array.isArray(users) ||
|
||||
!Array.isArray(movieTitles) ||
|
||||
!votes ||
|
||||
typeof votes !== "object"
|
||||
) {
|
||||
return output;
|
||||
}
|
||||
for (const name of users) {
|
||||
296
styles.css
296
styles.css
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 } });
|
||||
});
|
||||
@@ -1,12 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun-types"]
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "scripts/**/*", "tests/**/*"]
|
||||
"include": ["src/**/*", "tests/**/*", "vite.config.ts", "drizzle.config.ts"]
|
||||
}
|
||||
|
||||
24
vite.config.ts
Normal file
24
vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/movie-select/",
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: "dist/client",
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/movie-select/api": {
|
||||
target: "http://localhost:3001",
|
||||
rewrite: (path) => path.replace(/^\/movie-select/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": new URL("./src", import.meta.url).pathname,
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user