diff --git a/.gitignore b/.gitignore index 6c58e87..848c600 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,8 @@ count.txt .output .vinxi todos.json -.pnpm-store \ No newline at end of file +.pnpm-store.claude/settings.local.json + +# AI setup files (in separate PR) +.agents/ +.claude/skills/ diff --git a/CLAUDE.md b/CLAUDE.md index ee9e0b5..5a0582c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,35 +1,41 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to Claude Code (claude.ai/code) when working with +code in this repository. ## Project Overview -Mad CSS is a TanStack Start application for "The Ultimate CSS Tournament" - an event website featuring 16 developers battling for CSS glory. Built with React 19, TanStack Router, and deploys to Cloudflare Workers. +Mad CSS is a TanStack Start application for "The Ultimate CSS Tournament" - an +event website featuring 16 developers battling for CSS glory. Built with React +19, TanStack Router, and deploys to Cloudflare Workers. ## Commands +**Package manager: pnpm** + ```bash # Development -npm run dev # Start dev server on port 3000 +pnpm dev # Start dev server on port 3000 +[Note] I will run the dev command myself unless otherwise specified # Build & Deploy -npm run build # Build for production -npm run deploy # Build and deploy to Cloudflare Workers +pnpm build # Build for production +pnpm deploy # Build and deploy to Cloudflare Workers # Code Quality -npm run check # Run Biome linter and formatter checks -npm run lint # Lint only -npm run format # Format only +pnpm check # Run Biome linter and formatter checks +pnpm lint # Lint only +pnpm format # Format only # Testing -npm run test # Run Vitest tests +pnpm test # Run Vitest tests # Database -npm run db:generate # Generate Drizzle migrations from schema -npm run db:migrate:local # Apply migrations to local D1 -npm run db:migrate:prod # Apply migrations to production D1 -npm run db:studio # Open Drizzle Studio -npm run db:setup # Generate + migrate local (full setup) +pnpm db:generate # Generate Drizzle migrations from schema +pnpm db:migrate:local # Apply migrations to local D1 +pnpm db:migrate:prod # Apply migrations to production D1 +pnpm db:studio # Open Drizzle Studio +pnpm db:setup # Generate + migrate local (full setup) ``` ## Database Setup @@ -66,6 +72,20 @@ BETTER_AUTH_SECRET=your_random_secret BETTER_AUTH_URL=http://localhost:3000 ``` +### GitHub OAuth Setup + +1. Go to GitHub Settings > Developer settings > OAuth Apps > New OAuth App +2. Fill in: + - **Application name:** Mad CSS (Local) or similar + - **Homepage URL:** `http://localhost:3000/test` + - **Authorization callback URL:** + `http://localhost:3000/api/auth/callback/github` +3. Click "Register application" +4. Copy the **Client ID** to `GITHUB_CLIENT_ID` in `.dev.vars` +5. Generate a new **Client Secret** and copy to `GITHUB_CLIENT_SECRET` + +The login flow redirects to `/test` after authentication. + ### Production Deployment 1. Set secrets in Cloudflare dashboard (Workers > Settings > Variables): @@ -82,21 +102,162 @@ BETTER_AUTH_URL=http://localhost:3000 **Stack:** TanStack Start (SSR framework) + React 19 + Vite + Cloudflare Workers -**File-based routing:** Routes live in `src/routes/`. TanStack Router auto-generates `src/routeTree.gen.ts` - don't edit this file manually. +**File-based routing:** Routes live in `src/routes/`. TanStack Router +auto-generates `src/routeTree.gen.ts` - don't edit this file manually. **Key directories:** - `src/routes/` - Page components and API routes - `src/routes/__root.tsx` - Root layout, includes Header and devtools -- `src/components/` - Reusable components (Header, Ticket, Roster) +- `src/components/` - Reusable components (Header, Ticket, LoginSection, + bracket/, roster/, footer/, rules/) +- `src/lib/` - Auth setup (better-auth) and utilities (cfImage.ts for Cloudflare + Images) +- `src/data/` - Player data (players.ts with 16 contestants) - `src/styles/` - CSS files imported directly into components -- `public/` - Static assets (logos, images) +- `public/` - Static assets (logos, images, card artwork) **Path alias:** `@/*` maps to `./src/*` -**Styling:** Plain CSS with CSS custom properties defined in `src/styles/styles.css`. Uses custom fonts (Kaltjer, CollegiateBlackFLF, Inter) and texture backgrounds. +**Styling:** Plain CSS with CSS custom properties defined in +`src/styles/styles.css`. Uses custom fonts (Kaltjer, CollegiateBlackFLF, Inter) +and texture backgrounds. + +## Design System & Aesthetic + +The app has a **retro sports tournament / arcade** aesthetic inspired by vintage +ticket stubs, torn paper textures, and classic sports programs. + +### Color Palette + +```css +--orange: #f3370e; /* Primary accent, CTAs, highlights */ +--yellow: #ffae00; /* Secondary accent, warnings, badges */ +--black: #000000; /* Borders, shadows, text */ +--white: #ffffff; /* Text on dark backgrounds */ +--beige: #f5eeda; /* Paper/background color */ +``` + +### Typography + +- **Block/Display:** `Alfa Slab One` (`--font-block`) - Headlines, buttons, + badges. Always uppercase with letter-spacing (0.05-0.1em) +- **Serif:** Custom serif font (`--font-serif`) - Body text, descriptions +- **Sans:** `Inter` (`--font-sans`) - UI elements, small text + +### Key Design Patterns + +**Borders & Shadows:** +- 3-4px solid black borders on interactive elements +- 4px black box-shadows that shift on hover/active states +- Button hover: `transform: translate(2px, 2px)` + reduced shadow +- Button active: `transform: translate(4px, 4px)` + no shadow + +**Torn Paper Edges:** +- Use CSS `mask-image` with paper texture PNGs +- Top edge: `repeating-paper-top.png` +- Bottom edge: `repeating-paper-bottom.png` +- Combined with `mask-composite: exclude` + +**Ticket Stub Elements:** +- Dashed tear lines (4px dashed borders) +- Notched edges using radial gradients +- Barcode decorations +- "ADMIT ONE" style typography + +**Buttons:** +```css +.button { + background: var(--yellow); + border: 3px solid var(--black); + box-shadow: 4px 4px 0 var(--black); + font-family: var(--font-block); + text-transform: uppercase; + letter-spacing: 0.05em; + transition: transform 0.1s, box-shadow 0.1s; +} +.button:hover { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--black); +} +.button:active { + transform: translate(4px, 4px); + box-shadow: none; +} +``` + +**Cards/Containers:** +- Beige (`--beige`) or yellow (`--yellow`) backgrounds +- Thick black borders (4-6px) +- Optional torn paper mask on edges + +**Status Badges:** +- Uppercase, small font (0.625-0.75rem) +- Solid fill for active states (yellow bg, black text) +- Outline style for inactive states (transparent bg, colored border) + +### What to Avoid + +- Rounded corners (keep things sharp/angular) +- Gradients (except for mask effects) +- Drop shadows (use solid offset shadows only) +- Generic sans-serif styling +- Soft/pastel colors ## Code Style - Biome for linting/formatting (tabs, double quotes) - TypeScript strict mode +- XY Flow library for tournament bracket visualization + +## Bracket System + +**Tournament structure (FEEDER_GAMES in Bracket.tsx):** +- 16 players, single elimination bracket +- Left side games: r1-0, r1-1, r1-2, r1-3 → qf-0, qf-1 → sf-0 +- Right side games: r1-4, r1-5, r1-6, r1-7 → qf-2, qf-3 → sf-1 +- Finals: sf-0 winner vs sf-1 winner + +**Tournament stages (sequential order):** +1. Left R1 - games r1-0, r1-1, r1-2, r1-3 +2. Right R1 - games r1-4, r1-5, r1-6, r1-7 +3. QF - games qf-0, qf-1, qf-2, qf-3 (all together) +4. SF - games sf-0, sf-1 (semifinals) +5. Finals + +**Node sizing logic (`isNodeLarge()` in Bracket.tsx):** +- A node is "large" (`round1` class, ~130px with bio) when: + - Its feeder game IS decided (we know the player) + - The current game is NOT decided (active round) +- A node is "small" (`later` class, ~90px, no bio) when: + - Its feeder is NOT decided (TBD state), OR + - The current game IS already decided (completed) + +**Dynamic Y positioning:** +- Node Y offsets adjust based on `isNodeLarge()` to center nodes properly +- QF: 0.5 (large) vs 0.62 (small) +- SF: 1.35 (large) vs 1.5 (small) +- Finals: 3.35 (large) vs 3.5 (small) + +**Key constants (Bracket.tsx):** +- `NODE_HEIGHT = 70`, `VERTICAL_GAP = 76`, `MATCH_GAP = 146` +- `ROUND_GAP = 220` (horizontal spacing between rounds) + +**User picking flow:** +- Users can pick winners for games where both players are known +- Picks stored in `predictions` object keyed by game ID +- `isPickable` flag enables click handlers on player nodes + +## Comment Policy + +### Unacceptable Comments + +- Comments that repeat what code does +- Commented-out code (delete it) +- Obvious comments ("increment counter") +- Comments instead of good naming + +### Principle + +Code should be self-documenting. If you need a comment to explain WHAT the code +does, consider refactoring to make it clearer. diff --git a/biome.json b/biome.json index cdfd60b..ab70548 100644 --- a/biome.json +++ b/biome.json @@ -24,7 +24,10 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "complexity": { + "noImportantStyles": "off" + } } }, "javascript": { diff --git a/drizzle/0000_daffy_shard.sql b/drizzle/0000_daffy_shard.sql new file mode 100644 index 0000000..19b6f08 --- /dev/null +++ b/drizzle/0000_daffy_shard.sql @@ -0,0 +1,94 @@ +CREATE TABLE `account` ( + `id` text PRIMARY KEY NOT NULL, + `account_id` text NOT NULL, + `provider_id` text NOT NULL, + `user_id` text NOT NULL, + `access_token` text, + `refresh_token` text, + `id_token` text, + `access_token_expires_at` integer, + `refresh_token_expires_at` integer, + `scope` text, + `password` text, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint +CREATE TABLE `session` ( + `id` text PRIMARY KEY NOT NULL, + `expires_at` integer NOT NULL, + `token` text NOT NULL, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer NOT NULL, + `ip_address` text, + `user_agent` text, + `user_id` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint +CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint +CREATE TABLE `user` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `email` text NOT NULL, + `email_verified` integer DEFAULT false NOT NULL, + `image` text, + `username` text, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint +CREATE UNIQUE INDEX `user_username_unique` ON `user` (`username`);--> statement-breakpoint +CREATE TABLE `user_bracket_status` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `is_locked` integer DEFAULT false NOT NULL, + `locked_at` integer, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_bracket_status_user_id_unique` ON `user_bracket_status` (`user_id`);--> statement-breakpoint +CREATE INDEX `user_bracket_status_userId_idx` ON `user_bracket_status` (`user_id`);--> statement-breakpoint +CREATE TABLE `user_prediction` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `game_id` text NOT NULL, + `predicted_winner_id` text NOT NULL, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `user_prediction_userId_idx` ON `user_prediction` (`user_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `user_prediction_userId_gameId_unique` ON `user_prediction` (`user_id`,`game_id`);--> statement-breakpoint +CREATE TABLE `user_score` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `round1_score` integer DEFAULT 0 NOT NULL, + `round2_score` integer DEFAULT 0 NOT NULL, + `round3_score` integer DEFAULT 0 NOT NULL, + `round4_score` integer DEFAULT 0 NOT NULL, + `total_score` integer DEFAULT 0 NOT NULL, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_score_user_id_unique` ON `user_score` (`user_id`);--> statement-breakpoint +CREATE INDEX `user_score_userId_idx` ON `user_score` (`user_id`);--> statement-breakpoint +CREATE INDEX `user_score_totalScore_idx` ON `user_score` (`total_score`);--> statement-breakpoint +CREATE TABLE `verification` ( + `id` text PRIMARY KEY NOT NULL, + `identifier` text NOT NULL, + `value` text NOT NULL, + `expires_at` integer NOT NULL, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL +); +--> statement-breakpoint +CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`); \ No newline at end of file diff --git a/drizzle/0000_rare_juggernaut.sql b/drizzle/0000_rare_juggernaut.sql deleted file mode 100644 index dc3063d..0000000 --- a/drizzle/0000_rare_juggernaut.sql +++ /dev/null @@ -1,53 +0,0 @@ -CREATE TABLE `account` ( - `id` text PRIMARY KEY NOT NULL, - `account_id` text NOT NULL, - `provider_id` text NOT NULL, - `user_id` text NOT NULL, - `access_token` text, - `refresh_token` text, - `id_token` text, - `access_token_expires_at` integer, - `refresh_token_expires_at` integer, - `scope` text, - `password` text, - `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, - `updated_at` integer NOT NULL, - FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint -CREATE TABLE `session` ( - `id` text PRIMARY KEY NOT NULL, - `expires_at` integer NOT NULL, - `token` text NOT NULL, - `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, - `updated_at` integer NOT NULL, - `ip_address` text, - `user_agent` text, - `user_id` text NOT NULL, - FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint -CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint -CREATE TABLE `user` ( - `id` text PRIMARY KEY NOT NULL, - `name` text NOT NULL, - `email` text NOT NULL, - `email_verified` integer DEFAULT false NOT NULL, - `image` text, - `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, - `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL -); ---> statement-breakpoint -CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint -CREATE TABLE `verification` ( - `id` text PRIMARY KEY NOT NULL, - `identifier` text NOT NULL, - `value` text NOT NULL, - `expires_at` integer NOT NULL, - `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, - `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL -); ---> statement-breakpoint -CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 55bd2c8..dc4f2a9 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "8e24b20b-75e1-4780-815e-9b34da83ea0e", + "id": "91a916f3-894b-49ab-8482-b845d0f5c835", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "account": { @@ -263,6 +263,13 @@ "notNull": false, "autoincrement": false }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, "created_at": { "name": "created_at", "type": "integer", @@ -287,6 +294,13 @@ "email" ], "isUnique": true + }, + "user_username_unique": { + "name": "user_username_unique", + "columns": [ + "username" + ], + "isUnique": true } }, "foreignKeys": {}, @@ -294,6 +308,282 @@ "uniqueConstraints": {}, "checkConstraints": {} }, + "user_bracket_status": { + "name": "user_bracket_status", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_locked": { + "name": "is_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "locked_at": { + "name": "locked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_bracket_status_user_id_unique": { + "name": "user_bracket_status_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + }, + "user_bracket_status_userId_idx": { + "name": "user_bracket_status_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_bracket_status_user_id_user_id_fk": { + "name": "user_bracket_status_user_id_user_id_fk", + "tableFrom": "user_bracket_status", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_prediction": { + "name": "user_prediction", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "predicted_winner_id": { + "name": "predicted_winner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_prediction_userId_idx": { + "name": "user_prediction_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "user_prediction_userId_gameId_unique": { + "name": "user_prediction_userId_gameId_unique", + "columns": [ + "user_id", + "game_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_prediction_user_id_user_id_fk": { + "name": "user_prediction_user_id_user_id_fk", + "tableFrom": "user_prediction", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_score": { + "name": "user_score", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "round1_score": { + "name": "round1_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "round2_score": { + "name": "round2_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "round3_score": { + "name": "round3_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "round4_score": { + "name": "round4_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_score": { + "name": "total_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_score_user_id_unique": { + "name": "user_score_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + }, + "user_score_userId_idx": { + "name": "user_score_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "user_score_totalScore_idx": { + "name": "user_score_totalScore_idx", + "columns": [ + "total_score" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_score_user_id_user_id_fk": { + "name": "user_score_user_id_user_id_fk", + "tableFrom": "user_score", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, "verification": { "name": "verification", "columns": { diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d434796..2c43aaf 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1767919267096, - "tag": "0000_rare_juggernaut", + "when": 1769646596047, + "tag": "0000_daffy_shard", "breakpoints": true } ] diff --git a/package.json b/package.json index e77685d..7a30947 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,12 @@ "@cloudflare/vite-plugin": "^1.20.1", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-devtools": "^0.9.2", - "@tanstack/react-router": "^1.146.0", + "@tanstack/react-query": "^5.90.20", + "@tanstack/react-router": "^1.157.16", "@tanstack/react-router-devtools": "^1.146.0", "@tanstack/react-router-ssr-query": "^1.146.0", - "@tanstack/react-start": "^1.146.0", - "@tanstack/router-plugin": "^1.146.0", + "@tanstack/react-start": "^1.157.16", + "@tanstack/router-plugin": "^1.157.16", "@xyflow/react": "^12.10.0", "better-auth": "^1.4.10", "drizzle-orm": "^0.45.1", @@ -34,7 +35,9 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "tailwindcss": "^4.1.18", - "vite-tsconfig-paths": "^6.0.3" + "vite-tsconfig-paths": "^6.0.3", + "workers-og": "^0.0.27", + "zod": "^4.3.6" }, "devDependencies": { "@biomejs/biome": "2.3.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d9bd84..37c150c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,27 +17,30 @@ importers: '@tanstack/react-devtools': specifier: ^0.9.2 version: 0.9.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10) + '@tanstack/react-query': + specifier: ^5.90.20 + version: 5.90.20(react@19.2.3) '@tanstack/react-router': - specifier: ^1.146.0 - version: 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.157.16 + version: 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-router-devtools': specifier: ^1.146.0 - version: 1.146.2(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.146.2)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10) + version: 1.146.2(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.16)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10) '@tanstack/react-router-ssr-query': specifier: ^1.146.0 - version: 1.146.2(@tanstack/query-core@5.90.16)(@tanstack/react-query@5.90.16(react@19.2.3))(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.146.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.146.2(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.3))(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': - specifier: ^1.146.0 - version: 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + specifier: ^1.157.16 + version: 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) '@tanstack/router-plugin': - specifier: ^1.146.0 - version: 1.146.2(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + specifier: ^1.157.16 + version: 1.157.16(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) '@xyflow/react': specifier: ^12.10.0 version: 12.10.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) better-auth: specifier: ^1.4.10 - version: 1.4.10(@tanstack/react-start@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0)(kysely@0.28.9))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(tsx@4.21.0)) + version: 1.4.10(@tanstack/react-start@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0)(kysely@0.28.9))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(tsx@4.21.0)) drizzle-orm: specifier: ^0.45.1 version: 0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0)(kysely@0.28.9) @@ -56,6 +59,12 @@ importers: vite-tsconfig-paths: specifier: ^6.0.3 version: 6.0.3(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + workers-og: + specifier: ^0.0.27 + version: 0.0.27 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@biomejs/biome': specifier: 2.3.11 @@ -127,22 +136,42 @@ packages: resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.28.6': + resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.28.5': resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} engines: {node: '>=6.9.0'} + '@babel/compat-data@7.28.6': + resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==} + engines: {node: '>=6.9.0'} + '@babel/core@7.28.5': resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} engines: {node: '>=6.9.0'} + '@babel/core@7.28.6': + resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==} + engines: {node: '>=6.9.0'} + '@babel/generator@7.28.5': resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} engines: {node: '>=6.9.0'} + '@babel/generator@7.28.6': + resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==} + engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.27.2': resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} @@ -151,12 +180,22 @@ packages: resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-transforms@7.28.3': resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-plugin-utils@7.27.1': resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} engines: {node: '>=6.9.0'} @@ -177,11 +216,20 @@ packages: resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} engines: {node: '>=6.9.0'} + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.28.5': resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.28.6': + resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-syntax-jsx@7.27.1': resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} engines: {node: '>=6.9.0'} @@ -214,14 +262,26 @@ packages: resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.28.5': resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.28.6': + resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==} + engines: {node: '>=6.9.0'} + '@babel/types@7.28.5': resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@babel/types@7.28.6': + resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} + engines: {node: '>=6.9.0'} + '@better-auth/core@1.4.10': resolution: {integrity: sha512-AThrfb6CpG80wqkanfrbN2/fGOYzhGladHFf3JhaWt/3/Vtf4h084T6PJLrDE7M/vCCGYvDI1DkvP3P1OB2HAg==} peerDependencies: @@ -1164,6 +1224,10 @@ packages: '@remix-run/node-fetch-server@0.8.1': resolution: {integrity: sha512-J1dev372wtJqmqn9U/qbpbZxbJSQrogNN2+Qv1lKlpATpe/WQ9aCZfl/xSb9d2Rgh1IyLSvNxZAXPZxruO6Xig==} + '@resvg/resvg-wasm@2.4.0': + resolution: {integrity: sha512-C7c51Nn4yTxXFKvgh2txJFNweaVcfUPQxwEUFw4aWsCmfiBDJsTSwviIF8EcwjQ6k8bPyMWCl1vw4BdxE569Cg==} + engines: {node: '>= 10'} + '@rolldown/pluginutils@1.0.0-beta.40': resolution: {integrity: sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w==} @@ -1295,6 +1359,11 @@ packages: cpu: [x64] os: [win32] + '@shuding/opentype.js@1.4.0-beta.0': + resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==} + engines: {node: '>= 8.0.0'} + hasBin: true + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -1455,12 +1524,12 @@ packages: peerDependencies: solid-js: '>=1.9.7' - '@tanstack/history@1.145.7': - resolution: {integrity: sha512-gMo/ReTUp0a3IOcZoI3hH6PLDC2R/5ELQ7P2yu9F6aEkA0wSQh+Q4qzMrtcKvF2ut0oE+16xWCGDo/TdYd6cEQ==} + '@tanstack/history@1.154.14': + resolution: {integrity: sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA==} engines: {node: '>=12'} - '@tanstack/query-core@5.90.16': - resolution: {integrity: sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==} + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} '@tanstack/react-devtools@0.9.2': resolution: {integrity: sha512-JNXvBO3jgq16GzTVm7p65n5zHNfMhnqF6Bm7CawjoqZrjEakxbM6Yvy63aKSIpbrdf+Wun2Xn8P0qD+vp56e1g==} @@ -1471,8 +1540,8 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-query@5.90.16': - resolution: {integrity: sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==} + '@tanstack/react-query@5.90.20': + resolution: {integrity: sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==} peerDependencies: react: ^18 || ^19 @@ -1498,29 +1567,29 @@ packages: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-router@1.146.2': - resolution: {integrity: sha512-Oq/shGk5nCNyK/YhB9SGByeU3wgjNVzpGoDovuOvIacE9hsicZYOv9EnII1fEku8xavqWtN8D9wr21z2CDanjA==} + '@tanstack/react-router@1.157.16': + resolution: {integrity: sha512-xwFQa7S7dhBhm3aJYwU79cITEYgAKSrcL6wokaROIvl2JyIeazn8jueWqUPJzFjv+QF6Q8euKRlKUEyb5q2ymg==} engines: {node: '>=12'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-start-client@1.146.2': - resolution: {integrity: sha512-zZ1PdU7MEEflqxBpdfSm51SlN/o9zLDAp4hf5zP5t93AxC9RO6I1SWFP5B65GdyVqG4ZE1OyUBOnIuom7E64sw==} + '@tanstack/react-start-client@1.157.16': + resolution: {integrity: sha512-r3XTxYPJXZ/szhbloxqT6CQtsoEjw8DjbnZh/3ZsQv2PLKTOl925cy7YVdQc2cWZyXtn5e19Ig78R+8tsoTpig==} engines: {node: '>=22.12.0'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-start-server@1.146.2': - resolution: {integrity: sha512-F/0ph0xcm60msreOC2aGTkDVXnHHaH3LiMuhHd/DRdmQlOdXC0NPKMw+R6I3fO52UUnKZYOfw6grJcg9YPdm6Q==} + '@tanstack/react-start-server@1.157.16': + resolution: {integrity: sha512-1YkBss4SUQ+HqVC1yGN/j7VNwjvdHHd3K58fASe0bz+uf7GrkGJlRXPkMJdxJkkmefYHQfyBL+q7o723N4CMYA==} engines: {node: '>=22.12.0'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-start@1.146.2': - resolution: {integrity: sha512-UFEVeNvMMcuMCm/v1taq+APAgwn3Ivcw/f30N1V9thohC8jR5MxCcS+R3od2DPSTFbzrzTAI1P83uok3xQuluA==} + '@tanstack/react-start@1.157.16': + resolution: {integrity: sha512-FO6UYjsZyNaC0ickSSvClqfVZemp9/HWnbRJQU2dOKYQsI+wnznhLp9IkgG90iFBLcuMAWhcNHMiIuz603GJBg==} engines: {node: '>=22.12.0'} peerDependencies: react: '>=18.0.0 || >=19.0.0' @@ -1533,8 +1602,8 @@ packages: 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 - '@tanstack/router-core@1.146.2': - resolution: {integrity: sha512-MmTDiT6fpe+WBWYAuhp8oyzULBJX4oblm1kCqHDngf9mK3qcnNm5nkKk4d3Fk80QZmHS4DcRNFaFHKbLUVlZog==} + '@tanstack/router-core@1.157.16': + resolution: {integrity: sha512-eJuVgM7KZYTTr4uPorbUzUflmljMVcaX2g6VvhITLnHmg9SBx9RAgtQ1HmT+72mzyIbRSlQ1q0fY/m+of/fosA==} engines: {node: '>=12'} '@tanstack/router-devtools-core@1.146.2': @@ -1548,16 +1617,16 @@ packages: csstype: optional: true - '@tanstack/router-generator@1.146.2': - resolution: {integrity: sha512-0eO/iL50OrNLtG613iHLmps8AVJC7WChDz+njFViTiWCf20RMEjeUlKTffdrREx3v/QeaLVuxlBvLkXRqSW0yg==} + '@tanstack/router-generator@1.157.16': + resolution: {integrity: sha512-Ae2M00VTFjjED7glSCi/mMLENRzhEym6NgjoOx7UVNbCC/rLU/5ASDe5VIlDa8QLEqP5Pj088Gi51gjmRuICvQ==} engines: {node: '>=12'} - '@tanstack/router-plugin@1.146.2': - resolution: {integrity: sha512-4eHhoH2z69KfJTXqLqWAnfseGxzAiw5BX7wDatzXR5ODYXOu+JBIEMiZrP2YDclxPLVuetmBrGAluWSduH8O/g==} + '@tanstack/router-plugin@1.157.16': + resolution: {integrity: sha512-YQg7L06xyCJAYyrEJNZGAnDL8oChILU+G/eSDIwEfcWn5iLk+47x1Gcdxr82++47PWmOPhzuTo8edDQXWs7kAA==} engines: {node: '>=12'} peerDependencies: '@rsbuild/core': '>=1.0.2' - '@tanstack/react-router': ^1.146.2 + '@tanstack/react-router': ^1.157.16 vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' vite-plugin-solid: ^2.11.10 webpack: '>=5.92.0' @@ -1580,37 +1649,37 @@ packages: '@tanstack/query-core': '>=5.90.0' '@tanstack/router-core': '>=1.127.0' - '@tanstack/router-utils@1.143.11': - resolution: {integrity: sha512-N24G4LpfyK8dOlnP8BvNdkuxg1xQljkyl6PcrdiPSA301pOjatRT1y8wuCCJZKVVD8gkd0MpCZ0VEjRMGILOtA==} + '@tanstack/router-utils@1.154.7': + resolution: {integrity: sha512-61bGx32tMKuEpVRseu2sh1KQe8CfB7793Mch/kyQt0EP3tD7X0sXmimCl3truRiDGUtI0CaSoQV1NPjAII1RBA==} engines: {node: '>=12'} - '@tanstack/start-client-core@1.146.2': - resolution: {integrity: sha512-ZACRxwxs4BLVxwUoLOLeyuJwbrjHEnL3QEuxoOPVDsazGPJHiD/0fA6aZoCadh+YFP/U3OoKtjfh7SwxN/SQVA==} + '@tanstack/start-client-core@1.157.16': + resolution: {integrity: sha512-O+7H133MWQTkOxmXJNhrLXiOhDcBlxvpEcCd/N25Ga6eyZ7/P5vvFzNkSSxeQNkZV+RiPWnA5B75gT+U+buz3w==} engines: {node: '>=22.12.0'} - '@tanstack/start-fn-stubs@1.143.8': - resolution: {integrity: sha512-2IKUPh/TlxwzwHMiHNeFw95+L2sD4M03Es27SxMR0A60Qc4WclpaD6gpC8FsbuNASM2jBxk2UyeYClJxW1GOAQ==} + '@tanstack/start-fn-stubs@1.154.7': + resolution: {integrity: sha512-D69B78L6pcFN5X5PHaydv7CScQcKLzJeEYqs7jpuyyqGQHSUIZUjS955j+Sir8cHhuDIovCe2LmsYHeZfWf3dQ==} engines: {node: '>=22.12.0'} - '@tanstack/start-plugin-core@1.146.2': - resolution: {integrity: sha512-td6c2FxZdR1EQ2DjQ2j5ir/uxyqrQ2UAVEmqphJkHYqDGiPdXQ/LLHLDZciez4Yo6wJsIpNzmCXMEOG4r15YbA==} + '@tanstack/start-plugin-core@1.157.16': + resolution: {integrity: sha512-VmRXuvP5flryUAHeBM4Xb06n544qLtyA2cwmlQLRTUYtQiQEAdd9CvCGy8CPAly3f7eeXKqC7aX0v3MwWkLR8w==} engines: {node: '>=22.12.0'} peerDependencies: vite: '>=7.0.0' - '@tanstack/start-server-core@1.146.2': - resolution: {integrity: sha512-ugIEnZn84vR96a+G2ICfE7F5iiV5Tn45Y1omUe+tiXWIngb4tDvYTTFpNTIbD0NqoxfKyvN9YFbJH9OKtApDsQ==} + '@tanstack/start-server-core@1.157.16': + resolution: {integrity: sha512-PEltFleYfiqz6+KcmzNXxc1lXgT7VDNKP6G6i1TirdHBDbRJ9CIY+ASLPlhrRwqwA2PL9PpFjXZl8u5bH/+Q9A==} engines: {node: '>=22.12.0'} - '@tanstack/start-storage-context@1.146.2': - resolution: {integrity: sha512-kX9VzyJPqQR0hUVWbThLxDp8XHkGMmS/oFh9Yj5njXzHGxNm8y0nhEqKG7x+2o/Idxwqaf6mBOSp6FlONbrtEQ==} + '@tanstack/start-storage-context@1.157.16': + resolution: {integrity: sha512-56izE0oihAw2YRwYUEds2H+uO5dyT2CahXCgWX62+l+FHou09M9mSep68n1lBKPdphC2ZU3cPV7wnvgeraJWHg==} engines: {node: '>=22.12.0'} '@tanstack/store@0.8.0': resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} - '@tanstack/virtual-file-routes@1.145.4': - resolution: {integrity: sha512-CI75JrfqSluhdGwLssgVeQBaCphgfkMQpi8MCY3UJX1hoGzXa8kHYJcUuIFMOLs1q7zqHy++EVVtMK03osR5wQ==} + '@tanstack/virtual-file-routes@1.154.7': + resolution: {integrity: sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg==} engines: {node: '>=12'} '@testing-library/dom@10.4.1': @@ -1783,6 +1852,13 @@ packages: babel-dead-code-elimination@1.0.11: resolution: {integrity: sha512-mwq3W3e/pKSI6TG8lXMiDWvEi1VXYlSBlJlB3l+I0bAb5u1RNUl88udos85eOPNK3m5EXK9uO7d2g08pesTySQ==} + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1895,6 +1971,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + camelize@1.0.1: + resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} + caniuse-lite@1.0.30001762: resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} @@ -1909,8 +1988,8 @@ packages: cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} - cheerio@1.1.2: - resolution: {integrity: sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==} + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} engines: {node: '>=20.18.1'} chokidar@3.6.0: @@ -1951,9 +2030,26 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + css-background-parser@0.1.0: + resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==} + + css-box-shadow@1.0.0-3: + resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==} + + css-color-keywords@1.0.0: + resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} + engines: {node: '>=4'} + + css-gradient-parser@0.0.16: + resolution: {integrity: sha512-3O5QdqgFRUbXvK1x5INf1YkBz1UKSWqrd63vWsum8MNHDBYD5urm3QtxZbKU259OrEXNM26lP/MPY3d1IGkBgA==} + engines: {node: '>=16'} + css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-to-react-native@3.2.0: + resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} + css-tree@3.1.0: resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -2042,8 +2138,8 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - diff@8.0.2: - resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} dom-accessibility-api@0.5.16: @@ -2161,6 +2257,10 @@ packages: electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + emoji-regex-xs@2.0.1: + resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==} + engines: {node: '>=10.0.0'} + encoding-sniffer@0.2.1: resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} @@ -2179,6 +2279,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -2214,6 +2318,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -2246,6 +2353,9 @@ packages: picomatch: optional: true + fflate@0.7.4: + resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -2293,8 +2403,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - h3@2.0.1-rc.7: - resolution: {integrity: sha512-qbrRu1OLXmUYnysWOCVrYhtC/m8ZuXu/zCbo3U/KyphJxbPFiC76jHYwVrmEcss9uNAHO5BoUguQ46yEpgI2PA==} + h3@2.0.1-rc.11: + resolution: {integrity: sha512-2myzjCqy32c1As9TjZW9fNZXtLqNedjFSrdFy2AjFBQQ3LzrnGoDdFDYfC0tV2e4vcyfJ2Sfo/F6NQhO2Ly/Mw==} engines: {node: '>=20.11.1'} peerDependencies: crossws: ^0.4.1 @@ -2302,12 +2412,16 @@ packages: crossws: optional: true + hex-rgb@4.3.0: + resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} + engines: {node: '>=6'} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - htmlparser2@10.0.0: - resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} @@ -2389,6 +2503,9 @@ packages: engines: {node: '>=6'} hasBin: true + just-camel-case@6.2.0: + resolution: {integrity: sha512-ICenRLXwkQYLk3UyvLQZ+uKuwFVJ3JHFYFn7F2782G2Mv2hW8WPePqgdhpnjGaqkYtSVWnyCESZhGXUmY3/bEg==} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -2470,6 +2587,9 @@ packages: resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + lru-cache@11.2.4: resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} engines: {node: 20 || >=22} @@ -2547,6 +2667,12 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + + parse-css-color@0.2.1: + resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==} + parse5-htmlparser2-tree-adapter@7.1.0: resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} @@ -2576,6 +2702,9 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -2585,8 +2714,8 @@ packages: engines: {node: '>=10'} hasBin: true - prettier@3.7.4: - resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} hasBin: true @@ -2654,6 +2783,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + satori@0.15.2: + resolution: {integrity: sha512-vu/49vdc8MzV5jUchs3TIRDCOkOvMc1iJ11MrZvhg9tE4ziKIEIBjBZvies6a9sfM2vQ2gc3dXeu6rCK7AztHA==} + engines: {node: '>=16'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -2676,8 +2809,8 @@ packages: peerDependencies: seroval: ^1.0 - seroval-plugins@1.4.2: - resolution: {integrity: sha512-X7p4MEDTi+60o2sXZ4bnDBhgsUYDSkQEvzYZuJyFqWg9jcoPsHts5nrg5O956py2wyt28lUrBxk0M0/wU8URpA==} + seroval-plugins@1.5.0: + resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} engines: {node: '>=10'} peerDependencies: seroval: ^1.0 @@ -2686,8 +2819,8 @@ packages: resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} engines: {node: '>=10'} - seroval@1.4.2: - resolution: {integrity: sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ==} + seroval@1.5.0: + resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} engines: {node: '>=10'} set-cookie-parser@2.7.2: @@ -2731,8 +2864,8 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} - srvx@0.10.0: - resolution: {integrity: sha512-NqIsR+wQCfkvvwczBh8J8uM4wTZx41K2lLSEp/3oMp917ODVVMtW5Me4epCmQ3gH8D+0b+/t4xxkUKutyhimTA==} + srvx@0.10.1: + resolution: {integrity: sha512-A//xtfak4eESMWWydSRFUVvCTQbSwivnGCEf8YGPe2eHU0+Z6znfUTCPF0a7oV3sObSOcrXHlL6Bs9vVctfXdg==} engines: {node: '>=20.16.0'} hasBin: true @@ -2746,6 +2879,9 @@ packages: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} + string.prototype.codepointat@0.2.1: + resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -2774,6 +2910,9 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -2840,8 +2979,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - ufo@1.6.2: - resolution: {integrity: sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==} + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -2850,13 +2989,16 @@ packages: resolution: {integrity: sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ==} engines: {node: '>=20.18.1'} - undici@7.18.2: - resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} + undici@7.19.2: + resolution: {integrity: sha512-4VQSpGEGsWzk0VYxyB/wVX/Q7qf9t5znLRgs0dzszr9w9Fej/8RVNQ+S20vdXSAyra/bJ7ZQfGv6ZMj7UEbzSg==} engines: {node: '>=20.18.1'} unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unplugin@2.3.11: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} @@ -3002,6 +3144,9 @@ packages: engines: {node: '>=16'} hasBin: true + workers-og@0.0.27: + resolution: {integrity: sha512-QvwptQ0twmouQHiITUi3kYxEPCLdueC/U4msQ2xMz2iktd+iseSs7zlREw3T1dAsPxPw73FQlw8cXFsfANZPlw==} + wrangler@4.58.0: resolution: {integrity: sha512-Jm6EYtlt8iUcznOCPSMYC54DYkwrMNESzbH0Vh3GFHv/7XVw5gBC13YJAB+nWMRGJ+6B2dMzy/NVQS4ONL51Pw==} engines: {node: '>=20.0.0'} @@ -3053,6 +3198,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yoga-wasm-web@0.3.3: + resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==} + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} @@ -3062,8 +3210,8 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.3.5: - resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} @@ -3108,8 +3256,16 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.28.6': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.28.5': {} + '@babel/compat-data@7.28.6': {} + '@babel/core@7.28.5': dependencies: '@babel/code-frame': 7.27.1 @@ -3130,6 +3286,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/core@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/template': 7.28.6 + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/generator@7.28.5': dependencies: '@babel/parser': 7.28.5 @@ -3138,6 +3314,14 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.28.6': + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/helper-compilation-targets@7.27.2': dependencies: '@babel/compat-data': 7.28.5 @@ -3146,6 +3330,14 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + '@babel/helper-globals@7.28.0': {} '@babel/helper-module-imports@7.27.1': @@ -3155,6 +3347,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -3164,6 +3363,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.6 + transitivePeerDependencies: + - supports-color + '@babel/helper-plugin-utils@7.27.1': {} '@babel/helper-string-parser@7.27.1': {} @@ -3177,10 +3385,19 @@ snapshots: '@babel/template': 7.27.2 '@babel/types': 7.28.5 + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 + '@babel/parser@7.28.5': dependencies: '@babel/types': 7.28.5 + '@babel/parser@7.28.6': + dependencies: + '@babel/types': 7.28.6 + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -3209,6 +3426,12 @@ snapshots: '@babel/parser': 7.28.5 '@babel/types': 7.28.5 + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + '@babel/traverse@7.28.5': dependencies: '@babel/code-frame': 7.27.1 @@ -3221,25 +3444,42 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.6 + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.28.5': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)': + '@babel/types@7.28.6': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)': dependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@standard-schema/spec': 1.1.0 - better-call: 1.1.7(zod@4.3.5) + better-call: 1.1.7(zod@4.3.6) jose: 6.1.3 kysely: 0.28.9 nanostores: 1.1.0 - zod: 4.3.5 + zod: 4.3.6 - '@better-auth/telemetry@1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0))': + '@better-auth/telemetry@1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0))': dependencies: - '@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) + '@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 @@ -3804,6 +4044,8 @@ snapshots: '@remix-run/node-fetch-server@0.8.1': {} + '@resvg/resvg-wasm@2.4.0': {} + '@rolldown/pluginutils@1.0.0-beta.40': {} '@rolldown/pluginutils@1.0.0-beta.53': {} @@ -3883,6 +4125,11 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.55.1': optional: true + '@shuding/opentype.js@1.4.0-beta.0': + dependencies: + fflate: 0.7.4 + string.prototype.codepointat: 0.2.1 + '@sindresorhus/is@7.2.0': {} '@solid-primitives/event-listener@2.4.3(solid-js@1.9.10)': @@ -4046,9 +4293,9 @@ snapshots: - csstype - utf-8-validate - '@tanstack/history@1.145.7': {} + '@tanstack/history@1.154.14': {} - '@tanstack/query-core@5.90.16': {} + '@tanstack/query-core@5.90.20': {} '@tanstack/react-devtools@0.9.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)': dependencies: @@ -4063,76 +4310,76 @@ snapshots: - solid-js - utf-8-validate - '@tanstack/react-query@5.90.16(react@19.2.3)': + '@tanstack/react-query@5.90.20(react@19.2.3)': dependencies: - '@tanstack/query-core': 5.90.16 + '@tanstack/query-core': 5.90.20 react: 19.2.3 - '@tanstack/react-router-devtools@1.146.2(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.146.2)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)': + '@tanstack/react-router-devtools@1.146.2(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.16)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)': dependencies: - '@tanstack/react-router': 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-devtools-core': 1.146.2(@tanstack/router-core@1.146.2)(csstype@3.2.3)(solid-js@1.9.10) + '@tanstack/react-router': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/router-devtools-core': 1.146.2(@tanstack/router-core@1.157.16)(csstype@3.2.3)(solid-js@1.9.10) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - '@tanstack/router-core': 1.146.2 + '@tanstack/router-core': 1.157.16 transitivePeerDependencies: - csstype - solid-js - '@tanstack/react-router-ssr-query@1.146.2(@tanstack/query-core@5.90.16)(@tanstack/react-query@5.90.16(react@19.2.3))(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.146.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-router-ssr-query@1.146.2(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.3))(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/query-core': 5.90.16 - '@tanstack/react-query': 5.90.16(react@19.2.3) - '@tanstack/react-router': 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-ssr-query-core': 1.146.2(@tanstack/query-core@5.90.16)(@tanstack/router-core@1.146.2) + '@tanstack/query-core': 5.90.20 + '@tanstack/react-query': 5.90.20(react@19.2.3) + '@tanstack/react-router': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/router-ssr-query-core': 1.146.2(@tanstack/query-core@5.90.20)(@tanstack/router-core@1.157.16) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - '@tanstack/router-core' - '@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/history': 1.145.7 + '@tanstack/history': 1.154.14 '@tanstack/react-store': 0.8.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-core': 1.146.2 + '@tanstack/router-core': 1.157.16 isbot: 5.1.32 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/react-start-client@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-start-client@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/react-router': 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-core': 1.146.2 - '@tanstack/start-client-core': 1.146.2 + '@tanstack/react-router': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/router-core': 1.157.16 + '@tanstack/start-client-core': 1.157.16 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/react-start-server@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-start-server@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/history': 1.145.7 - '@tanstack/react-router': 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-core': 1.146.2 - '@tanstack/start-client-core': 1.146.2 - '@tanstack/start-server-core': 1.146.2 + '@tanstack/history': 1.154.14 + '@tanstack/react-router': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/router-core': 1.157.16 + '@tanstack/start-client-core': 1.157.16 + '@tanstack/start-server-core': 1.157.16 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - crossws - '@tanstack/react-start@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + '@tanstack/react-start@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: - '@tanstack/react-router': 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/react-start-client': 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/react-start-server': 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-utils': 1.143.11 - '@tanstack/start-client-core': 1.146.2 - '@tanstack/start-plugin-core': 1.146.2(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) - '@tanstack/start-server-core': 1.146.2 + '@tanstack/react-router': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-start-client': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-start-server': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/router-utils': 1.154.7 + '@tanstack/start-client-core': 1.157.16 + '@tanstack/start-plugin-core': 1.157.16(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@tanstack/start-server-core': 1.157.16 pathe: 2.0.3 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -4151,19 +4398,19 @@ snapshots: react-dom: 19.2.3(react@19.2.3) use-sync-external-store: 1.6.0(react@19.2.3) - '@tanstack/router-core@1.146.2': + '@tanstack/router-core@1.157.16': dependencies: - '@tanstack/history': 1.145.7 + '@tanstack/history': 1.154.14 '@tanstack/store': 0.8.0 cookie-es: 2.0.0 - seroval: 1.4.2 - seroval-plugins: 1.4.2(seroval@1.4.2) + seroval: 1.5.0 + seroval-plugins: 1.5.0(seroval@1.5.0) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/router-devtools-core@1.146.2(@tanstack/router-core@1.146.2)(csstype@3.2.3)(solid-js@1.9.10)': + '@tanstack/router-devtools-core@1.146.2(@tanstack/router-core@1.157.16)(csstype@3.2.3)(solid-js@1.9.10)': dependencies: - '@tanstack/router-core': 1.146.2 + '@tanstack/router-core': 1.157.16 clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) solid-js: 1.9.10 @@ -4171,12 +4418,12 @@ snapshots: optionalDependencies: csstype: 3.2.3 - '@tanstack/router-generator@1.146.2': + '@tanstack/router-generator@1.157.16': dependencies: - '@tanstack/router-core': 1.146.2 - '@tanstack/router-utils': 1.143.11 - '@tanstack/virtual-file-routes': 1.145.4 - prettier: 3.7.4 + '@tanstack/router-core': 1.157.16 + '@tanstack/router-utils': 1.154.7 + '@tanstack/virtual-file-routes': 1.154.7 + prettier: 3.8.1 recast: 0.23.11 source-map: 0.7.6 tsx: 4.21.0 @@ -4184,7 +4431,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.146.2(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + '@tanstack/router-plugin@1.157.16(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) @@ -4192,67 +4439,67 @@ snapshots: '@babel/template': 7.27.2 '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 - '@tanstack/router-core': 1.146.2 - '@tanstack/router-generator': 1.146.2 - '@tanstack/router-utils': 1.143.11 - '@tanstack/virtual-file-routes': 1.145.4 + '@tanstack/router-core': 1.157.16 + '@tanstack/router-generator': 1.157.16 + '@tanstack/router-utils': 1.154.7 + '@tanstack/virtual-file-routes': 1.154.7 babel-dead-code-elimination: 1.0.11 chokidar: 3.6.0 unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-router': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) vite: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) transitivePeerDependencies: - supports-color - '@tanstack/router-ssr-query-core@1.146.2(@tanstack/query-core@5.90.16)(@tanstack/router-core@1.146.2)': + '@tanstack/router-ssr-query-core@1.146.2(@tanstack/query-core@5.90.20)(@tanstack/router-core@1.157.16)': dependencies: - '@tanstack/query-core': 5.90.16 - '@tanstack/router-core': 1.146.2 + '@tanstack/query-core': 5.90.20 + '@tanstack/router-core': 1.157.16 - '@tanstack/router-utils@1.143.11': + '@tanstack/router-utils@1.154.7': dependencies: - '@babel/core': 7.28.5 - '@babel/generator': 7.28.5 - '@babel/parser': 7.28.5 + '@babel/core': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/parser': 7.28.6 ansis: 4.2.0 - diff: 8.0.2 + diff: 8.0.3 pathe: 2.0.3 tinyglobby: 0.2.15 transitivePeerDependencies: - supports-color - '@tanstack/start-client-core@1.146.2': + '@tanstack/start-client-core@1.157.16': dependencies: - '@tanstack/router-core': 1.146.2 - '@tanstack/start-fn-stubs': 1.143.8 - '@tanstack/start-storage-context': 1.146.2 - seroval: 1.4.2 + '@tanstack/router-core': 1.157.16 + '@tanstack/start-fn-stubs': 1.154.7 + '@tanstack/start-storage-context': 1.157.16 + seroval: 1.5.0 tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/start-fn-stubs@1.143.8': {} + '@tanstack/start-fn-stubs@1.154.7': {} - '@tanstack/start-plugin-core@1.146.2(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + '@tanstack/start-plugin-core@1.157.16(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@babel/code-frame': 7.27.1 - '@babel/core': 7.28.5 - '@babel/types': 7.28.5 + '@babel/core': 7.28.6 + '@babel/types': 7.28.6 '@rolldown/pluginutils': 1.0.0-beta.40 - '@tanstack/router-core': 1.146.2 - '@tanstack/router-generator': 1.146.2 - '@tanstack/router-plugin': 1.146.2(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) - '@tanstack/router-utils': 1.143.11 - '@tanstack/start-client-core': 1.146.2 - '@tanstack/start-server-core': 1.146.2 - babel-dead-code-elimination: 1.0.11 - cheerio: 1.1.2 + '@tanstack/router-core': 1.157.16 + '@tanstack/router-generator': 1.157.16 + '@tanstack/router-plugin': 1.157.16(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@tanstack/router-utils': 1.154.7 + '@tanstack/start-client-core': 1.157.16 + '@tanstack/start-server-core': 1.157.16 + babel-dead-code-elimination: 1.0.12 + cheerio: 1.2.0 exsolve: 1.0.8 pathe: 2.0.3 - srvx: 0.10.0 + srvx: 0.10.1 tinyglobby: 0.2.15 - ufo: 1.6.2 + ufo: 1.6.3 vite: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) vitefu: 1.1.1(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) xmlbuilder2: 4.0.3 @@ -4265,25 +4512,25 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/start-server-core@1.146.2': + '@tanstack/start-server-core@1.157.16': dependencies: - '@tanstack/history': 1.145.7 - '@tanstack/router-core': 1.146.2 - '@tanstack/start-client-core': 1.146.2 - '@tanstack/start-storage-context': 1.146.2 - h3-v2: h3@2.0.1-rc.7 - seroval: 1.4.2 + '@tanstack/history': 1.154.14 + '@tanstack/router-core': 1.157.16 + '@tanstack/start-client-core': 1.157.16 + '@tanstack/start-storage-context': 1.157.16 + h3-v2: h3@2.0.1-rc.11 + seroval: 1.5.0 tiny-invariant: 1.3.3 transitivePeerDependencies: - crossws - '@tanstack/start-storage-context@1.146.2': + '@tanstack/start-storage-context@1.157.16': dependencies: - '@tanstack/router-core': 1.146.2 + '@tanstack/router-core': 1.157.16 '@tanstack/store@0.8.0': {} - '@tanstack/virtual-file-routes@1.145.4': {} + '@tanstack/virtual-file-routes@1.154.7': {} '@testing-library/dom@10.4.1': dependencies: @@ -4489,26 +4736,37 @@ snapshots: transitivePeerDependencies: - supports-color + babel-dead-code-elimination@1.0.12: + dependencies: + '@babel/core': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + transitivePeerDependencies: + - supports-color + + base64-js@0.0.8: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.9.11: {} - better-auth@1.4.10(@tanstack/react-start@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0)(kysely@0.28.9))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(tsx@4.21.0)): + better-auth@1.4.10(@tanstack/react-start@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0)(kysely@0.28.9))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(tsx@4.21.0)): dependencies: - '@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) - '@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) + '@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 '@noble/hashes': 2.0.1 - better-call: 1.1.7(zod@4.3.5) + better-call: 1.1.7(zod@4.3.6) defu: 6.1.4 jose: 6.1.3 kysely: 0.28.9 nanostores: 1.1.0 - zod: 4.3.5 + zod: 4.3.6 optionalDependencies: - '@tanstack/react-start': 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@tanstack/react-start': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) better-sqlite3: 12.5.0 drizzle-kit: 0.31.8 drizzle-orm: 0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0)(kysely@0.28.9) @@ -4517,14 +4775,14 @@ snapshots: solid-js: 1.9.10 vitest: 4.0.16(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(tsx@4.21.0) - better-call@1.1.7(zod@4.3.5): + better-call@1.1.7(zod@4.3.6): dependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 rou3: 0.7.12 set-cookie-parser: 2.7.2 optionalDependencies: - zod: 4.3.5 + zod: 4.3.6 better-sqlite3@12.5.0: dependencies: @@ -4570,6 +4828,8 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + camelize@1.0.1: {} + caniuse-lite@1.0.30001762: {} chai@6.2.2: {} @@ -4585,18 +4845,18 @@ snapshots: domhandler: 5.0.3 domutils: 3.2.2 - cheerio@1.1.2: + cheerio@1.2.0: dependencies: cheerio-select: 2.1.0 dom-serializer: 2.0.0 domhandler: 5.0.3 domutils: 3.2.2 encoding-sniffer: 0.2.1 - htmlparser2: 10.0.0 + htmlparser2: 10.1.0 parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 parse5-parser-stream: 7.1.2 - undici: 7.18.2 + undici: 7.19.2 whatwg-mimetype: 4.0.0 chokidar@3.6.0: @@ -4639,6 +4899,14 @@ snapshots: cookie@1.1.1: {} + css-background-parser@0.1.0: {} + + css-box-shadow@1.0.0-3: {} + + css-color-keywords@1.0.0: {} + + css-gradient-parser@0.0.16: {} + css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -4647,6 +4915,12 @@ snapshots: domutils: 3.2.2 nth-check: 2.1.1 + css-to-react-native@3.2.0: + dependencies: + camelize: 1.0.1 + css-color-keywords: 1.0.0 + postcss-value-parser: 4.2.0 + css-tree@3.1.0: dependencies: mdn-data: 2.12.2 @@ -4722,7 +4996,7 @@ snapshots: detect-libc@2.1.2: {} - diff@8.0.2: {} + diff@8.0.3: {} dom-accessibility-api@0.5.16: {} @@ -4761,6 +5035,8 @@ snapshots: electron-to-chromium@1.5.267: {} + emoji-regex-xs@2.0.1: {} + encoding-sniffer@0.2.1: dependencies: iconv-lite: 0.6.3 @@ -4779,6 +5055,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + error-stack-parser-es@1.0.5: {} es-module-lexer@1.7.0: {} @@ -4904,6 +5182,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + esprima@4.0.1: {} estree-walker@3.0.3: @@ -4922,6 +5202,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.7.4: {} + file-uri-to-path@1.0.0: {} fill-range@7.1.1: @@ -4957,10 +5239,12 @@ snapshots: graceful-fs@4.2.11: {} - h3@2.0.1-rc.7: + h3@2.0.1-rc.11: dependencies: rou3: 0.7.12 - srvx: 0.10.0 + srvx: 0.10.1 + + hex-rgb@4.3.0: {} html-encoding-sniffer@6.0.0: dependencies: @@ -4968,12 +5252,12 @@ snapshots: transitivePeerDependencies: - '@exodus/crypto' - htmlparser2@10.0.0: + htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 domhandler: 5.0.3 domutils: 3.2.2 - entities: 6.0.1 + entities: 7.0.1 http-proxy-agent@7.0.2: dependencies: @@ -5059,6 +5343,8 @@ snapshots: json5@2.2.3: {} + just-camel-case@6.2.0: {} + kleur@4.1.5: {} kysely@0.28.9: {} @@ -5117,6 +5403,11 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + lru-cache@11.2.4: {} lru-cache@5.1.1: @@ -5187,6 +5478,13 @@ snapshots: dependencies: wrappy: 1.0.2 + pako@0.2.9: {} + + parse-css-color@0.2.1: + dependencies: + color-name: 1.1.4 + hex-rgb: 4.3.0 + parse5-htmlparser2-tree-adapter@7.1.0: dependencies: domhandler: 5.0.3 @@ -5214,6 +5512,8 @@ snapshots: picomatch@4.0.3: {} + postcss-value-parser@4.2.0: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -5235,7 +5535,7 @@ snapshots: tar-fs: 2.1.4 tunnel-agent: 0.6.0 - prettier@3.7.4: {} + prettier@3.8.1: {} pretty-format@27.5.1: dependencies: @@ -5327,6 +5627,20 @@ snapshots: safer-buffer@2.1.2: {} + satori@0.15.2: + dependencies: + '@shuding/opentype.js': 1.4.0-beta.0 + css-background-parser: 0.1.0 + css-box-shadow: 1.0.0-3 + css-gradient-parser: 0.0.16 + css-to-react-native: 3.2.0 + emoji-regex-xs: 2.0.1 + escape-html: 1.0.3 + linebreak: 1.1.0 + parse-css-color: 0.2.1 + postcss-value-parser: 4.2.0 + yoga-wasm-web: 0.3.3 + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -5341,13 +5655,13 @@ snapshots: dependencies: seroval: 1.3.2 - seroval-plugins@1.4.2(seroval@1.4.2): + seroval-plugins@1.5.0(seroval@1.5.0): dependencies: - seroval: 1.4.2 + seroval: 1.5.0 seroval@1.3.2: {} - seroval@1.4.2: {} + seroval@1.5.0: {} set-cookie-parser@2.7.2: {} @@ -5410,7 +5724,7 @@ snapshots: source-map@0.7.6: {} - srvx@0.10.0: {} + srvx@0.10.1: {} stackback@0.0.2: {} @@ -5418,6 +5732,8 @@ snapshots: stoppable@1.1.0: {} + string.prototype.codepointat@0.2.1: {} + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -5447,6 +5763,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + tiny-inflate@1.0.3: {} + tiny-invariant@1.3.3: {} tiny-warning@1.0.3: {} @@ -5499,18 +5817,23 @@ snapshots: typescript@5.9.3: {} - ufo@1.6.2: {} + ufo@1.6.3: {} undici-types@7.16.0: {} undici@7.14.0: {} - undici@7.18.2: {} + undici@7.19.2: {} unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 @@ -5632,6 +5955,13 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260107.1 '@cloudflare/workerd-windows-64': 1.20260107.1 + workers-og@0.0.27: + dependencies: + '@resvg/resvg-wasm': 2.4.0 + just-camel-case: 6.2.0 + satori: 0.15.2 + yoga-wasm-web: 0.3.3 + wrangler@4.58.0: dependencies: '@cloudflare/kv-asset-handler': 0.4.1 @@ -5667,6 +5997,8 @@ snapshots: yallist@3.1.1: {} + yoga-wasm-web@0.3.3: {} + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 @@ -5682,7 +6014,7 @@ snapshots: zod@3.25.76: {} - zod@4.3.5: {} + zod@4.3.6: {} zustand@4.5.7(@types/react@19.2.7)(react@19.2.3): dependencies: diff --git a/public/fonts/DSEG7Classic-Bold.woff2 b/public/fonts/DSEG7Classic-Bold.woff2 new file mode 100644 index 0000000..558eec4 Binary files /dev/null and b/public/fonts/DSEG7Classic-Bold.woff2 differ diff --git a/scripts/seed-leaderboard.sql b/scripts/seed-leaderboard.sql new file mode 100644 index 0000000..d5fd3e7 --- /dev/null +++ b/scripts/seed-leaderboard.sql @@ -0,0 +1,110 @@ +-- Seed tournament results (Round 1 only for testing) +INSERT OR REPLACE INTO tournament_result (id, game_id, winner_id, created_at, updated_at) VALUES + ('tr-r1-0', 'r1-0', 'wes-bos', 1737331200000, 1737331200000), + ('tr-r1-1', 'r1-1', 'kevin-powell', 1737331200000, 1737331200000), + ('tr-r1-2', 'r1-2', 'adam-wathan', 1737331200000, 1737331200000), + ('tr-r1-3', 'r1-3', 'josh-comeau', 1737331200000, 1737331200000), + ('tr-r1-4', 'r1-4', 'kyle-cook', 1737331200000, 1737331200000), + ('tr-r1-5', 'r1-5', 'bramus', 1737331200000, 1737331200000), + ('tr-r1-6', 'r1-6', 'cassie-evans', 1737331200000, 1737331200000), + ('tr-r1-7', 'r1-7', 'miriam-suzanne', 1737331200000, 1737331200000); + +-- Add some quarterfinal results +INSERT OR REPLACE INTO tournament_result (id, game_id, winner_id, created_at, updated_at) VALUES + ('tr-qf-0', 'qf-0', 'kevin-powell', 1737331200000, 1737331200000), + ('tr-qf-1', 'qf-1', 'josh-comeau', 1737331200000, 1737331200000); + +-- Create dummy test users (for demo purposes) +-- Note: In production, users come from GitHub OAuth +INSERT OR REPLACE INTO user (id, name, email, email_verified, image, created_at, updated_at) VALUES + ('test-user-1', 'CSS Wizard', 'wizard@test.com', 1, 'https://avatars.githubusercontent.com/u/1?v=4', 1737331200000, 1737331200000), + ('test-user-2', 'Flexbox Fan', 'flex@test.com', 1, 'https://avatars.githubusercontent.com/u/2?v=4', 1737331200000, 1737331200000), + ('test-user-3', 'Grid Master', 'grid@test.com', 1, 'https://avatars.githubusercontent.com/u/3?v=4', 1737331200000, 1737331200000), + ('test-user-4', 'Animation Ace', 'anim@test.com', 1, 'https://avatars.githubusercontent.com/u/4?v=4', 1737331200000, 1737331200000), + ('test-user-5', 'Selector Savant', 'select@test.com', 1, 'https://avatars.githubusercontent.com/u/5?v=4', 1737331200000, 1737331200000); + +-- Mark test users' brackets as locked +INSERT OR REPLACE INTO user_bracket_status (id, user_id, is_locked, locked_at, created_at) VALUES + ('bs-1', 'test-user-1', 1, 1737331200000, 1737331200000), + ('bs-2', 'test-user-2', 1, 1737331200000, 1737331200000), + ('bs-3', 'test-user-3', 1, 1737331200000, 1737331200000), + ('bs-4', 'test-user-4', 1, 1737331200000, 1737331200000), + ('bs-5', 'test-user-5', 1, 1737331200000, 1737331200000); + +-- User 1 predictions (perfect Round 1, 1/2 QF = 80 + 20 = 100) +INSERT OR REPLACE INTO user_prediction (id, user_id, game_id, predicted_winner_id, created_at, updated_at) VALUES + ('p1-r1-0', 'test-user-1', 'r1-0', 'wes-bos', 1737331200000, 1737331200000), + ('p1-r1-1', 'test-user-1', 'r1-1', 'kevin-powell', 1737331200000, 1737331200000), + ('p1-r1-2', 'test-user-1', 'r1-2', 'adam-wathan', 1737331200000, 1737331200000), + ('p1-r1-3', 'test-user-1', 'r1-3', 'josh-comeau', 1737331200000, 1737331200000), + ('p1-r1-4', 'test-user-1', 'r1-4', 'kyle-cook', 1737331200000, 1737331200000), + ('p1-r1-5', 'test-user-1', 'r1-5', 'bramus', 1737331200000, 1737331200000), + ('p1-r1-6', 'test-user-1', 'r1-6', 'cassie-evans', 1737331200000, 1737331200000), + ('p1-r1-7', 'test-user-1', 'r1-7', 'miriam-suzanne', 1737331200000, 1737331200000), + ('p1-qf-0', 'test-user-1', 'qf-0', 'kevin-powell', 1737331200000, 1737331200000), + ('p1-qf-1', 'test-user-1', 'qf-1', 'adam-wathan', 1737331200000, 1737331200000); + +-- User 2 predictions (6/8 Round 1, 2/2 QF = 60 + 40 = 100) +INSERT OR REPLACE INTO user_prediction (id, user_id, game_id, predicted_winner_id, created_at, updated_at) VALUES + ('p2-r1-0', 'test-user-2', 'r1-0', 'wes-bos', 1737331200000, 1737331200000), + ('p2-r1-1', 'test-user-2', 'r1-1', 'kevin-powell', 1737331200000, 1737331200000), + ('p2-r1-2', 'test-user-2', 'r1-2', 'ania-kubow', 1737331200000, 1737331200000), + ('p2-r1-3', 'test-user-2', 'r1-3', 'josh-comeau', 1737331200000, 1737331200000), + ('p2-r1-4', 'test-user-2', 'r1-4', 'kyle-cook', 1737331200000, 1737331200000), + ('p2-r1-5', 'test-user-2', 'r1-5', 'bramus', 1737331200000, 1737331200000), + ('p2-r1-6', 'test-user-2', 'r1-6', 'stephanie-eckles', 1737331200000, 1737331200000), + ('p2-r1-7', 'test-user-2', 'r1-7', 'miriam-suzanne', 1737331200000, 1737331200000), + ('p2-qf-0', 'test-user-2', 'qf-0', 'kevin-powell', 1737331200000, 1737331200000), + ('p2-qf-1', 'test-user-2', 'qf-1', 'josh-comeau', 1737331200000, 1737331200000); + +-- User 3 predictions (5/8 Round 1, 1/2 QF = 50 + 20 = 70) +INSERT OR REPLACE INTO user_prediction (id, user_id, game_id, predicted_winner_id, created_at, updated_at) VALUES + ('p3-r1-0', 'test-user-3', 'r1-0', 'scott-tolinski', 1737331200000, 1737331200000), + ('p3-r1-1', 'test-user-3', 'r1-1', 'kevin-powell', 1737331200000, 1737331200000), + ('p3-r1-2', 'test-user-3', 'r1-2', 'adam-wathan', 1737331200000, 1737331200000), + ('p3-r1-3', 'test-user-3', 'r1-3', 'josh-comeau', 1737331200000, 1737331200000), + ('p3-r1-4', 'test-user-3', 'r1-4', 'jen-simmons', 1737331200000, 1737331200000), + ('p3-r1-5', 'test-user-3', 'r1-5', 'rachel-andrew', 1737331200000, 1737331200000), + ('p3-r1-6', 'test-user-3', 'r1-6', 'cassie-evans', 1737331200000, 1737331200000), + ('p3-r1-7', 'test-user-3', 'r1-7', 'miriam-suzanne', 1737331200000, 1737331200000), + ('p3-qf-0', 'test-user-3', 'qf-0', 'wes-bos', 1737331200000, 1737331200000), + ('p3-qf-1', 'test-user-3', 'qf-1', 'josh-comeau', 1737331200000, 1737331200000); + +-- User 4 predictions (4/8 Round 1, 0/2 QF = 40 + 0 = 40) +INSERT OR REPLACE INTO user_prediction (id, user_id, game_id, predicted_winner_id, created_at, updated_at) VALUES + ('p4-r1-0', 'test-user-4', 'r1-0', 'wes-bos', 1737331200000, 1737331200000), + ('p4-r1-1', 'test-user-4', 'r1-1', 'adam-argyle', 1737331200000, 1737331200000), + ('p4-r1-2', 'test-user-4', 'r1-2', 'adam-wathan', 1737331200000, 1737331200000), + ('p4-r1-3', 'test-user-4', 'r1-3', 'cassidy-williams', 1737331200000, 1737331200000), + ('p4-r1-4', 'test-user-4', 'r1-4', 'kyle-cook', 1737331200000, 1737331200000), + ('p4-r1-5', 'test-user-4', 'r1-5', 'rachel-andrew', 1737331200000, 1737331200000), + ('p4-r1-6', 'test-user-4', 'r1-6', 'stephanie-eckles', 1737331200000, 1737331200000), + ('p4-r1-7', 'test-user-4', 'r1-7', 'css-ninja', 1737331200000, 1737331200000), + ('p4-qf-0', 'test-user-4', 'qf-0', 'wes-bos', 1737331200000, 1737331200000), + ('p4-qf-1', 'test-user-4', 'qf-1', 'adam-wathan', 1737331200000, 1737331200000); + +-- User 5 predictions (3/8 Round 1, 1/2 QF = 30 + 20 = 50) +INSERT OR REPLACE INTO user_prediction (id, user_id, game_id, predicted_winner_id, created_at, updated_at) VALUES + ('p5-r1-0', 'test-user-5', 'r1-0', 'scott-tolinski', 1737331200000, 1737331200000), + ('p5-r1-1', 'test-user-5', 'r1-1', 'adam-argyle', 1737331200000, 1737331200000), + ('p5-r1-2', 'test-user-5', 'r1-2', 'ania-kubow', 1737331200000, 1737331200000), + ('p5-r1-3', 'test-user-5', 'r1-3', 'josh-comeau', 1737331200000, 1737331200000), + ('p5-r1-4', 'test-user-5', 'r1-4', 'jen-simmons', 1737331200000, 1737331200000), + ('p5-r1-5', 'test-user-5', 'r1-5', 'bramus', 1737331200000, 1737331200000), + ('p5-r1-6', 'test-user-5', 'r1-6', 'stephanie-eckles', 1737331200000, 1737331200000), + ('p5-r1-7', 'test-user-5', 'r1-7', 'css-ninja', 1737331200000, 1737331200000), + ('p5-qf-0', 'test-user-5', 'qf-0', 'kevin-powell', 1737331200000, 1737331200000), + ('p5-qf-1', 'test-user-5', 'qf-1', 'adam-wathan', 1737331200000, 1737331200000); + +-- Insert user scores (calculated based on above predictions) +-- User 1: R1=80, R2=20 (1 correct of 2 played), Total=100 +-- User 2: R1=60, R2=40 (2 correct), Total=100 +-- User 3: R1=50, R2=20 (1 correct), Total=70 +-- User 4: R1=40, R2=0, Total=40 +-- User 5: R1=30, R2=20 (1 correct), Total=50 +INSERT OR REPLACE INTO user_score (id, user_id, round1_score, round2_score, round3_score, round4_score, total_score, created_at, updated_at) VALUES + ('score-1', 'test-user-1', 80, 20, 0, 0, 100, 1737331200000, 1737331200000), + ('score-2', 'test-user-2', 60, 40, 0, 0, 100, 1737331200000, 1737331200000), + ('score-3', 'test-user-3', 50, 20, 0, 0, 70, 1737331200000, 1737331200000), + ('score-5', 'test-user-5', 30, 20, 0, 0, 50, 1737331200000, 1737331200000), + ('score-4', 'test-user-4', 40, 0, 0, 0, 40, 1737331200000, 1737331200000); diff --git a/src/components/AdminButton.tsx b/src/components/AdminButton.tsx new file mode 100644 index 0000000..87fb33d --- /dev/null +++ b/src/components/AdminButton.tsx @@ -0,0 +1,159 @@ +import { Link, useLocation } from "@tanstack/react-router"; +import { useEffect, useRef, useState } from "react"; +import { useSession } from "@/lib/auth-client"; +import { buildResultsUpToStage, type SimulationStage } from "@/lib/simulation"; +import "@/styles/admin-button.css"; + +const STAGES: { value: string; label: string }[] = [ + { value: "default", label: "Default (Live Data)" }, + { value: "r1-left", label: "R1 Left Complete" }, + { value: "r1-right", label: "R1 Right Complete" }, + { value: "quarterfinals", label: "Quarterfinals Complete" }, + { value: "semifinals", label: "Semifinals Complete" }, + { value: "finals", label: "Champion Crowned" }, +]; + +export function AdminButton() { + const location = useLocation(); + const { data: session } = useSession(); + const [isAdmin, setIsAdmin] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [simStage, setSimStage] = useState("default"); + const popoverRef = useRef(null); + const buttonRef = useRef(null); + + // Fetch the user's admin status for client-side check (UI only) + useEffect(() => { + if (!session?.user) { + setIsAdmin(false); + return; + } + + fetch("/api/admin/check") + .then((res) => { + if (res.ok) return res.json(); + return null; + }) + .then((data) => { + setIsAdmin(data?.isAdmin ?? false); + }) + .catch(() => { + setIsAdmin(false); + }); + }, [session?.user]); + + // Close popover when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + popoverRef.current && + buttonRef.current && + !popoverRef.current.contains(event.target as Node) && + !buttonRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + } + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); + } + }, [isOpen]); + + const handleStageChange = (stage: string) => { + setSimStage(stage); + if (stage === "default") { + window.dispatchEvent( + new CustomEvent("tournament-results-changed", { detail: {} }), + ); + } else { + const results = buildResultsUpToStage(stage as SimulationStage); + window.dispatchEvent( + new CustomEvent("tournament-results-changed", { detail: { results } }), + ); + } + }; + + // Don't show on admin page itself + if (location.pathname === "/admin") { + return null; + } + + // Only show for admin users (client-side check for UI only) + if (!isAdmin) { + return null; + } + + return ( +
+ {isOpen && ( +
+ + + Admin Dashboard + + +
+ +
+ + +
+
+ )} + + +
+ ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 3cef0ab..5711497 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -6,7 +6,6 @@ export function Header() { return (
- {/* TODO this should be a vector image */} Mad CSS Logo
diff --git a/src/components/LoginSection.tsx b/src/components/LoginSection.tsx index 08b68c9..88f8fd0 100644 --- a/src/components/LoginSection.tsx +++ b/src/components/LoginSection.tsx @@ -1,55 +1,473 @@ +import { useState } from "react"; +import { usePredictionsContext } from "@/context/PredictionsContext"; +import { getNextGameTime, TOTAL_GAMES } from "@/data/players"; +import { useCountdown } from "@/hooks/useCountdown"; import { authClient } from "@/lib/auth-client"; +import { Scoreboard } from "./scoreboard/Scoreboard"; import "@/styles/login.css"; -export function LoginSection() { +const ROUND_LABELS: Record = { + "left-r1": "Left R1", + "right-r1": "Right R1", + qf: "Quarterfinals", + sf: "Semifinals", + final: "Finals", +}; + +// Sub-component: Header with avatar, name, and sign out +function LoginSectionHeader({ + userImage, + userName, +}: { + userImage: string | null | undefined; + userName: string | null | undefined; +}) { + return ( +
+ +

+ Welcome back, {userName} +

+ +
+ ); +} + +// Sub-component: Progress display with pick count and countdown +function LoginSectionProgress({ + pickCount, + deadline, + countdown, + isUrgent, +}: { + pickCount: number; + deadline: string | undefined; + countdown: ReturnType; + isUrgent: boolean; +}) { + return ( +
+
+
+ {pickCount} / {TOTAL_GAMES} picks +
+ {deadline && countdown.totalMs > 0 && ( + + )} +
+
+ ); +} + +// Sub-component: Action buttons (save, lock, reset) +function LoginSectionActions({ + canLock, + isSaving, + hasChanges, + pickCount, + showLockConfirm, + setShowLockConfirm, + onSave, + onLock, + onReset, +}: { + canLock: boolean; + isSaving: boolean; + hasChanges: boolean; + pickCount: number; + showLockConfirm: boolean; + setShowLockConfirm: (show: boolean) => void; + onSave: (() => Promise) | undefined; + onLock: (() => Promise) | undefined; + onReset: (() => void) | undefined; +}) { + return ( +
+ {showLockConfirm ? ( +
+

Lock your bracket? This cannot be undone.

+
+ + +
+
+ ) : ( + <> + + + {pickCount > 0 && ( + + )} + + )} +
+ ); +} + +// Sub-component: Share buttons (copy link, X, Bluesky) +function LoginSectionShare({ + twitterShareUrl, + blueskyShareUrl, + copied, + onCopyLink, +}: { + twitterShareUrl: string | null; + blueskyShareUrl: string | null; + copied: boolean; + onCopyLink: () => Promise; +}) { + return ( +
+
+ + Share your bracket +
+
+ + {twitterShareUrl && ( + + + Share on X + + )} + {blueskyShareUrl && ( + + + Bluesky + + )} +
+
+ ); +} + +export interface LoginSectionProps { + username?: string | null; + showPicks?: boolean; + onToggleShowPicks?: () => void; +} + +export function LoginSection({ + username = null, + showPicks = false, + onToggleShowPicks, +}: LoginSectionProps) { + const ctx = usePredictionsContext(); + + const pickCount = ctx?.pickCount ?? 0; + const isLocked = ctx?.isLocked ?? false; + const isSaving = ctx?.isSaving ?? false; + const hasChanges = ctx?.hasChanges ?? false; + const error = ctx?.error ?? null; + const deadline = ctx?.deadline; + const isDeadlinePassed = ctx?.isDeadlinePassed ?? false; + const onSave = ctx?.savePredictions; + const onLock = ctx?.lockBracket; + const onReset = ctx?.resetPredictions; const { data: session, isPending } = authClient.useSession(); + const [showLockConfirm, setShowLockConfirm] = useState(false); + const [copied, setCopied] = useState(false); + const countdown = useCountdown(deadline); + const isUrgent = + countdown.totalMs > 0 && countdown.totalMs < 24 * 60 * 60 * 1000; + + // Next game countdown + const nextGame = getNextGameTime(); + const nextGameCountdown = useCountdown(nextGame?.time); + const nextGameLabel = nextGame ? ROUND_LABELS[nextGame.round] : null; + + const shareUrl = username + ? `${typeof window !== "undefined" ? window.location.origin : ""}/bracket/${username}` + : null; + + const handleCopyLink = async () => { + if (!shareUrl) return; + try { + await navigator.clipboard.writeText(shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback for older browsers + const input = document.createElement("input"); + input.value = shareUrl; + document.body.appendChild(input); + input.select(); + document.execCommand("copy"); + document.body.removeChild(input); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const twitterShareUrl = username + ? `https://twitter.com/intent/tweet?text=${encodeURIComponent(`Check out my March Mad CSS bracket picks! 🏀\n\n${shareUrl}`)}` + : null; + + const blueskyShareUrl = username + ? `https://bsky.app/intent/compose?text=${encodeURIComponent(`Check out my March Mad CSS bracket picks! 🏀\n\n${shareUrl}`)}` + : null; if (isPending) { return (
- ... + Loading...
); } if (session?.user) { + const canLock = pickCount === TOTAL_GAMES && !isLocked && !isDeadlinePassed; + return (
- {session.user.name} -
-

- You're in, {session.user.name}! -

-

Lock in your picks below.

-
- + + {/* Status badges for locked/deadline states */} + {isLocked && ( + <> +
+ ✓ Your bracket is locked in! +
+ + {/* Next results countdown */} + {nextGame && nextGameCountdown.totalMs > 0 && ( +
+ + {nextGameLabel} results in: + + +
+ )} + + {/* Toggle to show picks vs results */} + {onToggleShowPicks && ( + + )} + + {/* Share section - only show when locked and username exists */} + {shareUrl && ( + + )} + + )} + + {isDeadlinePassed && !isLocked && ( +
Deadline has passed
+ )} + + {/* Progress section - only show when not locked */} + {!isLocked && !isDeadlinePassed && ( + <> + + + {/* Instructions */} +
+ + + Click any player to pick them as the winner of that match + +
+ + + + )} + + {error &&

{error}

}
); } + // Logged out state return (
-
-

Think you can call it?

-

- Lock in your predictions before Round 1 and compete - for mass internet clout. Perfect bracket = mass internet clout. -

-
+

Think you can call it?

+

+ Lock in your predictions before Round 1 and compete for + mass internet clout. Perfect bracket = mass internet clout. +

+ {deadline && countdown.totalMs > 0 ? ( + + ) : ( + nextGame && + nextGameCountdown.totalMs > 0 && ( +
+ + {nextGameLabel} results in: + + +
+ ) + )}
+ ); +} diff --git a/src/components/bracket/Bracket.tsx b/src/components/bracket/Bracket.tsx index b362862..9809de7 100644 --- a/src/components/bracket/Bracket.tsx +++ b/src/components/bracket/Bracket.tsx @@ -8,24 +8,33 @@ import { ReactFlow, type ReactFlowInstance, } from "@xyflow/react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import "@xyflow/react/dist/style.css"; +import { usePredictionsContext } from "@/context/PredictionsContext"; +import { ALL_GAME_IDS, bracket, splitForDisplay } from "@/data/players"; +import { getPickablePlayersForGame } from "@/hooks/usePredictions"; +import type { NodeContext } from "./bracketTypes"; import { - bracket, - type Game, - isLoser, - isWinner, - type Player, - splitForDisplay, -} from "@/data/players"; + generateChampionshipNode, + generateFinalistNode, + generateQuarterNodes, + generateRound1Nodes, + generateSemiNodes, +} from "./nodeGenerators"; import { EmptySlotFlow, PlayerNodeFlow } from "./PlayerNode"; import "./bracket.css"; -// Ring colors for each side -const LEFT_RING_COLOR = "#f3370e"; -const RIGHT_RING_COLOR = "#5CE1E6"; +export interface BracketProps { + isInteractive?: boolean; + predictions?: Record; + onPick?: (gameId: string, playerId: string) => void; + isLocked?: boolean; + isAuthenticated?: boolean; + getPickablePlayers?: (gameId: string) => string[]; + tournamentResults?: Record; + showPicks?: boolean; +} -// Custom edge component function BracketEdge({ sourceX, sourceY, @@ -35,12 +44,9 @@ function BracketEdge({ targetPosition, target, }: EdgeProps) { - // For finalist-to-champ edges, draw a custom path with lowered horizontal line if (target === "championship") { - const horizontalY = sourceY + 30; // Lower the horizontal line to connect finalists - // Extend the line up to close the gap to the champion card + const horizontalY = sourceY + 30; const edgePath = `M ${sourceX} ${sourceY} L ${sourceX} ${horizontalY} L ${targetX} ${horizontalY} L ${targetX} ${targetY - 35}`; - // No drop-shadow filter to avoid shadow overlap where lines meet return ( ; } -// Register custom node types const nodeTypes = { playerNode: PlayerNodeFlow, emptySlot: EmptySlotFlow, }; -// Register custom edge types const edgeTypes = { bracket: BracketEdge, }; -// Node dimensions for positioning -const NODE_HEIGHT = 70; -const VERTICAL_GAP = 76; -const MATCH_GAP = NODE_HEIGHT + VERTICAL_GAP; -const ROUND_GAP = 220; - -// Get the appropriate photo path based on elimination status -function getPhotoPath(player: Player, isEliminated: boolean): string { - // Photos are stored as /avatars/color/name.png and /avatars/bw/name.png - // player.photo is like /avatars/name.png, so we need to insert the subfolder - const filename = player.photo.replace("/avatars/", ""); - return isEliminated - ? `/avatars/bw/${filename}` - : `/avatars/color/${filename}`; -} - -// Convert a Player to PlayerData for the node -function playerToNodeData( - player: Player, - game: Game, - ringColor: string, - side: "left" | "right", - round: "round1" | "later" = "later", -): { - photo: string; - name: string; - byline: string; - ringColor: string; - isWinner: boolean; - isEliminated: boolean; - side: "left" | "right"; - round: "round1" | "later"; -} { - const isEliminated = isLoser(game, player); - return { - photo: getPhotoPath(player, isEliminated), - name: player.name, - byline: player.byline, - ringColor, - isWinner: isWinner(game, player), - isEliminated, - side, - round, - }; -} - -// Create a node for either a player or an empty slot -function createNode( - id: string, - player: Player | undefined, - game: Game, - ringColor: string, - position: { x: number; y: number }, - side: "left" | "right", - round: "round1" | "later" = "later", - emptyText?: string, -): Node { - if (player) { - return { - id, - type: "playerNode", - position, - data: playerToNodeData(player, game, ringColor, side, round), - }; - } - return { - id, - type: "emptySlot", - position, - data: { text: emptyText, side, ringColor, round }, - }; -} - -// Generate nodes from bracket data -function generateNodes(): Node[] { - const nodes: Node[] = []; - - // Split each round into left/right halves - const round1 = splitForDisplay(bracket.round1); - const quarters = splitForDisplay(bracket.quarters); - const semis = splitForDisplay(bracket.semis); - - // =========================================================================== - // LEFT SIDE (first half of each round) - // =========================================================================== - - // Round 1 - Left side (games 0-3) - round1.left.forEach((game, gameIndex) => { - const baseY = gameIndex * 2 * MATCH_GAP; - - // Player 1 - nodes.push( - createNode( - `${game.id}-p1`, - game.player1, - game, - LEFT_RING_COLOR, - { - x: 0, - y: baseY, - }, - "left", - "round1", - ), - ); - - // Player 2 - nodes.push( - createNode( - `${game.id}-p2`, - game.player2, - game, - LEFT_RING_COLOR, - { - x: 0, - y: baseY + MATCH_GAP, - }, - "left", - "round1", - ), +function generateNodes( + isInteractive: boolean, + predictions: Record = {}, + onPick?: (gameId: string, playerId: string) => void, + isPickingEnabled = false, + tournamentResults: Record = {}, + showPicks = false, + isLocked = false, +): Node[] { + const hasResults = Object.keys(tournamentResults).length > 0; + + const pickablePlayersCache: Record< + string, + [string | undefined, string | undefined] + > = {}; + for (const gameId of ALL_GAME_IDS) { + pickablePlayersCache[gameId] = getPickablePlayersForGame( + gameId, + predictions, ); - }); - - // Quarterfinals - Left side (games 0-1) - quarters.left.forEach((game, gameIndex) => { - const baseY = gameIndex * 4 * MATCH_GAP + MATCH_GAP * 0.62; - - // Player 1 - nodes.push( - createNode( - `${game.id}-p1`, - game.player1, - game, - LEFT_RING_COLOR, - { - x: ROUND_GAP, - y: baseY, - }, - "left", - ), - ); - - // Player 2 - nodes.push( - createNode( - `${game.id}-p2`, - game.player2, - game, - LEFT_RING_COLOR, - { - x: ROUND_GAP, - y: baseY + 2 * MATCH_GAP, - }, - "left", - ), - ); - }); - - // Semifinals - Left side (game 0) - semis.left.forEach((game) => { - const baseY = 1.5 * MATCH_GAP; - - // Player 1 - nodes.push( - createNode( - `${game.id}-p1`, - game.player1, - game, - LEFT_RING_COLOR, - { - x: ROUND_GAP * 2, - y: baseY, - }, - "left", - ), - ); - - // Player 2 - nodes.push( - createNode( - `${game.id}-p2`, - game.player2, - game, - LEFT_RING_COLOR, - { - x: ROUND_GAP * 2, - y: baseY + 4 * MATCH_GAP, - }, - "left", - ), - ); - }); - - // Left finalist slot - nodes.push({ - id: `left-finalist`, - type: "emptySlot", - position: { x: ROUND_GAP * 3 + 23, y: 3.5 * MATCH_GAP }, - data: { text: "Finalist TBD", side: "left", ringColor: LEFT_RING_COLOR }, - }); - - // =========================================================================== - // RIGHT SIDE (second half of each round) - // =========================================================================== - const rightStartX = ROUND_GAP * 7; - - // Round 1 - Right side (games 4-7) - round1.right.forEach((game, gameIndex) => { - const baseY = gameIndex * 2 * MATCH_GAP; - - // Player 1 - nodes.push( - createNode( - `${game.id}-p1`, - game.player1, - game, - RIGHT_RING_COLOR, - { - x: rightStartX, - y: baseY, - }, - "right", - "round1", - ), - ); - - // Player 2 - nodes.push( - createNode( - `${game.id}-p2`, - game.player2, - game, - RIGHT_RING_COLOR, - { - x: rightStartX, - y: baseY + MATCH_GAP, - }, - "right", - "round1", - ), - ); - }); - - // Quarterfinals - Right side (games 2-3) ROUND 2 - quarters.right.forEach((game, gameIndex) => { - const baseY = gameIndex * 4 * MATCH_GAP + MATCH_GAP * 0.64; - - // Player 1 - nodes.push( - createNode( - `${game.id}-p1`, - game.player1, - game, - RIGHT_RING_COLOR, - { - x: rightStartX - ROUND_GAP, - y: baseY, - }, - "right", - ), - ); - - // Player 2 - nodes.push( - createNode( - `${game.id}-p2`, - game.player2, - game, - RIGHT_RING_COLOR, - { - x: rightStartX - ROUND_GAP, - y: baseY + 2 * MATCH_GAP, - }, - "right", - ), - ); - }); - - // Semifinals - Right side (game 1) ROUND 3 - semis.right.forEach((game) => { - const baseY = 1.5 * MATCH_GAP; - - // Player 1 - nodes.push( - createNode( - `${game.id}-p1`, - game.player1, - game, - RIGHT_RING_COLOR, - { - x: rightStartX - ROUND_GAP * 2, - y: baseY, - }, - "right", - ), - ); - - // Player 2 - nodes.push( - createNode( - `${game.id}-p2`, - game.player2, - game, - RIGHT_RING_COLOR, - { - x: rightStartX - ROUND_GAP * 2, - y: baseY + 4 * MATCH_GAP, - }, - "right", - ), - ); - }); - - // Right finalist slot - nodes.push({ - id: `right-finalist`, - type: "emptySlot", - position: { x: rightStartX - ROUND_GAP * 2.5, y: 3.5 * MATCH_GAP }, - data: { - text: "Finalist TBD", - side: "right", - ringColor: RIGHT_RING_COLOR, - }, - }); + } - // =========================================================================== - // CHAMPIONSHIP (center) - // =========================================================================== - const finalGame = bracket.finals[0]; - // Center between left finalist (x=3) and right finalist (x=4.5) - nodes.push({ - id: "championship", - type: finalGame?.winner ? "playerNode" : "emptySlot", - position: { - x: ROUND_GAP * 3.75, - y: 0, - }, - data: finalGame?.winner - ? playerToNodeData(finalGame.winner, finalGame, "#FFD700", "left") - : { text: "CHAMPION", side: "left", ringColor: "#FFD700" }, - }); + const ctx: NodeContext = { + hasResults, + tournamentResults, + predictions, + pickablePlayersCache, + isInteractive, + isPickingEnabled, + showPicks, + isLocked, + onPick, + }; - return nodes; + return [ + ...generateRound1Nodes({ side: "left", ctx }), + ...generateRound1Nodes({ side: "right", ctx }), + ...generateQuarterNodes({ side: "left", ctx }), + ...generateQuarterNodes({ side: "right", ctx }), + ...generateSemiNodes({ side: "left", ctx }), + ...generateSemiNodes({ side: "right", ctx }), + generateFinalistNode({ side: "left", ctx }), + generateFinalistNode({ side: "right", ctx }), + generateChampionshipNode(ctx), + ]; } -// Edge style const edgeStyle: React.CSSProperties = { stroke: "#ffffff", strokeWidth: 3, filter: "drop-shadow(0px 0px 7px black)", }; -// Generate edges connecting the bracket function generateEdges(): Edge[] { const edges: Edge[] = []; - - // Split each round into left/right halves const round1 = splitForDisplay(bracket.round1); const quarters = splitForDisplay(bracket.quarters); const semis = splitForDisplay(bracket.semis); - // =========================================================================== // LEFT SIDE EDGES - // =========================================================================== - - // Round 1 to Quarters (left) round1.left.forEach((game, gameIndex) => { const quarterGame = quarters.left[Math.floor(gameIndex / 2)]; - // Player 1 to quarter game edges.push({ id: `${game.id}-p1-to-${quarterGame.id}`, source: `${game.id}-p1`, @@ -455,7 +152,6 @@ function generateEdges(): Edge[] { targetHandle: "in-top", }); - // Player 2 to quarter game edges.push({ id: `${game.id}-p2-to-${quarterGame.id}`, source: `${game.id}-p2`, @@ -466,7 +162,6 @@ function generateEdges(): Edge[] { }); }); - // Quarters to Semis (left) quarters.left.forEach((game, gameIndex) => { const semiGame = semis.left[0]; @@ -491,7 +186,6 @@ function generateEdges(): Edge[] { }); }); - // Semis to Left Finalist semis.left.forEach((game) => { edges.push({ id: `${game.id}-p1-to-left-finalist`, @@ -514,7 +208,6 @@ function generateEdges(): Edge[] { }); }); - // Left finalist to Championship edges.push({ id: "left-finalist-to-champ", source: `left-finalist`, @@ -525,11 +218,7 @@ function generateEdges(): Edge[] { targetHandle: "in-bottom", }); - // =========================================================================== // RIGHT SIDE EDGES - // =========================================================================== - - // Round 1 to Quarters (right) round1.right.forEach((game, gameIndex) => { const quarterGame = quarters.right[Math.floor(gameIndex / 2)]; @@ -554,7 +243,6 @@ function generateEdges(): Edge[] { }); }); - // Quarters to Semis (right) quarters.right.forEach((game, gameIndex) => { const semiGame = semis.right[0]; @@ -579,7 +267,6 @@ function generateEdges(): Edge[] { }); }); - // Semis to Right Finalist semis.right.forEach((game) => { edges.push({ id: `${game.id}-p1-to-right-finalist`, @@ -602,7 +289,6 @@ function generateEdges(): Edge[] { }); }); - // Right finalist to Championship edges.push({ id: "right-finalist-to-champ", source: `right-finalist`, @@ -616,24 +302,56 @@ function generateEdges(): Edge[] { return edges; } -const initialNodes = generateNodes(); -const initialEdges = generateEdges(); - const defaultEdgeOptions = { type: "bracket", style: edgeStyle, }; -// Padding used for fitView const FIT_VIEW_PADDING = 0.05; -function BracketContent() { +function BracketContent({ + isInteractive = false, + predictions: propsPredictions, + onPick: propsOnPick, + isLocked: propsIsLocked, + isAuthenticated = false, + tournamentResults = {}, + showPicks = false, +}: BracketProps) { + const ctx = usePredictionsContext(); + + // Use context if available, otherwise fall back to props + const predictions = ctx?.predictions ?? propsPredictions ?? {}; + const onPick = ctx?.setPrediction ?? propsOnPick; + const isLocked = ctx?.isLocked ?? propsIsLocked ?? false; + + const isPickingEnabled = isInteractive && isAuthenticated && !isLocked; + const nodes = useMemo( + () => + generateNodes( + isInteractive, + predictions, + onPick, + isPickingEnabled, + tournamentResults, + showPicks, + isLocked, + ), + [ + isInteractive, + predictions, + onPick, + isPickingEnabled, + tournamentResults, + showPicks, + isLocked, + ], + ); + const edges = useMemo(() => generateEdges(), []); const containerRef = useRef(null); const [containerHeight, setContainerHeight] = useState(null); const rfInstanceRef = useRef(null); const boundsRef = useRef<{ width: number; height: number } | null>(null); - - // Scroll zoom lock state - prevents scroll trap const [scrollZoomLocked, setScrollZoomLocked] = useState(true); const unlockTimerRef = useRef | null>(null); @@ -645,7 +363,6 @@ function BracketContent() { }, []); const handleMouseEnter = useCallback(() => { - // Start 1.5s timer to unlock scroll zoom clearUnlockTimer(); unlockTimerRef.current = setTimeout(() => { setScrollZoomLocked(false); @@ -653,18 +370,10 @@ function BracketContent() { }, [clearUnlockTimer]); const handleMouseLeave = useCallback(() => { - // Lock scroll zoom and cancel any pending unlock clearUnlockTimer(); setScrollZoomLocked(true); }, [clearUnlockTimer]); - const handleClick = useCallback(() => { - // Instantly unlock on click - clearUnlockTimer(); - setScrollZoomLocked(false); - }, [clearUnlockTimer]); - - // Cleanup timer on unmount useEffect(() => { return () => clearUnlockTimer(); }, [clearUnlockTimer]); @@ -676,14 +385,8 @@ function BracketContent() { if (containerWidth === 0) return; const { width: contentWidth, height: contentHeight } = boundsRef.current; - - // Calculate the aspect ratio of the bracket content const aspectRatio = contentHeight / contentWidth; - - // Account for fitView padding const paddingMultiplier = 1 + FIT_VIEW_PADDING * 2; - - // Calculate height based on width and aspect ratio const scaledHeight = containerWidth * aspectRatio * paddingMultiplier; setContainerHeight(scaledHeight); @@ -691,80 +394,46 @@ function BracketContent() { const handleInit = (instance: ReactFlowInstance) => { rfInstanceRef.current = instance; - - // Get the bounds of all nodes (includes node dimensions) const nodes = instance.getNodes(); const bounds = getNodesBounds(nodes); - - // Store bounds for recalculation on resize boundsRef.current = { width: bounds.width, height: bounds.height }; - calculateHeight(); - - // Call fitView after a small delay to ensure nodes are fully rendered - requestAnimationFrame(() => { - instance.fitView({ padding: FIT_VIEW_PADDING }); - }); }; - // Re-fit view when height changes - useEffect(() => { - if (containerHeight && rfInstanceRef.current) { - // Small delay to let the DOM update with new height - const timer = setTimeout(() => { - rfInstanceRef.current?.fitView({ padding: FIT_VIEW_PADDING }); - }, 10); - return () => clearTimeout(timer); - } - }, [containerHeight]); - - // Recalculate height on window resize useEffect(() => { const handleResize = () => { calculateHeight(); + if (rfInstanceRef.current) { + rfInstanceRef.current.fitView({ padding: FIT_VIEW_PADDING }); + } }; - window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, [calculateHeight]); + useEffect(() => { + if (containerHeight && rfInstanceRef.current) { + requestAnimationFrame(() => { + rfInstanceRef.current?.fitView({ padding: FIT_VIEW_PADDING }); + }); + } + }, [containerHeight]); + return ( - // biome-ignore lint/a11y/useKeyWithClickEvents: this is a enhancement for mouse users. Feature still fully accessible. - // biome-ignore lint/a11y/noStaticElementInteractions: see above + // biome-ignore lint/a11y/noStaticElementInteractions: mouse events for scroll/zoom unlock UX
- {/* Debug indicator for scroll zoom lock state */} - {/*
- Scroll Zoom: {scrollZoomLocked ? "LOCKED" : "UNLOCKED"} -
*/} { + const data = node.data as { + isPickable?: boolean; + gameId?: string; + playerId?: string; + onPick?: (gameId: string, playerId: string) => void; + }; + if (data.isPickable && data.onPick && data.gameId && data.playerId) { + data.onPick(data.gameId, data.playerId); + } + }} > { @@ -795,5 +477,5 @@ export function Bracket() { return
; } - return ; + return ; } diff --git a/src/components/bracket/PlayerNode.tsx b/src/components/bracket/PlayerNode.tsx index f1ee954..2f779f4 100644 --- a/src/components/bracket/PlayerNode.tsx +++ b/src/components/bracket/PlayerNode.tsx @@ -1,6 +1,56 @@ import { Handle, Position } from "@xyflow/react"; +import { memo } from "react"; import "./bracket.css"; +// User's pick state for this player in this game +export type PickState = + | { status: "noPick" } // No pick made for this game yet + | { status: "none" } // Opponent was picked (this is the unpicked option) + | { status: "pending" } // User picked this player, waiting for result + | { status: "correct" } // User picked this player and they won + | { status: "incorrect" }; // User picked this player and they lost + +// Interaction mode for this node +export type InteractionMode = "view" | "pickable"; + +// Tournament result state for this player +export type TournamentResult = "pending" | "winner" | "eliminated"; + +// Prediction options using structured types +export interface PredictionState { + pickState: PickState; + interactionMode: InteractionMode; + onPick?: (gameId: string, playerId: string) => void; +} + +// Helper to derive CSS class flags from structured types +function deriveClassFlags(prediction?: PredictionState): { + isSelected: boolean; + isCorrect: boolean; + isIncorrect: boolean; + isPickable: boolean; + isUnpicked: boolean; +} { + if (!prediction) { + return { + isSelected: false, + isCorrect: false, + isIncorrect: false, + isPickable: false, + isUnpicked: false, + }; + } + + const { pickState, interactionMode } = prediction; + return { + isSelected: pickState.status === "pending", + isCorrect: pickState.status === "correct", + isIncorrect: pickState.status === "incorrect", + isPickable: interactionMode === "pickable", + isUnpicked: pickState.status === "none", + }; +} + export interface PlayerData { photo: string; name: string; @@ -8,8 +58,13 @@ export interface PlayerData { ringColor?: string; isWinner?: boolean; isEliminated?: boolean; + isLoser?: boolean; side?: "left" | "right"; round?: "round1" | "later"; + showBio?: boolean; + prediction?: PredictionState; + playerId?: string; + gameId?: string; [key: string]: unknown; } @@ -20,8 +75,13 @@ interface PlayerNodeProps { ringColor?: string; isWinner?: boolean; isEliminated?: boolean; + isLoser?: boolean; side?: "left" | "right"; round?: "round1" | "later"; + showBio?: boolean; + prediction?: PredictionState; + playerId?: string; + gameId?: string; } export function PlayerNode({ @@ -31,50 +91,154 @@ export function PlayerNode({ ringColor = "var(--orange)", isWinner = false, isEliminated = false, + isLoser = false, side = "left", round = "later", + showBio = true, + prediction, + playerId, + gameId, }: PlayerNodeProps) { + const { isSelected, isCorrect, isIncorrect, isPickable, isUnpicked } = + deriveClassFlags(prediction); + const onPick = prediction?.onPick; + const classNames = [ "player-node", isWinner && "player-node--winner", isEliminated && "player-node--eliminated", + isLoser && "player-node--loser", side === "right" && "player-node--right", round === "round1" && "player-node--round1", + isSelected && "player-node--selected", + isCorrect && "player-node--correct", + isIncorrect && "player-node--incorrect", + isPickable && "player-node--pickable", + isUnpicked && "player-node--unpicked", ] .filter(Boolean) .join(" "); + const handleClick = () => { + if (isPickable && onPick && gameId && playerId) { + onPick(gameId, playerId); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if ( + (e.key === "Enter" || e.key === " ") && + isPickable && + onPick && + gameId && + playerId + ) { + e.preventDefault(); + onPick(gameId, playerId); + } + }; + return ( -
+ // biome-ignore lint/a11y/noStaticElementInteractions: role is dynamically set to "button" when isPickable + // biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label valid when role="button" is applied +
{name} + {isCorrect && ( +
+ + Correct pick + + +
+ )} + {isIncorrect && ( +
+ + Incorrect pick + + + +
+ )} + {isSelected && !isCorrect && !isIncorrect && ( +
+ + Your pick + + +
+ )}

{name}

-

{byline}

+ {showBio && byline &&

{byline}

}
); } -function Handles() { +function Handles({ round = "later" }: { round?: "round1" | "later" }) { + // Photo ring center: large=~47px, small=~30px from top + const handleOffset = round === "round1" ? 47 : 30; + return ( <> - +
); -} +}); // Empty slot for matches not yet played export function EmptySlot({ @@ -155,7 +328,7 @@ export function EmptySlot({ } // React Flow wrapper for EmptySlot -export function EmptySlotFlow({ +export const EmptySlotFlow = memo(function EmptySlotFlow({ data, }: { data: { @@ -173,7 +346,7 @@ export function EmptySlotFlow({ ringColor={data?.ringColor} round={data?.round} /> - +
); -} +}); diff --git a/src/components/bracket/SimpleBracket.tsx b/src/components/bracket/SimpleBracket.tsx deleted file mode 100644 index 1c46ab9..0000000 --- a/src/components/bracket/SimpleBracket.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { - addEdge, - applyEdgeChanges, - applyNodeChanges, - Position, - ReactFlow, -} from "@xyflow/react"; -import { useCallback, useState } from "react"; -import "@xyflow/react/dist/style.css"; - -const handles = [ - { - type: "source", - position: Position.Right, - id: "out", - }, - { - type: "target", - position: Position.Top, - id: "in", - }, -]; -const initialNodes = [ - { - id: "n1", - position: { x: 0, y: 0 }, - data: { label: "Node 1" }, - handles, - }, - - { id: "n2", position: { x: 0, y: 100 }, data: { label: "Node 2" }, handles }, - - { id: "n3", position: { x: 50, y: 50 }, data: { label: "Node 3" }, handles }, -]; -const initialEdges = [ - { id: "n1-n3", source: "n1", target: "n3" }, - { id: "n2-n3", source: "n2", target: "n3" }, -]; - -export function SimpleBracket() { - const [nodes, setNodes] = useState(initialNodes); - const [edges, setEdges] = useState(initialEdges); - - const onNodesChange = useCallback( - (changes) => - setNodes((nodesSnapshot) => applyNodeChanges(changes, nodesSnapshot)), - [], - ); - const onEdgesChange = useCallback( - (changes) => - setEdges((edgesSnapshot) => applyEdgeChanges(changes, edgesSnapshot)), - [], - ); - const onConnect = useCallback( - (params) => setEdges((edgesSnapshot) => addEdge(params, edgesSnapshot)), - [], - ); - - return ( -
- -
- ); -} diff --git a/src/components/bracket/bracket.css b/src/components/bracket/bracket.css index c621278..fdabd02 100644 --- a/src/components/bracket/bracket.css +++ b/src/components/bracket/bracket.css @@ -1,3 +1,15 @@ +@property --ring-color { + syntax: ""; + inherits: true; + initial-value: #f97316; +} + +@property --inner-ring-color { + syntax: ""; + inherits: true; + initial-value: #facc15; +} + /* Player Node Base Styles */ .player-node { display: flex; @@ -9,7 +21,7 @@ /* Finalist and championship nodes - vertically stacked layout */ [data-id*="finalist"] &, [data-id="championship"] & { - flex-direction: column; + flex-direction: column !important; justify-content: center; align-items: center; width: auto; @@ -19,30 +31,15 @@ .player-info { text-align: center; margin-left: 0; + margin-right: 0; + padding: 10px 16px; } .player-photo-ring { + margin-left: 0; + margin-right: 0; margin-bottom: -30px; } } - - [data-id="left-finalist"] & { - transform: translateX(10px) translateY(-5px); - } - - [data-id*="right-finalist"] & { - transform: translateX(-10px) translateY(-5px); - } - - /* Finalist sizing */ - [data-id*="finalist"] & .player-photo-ring { - --photo-size: 65px; - } - - /* Championship sizing */ - [data-id="championship"] & .player-photo-ring { - --photo-size: 80px; - transform: translateX(10px); - } } /* Smaller avatars for non-round1 matches */ @@ -50,10 +47,28 @@ --photo-size: 50px; } +.player-node:not(.player-node--round1) .player-photo { + --brain-size: 13px; + --circle-size: 29px; +} + /* Keep large avatars for finalists and champion */ -[data-id*="finalist"] .player-node .player-photo-ring, -[data-id*="champion"] .player-node .player-photo-ring { - --photo-size: 85px; +[data-id*="finalist"] .player-node .player-photo-ring { + --photo-size: 75px; +} + +[data-id*="finalist"] .player-node .player-photo { + --brain-size: 18px; + --circle-size: 40px; +} + +[data-id="championship"] .player-node .player-photo-ring { + --photo-size: 95px; +} + +[data-id="championship"] .player-node .player-photo { + --brain-size: 22px; + --circle-size: 50px; } .player-photo-ring { @@ -85,6 +100,10 @@ linear-gradient(var(--ring-color), var(--ring-color)) border-box; padding: var(--ring-thickness); flex-shrink: 0; + transition: + box-shadow 0.3s ease, + --ring-color 0.3s ease, + --inner-ring-color 0.3s ease; } .player-photo-ring::before { @@ -94,6 +113,7 @@ border-radius: 50%; background: var(--inner-ring-color); padding: var(--gold-ring-thickness); + transition: background 0.3s ease; } .player-photo { @@ -103,8 +123,8 @@ width: 100%; height: calc(100% + var(--brain-size)); object-fit: cover; - filter: grayscale(10%); transform: translateY(calc(var(--brain-size) * -1)); + transform-origin: center bottom; --circle-size: 45px; /* Top 50% visible */ mask-image: @@ -121,6 +141,9 @@ mask-position: center center, center calc(var(--brain-size) / 2 + 0.5px); + transition: + filter 0.3s ease, + transform 0.3s ease; } /* Grey circle placeholder for empty slots */ @@ -190,6 +213,16 @@ border-color: #888; } +/* Loser State - shows actual tournament losers with gray styling */ +.player-node--loser .player-photo-ring { + --ring-color: #666; + --inner-ring-color: #888; +} + +.player-node--loser .player-photo { + filter: grayscale(100%); +} + /* Empty Slot State - grey version of player node */ .player-node--empty .player-photo-ring { --ring-color: var(--yellow); @@ -226,6 +259,91 @@ text-align: center; } +/* Pickable State - hoverable/clickable for making predictions */ +.player-node--pickable { + cursor: pointer; +} + +.player-node--pickable:hover .player-photo { + transform: translateY(calc(var(--brain-size) * -1)) scale(1.1); +} + +.player-node--pickable:focus { + outline: none; +} + +.player-node--pickable:focus-visible .player-photo-ring { + box-shadow: + 0 0 0 3px var(--yellow), + 0 0 0 5px var(--black); +} + +/* Selected State - user has picked this player (checkmark badge) */ +.player-node--selected .player-photo { + filter: grayscale(0%); +} + +.player-node--selected .player-photo-ring { + --ring-color: var(--yellow); +} + +/* Unpicked State - opponent of a picked player (gray ring) */ +.player-node--unpicked .player-photo-ring { + --ring-color: #666; + --inner-ring-color: #888; +} + +.player-node--unpicked .player-photo { + filter: grayscale(100%); +} + +/* Checkmark entrance animation */ +@keyframes checkmark-pop { + 0% { + transform: scale(0); + opacity: 0; + } + 60% { + transform: scale(1.2); + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.player-node__badge { + position: absolute; + top: -2px; + right: -2px; + width: 22px; + height: 22px; + border: 2px solid var(--black); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + animation: checkmark-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.player-node__badge--pending { + background: var(--yellow); + color: var(--black); +} + +.player-node__badge--correct { + --badge-correct: #22c55e; + background: var(--badge-correct); + color: var(--white); +} + +.player-node__badge--incorrect { + --badge-incorrect: #ef4444; + background: var(--badge-incorrect); + color: var(--white); +} + /* React Flow Container */ .bracket-container { width: 100%; diff --git a/src/components/bracket/bracketTypes.ts b/src/components/bracket/bracketTypes.ts new file mode 100644 index 0000000..09528d9 --- /dev/null +++ b/src/components/bracket/bracketTypes.ts @@ -0,0 +1,29 @@ +export interface NodeContext { + hasResults: boolean; + tournamentResults: Record; + predictions: Record; + pickablePlayersCache: Record< + string, + [string | undefined, string | undefined] + >; + isInteractive: boolean; + isPickingEnabled: boolean; + showPicks: boolean; + isLocked: boolean; + onPick?: (gameId: string, playerId: string) => void; +} + +export interface RoundGeneratorOptions { + side: "left" | "right"; + ctx: NodeContext; +} + +// Layout constants +export const NODE_HEIGHT = 70; +export const VERTICAL_GAP = 76; +export const MATCH_GAP = NODE_HEIGHT + VERTICAL_GAP; // 146 +export const ROUND_GAP = 220; +export const RIGHT_START_X = ROUND_GAP * 7; // 2380 + +export const LEFT_RING_COLOR = "#f3370e"; +export const RIGHT_RING_COLOR = "#5CE1E6"; diff --git a/src/components/bracket/nodeGenerators.ts b/src/components/bracket/nodeGenerators.ts new file mode 100644 index 0000000..5a4e1c9 --- /dev/null +++ b/src/components/bracket/nodeGenerators.ts @@ -0,0 +1,519 @@ +import type { Node } from "@xyflow/react"; +import { + bracket, + FEEDER_GAMES, + type Game, + isLoser, + isWinner, + type Player, + players, + splitForDisplay, +} from "@/data/players"; +import { getPlayerById, getPlayersForGame } from "@/lib/simulation"; +import type { NodeContext, RoundGeneratorOptions } from "./bracketTypes"; +import { + LEFT_RING_COLOR, + MATCH_GAP, + RIGHT_RING_COLOR, + RIGHT_START_X, + ROUND_GAP, +} from "./bracketTypes"; +import type { InteractionMode, PickState, PredictionState } from "./PlayerNode"; + +// Node is large if its feeder is decided AND the current game is not decided +function isNodeLarge( + gameId: string, + slot: "p1" | "p2", + hasResults: boolean, + tournamentResults: Record, +): boolean { + if (!hasResults) { + return gameId.startsWith("r1-"); // Initial state: only R1 is large + } + + if (gameId.startsWith("r1-")) { + return !tournamentResults[gameId]; // R1: large if game not decided + } + + const feeders = FEEDER_GAMES[gameId]; + if (!feeders) return false; + + const feederGame = slot === "p1" ? feeders[0] : feeders[1]; + if (!tournamentResults[feederGame]) return false; // Small if feeder not decided + return !tournamentResults[gameId]; // Large if feeder decided AND game not decided +} + +function getRingColor(side: "left" | "right"): string { + return side === "left" ? LEFT_RING_COLOR : RIGHT_RING_COLOR; +} + +function getPhotoPath(player: Player, isEliminated: boolean): string { + const filename = player.photo.replace("/avatars/", ""); + return isEliminated + ? `/avatars/bw/${filename}` + : `/avatars/color/${filename}`; +} + +function playerToNodeData( + player: Player, + game: Game, + ringColor: string, + side: "left" | "right", + round: "round1" | "later" = "later", + options?: { + prediction?: PredictionState; + isLoser?: boolean; + showBio?: boolean; + }, +): { + photo: string; + name: string; + byline: string; + ringColor: string; + isWinner: boolean; + isEliminated: boolean; + isLoser: boolean; + showBio: boolean; + side: "left" | "right"; + round: "round1" | "later"; + prediction?: PredictionState; + playerId?: string; + gameId?: string; +} { + const isEliminated = + options?.isLoser !== undefined ? options.isLoser : isLoser(game, player); + return { + photo: getPhotoPath(player, isEliminated), + name: player.name, + byline: player.byline, + ringColor, + isWinner: isWinner(game, player), + isEliminated, + isLoser: isEliminated, + showBio: options?.showBio ?? true, + side, + round, + prediction: options?.prediction, + playerId: player.id, + gameId: game.id, + }; +} + +function createNode( + id: string, + player: Player | undefined, + game: Game, + ringColor: string, + position: { x: number; y: number }, + side: "left" | "right", + round: "round1" | "later" = "later", + emptyText?: string, + nodeOptions?: { + prediction?: PredictionState; + isLoser?: boolean; + showBio?: boolean; + }, +): Node { + if (player) { + return { + id, + type: "playerNode", + position, + data: playerToNodeData(player, game, ringColor, side, round, nodeOptions), + }; + } + return { + id, + type: "emptySlot", + position, + data: { text: emptyText, side, ringColor, round }, + }; +} + +function getPredictionOptions( + game: Game, + player: Player | undefined, + ctx: NodeContext, +): PredictionState | undefined { + // If not showing picks, don't return any pick state + if (!ctx.showPicks) { + return undefined; + } + + const userPick = ctx.predictions[game.id]; + const actualWinner = ctx.tournamentResults[game.id]; + + const defaults: PredictionState = { + pickState: { status: "noPick" }, + interactionMode: "view", + }; + + if (!player) { + return defaults; + } + + // Determine if this player can be picked + const pickablePlayers = ctx.pickablePlayersCache[game.id]; + const bothPlayersDetermined = + pickablePlayers[0] !== undefined && pickablePlayers[1] !== undefined; + const canPick = + ctx.isInteractive && + ctx.isPickingEnabled && + bothPlayersDetermined && + (player.id === pickablePlayers[0] || player.id === pickablePlayers[1]); + const interactionMode: InteractionMode = canPick ? "pickable" : "view"; + const onPick = canPick ? ctx.onPick : undefined; + + // Game has a result - determine correct/incorrect + if (actualWinner) { + if (ctx.isLocked && !ctx.showPicks) { + return defaults; + } + const isWinnerPlayer = actualWinner === player.id; + const wasPickedForThisGame = userPick === player.id; + + let pickState: PickState; + if (wasPickedForThisGame) { + pickState = isWinnerPlayer + ? { status: "correct" } + : { status: "incorrect" }; + } else { + pickState = { status: "none" }; + } + + return { pickState, interactionMode: "view" }; + } + + // No result yet - determine pending/none pick state + if (userPick) { + if (ctx.isLocked && !ctx.showPicks) { + return { pickState: { status: "noPick" }, interactionMode, onPick }; + } + const isPickedForThisGame = userPick === player.id; + const pickState: PickState = isPickedForThisGame + ? { status: "pending" } + : { status: "none" }; + return { pickState, interactionMode, onPick }; + } + + return { pickState: { status: "noPick" }, interactionMode, onPick }; +} + +function isPlayerLoser( + gameId: string, + playerId: string, + ctx: NodeContext, +): boolean { + if (!ctx.hasResults || ctx.showPicks) return false; + const winner = ctx.tournamentResults[gameId]; + if (!winner) return false; + return winner !== playerId; +} + +export function generateRound1Nodes({ + side, + ctx, +}: RoundGeneratorOptions): Node[] { + const nodes: Node[] = []; + const round1 = splitForDisplay(bracket.round1); + const games = side === "left" ? round1.left : round1.right; + const ringColor = getRingColor(side); + const xPos = side === "left" ? 0 : RIGHT_START_X; + + games.forEach((game, gameIndex) => { + const baseY = gameIndex * 2 * MATCH_GAP; + const player1 = game.player1; + const player2 = game.player2; + // For R1, slot doesn't matter - both check if game is decided + const gameLarge = isNodeLarge( + game.id, + "p1", + ctx.hasResults, + ctx.tournamentResults, + ); + + const p1Options = getPredictionOptions(game, player1, ctx); + const p1Loser = player1 ? isPlayerLoser(game.id, player1.id, ctx) : false; + nodes.push( + createNode( + `${game.id}-p1`, + player1, + game, + ringColor, + { x: xPos, y: baseY }, + side, + gameLarge ? "round1" : "later", + undefined, + { prediction: p1Options, showBio: true, isLoser: p1Loser }, + ), + ); + + const p2Options = getPredictionOptions(game, player2, ctx); + const p2Loser = player2 ? isPlayerLoser(game.id, player2.id, ctx) : false; + nodes.push( + createNode( + `${game.id}-p2`, + player2, + game, + ringColor, + { x: xPos, y: baseY + MATCH_GAP }, + side, + gameLarge ? "round1" : "later", + undefined, + { prediction: p2Options, showBio: true, isLoser: p2Loser }, + ), + ); + }); + + return nodes; +} + +export function generateQuarterNodes({ + side, + ctx, +}: RoundGeneratorOptions): Node[] { + const nodes: Node[] = []; + const quarters = splitForDisplay(bracket.quarters); + const games = side === "left" ? quarters.left : quarters.right; + const ringColor = getRingColor(side); + const xPos = side === "left" ? ROUND_GAP : RIGHT_START_X - ROUND_GAP; + + games.forEach((game, gameIndex) => { + const p1Large = isNodeLarge( + game.id, + "p1", + ctx.hasResults, + ctx.tournamentResults, + ); + const p2Large = isNodeLarge( + game.id, + "p2", + ctx.hasResults, + ctx.tournamentResults, + ); + const qfOffset = p1Large || p2Large ? MATCH_GAP * 0.5 : MATCH_GAP * 0.637; + const baseY = gameIndex * 4 * MATCH_GAP + qfOffset; + + let player1: Player | undefined; + let player2: Player | undefined; + if (ctx.showPicks || (ctx.isInteractive && !ctx.isLocked)) { + const pickablePlayers = ctx.pickablePlayersCache[game.id]; + player1 = pickablePlayers[0] + ? players.find((p) => p.id === pickablePlayers[0]) + : undefined; + player2 = pickablePlayers[1] + ? players.find((p) => p.id === pickablePlayers[1]) + : undefined; + } else if (ctx.hasResults) { + const [p1Id, p2Id] = getPlayersForGame(game.id, ctx.tournamentResults); + player1 = p1Id ? getPlayerById(p1Id) : undefined; + player2 = p2Id ? getPlayerById(p2Id) : undefined; + } else { + player1 = game.player1; + player2 = game.player2; + } + + const p1Options = getPredictionOptions(game, player1, ctx); + const p1Loser = player1 ? isPlayerLoser(game.id, player1.id, ctx) : false; + nodes.push( + createNode( + `${game.id}-p1`, + player1, + game, + ringColor, + { x: xPos, y: baseY }, + side, + p1Large ? "round1" : "later", + "TBD", + { prediction: p1Options, showBio: false, isLoser: p1Loser }, + ), + ); + + const p2Options = getPredictionOptions(game, player2, ctx); + const p2Loser = player2 ? isPlayerLoser(game.id, player2.id, ctx) : false; + nodes.push( + createNode( + `${game.id}-p2`, + player2, + game, + ringColor, + { x: xPos, y: baseY + 2 * MATCH_GAP }, + side, + p2Large ? "round1" : "later", + "TBD", + { prediction: p2Options, showBio: false, isLoser: p2Loser }, + ), + ); + }); + + return nodes; +} + +export function generateSemiNodes({ + side, + ctx, +}: RoundGeneratorOptions): Node[] { + const nodes: Node[] = []; + const semis = splitForDisplay(bracket.semis); + const games = side === "left" ? semis.left : semis.right; + const ringColor = getRingColor(side); + const xPos = side === "left" ? ROUND_GAP * 2 : RIGHT_START_X - ROUND_GAP * 2; + + games.forEach((game) => { + const p1Large = isNodeLarge( + game.id, + "p1", + ctx.hasResults, + ctx.tournamentResults, + ); + const p2Large = isNodeLarge( + game.id, + "p2", + ctx.hasResults, + ctx.tournamentResults, + ); + const sfOffset = p1Large || p2Large ? 1.35 : 1.5; + const baseY = sfOffset * MATCH_GAP; + + let player1: Player | undefined; + let player2: Player | undefined; + if (ctx.showPicks || (ctx.isInteractive && !ctx.isLocked)) { + const pickablePlayers = ctx.pickablePlayersCache[game.id]; + player1 = pickablePlayers[0] + ? players.find((p) => p.id === pickablePlayers[0]) + : undefined; + player2 = pickablePlayers[1] + ? players.find((p) => p.id === pickablePlayers[1]) + : undefined; + } else if (ctx.hasResults) { + const [p1Id, p2Id] = getPlayersForGame(game.id, ctx.tournamentResults); + player1 = p1Id ? getPlayerById(p1Id) : undefined; + player2 = p2Id ? getPlayerById(p2Id) : undefined; + } else { + player1 = game.player1; + player2 = game.player2; + } + + const p1Options = getPredictionOptions(game, player1, ctx); + const p1Loser = player1 ? isPlayerLoser(game.id, player1.id, ctx) : false; + nodes.push( + createNode( + `${game.id}-p1`, + player1, + game, + ringColor, + { x: xPos, y: baseY }, + side, + p1Large ? "round1" : "later", + "TBD", + { prediction: p1Options, showBio: false, isLoser: p1Loser }, + ), + ); + + const p2Options = getPredictionOptions(game, player2, ctx); + const p2Loser = player2 ? isPlayerLoser(game.id, player2.id, ctx) : false; + nodes.push( + createNode( + `${game.id}-p2`, + player2, + game, + ringColor, + { x: xPos, y: baseY + 4 * MATCH_GAP }, + side, + p2Large ? "round1" : "later", + "TBD", + { prediction: p2Options, showBio: false, isLoser: p2Loser }, + ), + ); + }); + + return nodes; +} + +export function generateFinalistNode({ + side, + ctx, +}: RoundGeneratorOptions): Node { + const finalGame = bracket.finals[0]; + const ringColor = getRingColor(side); + // Left finalist is p1 (fed from sf-0), right finalist is p2 (fed from sf-1) + const finalistLarge = isNodeLarge( + "final", + side === "left" ? "p1" : "p2", + ctx.hasResults, + ctx.tournamentResults, + ); + + let finalist: Player | undefined; + const sfGameId = side === "left" ? "sf-0" : "sf-1"; + + if (ctx.showPicks || (ctx.isInteractive && !ctx.isLocked)) { + const finalistId = ctx.predictions[sfGameId]; + finalist = finalistId + ? players.find((p) => p.id === finalistId) + : undefined; + } else if (ctx.hasResults) { + const [p1Id, p2Id] = getPlayersForGame("final", ctx.tournamentResults); + finalist = + side === "left" + ? p1Id + ? getPlayerById(p1Id) + : undefined + : p2Id + ? getPlayerById(p2Id) + : undefined; + } else { + finalist = side === "left" ? finalGame.player1 : finalGame.player2; + } + + const finalistOptions = getPredictionOptions(finalGame, finalist, ctx); + const finalistLoser = finalist + ? isPlayerLoser("final", finalist.id, ctx) + : false; + + const xPos = + side === "left" ? ROUND_GAP * 3 + 23 : RIGHT_START_X - ROUND_GAP * 2.5 - 23; + + return createNode( + `${side}-finalist`, + finalist, + finalGame, + ringColor, + { x: xPos, y: (finalistLarge ? 3.35 : 3.5) * MATCH_GAP }, + side, + finalistLarge ? "round1" : "later", + "Finalist TBD", + { prediction: finalistOptions, showBio: false, isLoser: finalistLoser }, + ); +} + +export function generateChampionshipNode(ctx: NodeContext): Node { + const finalGame = bracket.finals[0]; + + let champion: Player | undefined; + if (ctx.showPicks || (ctx.isInteractive && !ctx.isLocked)) { + const championId = ctx.predictions.final; + champion = championId + ? players.find((p) => p.id === championId) + : undefined; + } else if (ctx.hasResults && ctx.tournamentResults.final) { + champion = getPlayerById(ctx.tournamentResults.final); + } else { + champion = finalGame.winner; + } + + return { + id: "championship", + type: champion ? "playerNode" : "emptySlot", + position: { + x: ROUND_GAP * 3.75, + y: 0, + }, + data: champion + ? playerToNodeData(champion, finalGame, "#FFD700", "left", "later", { + isLoser: false, + showBio: false, + }) + : { text: "CHAMPION", side: "left", ringColor: "#FFD700" }, + }; +} diff --git a/src/components/leaderboard/Leaderboard.tsx b/src/components/leaderboard/Leaderboard.tsx new file mode 100644 index 0000000..6eb5b3e --- /dev/null +++ b/src/components/leaderboard/Leaderboard.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from "react"; +import type { LeaderboardEntry } from "@/routes/api/leaderboard/index"; +import { LeaderboardScore } from "./LeaderboardScore"; +import "./leaderboard.css"; + +export function Leaderboard() { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/leaderboard/") + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((data) => { + setEntries(data.leaderboard || []); + setLoading(false); + }) + .catch(() => { + setLoading(false); + }); + }, []); + + return ( +
+
+

The Leaderboard

+
+
+
+
+
+
+ +

Top Predictors

+ + {loading ? ( +
Loading...
+ ) : entries.length === 0 ? ( +
+ No scores yet. Lock your bracket and check back after Round 1! +
+ ) : ( + + + + + + + + + + {entries.map((entry) => ( + + + + + + ))} + +
#PlayerTotal
{entry.rank} + {entry.username ? ( + + {entry.userImage && ( + + )} + + {entry.userName} + + + ) : ( +
+ {entry.userImage && ( + + )} + + {entry.userName} + +
+ )} +
+ +
+ )} +
+
+
+
+ ); +} diff --git a/src/components/leaderboard/LeaderboardScore.tsx b/src/components/leaderboard/LeaderboardScore.tsx new file mode 100644 index 0000000..53137c0 --- /dev/null +++ b/src/components/leaderboard/LeaderboardScore.tsx @@ -0,0 +1,21 @@ +export function LeaderboardScore({ + value, + isTotal, +}: { + value: number; + isTotal?: boolean; +}) { + const digits = String(value).padStart(isTotal ? 3 : 2, "0"); + return ( +
+ {digits.split("").map((d, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: Digits don't reorder and spans are stateless + + {d} + + ))} +
+ ); +} diff --git a/src/components/leaderboard/leaderboard.css b/src/components/leaderboard/leaderboard.css new file mode 100644 index 0000000..154a713 --- /dev/null +++ b/src/components/leaderboard/leaderboard.css @@ -0,0 +1,273 @@ +.leaderboard-section { + padding-block: 40px 80px; + background: transparent; + background-image: none; +} + +.leaderboard-wrapper { + max-width: 800px; + margin: 0 auto; + filter: drop-shadow(0 8px 24px rgba(0, 0, 0, 0.5)); +} + +.leaderboard-frame { + --bolt-size: 16px; + position: relative; + background: var(--yellow); + border: 6px solid var(--black); + border-radius: 8px; + padding: 32px; +} + +/* Corner bolts */ +.leaderboard-bolt { + position: absolute; + width: var(--bolt-size); + height: var(--bolt-size); + background: #444; + border-radius: 50%; + border: 2px solid #222; + box-shadow: + inset 1px 1px 2px rgba(255, 255, 255, 0.3), + inset -1px -1px 2px rgba(0, 0, 0, 0.3); +} + +.leaderboard-bolt::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) rotate(45deg); + width: 60%; + height: 2px; + background: #222; +} + +.leaderboard-bolt--tl { + top: 8px; + left: 8px; +} +.leaderboard-bolt--tr { + top: 8px; + right: 8px; +} +.leaderboard-bolt--bl { + bottom: 8px; + left: 8px; +} +.leaderboard-bolt--br { + bottom: 8px; + right: 8px; +} + +.leaderboard-title { + font-family: var(--font-block); + font-size: 2rem; + text-transform: uppercase; + color: var(--black); + text-align: center; + margin-bottom: 16px; + letter-spacing: 2px; +} + +.leaderboard-table { + width: 100%; + border-collapse: collapse; + background: #1a1a1a; + border-radius: 4px; + overflow: hidden; +} + +.leaderboard-table th, +.leaderboard-table td { + padding: 10px 8px; + text-align: center; + border-bottom: 2px solid #333; +} + +.leaderboard-table th { + background: #222; + color: var(--yellow); + font-family: var(--font-sans); + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 1px; +} + +.leaderboard-table th:nth-child(2) { + text-align: left; +} + +.leaderboard-table th:nth-child(3) { + text-align: right; + padding-right: 16px; +} + +.leaderboard-table tbody tr { + transition: background-color 0.15s ease; +} + +.leaderboard-table tbody tr:hover { + background: #252525; +} + +.leaderboard-table tbody tr:last-child td { + border-bottom: none; +} + +/* Rank column */ +.leaderboard-rank { + font-family: var(--font-block); + font-size: 1.25rem; + color: var(--yellow); + width: 50px; +} + +/* Top 3 special styling */ +.leaderboard-table tbody tr:nth-child(1) .leaderboard-rank { + color: #ffd700; +} +.leaderboard-table tbody tr:nth-child(2) .leaderboard-rank { + color: #c0c0c0; +} +.leaderboard-table tbody tr:nth-child(3) .leaderboard-rank { + color: #cd7f32; +} + +/* Player cell with avatar */ +.leaderboard-player { + display: flex; + align-items: center; + gap: 10px; + text-align: left; +} + +.leaderboard-player--link { + text-decoration: none; + border-radius: 4px; + padding: 4px 8px; + margin: -4px -8px; + transition: background-color 0.15s ease; +} + +.leaderboard-player--link:hover { + background: rgba(255, 174, 0, 0.15); +} + +.leaderboard-player--link:hover .leaderboard-name { + color: var(--yellow); +} + +.leaderboard-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid var(--yellow); + object-fit: cover; + flex-shrink: 0; +} + +.leaderboard-name { + font-family: var(--font-sans); + font-weight: 500; + color: var(--white); + font-size: 1rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 240px; +} + +/* Total column */ +.leaderboard-table td.leaderboard-total { + text-align: right; + padding-right: 16px; +} + +/* LED digit styles - individual boxes with "8" outline */ +.leaderboard-digits { + display: inline-flex; + gap: 2px; +} + +.leaderboard-digit { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 40px; + background: #0a0a0a; + border: 2px solid var(--black); + box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.9); + font-family: "DSEG7Classic", monospace; + font-size: 1.4rem; + font-weight: bold; + color: var(--orange); + text-shadow: 0 0 10px rgba(243, 55, 14, 0.7); +} + +/* Inactive segment "8" outline */ +.leaderboard-digit::before { + content: "8"; + position: absolute; + color: rgba(243, 55, 14, 0.1); + text-shadow: none; +} + +/* Total column - green digits */ +.leaderboard-digits--total .leaderboard-digit { + color: #33ff33; + text-shadow: 0 0 10px rgba(51, 255, 51, 0.7); +} + +.leaderboard-digits--total .leaderboard-digit::before { + color: rgba(51, 255, 51, 0.1); +} + +/* Empty state */ +.leaderboard-empty { + text-align: center; + padding: 40px 20px; + color: #666; + font-style: italic; +} + +/* Mobile responsiveness */ +@media (max-width: 600px) { + .leaderboard-frame { + padding: 16px 24px; + } + + .leaderboard-title { + font-size: 1.5rem; + } + + .leaderboard-table th, + .leaderboard-table td { + padding: 8px 4px; + font-size: 0.8rem; + } + + .leaderboard-digit { + width: 20px; + height: 30px; + font-size: 1rem; + } + + .leaderboard-avatar { + width: 28px; + height: 28px; + } + + .leaderboard-name { + font-size: 0.85rem; + max-width: 120px; + } + + .leaderboard-table td.leaderboard-total, + .leaderboard-table th:nth-child(3) { + padding-right: 8px; + } +} diff --git a/src/components/scoreboard/Scoreboard.tsx b/src/components/scoreboard/Scoreboard.tsx new file mode 100644 index 0000000..a440b61 --- /dev/null +++ b/src/components/scoreboard/Scoreboard.tsx @@ -0,0 +1,32 @@ +import type { CountdownTime } from "@/hooks/useCountdown"; +import { ScoreboardSeparator } from "./ScoreboardSeparator"; +import { ScoreboardUnit } from "./ScoreboardUnit"; + +export function Scoreboard({ + countdown, + isUrgent, +}: { + countdown: CountdownTime; + isUrgent: boolean; +}) { + return ( +
+
+
+
+
+
+
+ + + + + + + +
+
+
+
+ ); +} diff --git a/src/components/scoreboard/ScoreboardSeparator.tsx b/src/components/scoreboard/ScoreboardSeparator.tsx new file mode 100644 index 0000000..1bd8036 --- /dev/null +++ b/src/components/scoreboard/ScoreboardSeparator.tsx @@ -0,0 +1,8 @@ +export function ScoreboardSeparator() { + return ( +
+ + +
+ ); +} diff --git a/src/components/scoreboard/ScoreboardUnit.tsx b/src/components/scoreboard/ScoreboardUnit.tsx new file mode 100644 index 0000000..8246301 --- /dev/null +++ b/src/components/scoreboard/ScoreboardUnit.tsx @@ -0,0 +1,18 @@ +export function ScoreboardUnit({ + value, + label, +}: { + value: number; + label: string; +}) { + const digits = String(value).padStart(2, "0"); + return ( +
+
+ {digits[0]} + {digits[1]} +
+ {label} +
+ ); +} diff --git a/src/context/PredictionsContext.tsx b/src/context/PredictionsContext.tsx new file mode 100644 index 0000000..625fd3f --- /dev/null +++ b/src/context/PredictionsContext.tsx @@ -0,0 +1,28 @@ +import { createContext, type ReactNode, use } from "react"; +import { + type UsePredictionsReturn, + usePredictions, +} from "@/hooks/usePredictions"; + +const PredictionsContext = createContext(null); + +export function PredictionsProvider({ + isAuthenticated, + userId, + children, +}: { + isAuthenticated: boolean; + userId?: string; + children: ReactNode; +}) { + const predictions = usePredictions(isAuthenticated, userId); + return ( + + {children} + + ); +} + +export function usePredictionsContext() { + return use(PredictionsContext); +} diff --git a/src/data/players.ts b/src/data/players.ts index 17fe7c9..208f917 100644 --- a/src/data/players.ts +++ b/src/data/players.ts @@ -1,3 +1,63 @@ +// ============================================================================= +// TOURNAMENT CONFIG +// ============================================================================= + +// Deadline for locking brackets (ISO 8601 format) +// After this time, no new brackets can be created or locked +export const BRACKET_DEADLINE = "2026-02-28T00:00:00Z"; + +// Game schedule - when results will be announced for each round +export const GAME_SCHEDULE = { + "left-r1": "2026-03-02T18:00:00Z", + "right-r1": "2026-03-09T18:00:00Z", + qf: "2026-03-16T18:00:00Z", + sf: "2026-03-23T18:00:00Z", + final: "2026-03-30T18:00:00Z", +} as const; + +// Get the next upcoming game time (or null if all games are done) +export function getNextGameTime(): { round: string; time: string } | null { + const now = Date.now(); + const rounds = Object.entries(GAME_SCHEDULE) as [string, string][]; + for (const [round, time] of rounds) { + if (new Date(time).getTime() > now) { + return { round, time }; + } + } + return null; +} + +// Total number of games in the bracket (8 + 4 + 2 + 1 = 15) +export const TOTAL_GAMES = 15; + +// Game IDs by round +export const ROUND_1_GAME_IDS = [ + "r1-0", + "r1-1", + "r1-2", + "r1-3", + "r1-4", + "r1-5", + "r1-6", + "r1-7", +] as const; + +export const QUARTER_GAME_IDS = ["qf-0", "qf-1", "qf-2", "qf-3"] as const; + +export const SEMI_GAME_IDS = ["sf-0", "sf-1"] as const; + +export const FINAL_GAME_IDS = ["final"] as const; + +// All game IDs in tournament order +export const ALL_GAME_IDS = [ + ...ROUND_1_GAME_IDS, + ...QUARTER_GAME_IDS, + ...SEMI_GAME_IDS, + ...FINAL_GAME_IDS, +] as const; + +export type GameId = (typeof ALL_GAME_IDS)[number]; + // ============================================================================= // PLAYER DEFINITIONS // ============================================================================= @@ -239,12 +299,12 @@ export const bracket: Bracket = { // ----- RIGHT SIDE (games 4-7) ----- - // Game 4: Kevin Powell vs TBD + // Game 4: Kevin Powell (bye round) { id: "r1-4", date: "2026-02-01", player1: kevinPowell, - // player2: TBD + player2: kevinPowell, }, // Game 5: Josh Comeau vs Cassidy Williams { @@ -366,3 +426,28 @@ export function splitForDisplay(games: T[]): { left: T[]; right: T[] } { right: games.slice(mid), }; } + +/** Get all completed game results from the bracket */ +export function getResultsFromBracket(): { + gameId: string; + winnerId: string; +}[] { + const results: { gameId: string; winnerId: string }[] = []; + for (const game of getAllGames()) { + if (game.winner) { + results.push({ gameId: game.id, winnerId: game.winner.id }); + } + } + return results; +} + +/** Mapping of which games feed into each slot (p1 from first, p2 from second) */ +export const FEEDER_GAMES: Record = { + "qf-0": ["r1-0", "r1-1"], + "qf-1": ["r1-2", "r1-3"], + "qf-2": ["r1-4", "r1-5"], + "qf-3": ["r1-6", "r1-7"], + "sf-0": ["qf-0", "qf-1"], + "sf-1": ["qf-2", "qf-3"], + final: ["sf-0", "sf-1"], +}; diff --git a/src/db/schema.ts b/src/db/schema.ts index 30e12e4..d9d9f89 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,5 +1,11 @@ import { relations, sql } from "drizzle-orm"; -import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { + index, + integer, + sqliteTable, + text, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; export const user = sqliteTable("user", { id: text("id").primaryKey(), @@ -9,6 +15,7 @@ export const user = sqliteTable("user", { .default(false) .notNull(), image: text("image"), + username: text("username").unique(), createdAt: integer("created_at", { mode: "timestamp_ms" }) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .notNull(), @@ -87,9 +94,12 @@ export const verification = sqliteTable( (table) => [index("verification_identifier_idx").on(table.identifier)], ); -export const userRelations = relations(user, ({ many }) => ({ +export const userRelations = relations(user, ({ many, one }) => ({ sessions: many(session), accounts: many(account), + predictions: many(userPrediction), + bracketStatus: one(userBracketStatus), + score: one(userScore), })); export const sessionRelations = relations(session, ({ one }) => ({ @@ -105,3 +115,107 @@ export const accountRelations = relations(account, ({ one }) => ({ references: [user.id], }), })); + +// ============================================================================= +// USER PREDICTIONS +// ============================================================================= + +export const userPrediction = sqliteTable( + "user_prediction", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + gameId: text("game_id").notNull(), // e.g., "r1-0", "qf-1", "sf-0", "final" + predictedWinnerId: text("predicted_winner_id").notNull(), // player id + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [ + index("user_prediction_userId_idx").on(table.userId), + uniqueIndex("user_prediction_userId_gameId_unique").on( + table.userId, + table.gameId, + ), + ], +); + +export const userBracketStatus = sqliteTable( + "user_bracket_status", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .unique() + .references(() => user.id, { onDelete: "cascade" }), + isLocked: integer("is_locked", { mode: "boolean" }) + .default(false) + .notNull(), + lockedAt: integer("locked_at", { mode: "timestamp_ms" }), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + }, + (table) => [index("user_bracket_status_userId_idx").on(table.userId)], +); + +export const userPredictionRelations = relations(userPrediction, ({ one }) => ({ + user: one(user, { + fields: [userPrediction.userId], + references: [user.id], + }), +})); + +export const userBracketStatusRelations = relations( + userBracketStatus, + ({ one }) => ({ + user: one(user, { + fields: [userBracketStatus.userId], + references: [user.id], + }), + }), +); + +// ============================================================================= +// USER SCORES (LEADERBOARD) +// ============================================================================= + +export const userScore = sqliteTable( + "user_score", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .unique() + .references(() => user.id, { onDelete: "cascade" }), + round1Score: integer("round1_score").default(0).notNull(), + round2Score: integer("round2_score").default(0).notNull(), + round3Score: integer("round3_score").default(0).notNull(), + round4Score: integer("round4_score").default(0).notNull(), + totalScore: integer("total_score").default(0).notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [ + index("user_score_userId_idx").on(table.userId), + index("user_score_totalScore_idx").on(table.totalScore), + ], +); + +export const userScoreRelations = relations(userScore, ({ one }) => ({ + user: one(user, { + fields: [userScore.userId], + references: [user.id], + }), +})); diff --git a/src/hooks/useCountdown.ts b/src/hooks/useCountdown.ts new file mode 100644 index 0000000..8665b77 --- /dev/null +++ b/src/hooks/useCountdown.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; + +export type CountdownTime = { + days: number; + hours: number; + minutes: number; + seconds: number; + totalMs: number; +}; + +function getTimeRemaining(deadline: string): CountdownTime { + const total = new Date(deadline).getTime() - Date.now(); + if (total <= 0) { + return { days: 0, hours: 0, minutes: 0, seconds: 0, totalMs: 0 }; + } + return { + days: Math.floor(total / (1000 * 60 * 60 * 24)), + hours: Math.floor((total / (1000 * 60 * 60)) % 24), + minutes: Math.floor((total / (1000 * 60)) % 60), + seconds: Math.floor((total / 1000) % 60), + totalMs: total, + }; +} + +export function useCountdown(deadline: string | undefined): CountdownTime { + const [time, setTime] = useState(() => + deadline + ? getTimeRemaining(deadline) + : { days: 0, hours: 0, minutes: 0, seconds: 0, totalMs: 0 }, + ); + + useEffect(() => { + if (!deadline) return; + setTime(getTimeRemaining(deadline)); + const interval = setInterval(() => { + setTime(getTimeRemaining(deadline)); + }, 1000); + return () => clearInterval(interval); + }, [deadline]); + + return time; +} diff --git a/src/hooks/usePredictions.test.ts b/src/hooks/usePredictions.test.ts new file mode 100644 index 0000000..8f118dd --- /dev/null +++ b/src/hooks/usePredictions.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { getPickablePlayersForGame } from "./usePredictions"; + +describe("getPickablePlayersForGame", () => { + describe("round 1 games", () => { + it("returns fixed players for r1-0", () => { + const predictions = {}; + const [p1, p2] = getPickablePlayersForGame("r1-0", predictions); + + expect(p1).toBe("jason-lengstorf"); + expect(p2).toBe("kyle-cook"); + }); + + it("returns fixed players regardless of predictions", () => { + const predictions = { "r1-0": "jason-lengstorf" }; + const [p1, p2] = getPickablePlayersForGame("r1-0", predictions); + + expect(p1).toBe("jason-lengstorf"); + expect(p2).toBe("kyle-cook"); + }); + }); + + describe("quarterfinal games", () => { + it("returns undefined when source games have no predictions", () => { + const predictions = {}; + const [p1, p2] = getPickablePlayersForGame("qf-0", predictions); + + expect(p1).toBeUndefined(); + expect(p2).toBeUndefined(); + }); + + it("returns player from r1-0 winner as p1", () => { + const predictions = { "r1-0": "jason-lengstorf" }; + const [p1, p2] = getPickablePlayersForGame("qf-0", predictions); + + expect(p1).toBe("jason-lengstorf"); + expect(p2).toBeUndefined(); + }); + + it("returns player from r1-1 winner as p2", () => { + const predictions = { "r1-1": "adam-wathan" }; + const [p1, p2] = getPickablePlayersForGame("qf-0", predictions); + + expect(p1).toBeUndefined(); + expect(p2).toBe("adam-wathan"); + }); + + it("returns both players when both source games have predictions", () => { + const predictions = { + "r1-0": "jason-lengstorf", + "r1-1": "adam-wathan", + }; + const [p1, p2] = getPickablePlayersForGame("qf-0", predictions); + + expect(p1).toBe("jason-lengstorf"); + expect(p2).toBe("adam-wathan"); + }); + }); + + describe("semifinal games", () => { + it("returns winners from quarterfinals", () => { + const predictions = { + "r1-0": "jason-lengstorf", + "r1-1": "adam-wathan", + "r1-2": "chris-coyier", + "r1-3": "scott-tolinski", + "qf-0": "jason-lengstorf", + "qf-1": "chris-coyier", + }; + const [p1, p2] = getPickablePlayersForGame("sf-0", predictions); + + expect(p1).toBe("jason-lengstorf"); + expect(p2).toBe("chris-coyier"); + }); + }); + + describe("final game", () => { + it("returns winners from semifinals", () => { + const predictions = { + "sf-0": "jason-lengstorf", + "sf-1": "kevin-powell", + }; + const [p1, p2] = getPickablePlayersForGame("final", predictions); + + expect(p1).toBe("jason-lengstorf"); + expect(p2).toBe("kevin-powell"); + }); + }); + + describe("invalid game IDs", () => { + it("returns undefined for unknown game ID", () => { + const predictions = {}; + const [p1, p2] = getPickablePlayersForGame("invalid-game", predictions); + + expect(p1).toBeUndefined(); + expect(p2).toBeUndefined(); + }); + }); +}); diff --git a/src/hooks/usePredictions.ts b/src/hooks/usePredictions.ts new file mode 100644 index 0000000..90875c4 --- /dev/null +++ b/src/hooks/usePredictions.ts @@ -0,0 +1,275 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { BRACKET_DEADLINE, bracket, TOTAL_GAMES } from "@/data/players"; +import { + useLockBracketMutation, + usePredictionsQuery, + useSavePredictionsMutation, +} from "./usePredictionsQuery"; + +export type Prediction = { + gameId: string; + predictedWinnerId: string; +}; + +export type PredictionsState = { + predictions: Record; // gameId -> playerId + isLocked: boolean; + lockedAt: string | null; + isLoading: boolean; + isSaving: boolean; + error: string | null; + pickCount: number; + deadline: string; + isDeadlinePassed: boolean; +}; + +// Maps game IDs to the game they feed into and which player slot +// e.g., r1-0 winner goes to qf-0 as player1 +const GAME_ADVANCEMENT_MAP: Record< + string, + { nextGameId: string; slot: "player1" | "player2" } +> = { + // Round 1 -> Quarterfinals + "r1-0": { nextGameId: "qf-0", slot: "player1" }, + "r1-1": { nextGameId: "qf-0", slot: "player2" }, + "r1-2": { nextGameId: "qf-1", slot: "player1" }, + "r1-3": { nextGameId: "qf-1", slot: "player2" }, + "r1-4": { nextGameId: "qf-2", slot: "player1" }, + "r1-5": { nextGameId: "qf-2", slot: "player2" }, + "r1-6": { nextGameId: "qf-3", slot: "player1" }, + "r1-7": { nextGameId: "qf-3", slot: "player2" }, + // Quarterfinals -> Semifinals + "qf-0": { nextGameId: "sf-0", slot: "player1" }, + "qf-1": { nextGameId: "sf-0", slot: "player2" }, + "qf-2": { nextGameId: "sf-1", slot: "player1" }, + "qf-3": { nextGameId: "sf-1", slot: "player2" }, + // Semifinals -> Finals + "sf-0": { nextGameId: "final", slot: "player1" }, + "sf-1": { nextGameId: "final", slot: "player2" }, +}; + +// Reverse map: for a given game, what are the source games for each player slot? +const GAME_SOURCE_MAP: Record< + string, + { player1Source?: string; player2Source?: string } +> = { + "qf-0": { player1Source: "r1-0", player2Source: "r1-1" }, + "qf-1": { player1Source: "r1-2", player2Source: "r1-3" }, + "qf-2": { player1Source: "r1-4", player2Source: "r1-5" }, + "qf-3": { player1Source: "r1-6", player2Source: "r1-7" }, + "sf-0": { player1Source: "qf-0", player2Source: "qf-1" }, + "sf-1": { player1Source: "qf-2", player2Source: "qf-3" }, + final: { player1Source: "sf-0", player2Source: "sf-1" }, +}; + +// Get the two players who can be picked for a given game +// Returns [player1Id, player2Id] where each slot preserves its position +// (undefined if the source game has no prediction yet) +export function getPickablePlayersForGame( + gameId: string, + predictions: Record, +): [string | undefined, string | undefined] { + // Round 1 games have fixed players based on the bracket structure + if (gameId.startsWith("r1-")) { + const gameIndex = Number.parseInt(gameId.split("-")[1], 10); + const game = bracket.round1[gameIndex]; + return [game?.player1?.id, game?.player2?.id]; + } + + // Later rounds: get winners from source games + // Preserve slot positions - player1 always comes from player1Source, etc. + const sources = GAME_SOURCE_MAP[gameId]; + if (!sources) return [undefined, undefined]; + + const player1 = sources.player1Source + ? predictions[sources.player1Source] + : undefined; + const player2 = sources.player2Source + ? predictions[sources.player2Source] + : undefined; + + return [player1, player2]; +} + +// Get games that need to be cleared when changing a pick +function getGamesToClear( + gameId: string, + oldPlayerId: string, + currentPredictions: Record, +): string[] { + const gamesToClear: string[] = []; + const advancement = GAME_ADVANCEMENT_MAP[gameId]; + + if (!advancement || !oldPlayerId) return gamesToClear; + + const { nextGameId } = advancement; + const currentPick = currentPredictions[nextGameId]; + + // If the old player was picked in the next game, we need to clear it + if (currentPick === oldPlayerId) { + gamesToClear.push(nextGameId); + // Recursively clear further games + gamesToClear.push( + ...getGamesToClear(nextGameId, oldPlayerId, currentPredictions), + ); + } + + return gamesToClear; +} + +export type UsePredictionsReturn = ReturnType; + +export function usePredictions(isAuthenticated: boolean, userId?: string) { + // TanStack Query for fetching predictions + const { + data: queryData, + isLoading: queryIsLoading, + error: queryError, + } = usePredictionsQuery(isAuthenticated ? userId : undefined); + + // Mutations + const saveMutation = useSavePredictionsMutation(userId); + const lockMutation = useLockBracketMutation(userId); + + // Local state for optimistic updates while picking + const [localPredictions, setLocalPredictions] = useState | null>(null); + + // Track if local state differs from server state + const hasChanges = useMemo(() => { + if (!localPredictions || !queryData) return false; + const serverKeys = Object.keys(queryData.predictions); + const localKeys = Object.keys(localPredictions); + if (serverKeys.length !== localKeys.length) return true; + return localKeys.some( + (key) => localPredictions[key] !== queryData.predictions[key], + ); + }, [localPredictions, queryData]); + + // Sync local state when query data changes (initial load or after mutation) + useEffect(() => { + if (queryData && !localPredictions) { + setLocalPredictions(queryData.predictions); + } + }, [queryData, localPredictions]); + + // Reset local state when user logs out + useEffect(() => { + if (!isAuthenticated) { + setLocalPredictions(null); + } + }, [isAuthenticated]); + + // The actual predictions to use (local if available, otherwise from query) + const predictions = localPredictions ?? queryData?.predictions ?? {}; + const isLocked = queryData?.isLocked ?? false; + const lockedAt = queryData?.lockedAt ?? null; + + // Calculate deadline status + const isDeadlinePassed = new Date() > new Date(BRACKET_DEADLINE); + + // Determine loading state + const isLoading = isAuthenticated && queryIsLoading; + + // Determine saving state + const isSaving = saveMutation.isPending || lockMutation.isPending; + + // Determine error state + const error = + queryError?.message || + saveMutation.error?.message || + lockMutation.error?.message || + null; + + // Set a prediction with cascading logic + const setPrediction = useCallback( + (gameId: string, playerId: string) => { + if (isLocked || isDeadlinePassed) return; + + setLocalPredictions((prev) => { + const current = prev ?? queryData?.predictions ?? {}; + const newPredictions = { ...current }; + const oldPlayerId = current[gameId]; + + // If changing pick, clear any cascaded picks of the old player + if (oldPlayerId && oldPlayerId !== playerId) { + const gamesToClear = getGamesToClear(gameId, oldPlayerId, current); + for (const clearGameId of gamesToClear) { + delete newPredictions[clearGameId]; + } + } + + // Set the new prediction + newPredictions[gameId] = playerId; + + return newPredictions; + }); + }, + [isLocked, isDeadlinePassed, queryData?.predictions], + ); + + // Save predictions to the server + const savePredictions = useCallback(async () => { + if (isLocked || isDeadlinePassed || !isAuthenticated || !localPredictions) + return; + + await saveMutation.mutateAsync(localPredictions); + }, [ + localPredictions, + isLocked, + isDeadlinePassed, + isAuthenticated, + saveMutation, + ]); + + // Reset all predictions + const resetPredictions = useCallback(() => { + if (isLocked || isDeadlinePassed) return; + + setLocalPredictions({}); + }, [isLocked, isDeadlinePassed]); + + // Lock the bracket + const lockBracket = useCallback(async () => { + if (isLocked || isDeadlinePassed || !isAuthenticated) return; + + // First save any unsaved predictions + if (hasChanges && localPredictions) { + await saveMutation.mutateAsync(localPredictions); + } + + await lockMutation.mutateAsync(); + }, [ + isLocked, + isDeadlinePassed, + isAuthenticated, + hasChanges, + localPredictions, + saveMutation, + lockMutation, + ]); + + const pickCount = Object.keys(predictions).length; + + return { + predictions, + isLocked, + lockedAt, + isLoading, + isSaving, + error, + pickCount, + deadline: BRACKET_DEADLINE, + isDeadlinePassed, + hasChanges, + totalGames: TOTAL_GAMES, + setPrediction, + savePredictions, + lockBracket, + resetPredictions, + getPickablePlayersForGame: (gameId: string) => + getPickablePlayersForGame(gameId, predictions), + }; +} diff --git a/src/hooks/usePredictionsQuery.ts b/src/hooks/usePredictionsQuery.ts new file mode 100644 index 0000000..2ffbfb1 --- /dev/null +++ b/src/hooks/usePredictionsQuery.ts @@ -0,0 +1,118 @@ +import { + type QueryClient, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; + +export type PredictionsData = { + predictions: Record; + isLocked: boolean; + lockedAt: string | null; +}; + +async function fetchPredictions(): Promise { + const response = await fetch("/api/predictions/"); + if (!response.ok) { + throw new Error("Failed to fetch predictions"); + } + const data = await response.json(); + + // Convert array to record + const predictions: Record = {}; + for (const pred of data.predictions) { + predictions[pred.gameId] = pred.predictedWinnerId; + } + + return { + predictions, + isLocked: data.isLocked, + lockedAt: data.lockedAt, + }; +} + +async function savePredictionsApi( + predictions: Record, +): Promise { + const predictionsArray = Object.entries(predictions).map( + ([gameId, predictedWinnerId]) => ({ + gameId, + predictedWinnerId, + }), + ); + + const response = await fetch("/api/predictions/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ predictions: predictionsArray }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to save predictions"); + } +} + +async function lockBracketApi(): Promise<{ lockedAt: string }> { + const response = await fetch("/api/predictions/lock", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to lock bracket"); + } + + return response.json(); +} + +export function predictionsQueryKey(userId: string | undefined) { + return ["predictions", userId] as const; +} + +export function usePredictionsQuery(userId: string | undefined) { + return useQuery({ + queryKey: predictionsQueryKey(userId), + queryFn: fetchPredictions, + enabled: !!userId, + staleTime: (query) => { + // If locked, cache for 1 hour (only admin unlock invalidates) + // If unlocked, cache for 30 seconds + return query.state.data?.isLocked ? 1000 * 60 * 60 : 1000 * 30; + }, + }); +} + +export function useSavePredictionsMutation(userId: string | undefined) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: savePredictionsApi, + onSuccess: () => { + if (userId) { + queryClient.invalidateQueries({ + queryKey: predictionsQueryKey(userId), + }); + } + }, + }); +} + +export function useLockBracketMutation(userId: string | undefined) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: lockBracketApi, + onSuccess: () => { + if (userId) { + queryClient.invalidateQueries({ + queryKey: predictionsQueryKey(userId), + }); + } + }, + }); +} + +// Helper to invalidate all predictions (used by admin unlock) +export function invalidateAllPredictions(queryClient: QueryClient) { + queryClient.invalidateQueries({ queryKey: ["predictions"] }); +} diff --git a/src/lib/admin.ts b/src/lib/admin.ts new file mode 100644 index 0000000..36f3f0c --- /dev/null +++ b/src/lib/admin.ts @@ -0,0 +1,37 @@ +import { and, eq } from "drizzle-orm"; +import type { Database } from "@/db"; +import * as schema from "@/db/schema"; + +// GitHub User IDs (immutable, verified via `gh api users/{username}`) +// These NEVER change even if the user changes their GitHub username +export const ADMIN_GITHUB_IDS = [ + "176013", // wesbos + "669383", // stolinski + "14241866", // w3cj + "3760543", // sergical +] as const; + +/** + * SERVER-SIDE ONLY: Check if a user is an admin by querying their GitHub account ID + * This is the ONLY secure way to check admin status - never trust client-supplied data + */ +export async function isAdminUser( + db: Database, + userId: string, +): Promise { + const githubAccount = await db + .select({ accountId: schema.account.accountId }) + .from(schema.account) + .where( + and( + eq(schema.account.userId, userId), + eq(schema.account.providerId, "github"), + ), + ) + .get(); + + if (!githubAccount) return false; + return ADMIN_GITHUB_IDS.includes( + githubAccount.accountId as (typeof ADMIN_GITHUB_IDS)[number], + ); +} diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 384588e..4cd345d 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -3,3 +3,12 @@ import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient(); export const { signIn, signOut, useSession } = authClient; + +// Client-side admin check - FOR UI DISPLAY ONLY, NOT FOR SECURITY +// Actual authorization must always be done server-side via isAdminUser() +export const ADMIN_GITHUB_IDS = [ + "176013", // wesbos + "669383", // stolinski + "14241866", // w3cj + "3760543", // sergical +] as const; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 7b25cd4..c5f14ba 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -2,6 +2,7 @@ import type { D1Database } from "@cloudflare/workers-types"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { tanstackStartCookies } from "better-auth/tanstack-start"; +import { eq } from "drizzle-orm"; import { createDb } from "@/db"; import * as schema from "@/db/schema"; @@ -13,10 +14,55 @@ export function createAuth(d1: D1Database) { provider: "sqlite", schema, }), + trustedOrigins: [ + "http://localhost:3000", + process.env.BETTER_AUTH_URL, + ].filter(Boolean) as string[], socialProviders: { github: { clientId: process.env.GITHUB_CLIENT_ID || "", clientSecret: process.env.GITHUB_CLIENT_SECRET || "", + mapProfileToUser: (profile) => ({ + username: profile.login, + }), + }, + }, + user: { + additionalFields: { + username: { + type: "string", + required: false, + }, + }, + }, + databaseHooks: { + user: { + create: { + after: async (user) => { + // Backfill username from GitHub account if not set + if (!user.username) { + const accounts = await db + .select() + .from(schema.account) + .where(eq(schema.account.userId, user.id)); + const githubAccount = accounts.find( + (a) => a.providerId === "github", + ); + if (githubAccount?.accountId) { + // accountId is the GitHub user ID, we'll fetch the username via API + // For now, use the name as fallback + const username = + user.name?.toLowerCase().replace(/\s+/g, "") || null; + if (username) { + await db + .update(schema.user) + .set({ username }) + .where(eq(schema.user.id, user.id)); + } + } + } + }, + }, }, }, plugins: [tanstackStartCookies()], diff --git a/src/lib/middleware/admin.ts b/src/lib/middleware/admin.ts new file mode 100644 index 0000000..e3290b9 --- /dev/null +++ b/src/lib/middleware/admin.ts @@ -0,0 +1,48 @@ +import type { D1Database } from "@cloudflare/workers-types"; +import { createDb } from "@/db"; +import { isAdminUser } from "@/lib/admin"; +import { createAuth } from "@/lib/auth"; + +type AdminResult = + | { success: true; user: { id: string; name: string; email: string } } + | { success: false; response: Response }; + +/** + * Validates request authentication and admin status. + * Use in admin API handlers to reduce auth boilerplate. + */ +export async function requireAdmin( + request: Request, + d1: D1Database, +): Promise { + const auth = createAuth(d1); + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session?.user) { + return { + success: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + }; + } + + const db = createDb(d1); + const isAdmin = await isAdminUser(db, session.user.id); + + if (!isAdmin) { + return { + success: false, + response: new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }), + }; + } + + return { + success: true, + user: session.user, + }; +} diff --git a/src/lib/middleware/auth.ts b/src/lib/middleware/auth.ts new file mode 100644 index 0000000..6f250b5 --- /dev/null +++ b/src/lib/middleware/auth.ts @@ -0,0 +1,33 @@ +import type { D1Database } from "@cloudflare/workers-types"; +import { createAuth } from "@/lib/auth"; + +type AuthResult = + | { success: true; user: { id: string; name: string; email: string } } + | { success: false; response: Response }; + +/** + * Validates request authentication and returns user or error response. + * Use in API handlers to reduce auth boilerplate. + */ +export async function requireAuth( + request: Request, + db: D1Database, +): Promise { + const auth = createAuth(db); + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session?.user) { + return { + success: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + }; + } + + return { + success: true, + user: session.user, + }; +} diff --git a/src/lib/schemas/prediction.ts b/src/lib/schemas/prediction.ts new file mode 100644 index 0000000..dc09362 --- /dev/null +++ b/src/lib/schemas/prediction.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import { ALL_GAME_IDS, players } from "@/data/players"; + +// Valid player IDs from tournament roster +const VALID_PLAYER_IDS = players.map((p) => p.id); + +export const gameIdSchema = z.enum(ALL_GAME_IDS); + +export const playerIdSchema = z + .string() + .refine((id) => VALID_PLAYER_IDS.includes(id), { + message: "Invalid player ID", + }); + +export const predictionSchema = z.object({ + gameId: gameIdSchema, + predictedWinnerId: playerIdSchema, +}); + +export const predictionsArraySchema = z.array(predictionSchema); + +export type Prediction = z.infer; +export type PredictionsArray = z.infer; diff --git a/src/lib/scoring.test.ts b/src/lib/scoring.test.ts new file mode 100644 index 0000000..96124e4 --- /dev/null +++ b/src/lib/scoring.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { calculateScoresForUser } from "./scoring"; + +describe("calculateScoresForUser", () => { + it("returns zero scores when no predictions match", () => { + const predictions = [{ gameId: "r1-0", predictedWinnerId: "player-a" }]; + const results = [{ gameId: "r1-0", winnerId: "player-b" }]; + + const scores = calculateScoresForUser(predictions, results); + + expect(scores).toEqual({ + round1Score: 0, + round2Score: 0, + round3Score: 0, + round4Score: 0, + totalScore: 0, + }); + }); + + it("returns zero scores when no results exist", () => { + const predictions = [{ gameId: "r1-0", predictedWinnerId: "player-a" }]; + const results: Array<{ gameId: string; winnerId: string }> = []; + + const scores = calculateScoresForUser(predictions, results); + + expect(scores).toEqual({ + round1Score: 0, + round2Score: 0, + round3Score: 0, + round4Score: 0, + totalScore: 0, + }); + }); + + it("awards 10 points for correct round 1 pick", () => { + const predictions = [{ gameId: "r1-0", predictedWinnerId: "player-a" }]; + const results = [{ gameId: "r1-0", winnerId: "player-a" }]; + + const scores = calculateScoresForUser(predictions, results); + + expect(scores.round1Score).toBe(10); + expect(scores.totalScore).toBe(10); + }); + + it("awards 20 points for correct quarterfinal pick", () => { + const predictions = [{ gameId: "qf-0", predictedWinnerId: "player-a" }]; + const results = [{ gameId: "qf-0", winnerId: "player-a" }]; + + const scores = calculateScoresForUser(predictions, results); + + expect(scores.round2Score).toBe(20); + expect(scores.totalScore).toBe(20); + }); + + it("awards 40 points for correct semifinal pick", () => { + const predictions = [{ gameId: "sf-0", predictedWinnerId: "player-a" }]; + const results = [{ gameId: "sf-0", winnerId: "player-a" }]; + + const scores = calculateScoresForUser(predictions, results); + + expect(scores.round3Score).toBe(40); + expect(scores.totalScore).toBe(40); + }); + + it("awards 80 points for correct final pick", () => { + const predictions = [{ gameId: "final", predictedWinnerId: "player-a" }]; + const results = [{ gameId: "final", winnerId: "player-a" }]; + + const scores = calculateScoresForUser(predictions, results); + + expect(scores.round4Score).toBe(80); + expect(scores.totalScore).toBe(80); + }); + + it("calculates total score across all rounds", () => { + const predictions = [ + { gameId: "r1-0", predictedWinnerId: "player-a" }, + { gameId: "r1-1", predictedWinnerId: "player-b" }, + { gameId: "qf-0", predictedWinnerId: "player-a" }, + { gameId: "sf-0", predictedWinnerId: "player-a" }, + { gameId: "final", predictedWinnerId: "player-a" }, + ]; + const results = [ + { gameId: "r1-0", winnerId: "player-a" }, + { gameId: "r1-1", winnerId: "player-b" }, + { gameId: "qf-0", winnerId: "player-a" }, + { gameId: "sf-0", winnerId: "player-a" }, + { gameId: "final", winnerId: "player-a" }, + ]; + + const scores = calculateScoresForUser(predictions, results); + + expect(scores.round1Score).toBe(20); // 2 correct R1 picks + expect(scores.round2Score).toBe(20); // 1 correct QF pick + expect(scores.round3Score).toBe(40); // 1 correct SF pick + expect(scores.round4Score).toBe(80); // 1 correct final pick + expect(scores.totalScore).toBe(160); + }); + + it("ignores predictions for games without results", () => { + const predictions = [ + { gameId: "r1-0", predictedWinnerId: "player-a" }, + { gameId: "qf-0", predictedWinnerId: "player-a" }, + ]; + const results = [ + { gameId: "r1-0", winnerId: "player-a" }, + // qf-0 has no result yet + ]; + + const scores = calculateScoresForUser(predictions, results); + + expect(scores.round1Score).toBe(10); + expect(scores.round2Score).toBe(0); + expect(scores.totalScore).toBe(10); + }); +}); diff --git a/src/lib/scoring.ts b/src/lib/scoring.ts new file mode 100644 index 0000000..66128d7 --- /dev/null +++ b/src/lib/scoring.ts @@ -0,0 +1,151 @@ +import type { D1Database } from "@cloudflare/workers-types"; +import { eq, inArray } from "drizzle-orm"; +import { + FINAL_GAME_IDS, + getResultsFromBracket, + QUARTER_GAME_IDS, + ROUND_1_GAME_IDS, + SEMI_GAME_IDS, +} from "@/data/players"; +import { createDb } from "@/db"; +import * as schema from "@/db/schema"; + +// Points per correct pick in each round +const ROUND_1_POINTS = 10; +const QUARTER_POINTS = 20; +const SEMI_POINTS = 40; +const FINAL_POINTS = 80; + +type RoundScores = { + round1Score: number; + round2Score: number; + round3Score: number; + round4Score: number; + totalScore: number; +}; + +export function calculateScoresForUser( + predictions: Array<{ gameId: string; predictedWinnerId: string }>, + results: Array<{ gameId: string; winnerId: string }>, +): RoundScores { + const resultsMap = new Map(results.map((r) => [r.gameId, r.winnerId])); + + let round1Score = 0; + let round2Score = 0; + let round3Score = 0; + let round4Score = 0; + + for (const prediction of predictions) { + const actualWinner = resultsMap.get(prediction.gameId); + if (!actualWinner) continue; // Game not played yet + + const isCorrect = prediction.predictedWinnerId === actualWinner; + if (!isCorrect) continue; + + if (ROUND_1_GAME_IDS.includes(prediction.gameId)) { + round1Score += ROUND_1_POINTS; + } else if (QUARTER_GAME_IDS.includes(prediction.gameId)) { + round2Score += QUARTER_POINTS; + } else if (SEMI_GAME_IDS.includes(prediction.gameId)) { + round3Score += SEMI_POINTS; + } else if (FINAL_GAME_IDS.includes(prediction.gameId)) { + round4Score += FINAL_POINTS; + } + } + + return { + round1Score, + round2Score, + round3Score, + round4Score, + totalScore: round1Score + round2Score + round3Score + round4Score, + }; +} + +export async function recalculateAllUserScores(database: D1Database) { + const db = createDb(database); + + // Get results from players.ts (single source of truth) + const bracketResults = getResultsFromBracket(); + const results = bracketResults.map((r) => ({ + gameId: r.gameId, + winnerId: r.winnerId, + })); + + if (results.length === 0) { + return { updated: 0 }; + } + + // Get all users with locked brackets + const lockedUsers = await db + .select({ userId: schema.userBracketStatus.userId }) + .from(schema.userBracketStatus) + .where(eq(schema.userBracketStatus.isLocked, true)); + + if (lockedUsers.length === 0) { + return { updated: 0 }; + } + + const userIds = lockedUsers.map((u) => u.userId); + + // Get all predictions for locked users + const allPredictions = await db + .select({ + userId: schema.userPrediction.userId, + gameId: schema.userPrediction.gameId, + predictedWinnerId: schema.userPrediction.predictedWinnerId, + }) + .from(schema.userPrediction) + .where(inArray(schema.userPrediction.userId, userIds)); + + // Group predictions by user + const predictionsByUser = new Map< + string, + Array<{ gameId: string; predictedWinnerId: string }> + >(); + for (const p of allPredictions) { + const existing = predictionsByUser.get(p.userId) || []; + existing.push({ gameId: p.gameId, predictedWinnerId: p.predictedWinnerId }); + predictionsByUser.set(p.userId, existing); + } + + // Calculate and upsert scores for each user + let updated = 0; + for (const userId of userIds) { + const userPredictions = predictionsByUser.get(userId) || []; + const scores = calculateScoresForUser(userPredictions, results); + + // Check if score record exists + const existing = await db + .select() + .from(schema.userScore) + .where(eq(schema.userScore.userId, userId)) + .limit(1); + + if (existing.length > 0) { + await db + .update(schema.userScore) + .set({ + round1Score: scores.round1Score, + round2Score: scores.round2Score, + round3Score: scores.round3Score, + round4Score: scores.round4Score, + totalScore: scores.totalScore, + }) + .where(eq(schema.userScore.userId, userId)); + } else { + await db.insert(schema.userScore).values({ + id: crypto.randomUUID(), + userId, + round1Score: scores.round1Score, + round2Score: scores.round2Score, + round3Score: scores.round3Score, + round4Score: scores.round4Score, + totalScore: scores.totalScore, + }); + } + updated++; + } + + return { updated }; +} diff --git a/src/lib/simulation.ts b/src/lib/simulation.ts new file mode 100644 index 0000000..3dd2400 --- /dev/null +++ b/src/lib/simulation.ts @@ -0,0 +1,214 @@ +import { + bracket, + FINAL_GAME_IDS, + type Player, + players, + QUARTER_GAME_IDS, + ROUND_1_GAME_IDS, + SEMI_GAME_IDS, +} from "@/data/players"; + +// ============================================================================= +// SIMULATION STAGES +// ============================================================================= + +export type SimulationStage = + | "r1-left" + | "r1-right" + | "quarterfinals" + | "semifinals" + | "finals"; + +export const SIMULATION_STAGES: SimulationStage[] = [ + "r1-left", + "r1-right", + "quarterfinals", + "semifinals", + "finals", +]; + +export const STAGE_CONFIG: Record< + SimulationStage, + { label: string; gameIds: readonly string[] } +> = { + "r1-left": { + label: "Round 1 - Left", + gameIds: ROUND_1_GAME_IDS.slice(0, 4), + }, + "r1-right": { + label: "Round 1 - Right", + gameIds: ROUND_1_GAME_IDS.slice(4), + }, + quarterfinals: { + label: "Quarterfinals", + gameIds: QUARTER_GAME_IDS, + }, + semifinals: { + label: "Semifinals", + gameIds: SEMI_GAME_IDS, + }, + finals: { + label: "Finals", + gameIds: FINAL_GAME_IDS, + }, +}; + +// ============================================================================= +// ACTIVE ROUND CALCULATION +// ============================================================================= + +export type ActiveRound = + | "round1" + | "quarters" + | "semis" + | "finals" + | "complete"; + +export function getActiveRound(results: Record): ActiveRound { + const r1LeftComplete = ROUND_1_GAME_IDS.slice(0, 4).every((g) => results[g]); + const r1RightComplete = ROUND_1_GAME_IDS.slice(4).every((g) => results[g]); + const qfComplete = QUARTER_GAME_IDS.every((g) => results[g]); + const sfComplete = SEMI_GAME_IDS.every((g) => results[g]); + const finalsComplete = FINAL_GAME_IDS.every((g) => results[g]); + + if (!r1LeftComplete || !r1RightComplete) return "round1"; + if (!qfComplete) return "quarters"; + if (!sfComplete) return "semis"; + if (!finalsComplete) return "finals"; + return "complete"; +} + +export function getRoundForGame( + gameId: string, +): "round1" | "quarters" | "semis" | "finals" { + if (gameId.startsWith("r1-")) return "round1"; + if (gameId.startsWith("qf-")) return "quarters"; + if (gameId.startsWith("sf-")) return "semis"; + return "finals"; +} + +export function isGameInActiveRound( + gameId: string, + activeRound: ActiveRound, +): boolean { + if (activeRound === "complete") return false; + return getRoundForGame(gameId) === activeRound; +} + +// ============================================================================= +// PLAYER ADVANCEMENT LOGIC +// ============================================================================= + +export function getPlayersForGame( + gameId: string, + results: Record, +): [string | undefined, string | undefined] { + // Round 1 games have fixed players from bracket data + if (gameId.startsWith("r1-")) { + const game = bracket.round1.find((g) => g.id === gameId); + return [game?.player1?.id, game?.player2?.id]; + } + + // Quarterfinals: winners from R1 + if (gameId === "qf-0") { + return [results["r1-0"], results["r1-1"]]; + } + if (gameId === "qf-1") { + return [results["r1-2"], results["r1-3"]]; + } + if (gameId === "qf-2") { + return [results["r1-4"], results["r1-5"]]; + } + if (gameId === "qf-3") { + return [results["r1-6"], results["r1-7"]]; + } + + // Semifinals: winners from QF + if (gameId === "sf-0") { + return [results["qf-0"], results["qf-1"]]; + } + if (gameId === "sf-1") { + return [results["qf-2"], results["qf-3"]]; + } + + // Finals: winners from SF + if (gameId === "final") { + return [results["sf-0"], results["sf-1"]]; + } + + return [undefined, undefined]; +} + +export function getPlayerById(playerId: string): Player | undefined { + return players.find((p) => p.id === playerId); +} + +// ============================================================================= +// RANDOM SIMULATION +// ============================================================================= + +export function simulateStage( + stage: SimulationStage, + currentResults: Record, +): Record { + const newResults = { ...currentResults }; + const gameIds = STAGE_CONFIG[stage].gameIds; + + for (const gameId of gameIds) { + const [p1, p2] = getPlayersForGame(gameId, newResults); + if (p1 && p2) { + // Always pick player 1 (top player) for deterministic simulation + newResults[gameId] = p1; + } + } + + return newResults; +} + +export function getCurrentStage( + results: Record, +): SimulationStage | null { + // Find the first incomplete stage + for (const stage of SIMULATION_STAGES) { + const gameIds = STAGE_CONFIG[stage].gameIds; + const allComplete = gameIds.every((id) => results[id]); + if (!allComplete) { + return stage; + } + } + return null; +} + +export function getPreviousStage( + stage: SimulationStage, +): SimulationStage | null { + const idx = SIMULATION_STAGES.indexOf(stage); + return idx > 0 ? SIMULATION_STAGES[idx - 1] : null; +} + +export function getNextStage(stage: SimulationStage): SimulationStage | null { + const idx = SIMULATION_STAGES.indexOf(stage); + return idx < SIMULATION_STAGES.length - 1 ? SIMULATION_STAGES[idx + 1] : null; +} + +// Get all game IDs up to and including a stage +export function getGameIdsUpToStage(stage: SimulationStage): string[] { + const gameIds: string[] = []; + for (const s of SIMULATION_STAGES) { + gameIds.push(...STAGE_CONFIG[s].gameIds); + if (s === stage) break; + } + return gameIds; +} + +// Build complete results up to and including a stage (simulating all stages) +export function buildResultsUpToStage( + stage: SimulationStage, +): Record { + let results: Record = {}; + for (const s of SIMULATION_STAGES) { + results = simulateStage(s, results); + if (s === stage) break; + } + return results; +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 363d6fd..e117205 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -9,48 +9,225 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as TestRouteImport } from './routes/test' +import { Route as AdminRouteImport } from './routes/admin' import { Route as IndexRouteImport } from './routes/index' +import { Route as BracketUsernameRouteImport } from './routes/bracket/$username' +import { Route as ApiPredictionsIndexRouteImport } from './routes/api/predictions/index' +import { Route as ApiLeaderboardIndexRouteImport } from './routes/api/leaderboard/index' +import { Route as ApiPredictionsLockRouteImport } from './routes/api/predictions/lock' +import { Route as ApiOgUsernameRouteImport } from './routes/api/og.$username' +import { Route as ApiLeaderboardCalculateRouteImport } from './routes/api/leaderboard/calculate' +import { Route as ApiBracketUsernameRouteImport } from './routes/api/bracket/$username' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' +import { Route as ApiAdminUsersRouteImport } from './routes/api/admin/users' +import { Route as ApiAdminCheckRouteImport } from './routes/api/admin/check' +import { Route as ApiAdminBracketsUnlockRouteImport } from './routes/api/admin/brackets/unlock' +const TestRoute = TestRouteImport.update({ + id: '/test', + path: '/test', + getParentRoute: () => rootRouteImport, +} as any) +const AdminRoute = AdminRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) +const BracketUsernameRoute = BracketUsernameRouteImport.update({ + id: '/bracket/$username', + path: '/bracket/$username', + getParentRoute: () => rootRouteImport, +} as any) +const ApiPredictionsIndexRoute = ApiPredictionsIndexRouteImport.update({ + id: '/api/predictions/', + path: '/api/predictions/', + getParentRoute: () => rootRouteImport, +} as any) +const ApiLeaderboardIndexRoute = ApiLeaderboardIndexRouteImport.update({ + id: '/api/leaderboard/', + path: '/api/leaderboard/', + getParentRoute: () => rootRouteImport, +} as any) +const ApiPredictionsLockRoute = ApiPredictionsLockRouteImport.update({ + id: '/api/predictions/lock', + path: '/api/predictions/lock', + getParentRoute: () => rootRouteImport, +} as any) +const ApiOgUsernameRoute = ApiOgUsernameRouteImport.update({ + id: '/api/og/$username', + path: '/api/og/$username', + getParentRoute: () => rootRouteImport, +} as any) +const ApiLeaderboardCalculateRoute = ApiLeaderboardCalculateRouteImport.update({ + id: '/api/leaderboard/calculate', + path: '/api/leaderboard/calculate', + getParentRoute: () => rootRouteImport, +} as any) +const ApiBracketUsernameRoute = ApiBracketUsernameRouteImport.update({ + id: '/api/bracket/$username', + path: '/api/bracket/$username', + getParentRoute: () => rootRouteImport, +} as any) const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ id: '/api/auth/$', path: '/api/auth/$', getParentRoute: () => rootRouteImport, } as any) +const ApiAdminUsersRoute = ApiAdminUsersRouteImport.update({ + id: '/api/admin/users', + path: '/api/admin/users', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAdminCheckRoute = ApiAdminCheckRouteImport.update({ + id: '/api/admin/check', + path: '/api/admin/check', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAdminBracketsUnlockRoute = ApiAdminBracketsUnlockRouteImport.update({ + id: '/api/admin/brackets/unlock', + path: '/api/admin/brackets/unlock', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/admin': typeof AdminRoute + '/test': typeof TestRoute + '/bracket/$username': typeof BracketUsernameRoute + '/api/admin/check': typeof ApiAdminCheckRoute + '/api/admin/users': typeof ApiAdminUsersRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/api/bracket/$username': typeof ApiBracketUsernameRoute + '/api/leaderboard/calculate': typeof ApiLeaderboardCalculateRoute + '/api/og/$username': typeof ApiOgUsernameRoute + '/api/predictions/lock': typeof ApiPredictionsLockRoute + '/api/leaderboard/': typeof ApiLeaderboardIndexRoute + '/api/predictions/': typeof ApiPredictionsIndexRoute + '/api/admin/brackets/unlock': typeof ApiAdminBracketsUnlockRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/admin': typeof AdminRoute + '/test': typeof TestRoute + '/bracket/$username': typeof BracketUsernameRoute + '/api/admin/check': typeof ApiAdminCheckRoute + '/api/admin/users': typeof ApiAdminUsersRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/api/bracket/$username': typeof ApiBracketUsernameRoute + '/api/leaderboard/calculate': typeof ApiLeaderboardCalculateRoute + '/api/og/$username': typeof ApiOgUsernameRoute + '/api/predictions/lock': typeof ApiPredictionsLockRoute + '/api/leaderboard': typeof ApiLeaderboardIndexRoute + '/api/predictions': typeof ApiPredictionsIndexRoute + '/api/admin/brackets/unlock': typeof ApiAdminBracketsUnlockRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/admin': typeof AdminRoute + '/test': typeof TestRoute + '/bracket/$username': typeof BracketUsernameRoute + '/api/admin/check': typeof ApiAdminCheckRoute + '/api/admin/users': typeof ApiAdminUsersRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/api/bracket/$username': typeof ApiBracketUsernameRoute + '/api/leaderboard/calculate': typeof ApiLeaderboardCalculateRoute + '/api/og/$username': typeof ApiOgUsernameRoute + '/api/predictions/lock': typeof ApiPredictionsLockRoute + '/api/leaderboard/': typeof ApiLeaderboardIndexRoute + '/api/predictions/': typeof ApiPredictionsIndexRoute + '/api/admin/brackets/unlock': typeof ApiAdminBracketsUnlockRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/api/auth/$' + fullPaths: + | '/' + | '/admin' + | '/test' + | '/bracket/$username' + | '/api/admin/check' + | '/api/admin/users' + | '/api/auth/$' + | '/api/bracket/$username' + | '/api/leaderboard/calculate' + | '/api/og/$username' + | '/api/predictions/lock' + | '/api/leaderboard/' + | '/api/predictions/' + | '/api/admin/brackets/unlock' fileRoutesByTo: FileRoutesByTo - to: '/' | '/api/auth/$' - id: '__root__' | '/' | '/api/auth/$' + to: + | '/' + | '/admin' + | '/test' + | '/bracket/$username' + | '/api/admin/check' + | '/api/admin/users' + | '/api/auth/$' + | '/api/bracket/$username' + | '/api/leaderboard/calculate' + | '/api/og/$username' + | '/api/predictions/lock' + | '/api/leaderboard' + | '/api/predictions' + | '/api/admin/brackets/unlock' + id: + | '__root__' + | '/' + | '/admin' + | '/test' + | '/bracket/$username' + | '/api/admin/check' + | '/api/admin/users' + | '/api/auth/$' + | '/api/bracket/$username' + | '/api/leaderboard/calculate' + | '/api/og/$username' + | '/api/predictions/lock' + | '/api/leaderboard/' + | '/api/predictions/' + | '/api/admin/brackets/unlock' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + AdminRoute: typeof AdminRoute + TestRoute: typeof TestRoute + BracketUsernameRoute: typeof BracketUsernameRoute + ApiAdminCheckRoute: typeof ApiAdminCheckRoute + ApiAdminUsersRoute: typeof ApiAdminUsersRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute + ApiBracketUsernameRoute: typeof ApiBracketUsernameRoute + ApiLeaderboardCalculateRoute: typeof ApiLeaderboardCalculateRoute + ApiOgUsernameRoute: typeof ApiOgUsernameRoute + ApiPredictionsLockRoute: typeof ApiPredictionsLockRoute + ApiLeaderboardIndexRoute: typeof ApiLeaderboardIndexRoute + ApiPredictionsIndexRoute: typeof ApiPredictionsIndexRoute + ApiAdminBracketsUnlockRoute: typeof ApiAdminBracketsUnlockRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/test': { + id: '/test' + path: '/test' + fullPath: '/test' + preLoaderRoute: typeof TestRouteImport + parentRoute: typeof rootRouteImport + } + '/admin': { + id: '/admin' + path: '/admin' + fullPath: '/admin' + preLoaderRoute: typeof AdminRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -58,6 +235,55 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/bracket/$username': { + id: '/bracket/$username' + path: '/bracket/$username' + fullPath: '/bracket/$username' + preLoaderRoute: typeof BracketUsernameRouteImport + parentRoute: typeof rootRouteImport + } + '/api/predictions/': { + id: '/api/predictions/' + path: '/api/predictions' + fullPath: '/api/predictions/' + preLoaderRoute: typeof ApiPredictionsIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/api/leaderboard/': { + id: '/api/leaderboard/' + path: '/api/leaderboard' + fullPath: '/api/leaderboard/' + preLoaderRoute: typeof ApiLeaderboardIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/api/predictions/lock': { + id: '/api/predictions/lock' + path: '/api/predictions/lock' + fullPath: '/api/predictions/lock' + preLoaderRoute: typeof ApiPredictionsLockRouteImport + parentRoute: typeof rootRouteImport + } + '/api/og/$username': { + id: '/api/og/$username' + path: '/api/og/$username' + fullPath: '/api/og/$username' + preLoaderRoute: typeof ApiOgUsernameRouteImport + parentRoute: typeof rootRouteImport + } + '/api/leaderboard/calculate': { + id: '/api/leaderboard/calculate' + path: '/api/leaderboard/calculate' + fullPath: '/api/leaderboard/calculate' + preLoaderRoute: typeof ApiLeaderboardCalculateRouteImport + parentRoute: typeof rootRouteImport + } + '/api/bracket/$username': { + id: '/api/bracket/$username' + path: '/api/bracket/$username' + fullPath: '/api/bracket/$username' + preLoaderRoute: typeof ApiBracketUsernameRouteImport + parentRoute: typeof rootRouteImport + } '/api/auth/$': { id: '/api/auth/$' path: '/api/auth/$' @@ -65,12 +291,45 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAuthSplatRouteImport parentRoute: typeof rootRouteImport } + '/api/admin/users': { + id: '/api/admin/users' + path: '/api/admin/users' + fullPath: '/api/admin/users' + preLoaderRoute: typeof ApiAdminUsersRouteImport + parentRoute: typeof rootRouteImport + } + '/api/admin/check': { + id: '/api/admin/check' + path: '/api/admin/check' + fullPath: '/api/admin/check' + preLoaderRoute: typeof ApiAdminCheckRouteImport + parentRoute: typeof rootRouteImport + } + '/api/admin/brackets/unlock': { + id: '/api/admin/brackets/unlock' + path: '/api/admin/brackets/unlock' + fullPath: '/api/admin/brackets/unlock' + preLoaderRoute: typeof ApiAdminBracketsUnlockRouteImport + parentRoute: typeof rootRouteImport + } } } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + AdminRoute: AdminRoute, + TestRoute: TestRoute, + BracketUsernameRoute: BracketUsernameRoute, + ApiAdminCheckRoute: ApiAdminCheckRoute, + ApiAdminUsersRoute: ApiAdminUsersRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, + ApiBracketUsernameRoute: ApiBracketUsernameRoute, + ApiLeaderboardCalculateRoute: ApiLeaderboardCalculateRoute, + ApiOgUsernameRoute: ApiOgUsernameRoute, + ApiPredictionsLockRoute: ApiPredictionsLockRoute, + ApiLeaderboardIndexRoute: ApiLeaderboardIndexRoute, + ApiPredictionsIndexRoute: ApiPredictionsIndexRoute, + ApiAdminBracketsUnlockRoute: ApiAdminBracketsUnlockRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index e0dde17..b274245 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,4 +1,5 @@ import { TanStackDevtools } from "@tanstack/react-devtools"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRootRoute, HeadContent, @@ -6,10 +7,21 @@ import { Scripts, } from "@tanstack/react-router"; import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; +import { AdminButton } from "@/components/AdminButton"; import { Footer } from "@/components/footer/Footer"; import { Header } from "@/components/Header"; +import { NotFound } from "@/components/NotFound"; import appCss from "../styles/styles.css?url"; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 min default + retry: 2, + }, + }, +}); + export const Route = createRootRoute({ head: () => ({ meta: [ @@ -79,15 +91,6 @@ export const Route = createRootRoute({ notFoundComponent: NotFound, }); -function NotFound() { - return ( -
-

404 - Page Not Found

-

The page you're looking for doesn't exist.

-
- ); -} - function RootDocument() { return ( @@ -95,22 +98,25 @@ function RootDocument() { -
- -
- {process.env.NODE_ENV === "development" && ( - , - }, - ]} - /> - )} + +
+ +
+ + {process.env.NODE_ENV === "development" && ( + , + }, + ]} + /> + )} + diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx new file mode 100644 index 0000000..e1a5416 --- /dev/null +++ b/src/routes/admin.tsx @@ -0,0 +1,534 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { createFileRoute, Link, redirect } from "@tanstack/react-router"; +import { createServerFn } from "@tanstack/react-start"; +import { getRequestHeaders } from "@tanstack/react-start/server"; +import { useEffect, useState } from "react"; +import { z } from "zod"; +import { TOTAL_GAMES } from "@/data/players"; +import { invalidateAllPredictions } from "@/hooks/usePredictionsQuery"; +import type { AdminStats, AdminUser } from "@/routes/api/admin/users"; +import "@/styles/admin.css"; + +const PAGE_SIZE = 20; + +const adminDataInputSchema = z.object({ + page: z.number().int().positive().default(1), + search: z.string().max(100).default(""), +}); + +interface PaginationData { + page: number; + pageSize: number; + totalCount: number; + totalPages: number; +} + +// Server function to check admin status (for beforeLoad redirect) +const checkAdminFn = createServerFn({ method: "GET" }).handler(async () => { + const { env } = await import("cloudflare:workers"); + const { createAuth } = await import("@/lib/auth"); + const { createDb } = await import("@/db"); + const { isAdminUser } = await import("@/lib/admin"); + + const headers = getRequestHeaders(); + const auth = createAuth(env.DB); + const session = await auth.api.getSession({ + headers: new Headers(headers), + }); + + if (!session?.user) { + return { authorized: false as const }; + } + + const db = createDb(env.DB); + const isAdmin = await isAdminUser(db, session.user.id); + + if (!isAdmin) { + return { authorized: false as const }; + } + + return { authorized: true as const }; +}); + +// Server function to fetch paginated admin data +const getAdminDataFn = createServerFn({ method: "GET" }) + .inputValidator((data: unknown) => adminDataInputSchema.parse(data ?? {})) + .handler(async ({ data }) => { + const { env } = await import("cloudflare:workers"); + const { createAuth } = await import("@/lib/auth"); + const { createDb } = await import("@/db"); + const { isAdminUser } = await import("@/lib/admin"); + const { count, desc, eq, like, sql } = await import("drizzle-orm"); + const schema = await import("@/db/schema"); + + const page = data.page; + const searchRaw = data.search.trim(); + // Escape LIKE wildcards to prevent unintended pattern matching + const search = searchRaw.replace(/[%_]/g, "\\$&"); + + const headers = getRequestHeaders(); + const auth = createAuth(env.DB); + const session = await auth.api.getSession({ + headers: new Headers(headers), + }); + + if (!session?.user) { + return { authorized: false as const }; + } + + const db = createDb(env.DB); + const isAdmin = await isAdminUser(db, session.user.id); + + if (!isAdmin) { + return { authorized: false as const }; + } + + // Build base query with optional search filter + const baseQuery = search + ? db + .select({ + id: schema.user.id, + name: schema.user.name, + username: schema.user.username, + image: schema.user.image, + }) + .from(schema.user) + .where(like(schema.user.username, `%${search}%`)) + : db + .select({ + id: schema.user.id, + name: schema.user.name, + username: schema.user.username, + image: schema.user.image, + }) + .from(schema.user); + + // Get total count for pagination + const countQuery = search + ? db + .select({ count: count() }) + .from(schema.user) + .where(like(schema.user.username, `%${search}%`)) + : db.select({ count: count() }).from(schema.user); + + const [{ count: totalCount }] = await countQuery; + + // Fetch paginated users + const offset = (page - 1) * PAGE_SIZE; + const users = await baseQuery + .orderBy(desc(schema.user.createdAt)) + .limit(PAGE_SIZE) + .offset(offset); + + // Get bracket statuses for these users + const userIds = users.map((u) => u.id); + const bracketStatuses = + userIds.length > 0 + ? await db + .select() + .from(schema.userBracketStatus) + .where( + sql`${schema.userBracketStatus.userId} IN (${sql.join( + userIds.map((id) => sql`${id}`), + sql`, `, + )})`, + ) + : []; + + // Get prediction counts for these users + const predictionCounts = + userIds.length > 0 + ? await db + .select({ + userId: schema.userPrediction.userId, + count: count(), + }) + .from(schema.userPrediction) + .where( + sql`${schema.userPrediction.userId} IN (${sql.join( + userIds.map((id) => sql`${id}`), + sql`, `, + )})`, + ) + .groupBy(schema.userPrediction.userId) + : []; + + // Get scores for these users + const scores = + userIds.length > 0 + ? await db + .select() + .from(schema.userScore) + .where( + sql`${schema.userScore.userId} IN (${sql.join( + userIds.map((id) => sql`${id}`), + sql`, `, + )})`, + ) + : []; + + // Map data + const statusMap = new Map( + bracketStatuses.map((s) => [ + s.userId, + { isLocked: s.isLocked, lockedAt: s.lockedAt }, + ]), + ); + const predictionMap = new Map( + predictionCounts.map((p) => [p.userId, p.count]), + ); + const scoreMap = new Map(scores.map((s) => [s.userId, s.totalScore])); + + const adminUsers: AdminUser[] = users.map((user) => ({ + id: user.id, + name: user.name, + username: user.username, + image: user.image, + isLocked: statusMap.get(user.id)?.isLocked ?? false, + lockedAt: statusMap.get(user.id)?.lockedAt?.getTime() ?? null, + predictionsCount: predictionMap.get(user.id) ?? 0, + totalScore: scoreMap.get(user.id) ?? 0, + })); + + // Get global stats (not filtered by search) using COUNT queries + const [{ count: allUsersCount }] = await db + .select({ count: count() }) + .from(schema.user); + + const [{ count: lockedCount }] = await db + .select({ count: count() }) + .from(schema.userBracketStatus) + .where(eq(schema.userBracketStatus.isLocked, true)); + const stats: AdminStats = { + totalUsers: allUsersCount, + lockedBrackets: lockedCount, + unlockedBrackets: allUsersCount - lockedCount, + }; + + return { + authorized: true as const, + users: adminUsers, + stats, + pagination: { + page, + pageSize: PAGE_SIZE, + totalCount, + totalPages: Math.ceil(totalCount / PAGE_SIZE), + }, + }; + }); + +export const Route = createFileRoute("/admin")({ + beforeLoad: async () => { + const data = await checkAdminFn(); + if (!data.authorized) { + throw redirect({ to: "/" }); + } + return data; + }, + loader: async () => { + const result = await getAdminDataFn({ data: { page: 1, search: "" } }); + if (!result.authorized) { + throw redirect({ to: "/" }); + } + return result; + }, + component: AdminPage, +}); + +function StatCard({ label, value }: { label: string; value: number }) { + const digits = String(value).split(""); + return ( +
+

{label}

+
+ {digits.map((digit, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static digit positions + + {digit} + + ))} +
+
+ ); +} + +function AdminPage() { + const loaderData = Route.useLoaderData(); + const queryClient = useQueryClient(); + + const [users, setUsers] = useState(loaderData.users); + const [stats, setStats] = useState(loaderData.stats); + const [pagination, setPagination] = useState( + loaderData.pagination, + ); + const [message, setMessage] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + const [isCalculating, setIsCalculating] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [searchInput, setSearchInput] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + const [unlockingUserId, setUnlockingUserId] = useState(null); + + // Fetch data function for subsequent requests (pagination, search) + const fetchData = async (page: number, search: string) => { + setIsLoading(true); + try { + const result = await getAdminDataFn({ data: { page, search } }); + if (result.authorized) { + setUsers(result.users); + setStats(result.stats); + setPagination(result.pagination); + } + } finally { + setIsLoading(false); + } + }; + + // Debounce search input and fetch results + // biome-ignore lint/correctness/useExhaustiveDependencies: fetchData is stable + useEffect(() => { + const timer = setTimeout(() => { + if (searchInput !== searchQuery) { + setSearchQuery(searchInput); + fetchData(1, searchInput); + } + }, 300); + return () => clearTimeout(timer); + }, [searchInput, searchQuery]); + + const handlePageChange = (newPage: number) => { + fetchData(newPage, searchQuery); + }; + + const handleRecalculateScores = async () => { + setIsCalculating(true); + setMessage(null); + + try { + const response = await fetch("/api/leaderboard/calculate", { + method: "POST", + }); + + const data = (await response.json()) as { + updated?: number; + error?: string; + }; + + if (response.ok) { + setMessage({ + type: "success", + text: `Recalculated scores for ${data.updated} users.`, + }); + // Refresh current page data + fetchData(pagination.page, searchQuery); + } else { + setMessage({ + type: "error", + text: data.error || "Failed to recalculate scores", + }); + } + } catch { + setMessage({ type: "error", text: "Network error while calculating" }); + } finally { + setIsCalculating(false); + } + }; + + const handleUnlockBracket = async (userId: string, userName: string) => { + if (!confirm(`Unlock bracket for ${userName}?`)) return; + + setUnlockingUserId(userId); + setMessage(null); + + try { + const response = await fetch("/api/admin/brackets/unlock", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId }), + }); + + const data = (await response.json()) as { + success?: boolean; + error?: string; + }; + + if (response.ok) { + setMessage({ + type: "success", + text: `Unlocked bracket for ${userName}`, + }); + // Invalidate predictions cache so unlocked user sees fresh data + invalidateAllPredictions(queryClient); + // Refresh current page data + fetchData(pagination.page, searchQuery); + } else { + setMessage({ + type: "error", + text: data.error || "Failed to unlock bracket", + }); + } + } catch { + setMessage({ type: "error", text: "Network error while unlocking" }); + } finally { + setUnlockingUserId(null); + } + }; + + return ( +
+
+

Admin Dashboard

+ + Back to Site + +
+ +
+ + + +
+ + {message && ( +
{message.text}
+ )} + +
+ +
+ +
+ setSearchInput(e.target.value)} + className="admin-search-input" + /> +
+ +
+ + + + + + + + + + + + + {users.length === 0 ? ( + + + + ) : ( + users.map((user) => ( + + + + + + + + + )) + )} + +
UserStatusPicksScoreLocked OnActions
+ {isLoading ? "Loading..." : "No users found"} +
+
+ {user.image ? ( + {user.name} + ) : ( +
+ )} +
+ {user.name} + {user.username && ( + + @{user.username} + + )} +
+
+
+ + {user.isLocked ? "Locked" : "Unlocked"} + + + {user.predictionsCount}/{TOTAL_GAMES} + {user.totalScore} + {user.lockedAt + ? new Date(user.lockedAt).toLocaleDateString() + : "-"} + + {user.username && user.isLocked && ( + <> + + View + + + + )} +
+
+ + {pagination.totalPages > 1 && ( +
+ + + Page {pagination.page} of {pagination.totalPages} + + +
+ )} +
+ ); +} diff --git a/src/routes/api/admin/brackets/unlock.ts b/src/routes/api/admin/brackets/unlock.ts new file mode 100644 index 0000000..746f7b3 --- /dev/null +++ b/src/routes/api/admin/brackets/unlock.ts @@ -0,0 +1,72 @@ +import { env } from "cloudflare:workers"; +import { createFileRoute } from "@tanstack/react-router"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { createDb } from "@/db"; +import { userBracketStatus } from "@/db/schema"; +import { requireAdmin } from "@/lib/middleware/admin"; + +const unlockRequestSchema = z.object({ + userId: z.string().min(1).max(50), +}); + +export const Route = createFileRoute("/api/admin/brackets/unlock")({ + server: { + handlers: { + POST: async ({ request }) => { + const authResult = await requireAdmin(request, env.DB); + if (!authResult.success) return authResult.response; + + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response( + JSON.stringify({ error: "Invalid JSON in request body" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const validationResult = unlockRequestSchema.safeParse(body); + if (!validationResult.success) { + return new Response( + JSON.stringify({ error: "Invalid request: userId is required" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const { userId } = validationResult.data; + + const db = createDb(env.DB); + + // Update bracket status to unlocked + const updateResult = await db + .update(userBracketStatus) + .set({ isLocked: false, lockedAt: null }) + .where(eq(userBracketStatus.userId, userId)) + .returning(); + + if (updateResult.length === 0) { + return new Response( + JSON.stringify({ error: "Bracket status not found" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }, + }, + }, +}); diff --git a/src/routes/api/admin/check.ts b/src/routes/api/admin/check.ts new file mode 100644 index 0000000..dca64dd --- /dev/null +++ b/src/routes/api/admin/check.ts @@ -0,0 +1,31 @@ +import { env } from "cloudflare:workers"; +import { createFileRoute } from "@tanstack/react-router"; +import { createDb } from "@/db"; +import { isAdminUser } from "@/lib/admin"; +import { createAuth } from "@/lib/auth"; + +export const Route = createFileRoute("/api/admin/check")({ + server: { + handlers: { + GET: async ({ request }) => { + const auth = createAuth(env.DB); + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session?.user) { + return new Response(JSON.stringify({ isAdmin: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + const db = createDb(env.DB); + const isAdmin = await isAdminUser(db, session.user.id); + + return new Response(JSON.stringify({ isAdmin }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }, + }, + }, +}); diff --git a/src/routes/api/admin/users.ts b/src/routes/api/admin/users.ts new file mode 100644 index 0000000..aae4d65 --- /dev/null +++ b/src/routes/api/admin/users.ts @@ -0,0 +1,117 @@ +import { env } from "cloudflare:workers"; +import { createFileRoute } from "@tanstack/react-router"; +import { count, desc } from "drizzle-orm"; +import { createDb } from "@/db"; +import * as schema from "@/db/schema"; +import { isAdminUser } from "@/lib/admin"; +import { createAuth } from "@/lib/auth"; + +export type AdminUser = { + id: string; + name: string; + username: string | null; + image: string | null; + isLocked: boolean; + lockedAt: number | null; + predictionsCount: number; + totalScore: number; +}; + +export type AdminStats = { + totalUsers: number; + lockedBrackets: number; + unlockedBrackets: number; +}; + +export const Route = createFileRoute("/api/admin/users")({ + server: { + handlers: { + GET: async ({ request }) => { + const auth = createAuth(env.DB); + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session?.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const db = createDb(env.DB); + + // Server-side admin validation + const isAdmin = await isAdminUser(db, session.user.id); + if (!isAdmin) { + return new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + + // Fetch all users with their bracket status, prediction counts, and scores + const users = await db + .select({ + id: schema.user.id, + name: schema.user.name, + username: schema.user.username, + image: schema.user.image, + }) + .from(schema.user) + .orderBy(desc(schema.user.createdAt)); + + // Get bracket statuses + const bracketStatuses = await db + .select() + .from(schema.userBracketStatus); + + // Get prediction counts per user + const predictionCounts = await db + .select({ + userId: schema.userPrediction.userId, + count: count(), + }) + .from(schema.userPrediction) + .groupBy(schema.userPrediction.userId); + + // Get scores + const scores = await db.select().from(schema.userScore); + + // Map bracket statuses + const statusMap = new Map( + bracketStatuses.map((s) => [ + s.userId, + { isLocked: s.isLocked, lockedAt: s.lockedAt }, + ]), + ); + const predictionMap = new Map( + predictionCounts.map((p) => [p.userId, p.count]), + ); + const scoreMap = new Map(scores.map((s) => [s.userId, s.totalScore])); + + const adminUsers: AdminUser[] = users.map((user) => ({ + id: user.id, + name: user.name, + username: user.username, + image: user.image, + isLocked: statusMap.get(user.id)?.isLocked ?? false, + lockedAt: statusMap.get(user.id)?.lockedAt?.getTime() ?? null, + predictionsCount: predictionMap.get(user.id) ?? 0, + totalScore: scoreMap.get(user.id) ?? 0, + })); + + // Calculate stats + const lockedCount = bracketStatuses.filter((s) => s.isLocked).length; + const stats: AdminStats = { + totalUsers: users.length, + lockedBrackets: lockedCount, + unlockedBrackets: users.length - lockedCount, + }; + + return new Response(JSON.stringify({ users: adminUsers, stats }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }, + }, + }, +}); diff --git a/src/routes/api/bracket/$username.ts b/src/routes/api/bracket/$username.ts new file mode 100644 index 0000000..bab9834 --- /dev/null +++ b/src/routes/api/bracket/$username.ts @@ -0,0 +1,100 @@ +import { env } from "cloudflare:workers"; +import { createFileRoute } from "@tanstack/react-router"; +import { eq } from "drizzle-orm"; +import { createDb } from "@/db"; +import * as schema from "@/db/schema"; + +export type PublicBracketResponse = { + user: { + name: string; + image: string | null; + username: string; + }; + predictions: Array<{ + gameId: string; + predictedWinnerId: string; + }>; + isLocked: boolean; + lockedAt: number | null; +}; + +export const Route = createFileRoute("/api/bracket/$username")({ + server: { + handlers: { + GET: async ({ params }) => { + const { username } = params; + const db = createDb(env.DB); + + // Find user by username + const users = await db + .select({ + id: schema.user.id, + name: schema.user.name, + image: schema.user.image, + username: schema.user.username, + }) + .from(schema.user) + .where(eq(schema.user.username, username)) + .limit(1); + + if (users.length === 0 || !users[0].username) { + return new Response(JSON.stringify({ error: "User not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + const user = users[0]; + + // Check if bracket is locked + const bracketStatus = await db + .select() + .from(schema.userBracketStatus) + .where(eq(schema.userBracketStatus.userId, user.id)) + .limit(1); + + const isLocked = bracketStatus[0]?.isLocked ?? false; + const lockedAt = bracketStatus[0]?.lockedAt?.getTime() ?? null; + + // Only show predictions if bracket is locked + if (!isLocked) { + return new Response( + JSON.stringify({ error: "Bracket not yet locked" }), + { + status: 403, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + // Fetch predictions + const predictions = await db + .select({ + gameId: schema.userPrediction.gameId, + predictedWinnerId: schema.userPrediction.predictedWinnerId, + }) + .from(schema.userPrediction) + .where(eq(schema.userPrediction.userId, user.id)); + + const response: PublicBracketResponse = { + user: { + name: user.name, + image: user.image, + username: user.username, + }, + predictions, + isLocked, + lockedAt, + }; + + return new Response(JSON.stringify(response), { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=60", + }, + }); + }, + }, + }, +}); diff --git a/src/routes/api/leaderboard/calculate.ts b/src/routes/api/leaderboard/calculate.ts new file mode 100644 index 0000000..155f9ad --- /dev/null +++ b/src/routes/api/leaderboard/calculate.ts @@ -0,0 +1,42 @@ +import { env } from "cloudflare:workers"; +import { createFileRoute } from "@tanstack/react-router"; +import { createDb } from "@/db"; +import { isAdminUser } from "@/lib/admin"; +import { requireAuth } from "@/lib/middleware/auth"; +import { recalculateAllUserScores } from "@/lib/scoring"; + +export const Route = createFileRoute("/api/leaderboard/calculate")({ + server: { + handlers: { + POST: async ({ request }) => { + const authResult = await requireAuth(request, env.DB); + if (!authResult.success) return authResult.response; + + const db = createDb(env.DB); + const userId = authResult.user.id; + + // Server-side admin validation + const isAdmin = await isAdminUser(db, userId); + if (!isAdmin) { + return new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + + const result = await recalculateAllUserScores(env.DB); + + return new Response( + JSON.stringify({ + success: true, + updated: result.updated, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }, + }, + }, +}); diff --git a/src/routes/api/leaderboard/index.ts b/src/routes/api/leaderboard/index.ts new file mode 100644 index 0000000..c4dee60 --- /dev/null +++ b/src/routes/api/leaderboard/index.ts @@ -0,0 +1,63 @@ +import { env } from "cloudflare:workers"; +import { createFileRoute } from "@tanstack/react-router"; +import { desc, eq } from "drizzle-orm"; +import { createDb } from "@/db"; +import * as schema from "@/db/schema"; + +export type LeaderboardEntry = { + rank: number; + userId: string; + userName: string; + userImage: string | null; + username: string | null; + round1Score: number; + round2Score: number; + round3Score: number; + round4Score: number; + totalScore: number; +}; + +export const Route = createFileRoute("/api/leaderboard/")({ + server: { + handlers: { + GET: async () => { + const db = createDb(env.DB); + + const scores = await db + .select({ + userId: schema.userScore.userId, + round1Score: schema.userScore.round1Score, + round2Score: schema.userScore.round2Score, + round3Score: schema.userScore.round3Score, + round4Score: schema.userScore.round4Score, + totalScore: schema.userScore.totalScore, + userName: schema.user.name, + userImage: schema.user.image, + username: schema.user.username, + }) + .from(schema.userScore) + .innerJoin(schema.user, eq(schema.userScore.userId, schema.user.id)) + .orderBy(desc(schema.userScore.totalScore)) + .limit(100); + + const leaderboard: LeaderboardEntry[] = scores.map((score, index) => ({ + rank: index + 1, + userId: score.userId, + userName: score.userName, + userImage: score.userImage, + username: score.username, + round1Score: score.round1Score, + round2Score: score.round2Score, + round3Score: score.round3Score, + round4Score: score.round4Score, + totalScore: score.totalScore, + })); + + return new Response(JSON.stringify({ leaderboard }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }, + }, + }, +}); diff --git a/src/routes/api/og.$username.ts b/src/routes/api/og.$username.ts new file mode 100644 index 0000000..fbd8345 --- /dev/null +++ b/src/routes/api/og.$username.ts @@ -0,0 +1,550 @@ +import { env } from "cloudflare:workers"; +import { createFileRoute } from "@tanstack/react-router"; +import { eq } from "drizzle-orm"; +import { ImageResponse } from "workers-og"; +import { bracket, type Player, players } from "@/data/players"; +import { createDb } from "@/db"; +import * as schema from "@/db/schema"; + +// Generate a basic OG image for cases where user doesn't exist or bracket isn't locked +function generateBasicOgImage(baseUrl: string): Response { + const logoUrl = `${baseUrl}/mad-css-logo.png`; + const bgImageUrl = `${baseUrl}/madcss-wide.jpg`; + + const html = ` +
+ + + + +
+ + + + + + March Mad CSS + + + Fill out your bracket! +
`; + + const response = new ImageResponse(html, { + width: 1200, + height: 630, + }); + + response.headers.set("Cache-Control", "public, max-age=3600, s-maxage=86400"); + + return response; +} + +export const Route = createFileRoute("/api/og/$username")({ + server: { + handlers: { + GET: async ({ params, request }) => { + const { username } = params; + const url = new URL(request.url); + const baseUrl = `${url.protocol}//${url.host}`; + const db = createDb(env.DB); + + // Find user by username + const users = await db + .select({ + id: schema.user.id, + name: schema.user.name, + image: schema.user.image, + username: schema.user.username, + }) + .from(schema.user) + .where(eq(schema.user.username, username)) + .limit(1); + + if (users.length === 0 || !users[0].username) { + return generateBasicOgImage(baseUrl); + } + + const user = users[0]; + + // Check if bracket is locked + const bracketStatus = await db + .select() + .from(schema.userBracketStatus) + .where(eq(schema.userBracketStatus.userId, users[0].id)) + .limit(1); + + if (!bracketStatus[0]?.isLocked) { + return generateBasicOgImage(baseUrl); + } + + // Get ALL predictions + const predictions = await db + .select({ + gameId: schema.userPrediction.gameId, + predictedWinnerId: schema.userPrediction.predictedWinnerId, + }) + .from(schema.userPrediction) + .where(eq(schema.userPrediction.userId, users[0].id)); + + // Build prediction map + const predictionMap = new Map(); + for (const p of predictions) { + predictionMap.set(p.gameId, p.predictedWinnerId); + } + + // Helper to get player by id + const getPlayer = (id: string): Player | null => + players.find((p) => p.id === id) ?? null; + + // Get predicted winner for a game + const getWinner = (gameId: string): Player | null => { + const winnerId = predictionMap.get(gameId); + return winnerId ? getPlayer(winnerId) : null; + }; + + // Build absolute URLs + const logoUrl = `${baseUrl}/mad-css-logo.png`; + const bgImageUrl = `${baseUrl}/madcss-wide.jpg`; + const userAvatarUrl = user.image || ""; + + const getPhotoUrl = (player: Player | null): string => { + if (!player) return ""; + if (player.photo.startsWith("http")) return player.photo; + // Photos are stored as /avatars/name.png but actual files are in /avatars/color/name.png + const filename = player.photo.replace("/avatars/", ""); + return `${baseUrl}/avatars/color/${encodeURI(filename)}`; + }; + + // ============================================ + // LAYOUT CONSTANTS - Bigger avatars, full height + // ============================================ + + // Canvas: 1200 x 630 + // Avatars extend into logo/footer areas for maximum visibility + const CENTER_X = 600; + + // Vertical positions + const LOGO_Y = 90; // Logo center + const CHAMP_Y = 340; // Champion center + const USER_Y = 570; // User info center + + // R1 Y positions: expanded to use full height (Y: 50 → 582) + const r1Y = [50, 126, 202, 278, 354, 430, 506, 582]; + + // Avatar sizes (bigger for visibility) + const SIZE_R1 = 55; + const SIZE_QF = 65; + const SIZE_SF = 80; + const SIZE_FINAL = 90; + const SIZE_CHAMP = 130; + + // X positions - adjusted for bigger avatars + const X_R1_L = 50; + const X_QF_L = 150; + const X_SF_L = 270; + const X_FINAL_L = 400; + + const X_R1_R = 1150; + const X_QF_R = 1050; + const X_SF_R = 930; + const X_FINAL_R = 800; + + // Junction X positions for lines + const JUNC_R1_QF_L = 100; + const JUNC_QF_SF_L = 210; + const JUNC_SF_FINAL_L = 335; + + const JUNC_R1_QF_R = 1100; + const JUNC_QF_SF_R = 990; + const JUNC_SF_FINAL_R = 865; + + // ============================================ + // HELPER FUNCTIONS + // ============================================ + + const avatar = ( + player: Player | null, + x: number, + y: number, + size: number, + options?: { + border?: number; + grayscale?: boolean; + showName?: boolean; + borderColor?: string; + backgroundColor?: string; + }, + ) => { + const border = options?.border ?? 3; + const grayscale = options?.grayscale ?? false; + const showName = options?.showName ?? false; + const borderColor = options?.borderColor ?? "#ffae00"; + const backgroundColor = options?.backgroundColor ?? "#ffae00"; + const filter = grayscale ? "filter: grayscale(100%);" : ""; + + // Background circle with colored border + const bgLeft = x - size / 2; + const bgTop = y - size / 2; + let html = `
`; + + // Image is taller and positioned higher so head pops out top + const popOut = Math.round(size * 0.15); // head pops out ~15% of size + const imgHeight = size + popOut; + const imgLeft = x - size / 2; + const imgTop = y - size / 2 - popOut; // shift up so head pops out + + if (!player) { + html += `
`; + } else { + // Satori requires width/height as HTML attributes, not just CSS + html += ``; + } + + if (showName && player) { + const nameY = bgTop + size + 4; + const name = player.name.split(" ")[0]; // First name only + html += `${name}`; + } + + return html; + }; + + const hLine = (x1: number, x2: number, y: number) => + `
`; + + const vLine = (x: number, y1: number, y2: number) => + `
`; + + // ============================================ + // GET BRACKET DATA + // ============================================ + + // Get R1 players from actual bracket structure (not players array) + // Left side: games 0-3, each has player1 and player2 + const r1Left: (Player | undefined)[] = []; + for (let i = 0; i < 4; i++) { + const game = bracket.round1[i]; + r1Left.push(game.player1, game.player2); + } + + // Right side: games 4-7, each has player1 and player2 + const r1Right: (Player | undefined)[] = []; + for (let i = 4; i < 8; i++) { + const game = bracket.round1[i]; + r1Right.push(game.player1, game.player2); + } + + // QF winners (results of R1 games) + const qfLeftPlayers = [0, 1, 2, 3].map((i) => getWinner(`r1-${i}`)); + const qfRightPlayers = [0, 1, 2, 3].map((i) => + getWinner(`r1-${i + 4}`), + ); + + // SF winners (results of QF games) + const sfLeftPlayers = [0, 1].map((i) => getWinner(`qf-${i}`)); + const sfRightPlayers = [0, 1].map((i) => getWinner(`qf-${i + 2}`)); + + // Finals players (results of SF games) + const finalLeft = getWinner("sf-0"); + const finalRight = getWinner("sf-1"); + + // Champion + const champion = getWinner("final"); + + // ============================================ + // CALCULATE Y POSITIONS FOR EACH ROUND + // ============================================ + + // QF Y positions (midpoint between R1 pairs) + const qfY = [0, 1, 2, 3].map((i) => (r1Y[i * 2] + r1Y[i * 2 + 1]) / 2); + + // SF Y positions (midpoint between QF pairs) + const sfY = [0, 1].map((i) => (qfY[i * 2] + qfY[i * 2 + 1]) / 2); + + // Finals Y position = Champion Y + const finalY = CHAMP_Y; + + // ============================================ + // BUILD BRACKET HTML + // ============================================ + + let bracketHtml = ""; + + // Side colors + const COLOR_LEFT = "#0f73ff"; // Blue + const COLOR_RIGHT = "#f3370e"; // Red + const BG_PICKED = "#ffae00"; // Yellow/orange for picked players + const BG_UNPICKED = "#666"; // Gray for unpicked players + + // --- LEFT SIDE --- + + // R1 avatars (gray background if not picked) + for (let i = 0; i < 8; i++) { + const matchIndex = Math.floor(i / 2); + const winner = getWinner(`r1-${matchIndex}`); + const player = r1Left[i]; + const isUnpicked = winner && player && winner.id !== player.id; + bracketHtml += avatar(player ?? null, X_R1_L, r1Y[i], SIZE_R1, { + grayscale: isUnpicked, + borderColor: COLOR_LEFT, + backgroundColor: isUnpicked ? BG_UNPICKED : BG_PICKED, + }); + } + + // R1 to QF lines + for (let i = 0; i < 4; i++) { + const y1 = r1Y[i * 2]; + const y2 = r1Y[i * 2 + 1]; + const midY = (y1 + y2) / 2; + // Horizontal from R1 to junction + bracketHtml += hLine(X_R1_L + SIZE_R1 / 2, JUNC_R1_QF_L, y1); + bracketHtml += hLine(X_R1_L + SIZE_R1 / 2, JUNC_R1_QF_L, y2); + // Vertical at junction + bracketHtml += vLine(JUNC_R1_QF_L, y1, y2); + // Horizontal from junction to QF + bracketHtml += hLine(JUNC_R1_QF_L, X_QF_L - SIZE_QF / 2, midY); + } + + // QF avatars (gray background if not picked for this QF game) + for (let i = 0; i < 4; i++) { + const qfGameIndex = Math.floor(i / 2); + const qfWinner = getWinner(`qf-${qfGameIndex}`); + const isUnpicked = + qfWinner && + qfLeftPlayers[i] && + qfLeftPlayers[i]?.id !== qfWinner.id; + bracketHtml += avatar(qfLeftPlayers[i], X_QF_L, qfY[i], SIZE_QF, { + grayscale: isUnpicked, + borderColor: COLOR_LEFT, + backgroundColor: isUnpicked ? BG_UNPICKED : BG_PICKED, + }); + } + + // QF to SF lines + for (let i = 0; i < 2; i++) { + const y1 = qfY[i * 2]; + const y2 = qfY[i * 2 + 1]; + const midY = (y1 + y2) / 2; + bracketHtml += hLine(X_QF_L + SIZE_QF / 2, JUNC_QF_SF_L, y1); + bracketHtml += hLine(X_QF_L + SIZE_QF / 2, JUNC_QF_SF_L, y2); + bracketHtml += vLine(JUNC_QF_SF_L, y1, y2); + bracketHtml += hLine(JUNC_QF_SF_L, X_SF_L - SIZE_SF / 2, midY); + } + + // SF avatars (with names, gray background if not picked for SF-0) + for (let i = 0; i < 2; i++) { + const sfWinner = getWinner("sf-0"); + const isUnpicked = + sfWinner && + sfLeftPlayers[i] && + sfLeftPlayers[i]?.id !== sfWinner.id; + bracketHtml += avatar(sfLeftPlayers[i], X_SF_L, sfY[i], SIZE_SF, { + showName: true, + grayscale: isUnpicked, + borderColor: COLOR_LEFT, + backgroundColor: isUnpicked ? BG_UNPICKED : BG_PICKED, + }); + } + + // SF to Finals lines + { + const y1 = sfY[0]; + const y2 = sfY[1]; + bracketHtml += hLine(X_SF_L + SIZE_SF / 2, JUNC_SF_FINAL_L, y1); + bracketHtml += hLine(X_SF_L + SIZE_SF / 2, JUNC_SF_FINAL_L, y2); + bracketHtml += vLine(JUNC_SF_FINAL_L, y1, y2); + bracketHtml += hLine( + JUNC_SF_FINAL_L, + X_FINAL_L - SIZE_FINAL / 2, + finalY, + ); + } + + // Finals avatar (left, with name, gray background if not picked as champion) + const finalLeftUnpicked = + champion && finalLeft && finalLeft.id !== champion.id; + bracketHtml += avatar(finalLeft, X_FINAL_L, finalY, SIZE_FINAL, { + showName: true, + grayscale: finalLeftUnpicked, + borderColor: COLOR_LEFT, + backgroundColor: finalLeftUnpicked ? BG_UNPICKED : BG_PICKED, + }); + + // Finals to Champion line (left) + bracketHtml += hLine( + X_FINAL_L + SIZE_FINAL / 2, + CENTER_X - SIZE_CHAMP / 2, + finalY, + ); + + // --- RIGHT SIDE --- + + // R1 avatars (gray background if not picked) + for (let i = 0; i < 8; i++) { + const matchIndex = Math.floor(i / 2) + 4; // r1-4 through r1-7 + const winner = getWinner(`r1-${matchIndex}`); + const player = r1Right[i]; + const isUnpicked = winner && player && winner.id !== player.id; + bracketHtml += avatar(player ?? null, X_R1_R, r1Y[i], SIZE_R1, { + grayscale: isUnpicked, + borderColor: COLOR_RIGHT, + backgroundColor: isUnpicked ? BG_UNPICKED : BG_PICKED, + }); + } + + // R1 to QF lines + for (let i = 0; i < 4; i++) { + const y1 = r1Y[i * 2]; + const y2 = r1Y[i * 2 + 1]; + const midY = (y1 + y2) / 2; + bracketHtml += hLine(X_R1_R - SIZE_R1 / 2, JUNC_R1_QF_R, y1); + bracketHtml += hLine(X_R1_R - SIZE_R1 / 2, JUNC_R1_QF_R, y2); + bracketHtml += vLine(JUNC_R1_QF_R, y1, y2); + bracketHtml += hLine(JUNC_R1_QF_R, X_QF_R + SIZE_QF / 2, midY); + } + + // QF avatars (gray background if not picked for this QF game) + for (let i = 0; i < 4; i++) { + const qfGameIndex = Math.floor(i / 2) + 2; // qf-2 and qf-3 for right side + const qfWinner = getWinner(`qf-${qfGameIndex}`); + const isUnpicked = + qfWinner && + qfRightPlayers[i] && + qfRightPlayers[i]?.id !== qfWinner.id; + bracketHtml += avatar(qfRightPlayers[i], X_QF_R, qfY[i], SIZE_QF, { + grayscale: isUnpicked, + borderColor: COLOR_RIGHT, + backgroundColor: isUnpicked ? BG_UNPICKED : BG_PICKED, + }); + } + + // QF to SF lines + for (let i = 0; i < 2; i++) { + const y1 = qfY[i * 2]; + const y2 = qfY[i * 2 + 1]; + const midY = (y1 + y2) / 2; + bracketHtml += hLine(X_QF_R - SIZE_QF / 2, JUNC_QF_SF_R, y1); + bracketHtml += hLine(X_QF_R - SIZE_QF / 2, JUNC_QF_SF_R, y2); + bracketHtml += vLine(JUNC_QF_SF_R, y1, y2); + bracketHtml += hLine(JUNC_QF_SF_R, X_SF_R + SIZE_SF / 2, midY); + } + + // SF avatars (with names, gray background if not picked for SF-1) + for (let i = 0; i < 2; i++) { + const sfWinner = getWinner("sf-1"); + const isUnpicked = + sfWinner && + sfRightPlayers[i] && + sfRightPlayers[i]?.id !== sfWinner.id; + bracketHtml += avatar(sfRightPlayers[i], X_SF_R, sfY[i], SIZE_SF, { + showName: true, + grayscale: isUnpicked, + borderColor: COLOR_RIGHT, + backgroundColor: isUnpicked ? BG_UNPICKED : BG_PICKED, + }); + } + + // SF to Finals lines + { + const y1 = sfY[0]; + const y2 = sfY[1]; + bracketHtml += hLine(X_SF_R - SIZE_SF / 2, JUNC_SF_FINAL_R, y1); + bracketHtml += hLine(X_SF_R - SIZE_SF / 2, JUNC_SF_FINAL_R, y2); + bracketHtml += vLine(JUNC_SF_FINAL_R, y1, y2); + bracketHtml += hLine( + JUNC_SF_FINAL_R, + X_FINAL_R + SIZE_FINAL / 2, + finalY, + ); + } + + // Finals avatar (right, with name, gray background if not picked as champion) + const finalRightUnpicked = + champion && finalRight && finalRight.id !== champion.id; + bracketHtml += avatar(finalRight, X_FINAL_R, finalY, SIZE_FINAL, { + showName: true, + grayscale: finalRightUnpicked, + borderColor: COLOR_RIGHT, + backgroundColor: finalRightUnpicked ? BG_UNPICKED : BG_PICKED, + }); + + // Finals to Champion line (right) + bracketHtml += hLine( + X_FINAL_R - SIZE_FINAL / 2, + CENTER_X + SIZE_CHAMP / 2, + finalY, + ); + + // --- CHAMPION (with yellow background, head pops out) --- + const champLeft = CENTER_X - SIZE_CHAMP / 2; + const champTop = CHAMP_Y - SIZE_CHAMP / 2; + + // Yellow background circle for champion + bracketHtml += `
`; + + // Champion image - taller with head popping out + const champPopOut = Math.round(SIZE_CHAMP * 0.15); + const champImgHeight = SIZE_CHAMP + champPopOut; + const champImgTop = champTop - champPopOut; + + if (champion) { + bracketHtml += ` + + `; + } else { + bracketHtml += ` +
+ `; + } + + // Champion name (24px font) + bracketHtml += ` + ${champion?.name ?? "Champion"} + `; + + // ============================================ + // FULL HTML + // ============================================ + + const html = ` +
+ + + + +
+ + + + + +
+ ${ + userAvatarUrl + ? `` + : `
+ ${user.username?.charAt(0).toUpperCase() || "?"} +
` + } + @${user.username}'s picks +
+ + + ${bracketHtml} +
`; + + const response = new ImageResponse(html, { + width: 1200, + height: 630, + }); + + response.headers.set( + "Cache-Control", + "public, max-age=3600, s-maxage=86400", + ); + + return response; + }, + }, + }, +}); diff --git a/src/routes/api/predictions/index.ts b/src/routes/api/predictions/index.ts new file mode 100644 index 0000000..e572150 --- /dev/null +++ b/src/routes/api/predictions/index.ts @@ -0,0 +1,145 @@ +import { env } from "cloudflare:workers"; +import { createFileRoute } from "@tanstack/react-router"; +import { eq } from "drizzle-orm"; +import { BRACKET_DEADLINE } from "@/data/players"; +import { createDb } from "@/db"; +import * as schema from "@/db/schema"; +import { requireAuth } from "@/lib/middleware/auth"; +import { predictionsArraySchema } from "@/lib/schemas/prediction"; + +export const Route = createFileRoute("/api/predictions/")({ + server: { + handlers: { + // GET: Fetch user's predictions and lock status + GET: async ({ request }) => { + const authResult = await requireAuth(request, env.DB); + if (!authResult.success) return authResult.response; + + const db = createDb(env.DB); + const userId = authResult.user.id; + + // Fetch predictions + const predictions = await db + .select({ + gameId: schema.userPrediction.gameId, + predictedWinnerId: schema.userPrediction.predictedWinnerId, + }) + .from(schema.userPrediction) + .where(eq(schema.userPrediction.userId, userId)); + + // Fetch bracket status + const bracketStatus = await db + .select() + .from(schema.userBracketStatus) + .where(eq(schema.userBracketStatus.userId, userId)) + .limit(1); + + const isLocked = bracketStatus[0]?.isLocked ?? false; + const lockedAt = bracketStatus[0]?.lockedAt ?? null; + + return new Response( + JSON.stringify({ + predictions, + isLocked, + lockedAt, + deadline: BRACKET_DEADLINE, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }, + + // POST: Save predictions (reject if locked or past deadline) + POST: async ({ request }) => { + const authResult = await requireAuth(request, env.DB); + if (!authResult.success) return authResult.response; + + const db = createDb(env.DB); + const userId = authResult.user.id; + + // Check if bracket is locked + const bracketStatus = await db + .select() + .from(schema.userBracketStatus) + .where(eq(schema.userBracketStatus.userId, userId)) + .limit(1); + + if (bracketStatus[0]?.isLocked) { + return new Response( + JSON.stringify({ error: "Bracket is already locked" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + // Check deadline + const now = new Date(); + if (now > new Date(BRACKET_DEADLINE)) { + return new Response( + JSON.stringify({ error: "Deadline has passed" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + // Parse and validate request body + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response( + JSON.stringify({ error: "Invalid JSON in request body" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const result = predictionsArraySchema.safeParse( + (body as { predictions?: unknown })?.predictions, + ); + + if (!result.success) { + return new Response( + JSON.stringify({ error: "Invalid predictions format" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const predictions = result.data; + + // Delete all existing predictions for this user, then insert new ones + await db + .delete(schema.userPrediction) + .where(eq(schema.userPrediction.userId, userId)); + + // Batch insert all predictions + if (predictions.length > 0) { + await db.insert(schema.userPrediction).values( + predictions.map((prediction) => ({ + id: crypto.randomUUID(), + userId, + gameId: prediction.gameId, + predictedWinnerId: prediction.predictedWinnerId, + })), + ); + } + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }, + }, + }, +}); diff --git a/src/routes/api/predictions/lock.ts b/src/routes/api/predictions/lock.ts new file mode 100644 index 0000000..d79e7fa --- /dev/null +++ b/src/routes/api/predictions/lock.ts @@ -0,0 +1,95 @@ +import { env } from "cloudflare:workers"; +import { createFileRoute } from "@tanstack/react-router"; +import { eq, sql } from "drizzle-orm"; +import { BRACKET_DEADLINE, TOTAL_GAMES } from "@/data/players"; +import { createDb } from "@/db"; +import * as schema from "@/db/schema"; +import { requireAuth } from "@/lib/middleware/auth"; + +export const Route = createFileRoute("/api/predictions/lock")({ + server: { + handlers: { + // POST: Lock bracket (requires all 15 picks, checks deadline) + POST: async ({ request }) => { + const authResult = await requireAuth(request, env.DB); + if (!authResult.success) return authResult.response; + + const db = createDb(env.DB); + const userId = authResult.user.id; + + // Check deadline first (stateless check) + const now = new Date(); + if (now > new Date(BRACKET_DEADLINE)) { + return new Response( + JSON.stringify({ error: "Deadline has passed" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + // Check if all 15 picks are made + const predictions = await db + .select() + .from(schema.userPrediction) + .where(eq(schema.userPrediction.userId, userId)); + + if (predictions.length < TOTAL_GAMES) { + return new Response( + JSON.stringify({ + error: `Need all ${TOTAL_GAMES} picks to lock bracket. You have ${predictions.length} picks.`, + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + // Lock the bracket atomically using upsert + const lockedAt = new Date(); + + const result = await db + .insert(schema.userBracketStatus) + .values({ + id: crypto.randomUUID(), + userId: userId, + isLocked: true, + lockedAt, + }) + .onConflictDoUpdate({ + target: schema.userBracketStatus.userId, + set: { + isLocked: true, + lockedAt, + }, + where: sql`${schema.userBracketStatus.isLocked} = false`, + }) + .returning(); + + // If no rows returned, bracket was already locked + if (result.length === 0) { + return new Response( + JSON.stringify({ error: "Bracket is already locked" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + return new Response( + JSON.stringify({ + success: true, + lockedAt: lockedAt.toISOString(), + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }, + }, + }, +}); diff --git a/src/routes/bracket/$username.tsx b/src/routes/bracket/$username.tsx new file mode 100644 index 0000000..849f6cb --- /dev/null +++ b/src/routes/bracket/$username.tsx @@ -0,0 +1,157 @@ +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { Bracket } from "@/components/bracket/Bracket"; +import { NotFound } from "@/components/NotFound"; +import "@/styles/share-bracket.css"; + +const usernameInputSchema = z.object({ + username: z + .string() + .min(1) + .max(39) + .regex(/^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/), +}); + +const getBracketData = createServerFn({ method: "GET" }) + .inputValidator((d: unknown) => usernameInputSchema.parse(d)) + .handler(async ({ data }) => { + const { username } = data; + const { env } = await import("cloudflare:workers"); + const { eq } = await import("drizzle-orm"); + const { createDb } = await import("@/db"); + const schema = await import("@/db/schema"); + + const db = createDb(env.DB); + + const users = await db + .select({ + id: schema.user.id, + name: schema.user.name, + image: schema.user.image, + username: schema.user.username, + }) + .from(schema.user) + .where(eq(schema.user.username, username)) + .limit(1); + + if (users.length === 0 || !users[0].username) { + return { found: false as const }; + } + + const user = users[0]; + + const bracketStatus = await db + .select() + .from(schema.userBracketStatus) + .where(eq(schema.userBracketStatus.userId, user.id)) + .limit(1); + + const isLocked = bracketStatus[0]?.isLocked ?? false; + + if (!isLocked) { + return { found: false as const }; + } + + const predictions = await db + .select({ + gameId: schema.userPrediction.gameId, + predictedWinnerId: schema.userPrediction.predictedWinnerId, + }) + .from(schema.userPrediction) + .where(eq(schema.userPrediction.userId, user.id)); + + return { + found: true as const, + user: { + name: user.name, + image: user.image, + username: user.username, + }, + predictions, + }; + }); + +export const Route = createFileRoute("/bracket/$username")({ + loader: async ({ params }) => { + const result = await getBracketData({ + data: { username: params.username }, + }); + if (!result.found) { + throw notFound(); + } + return result; + }, + notFoundComponent: () => ( + + ), + head: ({ params }) => { + const { username } = params; + const ogImageUrl = `/api/og/${username}`; + return { + meta: [ + { title: `${username}'s Bracket | March Mad CSS` }, + { + name: "description", + content: `Check out ${username}'s bracket picks for March Mad CSS!`, + }, + { + property: "og:title", + content: `${username}'s Bracket | March Mad CSS`, + }, + { + property: "og:description", + content: `Check out ${username}'s bracket picks!`, + }, + { property: "og:image", content: ogImageUrl }, + { property: "og:type", content: "website" }, + { name: "twitter:card", content: "summary_large_image" }, + { name: "twitter:title", content: `${username}'s Bracket` }, + { + name: "twitter:description", + content: `Check out ${username}'s bracket picks!`, + }, + { name: "twitter:image", content: ogImageUrl }, + ], + }; + }, + component: BracketPage, +}); + +function BracketPage() { + const data = Route.useLoaderData(); + + // Convert array predictions to record format + const predictions: Record = {}; + for (const p of data.predictions) { + predictions[p.gameId] = p.predictedWinnerId; + } + + return ( +
+
+
+
+ {data.user.image && ( + + )} +
+

{data.user.name}'s Bracket

+ + @{data.user.username} + +
+
+ + Make Your Own Picks + +
+
+ +
+ ); +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 0137660..3d19977 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,6 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; import { Bracket } from "@/components/bracket/Bracket"; -// import { LoginSection } from "@/components/LoginSection"; import { Roster } from "@/components/roster/Roster"; import { Rules } from "@/components/rules/Rules"; import { Ticket } from "@/components/Ticket"; @@ -18,14 +17,9 @@ function App() {

The Bracket

- {/* - - */}
- {/* */} - {/* */}
); } diff --git a/src/routes/test.tsx b/src/routes/test.tsx new file mode 100644 index 0000000..be87a65 --- /dev/null +++ b/src/routes/test.tsx @@ -0,0 +1,95 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { Bracket } from "@/components/bracket/Bracket"; +import { LoginSection } from "@/components/LoginSection"; +import { Leaderboard } from "@/components/leaderboard/Leaderboard"; +import { Roster } from "@/components/roster/Roster"; +import { Rules } from "@/components/rules/Rules"; +import { Ticket } from "@/components/Ticket"; +import { PredictionsProvider } from "@/context/PredictionsContext"; +import { getResultsFromBracket } from "@/data/players"; +import { authClient } from "@/lib/auth-client"; + +// Initialize tournament results from bracket data (single source of truth) +function getBracketResults(): Record { + const results: Record = {}; + for (const r of getResultsFromBracket()) { + results[r.gameId] = r.winnerId; + } + return results; +} + +export const Route = createFileRoute("/test")({ + component: TestPage, +}); + +function TestPage() { + const { data: session } = authClient.useSession(); + const isAuthenticated = !!session?.user; + const userId = session?.user?.id; + const [tournamentResults, setTournamentResults] = + useState>(getBracketResults); + const [showPicks, setShowPicks] = useState(true); + + // Listen for simulation overrides (temporary, memory-only) + useEffect(() => { + const handler = (e: Event) => { + const customEvent = e as CustomEvent<{ + results: Record | null; + }>; + if (customEvent.detail.results) { + // Simulation override + setTournamentResults(customEvent.detail.results); + } else { + // Reset to bracket data + setTournamentResults(getBracketResults()); + } + }; + window.addEventListener("tournament-results-changed", handler); + return () => + window.removeEventListener("tournament-results-changed", handler); + }, []); + + // Auto-scroll to bracket after fresh OAuth login (only once per session) + useEffect(() => { + const hasScrolled = sessionStorage.getItem("bracket-scrolled"); + if (isAuthenticated && !hasScrolled) { + sessionStorage.setItem("bracket-scrolled", "true"); + // Small delay to let bracket render first + setTimeout(() => { + document + .getElementById("bracket") + ?.scrollIntoView({ behavior: "smooth" }); + }, 100); + } + }, [isAuthenticated]); + + return ( +
+ +
+
+ +
+
+ +
+

The Bracket

+ setShowPicks(!showPicks)} + /> + +
+
+ + +
+ ); +} diff --git a/src/styles/admin-button.css b/src/styles/admin-button.css new file mode 100644 index 0000000..2b72b74 --- /dev/null +++ b/src/styles/admin-button.css @@ -0,0 +1,117 @@ +.admin-button-container { + position: fixed; + bottom: 1rem; + left: 1rem; + z-index: 1000; +} + +.admin-button { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--yellow); + border: 3px solid var(--black); + box-shadow: 4px 4px 0 var(--black); + color: var(--black); + font-family: var(--font-block); + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.05em; + text-decoration: none; + cursor: pointer; + transition: + transform 0.1s, + box-shadow 0.1s; +} + +.admin-button:hover { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--black); +} + +.admin-button:active { + transform: translate(4px, 4px); + box-shadow: none; +} + +.admin-button svg { + width: 1rem; + height: 1rem; +} + +.admin-popover { + position: absolute; + bottom: 100%; + left: 0; + margin-bottom: 0.5rem; + min-width: 200px; + background: var(--beige); + border: 3px solid var(--black); + box-shadow: 4px 4px 0 var(--black); + padding: 0.75rem; +} + +.admin-popover-link { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--yellow); + border: 2px solid var(--black); + color: var(--black); + font-family: var(--font-block); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + text-decoration: none; + transition: + transform 0.1s, + box-shadow 0.1s; + box-shadow: 2px 2px 0 var(--black); +} + +.admin-popover-link:hover { + transform: translate(1px, 1px); + box-shadow: 1px 1px 0 var(--black); +} + +.admin-popover-link svg { + width: 0.875rem; + height: 0.875rem; +} + +.admin-popover-divider { + height: 2px; + background: var(--black); + margin: 0.75rem 0; +} + +.admin-popover-section { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.admin-popover-label { + font-family: var(--font-block); + font-size: 0.625rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--black); +} + +.admin-popover-select { + padding: 0.5rem; + background: var(--white); + border: 2px solid var(--black); + font-family: var(--font-sans); + font-size: 0.75rem; + color: var(--black); + cursor: pointer; +} + +.admin-popover-select:focus { + outline: 2px solid var(--orange); + outline-offset: 1px; +} diff --git a/src/styles/admin.css b/src/styles/admin.css new file mode 100644 index 0000000..01f8db2 --- /dev/null +++ b/src/styles/admin.css @@ -0,0 +1,531 @@ +/* ============================================ + Admin Dashboard - Retro Tournament Style + ============================================ */ + +@font-face { + font-family: "DSEG7Classic"; + src: url("/fonts/DSEG7Classic-Bold.woff2") format("woff2"); + font-weight: bold; + font-style: normal; + font-display: swap; +} + +.admin-page { + padding: 3rem 2rem; + background: var(--beige); + mask-image: + url("./assets/paper/repeating-paper-top.png"), + url("./assets/paper/repeating-paper-bottom.png"), + linear-gradient(black, black); + mask-size: + auto 23px, + auto 40px, + 100% 100%; + mask-position: + center 0px, + center calc(100% + 15px), + center; + mask-repeat: repeat-x, repeat-x, no-repeat; + mask-composite: exclude; + mask-mode: alpha; +} + +/* ============================================ + Header + ============================================ */ + +.admin-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 4px solid var(--black); + flex-wrap: wrap; + gap: 1rem; +} + +.admin-header h1 { + font-family: var(--font-block); + font-size: 2rem; + color: var(--orange); + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0; +} + +/* ============================================ + Stats Cards - Retro Scoreboard Style + ============================================ */ + +.admin-stats { + display: flex; + gap: 1rem; + margin-bottom: 2rem; + flex-wrap: wrap; +} + +.stat-card { + flex: 1; + min-width: 140px; + padding: 1.25rem; + background: var(--yellow); + border: 4px solid var(--black); + text-align: center; + position: relative; +} + +.stat-card h3 { + font-family: var(--font-block); + font-size: 0.7rem; + color: var(--black); + margin: 0 0 1rem; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.stat-digits { + display: flex; + justify-content: center; + gap: 4px; +} + +.stat-digit { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 52px; + background: #0a0a0a; + border: 2px solid var(--black); + box-shadow: + inset 0 2px 6px rgba(0, 0, 0, 0.9), + inset 0 0 0 1px rgba(255, 255, 255, 0.05); + font-family: "DSEG7Classic", monospace; + font-size: 2.2rem; + font-weight: bold; + color: var(--orange); + text-shadow: + 0 0 10px rgba(243, 55, 14, 0.7), + 0 0 20px rgba(243, 55, 14, 0.4), + 0 0 30px rgba(243, 55, 14, 0.2); + line-height: 1; +} + +.stat-digit::before { + content: "8"; + position: absolute; + color: rgba(243, 55, 14, 0.1); + text-shadow: none; +} + +/* ============================================ + Actions & Search + ============================================ */ + +.admin-actions { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.admin-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 12px 20px; + background: var(--beige); + border: 3px solid var(--black); + box-shadow: 4px 4px 0 var(--black); + color: var(--black); + font-family: var(--font-block); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; + text-decoration: none; + cursor: pointer; + transition: + transform 0.1s, + box-shadow 0.1s; +} + +.admin-btn:hover:not(:disabled) { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--black); +} + +.admin-btn:active:not(:disabled) { + transform: translate(4px, 4px); + box-shadow: none; +} + +.admin-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + box-shadow: none; +} + +.admin-btn.primary { + background: var(--orange); + color: var(--white); +} + +.admin-search { + margin-bottom: 1.5rem; +} + +.admin-search-input { + width: 100%; + max-width: 400px; + padding: 12px 16px; + font-family: var(--font-block); + font-size: 0.85rem; + background: var(--white); + border: 3px solid var(--black); + color: var(--black); + outline: none; +} + +.admin-search-input::placeholder { + color: #888; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.admin-search-input:focus { + border-color: var(--orange); +} + +/* ============================================ + Table Container + ============================================ */ + +.admin-table-container { + background: var(--white); + border: 4px solid var(--black); + overflow: hidden; + margin-bottom: 1.5rem; +} + +.admin-table { + width: 100%; + border-collapse: collapse; +} + +.admin-table th, +.admin-table td { + padding: 1rem; + text-align: left; + border-bottom: 2px solid var(--black); + vertical-align: middle; + color: var(--black); +} + +.admin-table th { + background: var(--black); + font-family: var(--font-block); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--yellow); +} + +.admin-table tr:hover { + background: rgba(255, 174, 0, 0.1); +} + +.admin-table tr:last-child td { + border-bottom: none; +} + +/* Numeric columns - 7-segment style */ +.admin-table .numeric { + font-family: "DSEG7Classic", monospace; + font-size: 1rem; + color: var(--orange); +} + +/* ============================================ + User Cell + ============================================ */ + +.user-cell { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.user-avatar { + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + object-fit: cover; + border: 3px solid var(--orange); + background: var(--beige); + flex-shrink: 0; +} + +.user-info { + display: flex; + flex-direction: column; +} + +.user-name { + font-weight: 600; + color: var(--black); +} + +.user-username { + font-size: 0.85rem; + color: #666; +} + +/* ============================================ + Status Badges + ============================================ */ + +.status-badge { + display: inline-flex; + padding: 6px 12px; + font-family: var(--font-block); + font-size: 0.65rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.status-badge.locked { + background: var(--yellow); + color: var(--black); + border: 2px solid var(--black); +} + +.status-badge.unlocked { + background: transparent; + border: 2px solid var(--orange); + color: var(--orange); +} + +/* ============================================ + Action Buttons + ============================================ */ + +.actions-cell { + vertical-align: middle; +} + +.actions-cell > * { + display: inline-flex; + vertical-align: middle; + margin-right: 0.75rem; +} + +.actions-cell > *:last-child { + margin-right: 0; +} + +.view-bracket-link { + color: var(--black); + text-decoration: none; + font-family: var(--font-block); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 6px 12px; + background: var(--beige); + border: 2px solid var(--black); + transition: + transform 0.1s, + box-shadow 0.1s; + box-shadow: 2px 2px 0 var(--black); +} + +.view-bracket-link:hover { + transform: translate(1px, 1px); + box-shadow: 1px 1px 0 var(--black); +} + +.unlock-btn { + padding: 6px 12px; + background: transparent; + border: 2px solid var(--orange); + color: var(--orange); + font-family: var(--font-block); + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.05em; + cursor: pointer; + transition: + background 0.2s, + color 0.2s; +} + +.unlock-btn:hover:not(:disabled) { + background: var(--orange); + color: var(--white); +} + +.unlock-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ============================================ + Messages + ============================================ */ + +.admin-message { + padding: 1rem 1.25rem; + margin-bottom: 1.5rem; + font-family: var(--font-block); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.admin-message.success { + background: var(--yellow); + color: var(--black); + border: 3px solid var(--black); +} + +.admin-message.error { + background: var(--orange); + color: var(--white); + border: 3px solid var(--black); +} + +/* ============================================ + Loading & Empty States + ============================================ */ + +.loading-spinner { + display: flex; + align-items: center; + justify-content: center; + padding: 4rem; + font-family: var(--font-block); + color: var(--black); + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.admin-table.loading { + opacity: 0.6; + pointer-events: none; +} + +.no-results { + text-align: center; + padding: 2rem; + font-family: var(--font-block); + color: #888; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* ============================================ + Pagination + ============================================ */ + +.admin-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; +} + +.pagination-btn { + padding: 10px 16px; + background: var(--beige); + border: 3px solid var(--black); + box-shadow: 3px 3px 0 var(--black); + color: var(--black); + font-family: var(--font-block); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + cursor: pointer; + transition: + transform 0.1s, + box-shadow 0.1s; +} + +.pagination-btn:hover:not(:disabled) { + transform: translate(1px, 1px); + box-shadow: 2px 2px 0 var(--black); +} + +.pagination-btn:active:not(:disabled) { + transform: translate(3px, 3px); + box-shadow: none; +} + +.pagination-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + box-shadow: none; +} + +.pagination-info { + font-family: var(--font-block); + font-size: 0.75rem; + color: var(--black); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* ============================================ + Responsive + ============================================ */ + +@media (max-width: 768px) { + .admin-page { + padding: 1rem; + } + + .admin-container { + padding: 24px 16px 32px; + } + + .admin-header h1 { + font-size: 1.5rem; + } + + .stat-card .stat-value { + font-size: 2rem; + } + + .admin-table th, + .admin-table td { + padding: 0.75rem 0.5rem; + font-size: 0.85rem; + } + + /* Hide some columns on mobile */ + .admin-table th:nth-child(5), + .admin-table td:nth-child(5) { + display: none; + } +} + +@media (max-width: 480px) { + .admin-stats { + flex-direction: column; + } + + .stat-card { + min-width: 100%; + } + + .admin-search-input { + max-width: 100%; + } + + .pagination-btn { + padding: 8px 12px; + font-size: 0.7rem; + } +} diff --git a/src/styles/login.css b/src/styles/login.css index a95deb5..453797d 100644 --- a/src/styles/login.css +++ b/src/styles/login.css @@ -1,93 +1,907 @@ +/* ============================================ + BRACKET CTA - Tournament Control Panel + ============================================ */ + .bracket-cta { - display: flex; + max-width: 600px; + margin: 0 auto 24px; + padding: 40px 30px 50px; + background: linear-gradient( + 135deg, + rgba(0, 0, 0, 0.85) 0%, + rgba(20, 20, 20, 0.9) 100% + ); + border: 3px solid var(--yellow); + border-radius: 4px; + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.5), + 0 8px 32px rgba(0, 0, 0, 0.4), + inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.login-loading { + text-align: center; + opacity: 0.6; + padding: 20px; +} + +/* ============================================ + Logged Out State - Sign In CTA (Rules card style) + ============================================ */ + +.bracket-cta:not(.logged-in) { + text-align: center; + background: var(--beige); + border: none; + border-radius: 15px; + box-shadow: none; + mask-image: + url("./assets/paper/repeating-paper-top.png"), + url("./assets/paper/repeating-paper-bottom.png"), + linear-gradient(black, black); + mask-size: + auto 23px, + auto 40px, + 100% 100%; + mask-position: + center 0px, + center calc(100% + 15px), + center; + mask-repeat: repeat-x, repeat-x, no-repeat; + mask-composite: exclude; + mask-mode: alpha; +} + +.cta-headline { + font-family: var(--font-serif); + font-size: 2rem; + text-transform: none; + color: var(--orange); + margin-bottom: 8px; + letter-spacing: 0; + font-style: italic; +} + +.cta-sub { + font-size: 0.95rem; + line-height: 1.5; + color: var(--black); + margin-bottom: 20px; +} + +.cta-sub strong { + color: var(--orange); +} + +/* Scoreboard in logged-out state - centered */ +.bracket-cta:not(.logged-in) .scoreboard { + margin-bottom: 20px; +} + +.btn-github { + display: inline-flex; align-items: center; justify-content: center; - gap: 20px; - padding: 20px 30px; - margin: 0 auto 20px; - max-width: 700px; - background: rgba(0, 0, 0, 0.4); - border: 2px solid var(--yellow); - border-radius: 8px; + gap: 10px; + padding: 14px 28px; + background: var(--orange); + color: var(--white); + font-family: var(--font-block); + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.03em; + border: 3px solid var(--black); + border-radius: 0; + cursor: pointer; + transition: + transform 0.1s, + box-shadow 0.1s; + box-shadow: 4px 4px 0 var(--black); + will-change: transform; } +.btn-github:hover { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--black); +} + +.btn-github:active { + transform: translate(4px, 4px); + box-shadow: none; +} + +.github-icon { + width: 22px; + height: 22px; +} + +/* ============================================ + Logged In State - Control Panel (Same card style) + ============================================ */ + .bracket-cta.logged-in { - background: rgba(0, 0, 0, 0.3); - border-color: rgba(255, 255, 255, 0.2); + background: var(--beige); + border: none; + border-radius: 15px; + box-shadow: none; + mask-image: + url("./assets/paper/repeating-paper-top.png"), + url("./assets/paper/repeating-paper-bottom.png"), + linear-gradient(black, black); + mask-size: + auto 23px, + auto 40px, + 100% 100%; + mask-position: + center 0px, + center calc(100% + 15px), + center; + mask-repeat: repeat-x, repeat-x, no-repeat; + mask-composite: exclude; + mask-mode: alpha; } -.login-loading { - opacity: 0.5; +/* Header row: avatar + name + sign out */ +.cta-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 2px solid var(--black); +} + +.user-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + border: 3px solid var(--orange); + flex-shrink: 0; } -.cta-text { +.cta-welcome { flex: 1; + font-size: 1.1rem; + font-weight: 500; + margin: 0; + color: var(--black); } -.cta-headline { - font-size: 1.4rem; +.cta-welcome strong { + color: var(--orange); +} + +.btn-signout { + background: none; + border: none; + color: var(--black); + opacity: 0.5; + font-size: 0.8rem; + font-family: var(--font-block); text-transform: uppercase; - color: var(--yellow); - margin-bottom: 4px; + cursor: pointer; + padding: 4px 8px; } -.cta-sub { +.btn-signout:hover { + opacity: 1; +} + +/* Progress section */ +.cta-progress { + background: rgba(0, 0, 0, 0.08); + border-radius: 0; + padding: 16px; + margin-bottom: 16px; + border: 2px solid var(--black); +} + +.progress-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.progress-count { + font-family: var(--font-block); + font-size: 1.3rem; + color: var(--orange); + letter-spacing: 0.05em; +} + +.progress-count span { + color: var(--black); + opacity: 0.6; font-size: 0.9rem; - opacity: 0.9; - line-height: 1.4; } -.cta-welcome { +/* ============================================ + Retro Basketball Scoreboard + ============================================ */ + +@font-face { + font-family: "DSEG7Classic"; + src: url("/fonts/DSEG7Classic-Bold.woff2") format("woff2"); + font-weight: bold; + font-style: normal; + font-display: swap; +} + +.scoreboard { + --scoreboard-board: var(--yellow); + --scoreboard-digit-bg: #0a0a0a; + --scoreboard-digit-color: var(--orange); +} + +.scoreboard-frame { + position: relative; + background: var(--scoreboard-board); + border: 4px solid var(--black); + padding: 20px 24px 16px; +} + +/* Corner bolts */ +.scoreboard-rivet { + position: absolute; + width: 12px; + height: 12px; + background: radial-gradient(circle at 30% 30%, #666 0%, #333 50%, #111 100%); + border: 2px solid #000; + border-radius: 50%; + box-shadow: + inset 0 1px 2px rgba(255, 255, 255, 0.4), + 0 1px 2px rgba(0, 0, 0, 0.5); +} + +/* Bolt cross/slot detail */ +.scoreboard-rivet::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 8px; + height: 2px; + background: #111; + transform: translate(-50%, -50%); + box-shadow: 0 0 0 0.5px rgba(255, 255, 255, 0.1); +} + +.scoreboard-rivet--tl { + top: 8px; + left: 8px; +} + +.scoreboard-rivet--tr { + top: 8px; + right: 8px; +} + +.scoreboard-rivet--bl { + bottom: 8px; + left: 8px; +} + +.scoreboard-rivet--br { + bottom: 8px; + right: 8px; +} + +.scoreboard-display { + display: flex; + align-items: flex-start; + justify-content: center; + gap: 4px; +} + +.scoreboard-unit { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.scoreboard-digits { + display: flex; + gap: 3px; +} + +.scoreboard-digit { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 44px; + background: var(--scoreboard-digit-bg); + border: 2px solid var(--black); + box-shadow: + inset 0 2px 6px rgba(0, 0, 0, 0.9), + inset 0 0 0 1px rgba(255, 255, 255, 0.05); + font-family: "DSEG7Classic", monospace; + font-size: 1.9rem; + font-weight: bold; + color: var(--scoreboard-digit-color); + text-shadow: + 0 0 10px rgba(243, 55, 14, 0.7), + 0 0 20px rgba(243, 55, 14, 0.4), + 0 0 30px rgba(243, 55, 14, 0.2); + line-height: 1; +} + +/* Inactive segments - shows "8" outline */ +.scoreboard-digit::before { + content: "8"; + position: absolute; + color: rgba(243, 55, 14, 0.1); + text-shadow: none; +} + +.scoreboard-label { + font-family: var(--font-block); + font-size: 0.6rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--black); +} + +/* Separator dots */ +.scoreboard-separator { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + height: 44px; + padding: 0 6px; +} + +.separator-dot { + width: 6px; + height: 6px; + background: var(--black); + border-radius: 50%; +} + +/* Scanlines - hidden for paper style */ +.scoreboard-scanlines { + display: none; +} + +/* Urgent state (< 24 hours) */ +.scoreboard--urgent .scoreboard-digit { + animation: urgent-pulse 1s ease-in-out infinite; +} + +@keyframes urgent-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } +} + +/* Logged-in state: smaller scoreboard in progress header */ +.cta-progress .scoreboard { + transform: scale(0.75); + transform-origin: right center; + margin: -8px -8px -8px 0; +} + +.cta-progress .scoreboard-frame { + padding: 12px 16px 8px; +} + +.progress-bar { + height: 8px; + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + overflow: hidden; +} + +.progress-bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--orange) 0%, var(--yellow) 100%); + border-radius: 4px; + transition: width 0.4s ease; +} + +/* Status badges */ +.cta-status { + text-align: center; + padding: 12px; + border-radius: 0; + margin-bottom: 16px; + font-family: var(--font-block); + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.cta-status.locked { + background: transparent; + border: none; + color: var(--orange); font-size: 1rem; - margin-bottom: 2px; } -.btn-github { - display: inline-flex; +/* Next results countdown */ +.cta-next-results { + display: flex; + flex-direction: column; align-items: center; gap: 8px; - padding: 12px 20px; + margin-bottom: 16px; + padding: 12px; +} + +.next-results-label { + font-family: var(--font-block); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--black); +} + +/* Toggle button for showing picks vs results */ +.btn-toggle-picks { + display: block; + width: 100%; + padding: 12px 16px; + margin-bottom: 16px; + font-family: var(--font-block); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.04em; + background: var(--beige); + color: var(--black); + border: 3px solid var(--black); + border-radius: 0; + cursor: pointer; + text-align: center; + transition: + transform 0.1s, + box-shadow 0.1s; + box-shadow: 4px 4px 0 var(--black); +} + +.btn-toggle-picks:hover { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--black); +} + +.btn-toggle-picks:active { + transform: translate(4px, 4px); + box-shadow: none; +} + +.btn-toggle-picks.active { background: var(--yellow); color: var(--black); + border-color: var(--black); +} + +.cta-status.deadline-passed { + background: var(--orange); + border: 3px solid var(--black); + color: var(--white); +} + +/* Instructions */ +.cta-instructions { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + background: rgba(0, 0, 0, 0.05); + border: 2px solid var(--black); + border-radius: 0; + margin-bottom: 20px; + font-size: 0.85rem; + color: var(--black); +} + +.cta-instructions svg { + flex-shrink: 0; + color: var(--orange); +} + +/* Actions row */ +.cta-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +/* Button base styles - retro graphic style */ +.btn-save, +.btn-lock, +.btn-reset { + flex: 1; + min-width: 100px; + padding: 12px 16px; font-family: var(--font-block); - font-size: 0.9rem; + font-size: 0.8rem; text-transform: uppercase; - border: none; - border-radius: 5px; + letter-spacing: 0.04em; + border-radius: 0; cursor: pointer; + text-align: center; transition: - transform 0.15s ease, - background 0.15s ease; - white-space: nowrap; + transform 0.1s, + box-shadow 0.1s; } -.btn-github:hover { - background: #ffbe2e; - transform: translateY(-1px); +.btn-save { + background: var(--beige); + color: var(--black); + border: 3px solid var(--black); + box-shadow: 4px 4px 0 var(--black); } -.github-icon { - width: 20px; - height: 20px; +.btn-save:hover:not(:disabled) { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--black); } -.btn-link { - background: none; - border: none; +.btn-save:active:not(:disabled) { + transform: translate(4px, 4px); + box-shadow: none; +} + +.btn-save:disabled { + background: var(--beige); + color: rgba(0, 0, 0, 0.3); + border-color: rgba(0, 0, 0, 0.2); + box-shadow: none; + cursor: not-allowed; +} + +.btn-lock { + background: var(--orange); color: var(--white); - text-decoration: underline; + border: 3px solid var(--black); + box-shadow: 4px 4px 0 var(--black); +} + +.btn-lock:hover:not(:disabled) { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--black); +} + +.btn-lock:active:not(:disabled) { + transform: translate(4px, 4px); + box-shadow: none; +} + +.btn-lock:disabled { + background: rgba(0, 0, 0, 0.1); + color: rgba(0, 0, 0, 0.3); + border-color: rgba(0, 0, 0, 0.2); + box-shadow: none; + cursor: not-allowed; +} + +.btn-reset { + background: var(--beige); + color: var(--orange); + border: 3px solid var(--orange); + box-shadow: 4px 4px 0 var(--orange); + flex: 0 0 auto; + min-width: 80px; +} + +.btn-reset:hover:not(:disabled) { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--orange); +} + +.btn-reset:active:not(:disabled) { + transform: translate(4px, 4px); + box-shadow: none; +} + +.btn-reset:disabled { + background: var(--beige); + color: rgba(0, 0, 0, 0.3); + border-color: rgba(0, 0, 0, 0.2); + box-shadow: none; + cursor: not-allowed; +} + +/* Lock confirmation overlay */ +.lock-confirm { + background: rgba(0, 0, 0, 0.08); + padding: 16px; + border-radius: 0; + border: 3px solid var(--black); + text-align: center; + flex: 1 1 100%; +} + +.lock-confirm p { + font-size: 0.9rem; + margin-bottom: 14px; + color: var(--black); + font-family: var(--font-block); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.lock-confirm-buttons { + display: flex; + gap: 10px; + justify-content: center; +} + +.btn-lock-confirm { + padding: 10px 20px; + background: var(--yellow); + color: var(--black); + font-family: var(--font-block); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.03em; + border: 3px solid var(--black); + border-radius: 0; cursor: pointer; + transition: + transform 0.1s, + box-shadow 0.1s; + box-shadow: 4px 4px 0 var(--black); +} + +.btn-lock-confirm:hover:not(:disabled) { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--black); +} + +.btn-lock-confirm:active:not(:disabled) { + transform: translate(4px, 4px); + box-shadow: none; +} + +.btn-lock-confirm:disabled { + opacity: 0.4; + cursor: not-allowed; + box-shadow: none; +} + +.btn-cancel { + padding: 10px 20px; + background: var(--beige); + color: var(--black); + font-family: var(--font-block); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.03em; + border: 3px solid var(--black); + border-radius: 0; + cursor: pointer; + transition: + transform 0.1s, + box-shadow 0.1s; + box-shadow: 4px 4px 0 var(--black); +} + +.btn-cancel:hover { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--black); +} + +.btn-cancel:active { + transform: translate(4px, 4px); + box-shadow: none; +} + +/* Error message */ +.cta-error { + margin-top: 12px; + padding: 10px 14px; + background: var(--orange); + border: 3px solid var(--black); + border-radius: 0; + color: var(--white); font-size: 0.85rem; - opacity: 0.7; + text-align: center; + font-family: var(--font-block); + text-transform: uppercase; } -.btn-link:hover { - opacity: 1; +/* ============================================ + Share Section (when bracket is locked) + ============================================ */ + +.cta-share { + margin-top: 16px; } -.user-avatar { - width: 40px; - height: 40px; - border-radius: 50%; - border: 2px solid var(--yellow); +.cta-share-label { + font-family: var(--font-block); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--black); + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; + justify-content: center; +} + +.cta-share-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.btn-share { + flex: 1; + min-width: 120px; + padding: 12px 16px; + font-family: var(--font-block); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + border-radius: 0; + cursor: pointer; + text-align: center; + transition: + transform 0.1s, + box-shadow 0.1s; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + text-decoration: none; +} + +.btn-share--copy { + background: var(--beige); + color: var(--black); + border: 3px solid var(--black); + box-shadow: 4px 4px 0 var(--black); +} + +.btn-share--copy:hover { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--black); +} + +.btn-share--copy:active { + transform: translate(4px, 4px); + box-shadow: none; +} + +.btn-share--copy.copied { + background: var(--black); + color: var(--yellow); + border-color: var(--yellow); +} + +.btn-share--twitter { + background: var(--black); + color: var(--white); + border: 3px solid var(--yellow); + box-shadow: 4px 4px 0 var(--black); +} + +.btn-share--bluesky { + background: var(--bluesky); + color: var(--white); + border: 3px solid var(--black); + box-shadow: 4px 4px 0 var(--black); +} + +.btn-share--twitter:hover, +.btn-share--bluesky:hover { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--black); +} + +.btn-share--twitter:active, +.btn-share--bluesky:active { + transform: translate(4px, 4px); + box-shadow: none; +} + +.share-icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +/* ============================================ + Responsive + ============================================ */ + +@media (max-width: 600px) { + /* Scoreboard responsive - scale down */ + .scoreboard-digit { + width: 24px; + height: 36px; + font-size: 1.5rem; + } + + .scoreboard-separator { + height: 36px; + gap: 8px; + padding: 0 4px; + } + + .separator-dot { + width: 5px; + height: 5px; + } + + .scoreboard-frame { + padding: 16px 18px 12px; + } + + .scoreboard-label { + font-size: 0.5rem; + } + + .scoreboard-rivet { + width: 10px; + height: 10px; + } + + /* Progress header layout stacks on mobile */ + .progress-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .cta-progress .scoreboard { + transform: none; + margin: 0; + width: 100%; + } + + .cta-progress .scoreboard-display { + justify-content: space-between; + } +} + +@media (max-width: 480px) { + .bracket-cta { + margin-left: 12px; + margin-right: 12px; + padding: 18px; + } + + .cta-actions { + flex-direction: column; + } + + .btn-save, + .btn-lock, + .btn-reset { + flex: none; + width: 100%; + } + + /* Even smaller scoreboard for very narrow screens */ + .scoreboard-digit { + width: 18px; + height: 28px; + font-size: 1.1rem; + } + + .scoreboard-separator { + height: 28px; + gap: 4px; + } + + .separator-dot { + width: 4px; + height: 4px; + } + + .scoreboard-frame { + padding: 10px 12px 8px; + } } diff --git a/src/styles/share-bracket.css b/src/styles/share-bracket.css new file mode 100644 index 0000000..f7be655 --- /dev/null +++ b/src/styles/share-bracket.css @@ -0,0 +1,195 @@ +/* ============================================ + SHARE BRACKET PAGE + Retro Tournament Aesthetic (matches test page) + ============================================ */ + +/* ============================================ + Header Card - Paper Style + ============================================ */ + +.share-bracket-header { + max-width: 600px; + margin: 0 auto 2rem; + padding: 40px 30px; + background: var(--beige); + border-radius: 15px; + /* Torn paper edge effect */ + mask-image: + url("./assets/paper/repeating-paper-top.png"), + url("./assets/paper/repeating-paper-bottom.png"), + linear-gradient(black, black); + mask-size: + auto 23px, + auto 40px, + 100% 100%; + mask-position: + center 0px, + center calc(100% + 15px), + center; + mask-repeat: repeat-x, repeat-x, no-repeat; + mask-composite: exclude; + mask-mode: alpha; +} + +/* User info row */ +.share-bracket-user { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; +} + +.share-bracket-avatar { + width: 64px; + height: 64px; + border-radius: 50%; + border: 4px solid var(--orange); + flex-shrink: 0; +} + +.share-bracket-user-info { + flex: 1; +} + +.share-bracket-user-info h1 { + font-family: var(--font-block); + font-size: 1.5rem; + margin: 0 0 4px; + color: var(--orange); + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.share-bracket-username { + font-family: var(--font-sans); + font-size: 0.95rem; + color: var(--black); + opacity: 0.6; +} + +/* CTA button inside header card */ +.share-bracket-header .btn-primary { + width: 100%; +} + +/* ============================================ + Error States - Paper Card Style + ============================================ */ + +.share-bracket-error { + max-width: 500px; + margin: 4rem auto; + padding: 50px 40px; + background: var(--beige); + border-radius: 15px; + text-align: center; + /* Torn paper edge effect */ + mask-image: + url("./assets/paper/repeating-paper-top.png"), + url("./assets/paper/repeating-paper-bottom.png"), + linear-gradient(black, black); + mask-size: + auto 23px, + auto 40px, + 100% 100%; + mask-position: + center 0px, + center calc(100% + 15px), + center; + mask-repeat: repeat-x, repeat-x, no-repeat; + mask-composite: exclude; + mask-mode: alpha; +} + +.share-bracket-error h1 { + font-family: var(--font-block); + font-size: 1.75rem; + color: var(--orange); + margin: 0 0 12px; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.share-bracket-error p { + font-family: var(--font-sans); + color: var(--black); + margin: 0 0 28px; + font-size: 1rem; + line-height: 1.5; +} + +/* ============================================ + Primary Button - Retro Style + ============================================ */ + +.btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 16px 32px; + background: var(--yellow); + color: var(--black); + font-family: var(--font-block); + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.03em; + text-decoration: none; + border: 3px solid var(--black); + border-radius: 0; + cursor: pointer; + transition: + transform 0.1s, + box-shadow 0.1s; + box-shadow: 4px 4px 0 var(--black); +} + +.btn-primary:hover { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--black); +} + +.btn-primary:active { + transform: translate(4px, 4px); + box-shadow: none; +} + +/* ============================================ + Responsive + ============================================ */ + +@media (max-width: 600px) { + .share-bracket-header { + margin-left: 12px; + margin-right: 12px; + padding: 30px 20px; + } + + .share-bracket-user { + gap: 12px; + } + + .share-bracket-avatar { + width: 52px; + height: 52px; + } + + .share-bracket-user-info h1 { + font-size: 1.25rem; + } + + .share-bracket-error { + margin-left: 12px; + margin-right: 12px; + padding: 40px 24px; + } + + .share-bracket-error h1 { + font-size: 1.5rem; + } + + .btn-primary { + width: 100%; + padding: 14px 24px; + } +} diff --git a/src/styles/styles.css b/src/styles/styles.css index 3aec017..0cf8a16 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -6,6 +6,7 @@ --black: #000; --white: #fff; --beige: #f5eeda; + --bluesky: #0f73ff; /* TODO: Add web fonts */ --font-serif: "serif", serif; --font-block: "Alfa Slab One", "CollegiateBlackFLF", sans-serif; @@ -139,3 +140,75 @@ p { z-index: -1; } } + +/* 404 Not Found Page */ +.not-found { + max-width: 500px; + margin: 60px auto; + padding: 40px; + background: var(--beige); + border: 4px solid var(--black); + text-align: center; + mask-image: + url("./assets/paper/repeating-paper-top.png"), + url("./assets/paper/repeating-paper-bottom.png"), + linear-gradient(black, black); + mask-size: + auto 20px, + auto 20px, + 100% 100%; + mask-position: + center 0px, + center 100%, + center; + mask-repeat: repeat-x, repeat-x, no-repeat; + mask-composite: exclude; +} + +.not-found-code { + font-family: var(--font-block); + font-size: 8rem; + color: var(--orange); + line-height: 1; + letter-spacing: 0.05em; +} + +.not-found-title { + font-family: var(--font-serif); + font-size: 2.5rem; + color: var(--black); + margin: 0.5rem 0 1rem; + text-transform: uppercase; +} + +.not-found-text { + color: var(--black); + margin-bottom: 1.5rem; +} + +.not-found-btn { + display: inline-block; + padding: 12px 24px; + background: var(--yellow); + border: 3px solid var(--black); + box-shadow: 4px 4px 0 var(--black); + font-family: var(--font-block); + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--black); + text-decoration: none; + transition: + transform 0.1s, + box-shadow 0.1s; +} + +.not-found-btn:hover { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--black); +} + +.not-found-btn:active { + transform: translate(4px, 4px); + box-shadow: none; +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..0435746 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; +import tsConfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [ + tsConfigPaths({ + projects: ["./tsconfig.json"], + }), + ], + test: { + include: ["src/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 6f23356..ad6c726 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,12 +1,22 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 751a7ef0204e37564547937fa13c0dba) +// Generated by Wrangler by running `wrangler types` (hash: 435c6057ab132f3064832b0d4ac6355c) // Runtime types generated with workerd@1.20260107.1 2026-01-07 nodejs_compat declare namespace Cloudflare { interface Env { + GITHUB_CLIENT_ID: string; + GITHUB_CLIENT_SECRET: string; + BETTER_AUTH_URL: string; + BETTER_AUTH_SECRET: string; DB: D1Database; } } interface Env extends Cloudflare.Env {} +type StringifyValues> = { + [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; +}; +declare namespace NodeJS { + interface ProcessEnv extends StringifyValues> {} +} // Begin runtime types /*! ***************************************************************************** diff --git a/wrangler.jsonc b/wrangler.jsonc index 0dcc972..927da6f 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -3,9 +3,6 @@ "name": "mad-css", // account_id is set via CLOUDFLARE_ACCOUNT_ID env var "compatibility_date": "2026-01-07", - "build": { - "command": "pnpm run build" - }, "routes": [ { "pattern": "madcss.com/*", "zone_name": "madcss.com" }, { "pattern": "www.madcss.com/*", "zone_name": "madcss.com" }