From df2a0549d84bcc3362f6157299954c3859e4f310 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Fri, 30 Jan 2026 17:31:06 -0500 Subject: [PATCH 01/18] Add bracket picking feature with predictions, sharing, and admin tools Features: - User bracket predictions with cascading pick logic - Bracket locking with deadline enforcement - Share bracket as image (OG image generation) - Admin simulation panel for testing tournament progression - Leaderboard with scoring system Technical improvements: - Refactor bracket node generation into modular functions (nodeGenerators.ts) - Consolidate database migrations - Add Zod validation to server functions - Security fixes and input validation Co-Authored-By: Claude Opus 4.5 --- .gitignore | 6 +- CLAUDE.md | 197 +++- biome.json | 5 +- drizzle/0000_daffy_shard.sql | 94 ++ drizzle/0000_rare_juggernaut.sql | 53 - drizzle/meta/0000_snapshot.json | 292 +++++- drizzle/meta/_journal.json | 4 +- package.json | 10 +- pnpm-lock.yaml | 675 +++++++++---- public/fonts/DSEG7Classic-Bold.woff2 | Bin 0 -> 5132 bytes scripts/seed-leaderboard.sql | 110 +++ src/components/AdminButton.tsx | 159 +++ src/components/Header.tsx | 1 - src/components/LoginSection.tsx | 387 +++++++- src/components/bracket/Bracket.tsx | 574 +++-------- src/components/bracket/PlayerNode.tsx | 162 +++- src/components/bracket/SimpleBracket.tsx | 71 -- src/components/bracket/bracket.css | 165 +++- src/components/bracket/bracketTypes.ts | 29 + src/components/bracket/nodeGenerators.ts | 555 +++++++++++ src/components/leaderboard/Leaderboard.tsx | 102 ++ .../leaderboard/LeaderboardScore.tsx | 21 + src/components/leaderboard/leaderboard.css | 273 ++++++ src/components/scoreboard/Scoreboard.tsx | 32 + .../scoreboard/ScoreboardSeparator.tsx | 8 + src/components/scoreboard/ScoreboardUnit.tsx | 18 + src/data/players.ts | 89 +- src/db/schema.ts | 118 ++- src/hooks/useCountdown.ts | 42 + src/hooks/usePredictions.test.ts | 99 ++ src/hooks/usePredictions.ts | 303 ++++++ src/lib/admin.ts | 37 + src/lib/auth-client.ts | 9 + src/lib/auth.ts | 46 + src/lib/middleware/admin.ts | 48 + src/lib/middleware/auth.ts | 33 + src/lib/schemas/prediction.ts | 23 + src/lib/scoring.test.ts | 116 +++ src/lib/scoring.ts | 151 +++ src/lib/simulation.ts | 214 ++++ src/routeTree.gen.ts | 265 ++++- src/routes/__root.tsx | 2 + src/routes/admin.tsx | 529 ++++++++++ src/routes/api/admin/brackets/unlock.ts | 72 ++ src/routes/api/admin/check.ts | 31 + src/routes/api/admin/users.ts | 117 +++ src/routes/api/bracket/$username.ts | 100 ++ src/routes/api/leaderboard/calculate.ts | 42 + src/routes/api/leaderboard/index.ts | 63 ++ src/routes/api/og.$username.ts | 517 ++++++++++ src/routes/api/predictions/index.ts | 145 +++ src/routes/api/predictions/lock.ts | 95 ++ src/routes/bracket/$username.tsx | 153 +++ src/routes/index.tsx | 6 - src/routes/test.tsx | 118 +++ src/styles/admin-button.css | 117 +++ src/styles/admin.css | 531 ++++++++++ src/styles/login.css | 912 +++++++++++++++++- src/styles/share-bracket.css | 195 ++++ src/styles/styles.css | 1 + vitest.config.ts | 14 + worker-configuration.d.ts | 12 +- wrangler.jsonc | 3 - 63 files changed, 8471 insertions(+), 900 deletions(-) create mode 100644 drizzle/0000_daffy_shard.sql delete mode 100644 drizzle/0000_rare_juggernaut.sql create mode 100644 public/fonts/DSEG7Classic-Bold.woff2 create mode 100644 scripts/seed-leaderboard.sql create mode 100644 src/components/AdminButton.tsx delete mode 100644 src/components/bracket/SimpleBracket.tsx create mode 100644 src/components/bracket/bracketTypes.ts create mode 100644 src/components/bracket/nodeGenerators.ts create mode 100644 src/components/leaderboard/Leaderboard.tsx create mode 100644 src/components/leaderboard/LeaderboardScore.tsx create mode 100644 src/components/leaderboard/leaderboard.css create mode 100644 src/components/scoreboard/Scoreboard.tsx create mode 100644 src/components/scoreboard/ScoreboardSeparator.tsx create mode 100644 src/components/scoreboard/ScoreboardUnit.tsx create mode 100644 src/hooks/useCountdown.ts create mode 100644 src/hooks/usePredictions.test.ts create mode 100644 src/hooks/usePredictions.ts create mode 100644 src/lib/admin.ts create mode 100644 src/lib/middleware/admin.ts create mode 100644 src/lib/middleware/auth.ts create mode 100644 src/lib/schemas/prediction.ts create mode 100644 src/lib/scoring.test.ts create mode 100644 src/lib/scoring.ts create mode 100644 src/lib/simulation.ts create mode 100644 src/routes/admin.tsx create mode 100644 src/routes/api/admin/brackets/unlock.ts create mode 100644 src/routes/api/admin/check.ts create mode 100644 src/routes/api/admin/users.ts create mode 100644 src/routes/api/bracket/$username.ts create mode 100644 src/routes/api/leaderboard/calculate.ts create mode 100644 src/routes/api/leaderboard/index.ts create mode 100644 src/routes/api/og.$username.ts create mode 100644 src/routes/api/predictions/index.ts create mode 100644 src/routes/api/predictions/lock.ts create mode 100644 src/routes/bracket/$username.tsx create mode 100644 src/routes/test.tsx create mode 100644 src/styles/admin-button.css create mode 100644 src/styles/admin.css create mode 100644 src/styles/share-bracket.css create mode 100644 vitest.config.ts 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..2950257 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,11 @@ "@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-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 +34,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..4283ef9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,26 +18,26 @@ importers: 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-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.16)(@tanstack/react-query@5.90.16(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 +56,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 +133,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 +177,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 +213,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 +259,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 +1221,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 +1356,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,8 +1521,8 @@ 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': @@ -1498,29 +1564,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 +1599,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 +1614,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 +1646,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 +1849,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 +1968,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 +1985,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 +2027,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 +2135,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 +2254,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 +2276,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 +2315,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 +2350,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 +2400,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 +2409,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 +2500,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 +2584,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 +2664,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 +2699,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 +2711,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 +2780,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 +2806,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 +2816,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 +2861,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 +2876,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 +2907,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 +2976,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 +2986,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 +3141,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 +3195,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 +3207,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 +3253,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 +3283,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 +3311,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 +3327,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 +3344,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 +3360,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 +3382,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 +3423,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 +3441,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 +4041,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 +4122,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,7 +4290,7 @@ snapshots: - csstype - utf-8-validate - '@tanstack/history@1.145.7': {} + '@tanstack/history@1.154.14': {} '@tanstack/query-core@5.90.16': {} @@ -4068,71 +4312,71 @@ snapshots: '@tanstack/query-core': 5.90.16 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.16)(@tanstack/react-query@5.90.16(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/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.16)(@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 +4395,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 +4415,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 +4428,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 +4436,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.16)(@tanstack/router-core@1.157.16)': dependencies: '@tanstack/query-core': 5.90.16 - '@tanstack/router-core': 1.146.2 + '@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 +4509,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 +4733,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 +4772,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 +4825,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 +4842,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 +4896,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 +4912,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 +4993,7 @@ snapshots: detect-libc@2.1.2: {} - diff@8.0.2: {} + diff@8.0.3: {} dom-accessibility-api@0.5.16: {} @@ -4761,6 +5032,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 +5052,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 +5179,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + esprima@4.0.1: {} estree-walker@3.0.3: @@ -4922,6 +5199,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 +5236,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 +5249,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 +5340,8 @@ snapshots: json5@2.2.3: {} + just-camel-case@6.2.0: {} + kleur@4.1.5: {} kysely@0.28.9: {} @@ -5117,6 +5400,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 +5475,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 +5509,8 @@ snapshots: picomatch@4.0.3: {} + postcss-value-parser@4.2.0: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -5235,7 +5532,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 +5624,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 +5652,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 +5721,7 @@ snapshots: source-map@0.7.6: {} - srvx@0.10.0: {} + srvx@0.10.1: {} stackback@0.0.2: {} @@ -5418,6 +5729,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 +5760,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 +5814,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 +5952,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 +5994,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 +6011,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 0000000000000000000000000000000000000000..558eec408af275c2f840e012edcff769deff8902 GIT binary patch literal 5132 zcmV+n6!YtMPew8T0RR9102B-W4gdfE09pV5028qQ0RR9100000000000000000000 z0000#Mn+Uk92y)3U;u)02m}!b3b`1iW($E500A}vBm;pq1Rw?PW(R>X8^cZ`1Hr}t z2zh?}A}UGi3jSLHhhxa*!8FTY7}BNHqC`Y1Ll6UcV3+D>+Y|NO>iz7+^2ddFYC|v) z4yG9+@mzQyyU#b+-Ge#zPju`~Ra_=ja#c#Ta%ogjXC9zqUvK7zZb#5kB>0MKGxY@(~W$#by;-rNV5xcUL5;P6#2G;58m& zGQP-)df%%Nn~Eu-)q^q7;KfdB&NL^S%G z6t}oHo?!p_lZfwnf&|Ge0rOK8K0ZC?jf*(0+Ns1JjLm{(JycDjI8#fF8pg0n#q_<@ zdVn843dA$YjG4B>smu^3n@g=}v(r!BeRz4gx)=+NI+5lyk+?$SnP);J*`I-x#<9#N z`mx{w0eP5E=BECn7bRIyHQg{R8-Og?oqiBTagt_vQC4-+cKzV2t8Zv*;^=?4+0mQE z$L#^{PIh;E4 z>}m40VNZpFpsd}w96A)jkMI{+3y3)Y)saLb%YnJ;%bO(dDmfiJeR+*1Cz11+EPga>x^n?QA85~6KP}0&P)Wrgd zOp9Trh|J3fpL z2b|-R&Qx)VmqaZ0DI1(uX;#_D^PX`-d~0}Asi!(q+E5puqIu1z!Egs*@UdMiqI&nre1L@Z`U!`eO zae#a@;X?@?e!4cXo4q{qzcikazB)+dS>u6YmGAts?5o@BlM)$Ajy}@q06F6y0n6v! zmw4Rs_a1>kub*B^tl`$rI)WE}LW+w2JPN?(x8mKxhkRDWKce1kg`O>Qj{H2F8Mu*K zg1mvb;YwPc)`$4U;^z|>RzW;KYyjLsbuVA=B>!Ik^9Rtq>qo-jBK= z-}%GYAAX(yEer>nzBGRL8U_G_g%r|ltv#Vq@bAUJ#WWKz=4m@%)d%v>@e(vy-y>QAeEL+0U z`WRNiGCqzI7b<5er;e8^3L2Nc#e3Ra-O1$S8%{2&v+8F6w_n_Jpt#yMQxq#i zD32xRSnb@U;umq;_P9R3ER!>aZ*fhuGn+~C?a#b*XP5e=CM@!Pxpr+JSgH7cZT9zg z9J{^SMdbgd^)~&wz8-|j;u+Gz;)ZIw`@qhM=YoDV&UQs;_;^KOvB5-mTky?j#wnpZ zZ|#X|1{R>?Sz@_<$-E-EN5{c8oCJv*ASV*UFEviWy405&C-HSd$Ju}*ei34)2c z!hYkaT?_${$ztp)sz?1mAv`L!iz7k`0uM0iy*@@ ze>|H%b75}NynBq^w6*8CL6vUx65@lCh8S65()0i8u`8mV9Q;^$cJJGRFVDmI3EE=W z6=&!(TbC`G3E7pf2F4k0R+$%#8nQf+{7ukmY1$&Nvb`poK`dbz6KmIk3JF3jIOTPy zL&u+C(|-}Cw?%_yIvNZRmMKPt+WqLt(oO;C(hBj|nOcrTt&t0<9jUtWCtD*=-rS&( zhjHTX;}M+&DJa;U7p^Vsu+@~)0+xhR*rb@%W(RQ)%i3e8dzwt+^vIej|8%5!!4_Lg zG+-_W&^qL_S<*ihS|03FdCf_lQMLpXLZb!4sa1%iMLO)@NM;)o&8X|lQOie~h)3%V zjyt9*(rgx9W0dzE(;65uYo5w-N>~stP6{uQ<_WH%YfKp$@oHd_I58X-fuI?NTPM&# zVNHPCB1q}J;vu-esLKLN*0h#7Fhhunrfapa3|5#qJt`KEbYeT4tF+-ob!;swM8eBn zB`voFNy`JuGGxb)#f{JS&?p@Xwd!{$ z^ItP6C3bi!J$d%x-Q2s4cMEfC;CG*8zw@CTyyM8Bx~`&DGVb^rB`RrH$wTN% zTkuvF?>a=xsFHsu(OhX0+tC=RcU5TGu*kwiC70!gC9n=5`jXj5IAq2c%vW;g!+rQFw#Xv(3LEurIXZ&- zAl%>ws_LswwEppc7s-xpRDlKO>+tvxbXR`2!2BVr=~(nTz(_%_VU-y(#FXWPu>mHs zTH}biY)n*irBR0g+i+f{B5jlf%u$b;W;{&Y9;l%kl+`kp6s(lZ3sd;YX9m@Q_Z-BxO6HIXukp<&zBN7gaEIU`Ol%^%JRz=7~J z$t(no&IU{iES`unkhCti^Y{`Ls2h9Jm2dCX){UV-e^&{GRy{Qg{lRo*1TwlT! zQ%+y^_b_AVUbh%LwY0rNlDDz2H6?vK0INXf7FSBqN$(&GLt}&k#{APzw*WX%LUY6` zHNQHGv|6mjeC?Yr%O2N8D@huVnMOT;)+2>-44a@*9h={6Fg}sYx{A5gPL-u+q*P$+ z)qVVB_8HqTQ%kIeK&2Qc1{Lb}p1heL%`V|D#3hTD1A6E4`uceahW%OS+<-pRsq;XV z%e58Ti5gk+4gW8DyrS2*x5}L_wi|TTM>-h7KMJ7Hd*NqqCbi#}zZ{5k*~Y zUtj$&@Vg9Qi6-LH1`Vn&E=`&y@l-tFSzM6I=4;IQeqtv&(Mj-y4_{;Z7wmtExN5a zX~q5)1PH}94ZDb&`G^`O6Yw=wt2g$mURQeaCZKvV+Pkkqh9*6=oE=U(*#E*-=}8wT zut{Ea0QiO(=N*=%skkkO+$uU=5d9o?k?G?S9t!Bv^Uj!VXlj6{2B(c)%~tB7^JIH7 zd$VHEOeh$tG1&l!Une`f@JWfs1GzoT!#3UAefQGu=j@ny@tPoDqd`Ed z=^pa|Qda0<)1D{op|Sn?IKXWln{z*VRYhS1L@cokR0IBbvRx%m_~{zS2g?8A(I|^n zgu@)vAtg;a_365wD+X*;)z?R7^YDdY^tk`mTi7+i+aTZi%f{8qd-cliHwzP z8W%c!4)tX_6@RXegtCgbt77peIx1iVp#eXwH6%?*Phg40*gy)^ir4%!CP)PcdTKQx zNYSF5e-ssr#~f-^Dy^3(DI*~S3dp!qZbsY0^Un<`Q_yM#g|*UB2SdfX1;eF9u_@$% z*^E=hb790*SPR6%=|O-HL$FsAybWB5w#O0}{oJHu$?cpDE1Zo(xQYH?82}neF$`Kj zA%au|DM$^e3y+v35msL#a(toci&ZWPr~{YSt{@rPq$M)!3Lqo(Ks2xe-$tw2ng^>- ziiniJr4_+YWk=VQH)4{Ug3N}3qAap3)hMM<@z&H>M}#5Ig21Kc8U-MMVHt!RJ8S`| zmD1Vp*O{4swm;CmyInUO@U@exf;-QTIicL)^+O2ftSDA;e9!N z=k5Vuj}-~FQ1gR?gCN#fo_aJ}@RLg4t~pmY_1*=8_U7}RRp}fm`5-swDdElkHKa-W z|7-qU-ha3LvgiXGolH}OY#bX|hqpq8pC{Nl6Q_85&bB2=!u&%1I51|WWgi&o^8@M) zpdyVI=Q(jF`cO=X6XC;VM$JBs!Diyzu7Y2;CzRgp-dpSeEw~YWu-pqafQdl>(6Hmt zGkk@)wBwm#Oh1aJTlWEH#_8CvC><2-WfbkAW0QPfa)3`F;8~S_v@e)}S1n$?P_YnZ zN{)aMB?dbS!~hCDDS?+2Vt-I4o_Wxf0GC1zi1}ciS8jTOP@`Pb+uAgt^25qUh^PvI z_3>D0LyXQrv7xrTCOG04zT$`l{hgs;ff|8T({vYD9##=c5O0C-L=(iZA0dV5->*{x zC=3T&047=HbqC@Ru+?#abH%qL6BC3{laLEf1{|Wfvgd4H=BU7S7PO*6Y)H`iS-@C+ zO0HW2xW#;V_+7S^f2X<#Yo%h&z#BB$baJ{M-rA?>O0~=a<)nbSoDsh=zBpeM?F2Ij zdy71v>}gU{zsu_p$6$fDyWx z0vA#BVlTI>guK{gZVaNiAhY+e+)T?$%!pyGR9Uz!Bsw6#Db-L!6iF)-w?lzDxHEK} zGoDTimmENgCYVS3lQz19+Yi377~ z?(5SQEC4XvrU#P%lNE-!kdcDG~g5N-5?82-3QfFZm#NJmoe`Kz-a*KoJF9h zLS|NPtT5Yzup_(s0`^gb#PcMB?c;*%IjKLWRHy9$NEZ)`TLlo(iUmYie288J;F(rJ zwtZH|R3n#EZXpnlUIrpD83@pW8G98UWb(fK_yx}p?#ZhsHEyP_au|55qlv)<0 zARRS{X55vK{|5{@badD{xOz&5gO{8kNV-hR!Jz;Er4bP=r-uPm#d=XhF`4s3>ar|b zgQhAvXaGy;&%1zKCZ1cU*`^0eUV61`qA31V@mrZPqs2Ygpl{J7J@f z;!{Em9`;?u?8L{3bk&4igME=IQqNELR{^>X{JIP-xBEk7bF`xpKGc~31)3zDnknny zTYLkOZgE9rFc=YR2V?$|Cp!=DW#p23p3>7(;31( z$=?U5Lyaoxy{)gD#ovJ0tw;##7gOKQSI)@OFvCxFm8bI6hyt04qQ=1oss^xTl2k)b zG;BeWtT*(GL0pxmcvYFGit!XPF6!$NJl$O~QbR#h50pPjsr0}W6%-EwS5;(h5XV|? z-4}Dy;^l=?J26Q&2+Pb^ucxFRARQ6)G&wNT^S7%p*O9g$Luj@edRgA&?oqrB|PR0|pHlHe%G6aT6x(1^@t8@v#K} literal 0 HcmV?d00001 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..a3b599c 100644 --- a/src/components/LoginSection.tsx +++ b/src/components/LoginSection.tsx @@ -1,55 +1,392 @@ +import { useState } from "react"; +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", +}; + +export interface LoginSectionProps { + pickCount?: number; + isLocked?: boolean; + isSaving?: boolean; + hasChanges?: boolean; + error?: string | null; + deadline?: string; + isDeadlinePassed?: boolean; + username?: string | null; + onSave?: () => void; + onLock?: () => void; + onReset?: () => void; + showPicks?: boolean; + onToggleShowPicks?: () => void; +} + +export function LoginSection({ + pickCount = 0, + isLocked = false, + isSaving = false, + hasChanges = false, + error = null, + deadline, + isDeadlinePassed = false, + username = null, + onSave, + onLock, + onReset, + showPicks = false, + onToggleShowPicks, +}: LoginSectionProps) { 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! \ud83c\udfc0\n\n${shareUrl}`)}` + : null; + + const blueskyShareUrl = username + ? `https://bsky.app/intent/compose?text=${encodeURIComponent(`Check out my March Mad CSS bracket picks! \ud83c\udfc0\n\n${shareUrl}`)}` + : null; if (isPending) { return (
- ... + Loading...
); } if (session?.user) { + const canLock = pickCount === TOTAL_GAMES && !isLocked && !isDeadlinePassed; + return (
- {session.user.name} -
+ {/* Header: Avatar + Name + Sign Out */} +
+

- You're in, {session.user.name}! + Welcome back, {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 && ( +
+
+ + Share your bracket +
+
+ + {twitterShareUrl && ( + + + Share on X + + )} + {blueskyShareUrl && ( + + + Bluesky + + )} +
+
+ )} + + )} + + {isDeadlinePassed && !isLocked && ( +
Deadline has passed
+ )} + + {/* Progress section - only show when not locked */} + {!isLocked && !isDeadlinePassed && ( + <> +
+
+
+ {pickCount} / {TOTAL_GAMES} picks +
+ {deadline && countdown.totalMs > 0 && ( + + )} +
+
+ + {/* Instructions */} +
+ + + Click any player to pick them as the winner of that match + +
+ + {/* Actions */} +
+ {showLockConfirm ? ( +
+

Lock your bracket? This cannot be undone.

+
+ + +
+
+ ) : ( + <> + + + {pickCount > 0 && ( + + )} + + )} +
+ + )} + + {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: + + +
+ ) + )} +
+ +
+ 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..1ced654 --- /dev/null +++ b/src/routes/api/og.$username.ts @@ -0,0 +1,517 @@ +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"; + +export const Route = createFileRoute("/api/og/$username")({ + server: { + handlers: { + GET: async ({ params, request }) => { + const { username } = params; + const url = new URL(request.url); + 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("User not found", { status: 404 }); + } + + 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 new Response("Bracket not locked", { status: 403 }); + } + + // 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 baseUrl = `${url.protocol}//${url.host}`; + 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..3da080a --- /dev/null +++ b/src/routes/bracket/$username.tsx @@ -0,0 +1,153 @@ +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { Bracket } from "@/components/bracket/Bracket"; +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; + }, + 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..237221c --- /dev/null +++ b/src/routes/test.tsx @@ -0,0 +1,118 @@ +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 { getResultsFromBracket } from "@/data/players"; +import { usePredictions } from "@/hooks/usePredictions"; +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 [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); + }, []); + + const { + predictions, + isLocked, + isSaving, + error, + pickCount, + deadline, + isDeadlinePassed, + hasChanges, + setPrediction, + savePredictions, + lockBracket, + resetPredictions, + } = usePredictions(isAuthenticated); + + // 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..f64f459 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; 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" } From 3af94984161e93ae962719d3ee152318a79574fe Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Fri, 30 Jan 2026 19:04:45 -0500 Subject: [PATCH 02/18] Revert ROUND_GAP to 220px for proper bracket spacing Co-Authored-By: Claude Opus 4.5 --- src/components/bracket/bracketTypes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/bracket/bracketTypes.ts b/src/components/bracket/bracketTypes.ts index 806bcd5..09528d9 100644 --- a/src/components/bracket/bracketTypes.ts +++ b/src/components/bracket/bracketTypes.ts @@ -22,7 +22,7 @@ export interface RoundGeneratorOptions { export const NODE_HEIGHT = 70; export const VERTICAL_GAP = 76; export const MATCH_GAP = NODE_HEIGHT + VERTICAL_GAP; // 146 -export const ROUND_GAP = 340; +export const ROUND_GAP = 220; export const RIGHT_START_X = ROUND_GAP * 7; // 2380 export const LEFT_RING_COLOR = "#f3370e"; From 7af882ceabb21b17c58df8314c8a36b28bf68d18 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sat, 31 Jan 2026 16:48:30 -0500 Subject: [PATCH 03/18] Refactor predictions state to use React context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create PredictionsContext to reduce prop drilling from test.tsx - LoginSection now consumes from context (12 props → 3 props) - Bracket supports both context mode and props fallback for SSR routes - Export UsePredictionsReturn type from usePredictions hook Co-Authored-By: Claude Opus 4.5 --- src/components/LoginSection.tsx | 33 +++++++--------- src/components/bracket/Bracket.tsx | 14 +++++-- src/context/PredictionsContext.tsx | 26 +++++++++++++ src/hooks/usePredictions.ts | 2 + src/routes/test.tsx | 60 +++++++++--------------------- 5 files changed, 69 insertions(+), 66 deletions(-) create mode 100644 src/context/PredictionsContext.tsx diff --git a/src/components/LoginSection.tsx b/src/components/LoginSection.tsx index a3b599c..4ebc3af 100644 --- a/src/components/LoginSection.tsx +++ b/src/components/LoginSection.tsx @@ -1,4 +1,5 @@ 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"; @@ -14,36 +15,28 @@ const ROUND_LABELS: Record = { }; export interface LoginSectionProps { - pickCount?: number; - isLocked?: boolean; - isSaving?: boolean; - hasChanges?: boolean; - error?: string | null; - deadline?: string; - isDeadlinePassed?: boolean; username?: string | null; - onSave?: () => void; - onLock?: () => void; - onReset?: () => void; showPicks?: boolean; onToggleShowPicks?: () => void; } export function LoginSection({ - pickCount = 0, - isLocked = false, - isSaving = false, - hasChanges = false, - error = null, - deadline, - isDeadlinePassed = false, username = null, - onSave, - onLock, - onReset, 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); diff --git a/src/components/bracket/Bracket.tsx b/src/components/bracket/Bracket.tsx index 6860b45..9809de7 100644 --- a/src/components/bracket/Bracket.tsx +++ b/src/components/bracket/Bracket.tsx @@ -10,6 +10,7 @@ import { } from "@xyflow/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"; @@ -310,13 +311,20 @@ const FIT_VIEW_PADDING = 0.05; function BracketContent({ isInteractive = false, - predictions = {}, - onPick, - isLocked = 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( () => diff --git a/src/context/PredictionsContext.tsx b/src/context/PredictionsContext.tsx new file mode 100644 index 0000000..12d818e --- /dev/null +++ b/src/context/PredictionsContext.tsx @@ -0,0 +1,26 @@ +import { createContext, type ReactNode, useContext } from "react"; +import { + type UsePredictionsReturn, + usePredictions, +} from "@/hooks/usePredictions"; + +const PredictionsContext = createContext(null); + +export function PredictionsProvider({ + isAuthenticated, + children, +}: { + isAuthenticated: boolean; + children: ReactNode; +}) { + const predictions = usePredictions(isAuthenticated); + return ( + + {children} + + ); +} + +export function usePredictionsContext() { + return useContext(PredictionsContext); +} diff --git a/src/hooks/usePredictions.ts b/src/hooks/usePredictions.ts index ac5e29a..7776313 100644 --- a/src/hooks/usePredictions.ts +++ b/src/hooks/usePredictions.ts @@ -112,6 +112,8 @@ function getGamesToClear( return gamesToClear; } +export type UsePredictionsReturn = ReturnType; + export function usePredictions(isAuthenticated: boolean) { const [predictions, setPredictions] = useState>({}); const [isLocked, setIsLocked] = useState(false); diff --git a/src/routes/test.tsx b/src/routes/test.tsx index 237221c..e41eff5 100644 --- a/src/routes/test.tsx +++ b/src/routes/test.tsx @@ -6,8 +6,8 @@ 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 { usePredictions } from "@/hooks/usePredictions"; import { authClient } from "@/lib/auth-client"; // Initialize tournament results from bracket data (single source of truth) @@ -47,21 +47,6 @@ function TestPage() { window.removeEventListener("tournament-results-changed", handler); }, []); - const { - predictions, - isLocked, - isSaving, - error, - pickCount, - deadline, - isDeadlinePassed, - hasChanges, - setPrediction, - savePredictions, - lockBracket, - resetPredictions, - } = usePredictions(isAuthenticated); - // Auto-scroll to bracket after fresh OAuth login (only once per session) useEffect(() => { const hasScrolled = sessionStorage.getItem("bracket-scrolled"); @@ -84,33 +69,22 @@ function TestPage() { -
-

The Bracket

- setShowPicks(!showPicks)} - /> - -
+ +
+

The Bracket

+ setShowPicks(!showPicks)} + /> + +
+
From 419bd66c4b1657f198f389fdde10c74c2fa6fabf Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sat, 31 Jan 2026 19:18:12 -0500 Subject: [PATCH 04/18] Fix players showing grayscale when Show My Picks is OFF When showPicks toggle is disabled, getPredictionOptions now returns undefined instead of { pickState: "none" }, preventing unpicked styling from being applied to Round 1 players. Co-Authored-By: Claude Opus 4.5 --- package.json | 1 + pnpm-lock.yaml | 31 +- src/components/LoginSection.tsx | 462 ++++++++++++++--------- src/components/bracket/PlayerNode.tsx | 78 +++- src/components/bracket/nodeGenerators.ts | 146 +++---- src/context/PredictionsContext.tsx | 4 +- src/hooks/usePredictions.ts | 196 ++++------ src/hooks/usePredictionsQuery.ts | 118 ++++++ src/routes/__root.tsx | 46 ++- src/routes/admin.tsx | 5 + src/routes/test.tsx | 7 +- 11 files changed, 663 insertions(+), 431 deletions(-) create mode 100644 src/hooks/usePredictionsQuery.ts diff --git a/package.json b/package.json index 2950257..7a30947 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@cloudflare/vite-plugin": "^1.20.1", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-devtools": "^0.9.2", + "@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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4283ef9..37c150c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ 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.157.16 version: 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -25,7 +28,7 @@ importers: 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.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) + 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.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)) @@ -1525,8 +1528,8 @@ packages: 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==} @@ -1537,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 @@ -4292,7 +4295,7 @@ snapshots: '@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: @@ -4307,9 +4310,9 @@ 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.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)': @@ -4324,12 +4327,12 @@ snapshots: - 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.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-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/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.16)(@tanstack/router-core@1.157.16) + '@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: @@ -4450,9 +4453,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-ssr-query-core@1.146.2(@tanstack/query-core@5.90.16)(@tanstack/router-core@1.157.16)': + '@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/query-core': 5.90.20 '@tanstack/router-core': 1.157.16 '@tanstack/router-utils@1.154.7': diff --git a/src/components/LoginSection.tsx b/src/components/LoginSection.tsx index 4ebc3af..88f8fd0 100644 --- a/src/components/LoginSection.tsx +++ b/src/components/LoginSection.tsx @@ -14,6 +14,252 @@ const ROUND_LABELS: Record = { 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; @@ -73,11 +319,11 @@ export function LoginSection({ }; const twitterShareUrl = username - ? `https://twitter.com/intent/tweet?text=${encodeURIComponent(`Check out my March Mad CSS bracket picks! \ud83c\udfc0\n\n${shareUrl}`)}` + ? `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! \ud83c\udfc0\n\n${shareUrl}`)}` + ? `https://bsky.app/intent/compose?text=${encodeURIComponent(`Check out my March Mad CSS bracket picks! 🏀\n\n${shareUrl}`)}` : null; if (isPending) { @@ -93,27 +339,10 @@ export function LoginSection({ return (
- {/* Header: Avatar + Name + Sign Out */} -
- -

- Welcome back, {session.user.name} -

- -
+ {/* Status badges for locked/deadline states */} {isLocked && ( @@ -145,97 +374,12 @@ export function LoginSection({ {/* Share section - only show when locked and username exists */} {shareUrl && ( -
-
- - Share your bracket -
-
- - {twitterShareUrl && ( - - - Share on X - - )} - {blueskyShareUrl && ( - - - Bluesky - - )} -
-
+ )} )} @@ -247,16 +391,12 @@ export function LoginSection({ {/* Progress section - only show when not locked */} {!isLocked && !isDeadlinePassed && ( <> -
-
-
- {pickCount} / {TOTAL_GAMES} picks -
- {deadline && countdown.totalMs > 0 && ( - - )} -
-
+ {/* Instructions */}
@@ -279,69 +419,17 @@ export function LoginSection({
- {/* Actions */} -
- {showLockConfirm ? ( -
-

Lock your bracket? This cannot be undone.

-
- - -
-
- ) : ( - <> - - - {pickCount > 0 && ( - - )} - - )} -
+ )} diff --git a/src/components/bracket/PlayerNode.tsx b/src/components/bracket/PlayerNode.tsx index 483cc0b..5922ab1 100644 --- a/src/components/bracket/PlayerNode.tsx +++ b/src/components/bracket/PlayerNode.tsx @@ -2,6 +2,54 @@ 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: "none" } // User hasn't picked (or 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; @@ -13,6 +61,9 @@ export interface PlayerData { side?: "left" | "right"; round?: "round1" | "later"; showBio?: boolean; + // Structured prediction state (preferred) + prediction?: PredictionState; + // Legacy boolean flags (for backward compatibility) isSelected?: boolean; isCorrect?: boolean; isIncorrect?: boolean; @@ -35,6 +86,9 @@ interface PlayerNodeProps { side?: "left" | "right"; round?: "round1" | "later"; showBio?: boolean; + // Structured prediction state (preferred) + prediction?: PredictionState; + // Legacy boolean flags (for backward compatibility) isSelected?: boolean; isCorrect?: boolean; isIncorrect?: boolean; @@ -56,15 +110,26 @@ export function PlayerNode({ side = "left", round = "later", showBio = true, - isSelected = false, - isCorrect = false, - isIncorrect = false, - isPickable = false, - isUnpicked = false, + prediction, + // Legacy boolean props (used if prediction is not provided) + isSelected: legacySelected = false, + isCorrect: legacyCorrect = false, + isIncorrect: legacyIncorrect = false, + isPickable: legacyPickable = false, + isUnpicked: legacyUnpicked = false, playerId, gameId, - onPick, + onPick: legacyOnPick, }: PlayerNodeProps) { + // Derive flags from structured prediction state or use legacy props + const derived = prediction ? deriveClassFlags(prediction) : null; + const isSelected = derived?.isSelected ?? legacySelected; + const isCorrect = derived?.isCorrect ?? legacyCorrect; + const isIncorrect = derived?.isIncorrect ?? legacyIncorrect; + const isPickable = derived?.isPickable ?? legacyPickable; + const isUnpicked = derived?.isUnpicked ?? legacyUnpicked; + const onPick = prediction?.onPick ?? legacyOnPick; + const classNames = [ "player-node", isWinner && "player-node--winner", @@ -240,6 +305,7 @@ export const PlayerNodeFlow = memo(function PlayerNodeFlow({ side={data.side} round={data.round} showBio={data.showBio} + prediction={data.prediction} isSelected={data.isSelected} isCorrect={data.isCorrect} isIncorrect={data.isIncorrect} diff --git a/src/components/bracket/nodeGenerators.ts b/src/components/bracket/nodeGenerators.ts index add5b4b..9aeecf4 100644 --- a/src/components/bracket/nodeGenerators.ts +++ b/src/components/bracket/nodeGenerators.ts @@ -18,6 +18,7 @@ import { 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( @@ -60,14 +61,9 @@ function playerToNodeData( side: "left" | "right", round: "round1" | "later" = "later", options?: { - isSelected?: boolean; - isPickable?: boolean; - isCorrect?: boolean; - isIncorrect?: boolean; - isUnpicked?: boolean; + prediction?: PredictionState; isLoser?: boolean; showBio?: boolean; - onPick?: (gameId: string, playerId: string) => void; }, ): { photo: string; @@ -80,14 +76,9 @@ function playerToNodeData( showBio: boolean; side: "left" | "right"; round: "round1" | "later"; - isSelected?: boolean; - isPickable?: boolean; - isCorrect?: boolean; - isIncorrect?: boolean; - isUnpicked?: boolean; + prediction?: PredictionState; playerId?: string; gameId?: string; - onPick?: (gameId: string, playerId: string) => void; } { const isEliminated = options?.isLoser !== undefined ? options.isLoser : isLoser(game, player); @@ -102,14 +93,9 @@ function playerToNodeData( showBio: options?.showBio ?? true, side, round, - isSelected: options?.isSelected, - isPickable: options?.isPickable, - isCorrect: options?.isCorrect, - isIncorrect: options?.isIncorrect, - isUnpicked: options?.isUnpicked, + prediction: options?.prediction, playerId: player.id, gameId: game.id, - onPick: options?.onPick, }; } @@ -122,15 +108,10 @@ function createNode( side: "left" | "right", round: "round1" | "later" = "later", emptyText?: string, - predictionOptions?: { - isSelected?: boolean; - isPickable?: boolean; - isCorrect?: boolean; - isIncorrect?: boolean; - isUnpicked?: boolean; + nodeOptions?: { + prediction?: PredictionState; isLoser?: boolean; showBio?: boolean; - onPick?: (gameId: string, playerId: string) => void; }, ): Node { if (player) { @@ -138,14 +119,7 @@ function createNode( id, type: "playerNode", position, - data: playerToNodeData( - player, - game, - ringColor, - side, - round, - predictionOptions, - ), + data: playerToNodeData(player, game, ringColor, side, round, nodeOptions), }; } return { @@ -160,47 +134,25 @@ function getPredictionOptions( game: Game, player: Player | undefined, ctx: NodeContext, -): { - isSelected: boolean; - isPickable: boolean; - isCorrect: boolean; - isIncorrect: boolean; - isUnpicked: boolean; - onPick?: (gameId: string, playerId: string) => void; -} { +): 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 = { - isSelected: false, - isPickable: false, - isCorrect: false, - isIncorrect: false, - isUnpicked: false, - onPick: undefined as - | ((gameId: string, playerId: string) => void) - | undefined, + const defaults: PredictionState = { + pickState: { status: "none" }, + interactionMode: "view", }; if (!player) { return defaults; } - if (actualWinner) { - if (ctx.isLocked && !ctx.showPicks) { - return defaults; - } - const isWinnerPlayer = actualWinner === player.id; - const wasPickedForThisGame = userPick === player.id; - return { - isSelected: !ctx.isLocked && wasPickedForThisGame, - isPickable: false, - isCorrect: wasPickedForThisGame && isWinnerPlayer, - isIncorrect: wasPickedForThisGame && !isWinnerPlayer, - isUnpicked: !isWinnerPlayer, - }; - } - + // Determine if this player can be picked const pickablePlayers = ctx.pickablePlayersCache[game.id]; const bothPlayersDetermined = pickablePlayers[0] !== undefined && pickablePlayers[1] !== undefined; @@ -209,31 +161,42 @@ function getPredictionOptions( 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 { - ...defaults, - isPickable: canPick, - onPick: canPick ? ctx.onPick : undefined, - }; + return { pickState: { status: "none" }, interactionMode, onPick }; } const isPickedForThisGame = userPick === player.id; - return { - isSelected: !ctx.isLocked && isPickedForThisGame, - isPickable: canPick, - isCorrect: false, - isIncorrect: false, - isUnpicked: !isPickedForThisGame, - onPick: canPick ? ctx.onPick : undefined, - }; + const pickState: PickState = isPickedForThisGame + ? { status: "pending" } + : { status: "none" }; + return { pickState, interactionMode, onPick }; } - return { - ...defaults, - isPickable: canPick, - onPick: canPick ? ctx.onPick : undefined, - }; + return { pickState: { status: "none" }, interactionMode, onPick }; } function isPlayerLoser( @@ -281,7 +244,7 @@ export function generateRound1Nodes({ side, gameLarge ? "round1" : "later", undefined, - { ...p1Options, showBio: true, isLoser: p1Loser }, + { prediction: p1Options, showBio: true, isLoser: p1Loser }, ), ); @@ -297,7 +260,7 @@ export function generateRound1Nodes({ side, gameLarge ? "round1" : "later", undefined, - { ...p2Options, showBio: true, isLoser: p2Loser }, + { prediction: p2Options, showBio: true, isLoser: p2Loser }, ), ); }); @@ -362,7 +325,7 @@ export function generateQuarterNodes({ side, p1Large ? "round1" : "later", "TBD", - { ...p1Options, showBio: false, isLoser: p1Loser }, + { prediction: p1Options, showBio: false, isLoser: p1Loser }, ), ); @@ -378,7 +341,7 @@ export function generateQuarterNodes({ side, p2Large ? "round1" : "later", "TBD", - { ...p2Options, showBio: false, isLoser: p2Loser }, + { prediction: p2Options, showBio: false, isLoser: p2Loser }, ), ); }); @@ -443,7 +406,7 @@ export function generateSemiNodes({ side, p1Large ? "round1" : "later", "TBD", - { ...p1Options, showBio: false, isLoser: p1Loser }, + { prediction: p1Options, showBio: false, isLoser: p1Loser }, ), ); @@ -459,7 +422,7 @@ export function generateSemiNodes({ side, p2Large ? "round1" : "later", "TBD", - { ...p2Options, showBio: false, isLoser: p2Loser }, + { prediction: p2Options, showBio: false, isLoser: p2Loser }, ), ); }); @@ -520,7 +483,7 @@ export function generateFinalistNode({ side, finalistLarge ? "round1" : "later", "Finalist TBD", - { ...finalistOptions, showBio: false, isLoser: finalistLoser }, + { prediction: finalistOptions, showBio: false, isLoser: finalistLoser }, ); } @@ -548,7 +511,8 @@ export function generateChampionshipNode(ctx: NodeContext): Node { }, data: champion ? playerToNodeData(champion, finalGame, "#FFD700", "left", "later", { - isPickable: false, + isLoser: false, + showBio: false, }) : { text: "CHAMPION", side: "left", ringColor: "#FFD700" }, }; diff --git a/src/context/PredictionsContext.tsx b/src/context/PredictionsContext.tsx index 12d818e..129e9ed 100644 --- a/src/context/PredictionsContext.tsx +++ b/src/context/PredictionsContext.tsx @@ -8,12 +8,14 @@ const PredictionsContext = createContext(null); export function PredictionsProvider({ isAuthenticated, + userId, children, }: { isAuthenticated: boolean; + userId?: string; children: ReactNode; }) { - const predictions = usePredictions(isAuthenticated); + const predictions = usePredictions(isAuthenticated, userId); return ( {children} diff --git a/src/hooks/usePredictions.ts b/src/hooks/usePredictions.ts index 7776313..90875c4 100644 --- a/src/hooks/usePredictions.ts +++ b/src/hooks/usePredictions.ts @@ -1,5 +1,10 @@ -import { useCallback, useEffect, useState } from "react"; +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; @@ -114,71 +119,83 @@ function getGamesToClear( export type UsePredictionsReturn = ReturnType; -export function usePredictions(isAuthenticated: boolean) { - const [predictions, setPredictions] = useState>({}); - const [isLocked, setIsLocked] = useState(false); - const [lockedAt, setLockedAt] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); - const [error, setError] = useState(null); - const [hasChanges, setHasChanges] = useState(false); +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]); - // Calculate deadline status - const isDeadlinePassed = new Date() > new Date(BRACKET_DEADLINE); + // Sync local state when query data changes (initial load or after mutation) + useEffect(() => { + if (queryData && !localPredictions) { + setLocalPredictions(queryData.predictions); + } + }, [queryData, localPredictions]); - // Fetch predictions on mount, clear on sign out + // Reset local state when user logs out useEffect(() => { if (!isAuthenticated) { - setPredictions({}); - setIsLocked(false); - setLockedAt(null); - setIsLoading(false); - setHasChanges(false); - setError(null); - return; + setLocalPredictions(null); } + }, [isAuthenticated]); - async function fetchPredictions() { - try { - const response = await fetch("/api/predictions/"); - if (!response.ok) { - throw new Error("Failed to fetch predictions"); - } - const data = await response.json(); + // 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; - // Convert array to record - const predictionsRecord: Record = {}; - for (const pred of data.predictions) { - predictionsRecord[pred.gameId] = pred.predictedWinnerId; - } + // Calculate deadline status + const isDeadlinePassed = new Date() > new Date(BRACKET_DEADLINE); - setPredictions(predictionsRecord); - setIsLocked(data.isLocked); - setLockedAt(data.lockedAt); - } catch (err) { - setError( - err instanceof Error ? err.message : "Failed to load predictions", - ); - } finally { - setIsLoading(false); - } - } + // Determine loading state + const isLoading = isAuthenticated && queryIsLoading; - fetchPredictions(); - }, [isAuthenticated]); + // 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; - setPredictions((prev) => { - const newPredictions = { ...prev }; - const oldPlayerId = prev[gameId]; + 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, prev); + const gamesToClear = getGamesToClear(gameId, oldPlayerId, current); for (const clearGameId of gamesToClear) { delete newPredictions[clearGameId]; } @@ -189,56 +206,29 @@ export function usePredictions(isAuthenticated: boolean) { return newPredictions; }); - - setHasChanges(true); - setError(null); }, - [isLocked, isDeadlinePassed], + [isLocked, isDeadlinePassed, queryData?.predictions], ); // Save predictions to the server const savePredictions = useCallback(async () => { - if (isLocked || isDeadlinePassed || !isAuthenticated) return; - - setIsSaving(true); - setError(null); - - try { - 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 (isLocked || isDeadlinePassed || !isAuthenticated || !localPredictions) + return; - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || "Failed to save predictions"); - } - - setHasChanges(false); - } catch (err) { - setError( - err instanceof Error ? err.message : "Failed to save predictions", - ); - } finally { - setIsSaving(false); - } - }, [predictions, isLocked, isDeadlinePassed, isAuthenticated]); + await saveMutation.mutateAsync(localPredictions); + }, [ + localPredictions, + isLocked, + isDeadlinePassed, + isAuthenticated, + saveMutation, + ]); // Reset all predictions const resetPredictions = useCallback(() => { if (isLocked || isDeadlinePassed) return; - setPredictions({}); - setHasChanges(true); - setError(null); + setLocalPredictions({}); }, [isLocked, isDeadlinePassed]); // Lock the bracket @@ -246,39 +236,19 @@ export function usePredictions(isAuthenticated: boolean) { if (isLocked || isDeadlinePassed || !isAuthenticated) return; // First save any unsaved predictions - if (hasChanges) { - await savePredictions(); + if (hasChanges && localPredictions) { + await saveMutation.mutateAsync(localPredictions); } - setIsSaving(true); - setError(null); - - try { - 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"); - } - - const data = await response.json(); - setIsLocked(true); - setLockedAt(data.lockedAt); - setHasChanges(false); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to lock bracket"); - } finally { - setIsSaving(false); - } + await lockMutation.mutateAsync(); }, [ isLocked, isDeadlinePassed, isAuthenticated, hasChanges, - savePredictions, + localPredictions, + saveMutation, + lockMutation, ]); const pickCount = Object.keys(predictions).length; 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/routes/__root.tsx b/src/routes/__root.tsx index 8ee6bf8..7a37643 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, @@ -11,6 +12,15 @@ import { Footer } from "@/components/footer/Footer"; import { Header } from "@/components/Header"; 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: [ @@ -96,23 +106,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 index 00ba763..e1a5416 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -1,9 +1,11 @@ +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"; @@ -253,6 +255,7 @@ function StatCard({ label, value }: { label: string; value: number }) { function AdminPage() { const loaderData = Route.useLoaderData(); + const queryClient = useQueryClient(); const [users, setUsers] = useState(loaderData.users); const [stats, setStats] = useState(loaderData.stats); @@ -357,6 +360,8 @@ function AdminPage() { 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 { diff --git a/src/routes/test.tsx b/src/routes/test.tsx index e41eff5..be87a65 100644 --- a/src/routes/test.tsx +++ b/src/routes/test.tsx @@ -19,11 +19,14 @@ function getBracketResults(): Record { return results; } -export const Route = createFileRoute("/test")({ component: TestPage }); +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); @@ -69,7 +72,7 @@ function TestPage() {
- +

The Bracket

Date: Sat, 31 Jan 2026 20:20:08 -0500 Subject: [PATCH 05/18] Fix players showing grayscale when no picks exist Add "noPick" status to distinguish "no pick made" from "opponent was picked". Remove legacy boolean props from PlayerNode and cleanup grayscale filter. Co-Authored-By: Claude Opus 4.5 --- src/components/bracket/PlayerNode.tsx | 43 +++--------------------- src/components/bracket/bracket.css | 1 - src/components/bracket/nodeGenerators.ts | 6 ++-- src/context/PredictionsContext.tsx | 4 +-- 4 files changed, 10 insertions(+), 44 deletions(-) diff --git a/src/components/bracket/PlayerNode.tsx b/src/components/bracket/PlayerNode.tsx index 5922ab1..2f779f4 100644 --- a/src/components/bracket/PlayerNode.tsx +++ b/src/components/bracket/PlayerNode.tsx @@ -4,7 +4,8 @@ import "./bracket.css"; // User's pick state for this player in this game export type PickState = - | { status: "none" } // User hasn't picked (or this is the unpicked option) + | { 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 @@ -61,17 +62,9 @@ export interface PlayerData { side?: "left" | "right"; round?: "round1" | "later"; showBio?: boolean; - // Structured prediction state (preferred) prediction?: PredictionState; - // Legacy boolean flags (for backward compatibility) - isSelected?: boolean; - isCorrect?: boolean; - isIncorrect?: boolean; - isPickable?: boolean; - isUnpicked?: boolean; playerId?: string; gameId?: string; - onPick?: (gameId: string, playerId: string) => void; [key: string]: unknown; } @@ -86,17 +79,9 @@ interface PlayerNodeProps { side?: "left" | "right"; round?: "round1" | "later"; showBio?: boolean; - // Structured prediction state (preferred) prediction?: PredictionState; - // Legacy boolean flags (for backward compatibility) - isSelected?: boolean; - isCorrect?: boolean; - isIncorrect?: boolean; - isPickable?: boolean; - isUnpicked?: boolean; playerId?: string; gameId?: string; - onPick?: (gameId: string, playerId: string) => void; } export function PlayerNode({ @@ -111,24 +96,12 @@ export function PlayerNode({ round = "later", showBio = true, prediction, - // Legacy boolean props (used if prediction is not provided) - isSelected: legacySelected = false, - isCorrect: legacyCorrect = false, - isIncorrect: legacyIncorrect = false, - isPickable: legacyPickable = false, - isUnpicked: legacyUnpicked = false, playerId, gameId, - onPick: legacyOnPick, }: PlayerNodeProps) { - // Derive flags from structured prediction state or use legacy props - const derived = prediction ? deriveClassFlags(prediction) : null; - const isSelected = derived?.isSelected ?? legacySelected; - const isCorrect = derived?.isCorrect ?? legacyCorrect; - const isIncorrect = derived?.isIncorrect ?? legacyIncorrect; - const isPickable = derived?.isPickable ?? legacyPickable; - const isUnpicked = derived?.isUnpicked ?? legacyUnpicked; - const onPick = prediction?.onPick ?? legacyOnPick; + const { isSelected, isCorrect, isIncorrect, isPickable, isUnpicked } = + deriveClassFlags(prediction); + const onPick = prediction?.onPick; const classNames = [ "player-node", @@ -306,14 +279,8 @@ export const PlayerNodeFlow = memo(function PlayerNodeFlow({ round={data.round} showBio={data.showBio} prediction={data.prediction} - isSelected={data.isSelected} - isCorrect={data.isCorrect} - isIncorrect={data.isIncorrect} - isPickable={data.isPickable} - isUnpicked={data.isUnpicked} playerId={data.playerId} gameId={data.gameId} - onPick={data.onPick} />
diff --git a/src/components/bracket/bracket.css b/src/components/bracket/bracket.css index 9ee027e..fdabd02 100644 --- a/src/components/bracket/bracket.css +++ b/src/components/bracket/bracket.css @@ -123,7 +123,6 @@ 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; diff --git a/src/components/bracket/nodeGenerators.ts b/src/components/bracket/nodeGenerators.ts index 9aeecf4..5a4e1c9 100644 --- a/src/components/bracket/nodeGenerators.ts +++ b/src/components/bracket/nodeGenerators.ts @@ -144,7 +144,7 @@ function getPredictionOptions( const actualWinner = ctx.tournamentResults[game.id]; const defaults: PredictionState = { - pickState: { status: "none" }, + pickState: { status: "noPick" }, interactionMode: "view", }; @@ -187,7 +187,7 @@ function getPredictionOptions( // No result yet - determine pending/none pick state if (userPick) { if (ctx.isLocked && !ctx.showPicks) { - return { pickState: { status: "none" }, interactionMode, onPick }; + return { pickState: { status: "noPick" }, interactionMode, onPick }; } const isPickedForThisGame = userPick === player.id; const pickState: PickState = isPickedForThisGame @@ -196,7 +196,7 @@ function getPredictionOptions( return { pickState, interactionMode, onPick }; } - return { pickState: { status: "none" }, interactionMode, onPick }; + return { pickState: { status: "noPick" }, interactionMode, onPick }; } function isPlayerLoser( diff --git a/src/context/PredictionsContext.tsx b/src/context/PredictionsContext.tsx index 129e9ed..625fd3f 100644 --- a/src/context/PredictionsContext.tsx +++ b/src/context/PredictionsContext.tsx @@ -1,4 +1,4 @@ -import { createContext, type ReactNode, useContext } from "react"; +import { createContext, type ReactNode, use } from "react"; import { type UsePredictionsReturn, usePredictions, @@ -24,5 +24,5 @@ export function PredictionsProvider({ } export function usePredictionsContext() { - return useContext(PredictionsContext); + return use(PredictionsContext); } From c5cbcdbc38927e1103b310d48fb92ee8f3933be9 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sat, 31 Jan 2026 20:57:08 -0500 Subject: [PATCH 06/18] Return basic OG image instead of errors for missing/unlocked brackets Social media crawlers now get a valid preview image instead of 404/403 errors when sharing bracket URLs for non-existent users or unlocked brackets. Also extracts NotFound component for reuse and adds styled 404 page. Co-Authored-By: Claude Opus 4.5 --- src/components/NotFound.tsx | 21 ++++++++++ src/routes/__root.tsx | 10 +---- src/routes/api/og.$username.ts | 39 +++++++++++++++-- src/routes/bracket/$username.tsx | 6 ++- src/styles/styles.css | 72 ++++++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 13 deletions(-) create mode 100644 src/components/NotFound.tsx diff --git a/src/components/NotFound.tsx b/src/components/NotFound.tsx new file mode 100644 index 0000000..088d0a6 --- /dev/null +++ b/src/components/NotFound.tsx @@ -0,0 +1,21 @@ +interface NotFoundProps { + message?: string; +} + +export function NotFound({ message }: NotFoundProps) { + return ( +
+
+
404
+

Out of Bounds!

+

+ {message ?? + "This page doesn't exist. The CSS you're looking for may have been eliminated from the tournament."} +

+ + Back to Tournament + +
+
+ ); +} diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 7a37643..b274245 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -10,6 +10,7 @@ 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({ @@ -90,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 ( diff --git a/src/routes/api/og.$username.ts b/src/routes/api/og.$username.ts index 1ced654..fbd8345 100644 --- a/src/routes/api/og.$username.ts +++ b/src/routes/api/og.$username.ts @@ -6,12 +6,46 @@ 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 @@ -27,7 +61,7 @@ export const Route = createFileRoute("/api/og/$username")({ .limit(1); if (users.length === 0 || !users[0].username) { - return new Response("User not found", { status: 404 }); + return generateBasicOgImage(baseUrl); } const user = users[0]; @@ -40,7 +74,7 @@ export const Route = createFileRoute("/api/og/$username")({ .limit(1); if (!bracketStatus[0]?.isLocked) { - return new Response("Bracket not locked", { status: 403 }); + return generateBasicOgImage(baseUrl); } // Get ALL predictions @@ -69,7 +103,6 @@ export const Route = createFileRoute("/api/og/$username")({ }; // Build absolute URLs - const baseUrl = `${url.protocol}//${url.host}`; const logoUrl = `${baseUrl}/mad-css-logo.png`; const bgImageUrl = `${baseUrl}/madcss-wide.jpg`; const userAvatarUrl = user.image || ""; diff --git a/src/routes/bracket/$username.tsx b/src/routes/bracket/$username.tsx index 3da080a..849f6cb 100644 --- a/src/routes/bracket/$username.tsx +++ b/src/routes/bracket/$username.tsx @@ -2,6 +2,7 @@ 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({ @@ -81,6 +82,9 @@ export const Route = createFileRoute("/bracket/$username")({ } return result; }, + notFoundComponent: () => ( + + ), head: ({ params }) => { const { username } = params; const ogImageUrl = `/api/og/${username}`; @@ -147,7 +151,7 @@ function BracketPage() { - + ); } diff --git a/src/styles/styles.css b/src/styles/styles.css index f64f459..0cf8a16 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -140,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; +} From cf3a1eb020413f0ca92c807918cf4112ec0ded4a Mon Sep 17 00:00:00 2001 From: Wes Bos Date: Fri, 6 Feb 2026 11:30:45 -0500 Subject: [PATCH 07/18] adds db instructions --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 77ee136..92b9fa8 100644 --- a/README.md +++ b/README.md @@ -1 +1,29 @@ # MAD CSS + +## Development + +Just `pnpm install` then `pnpm dev` + +## Database + +Schema changes go through Drizzle. Generate a migration after editing the schema, then apply it. + +```bash +# Generate migration from schema changes +pnpm db:generate + +# Apply migrations locally (uses wrangler D1 --local) +pnpm db:migrate:local + +# Apply migrations to production +pnpm db:migrate:prod + +# Apply migrations to staging +pnpm db:migrate:staging + +# Both generate + migrate local in one shot +pnpm db:setup + +# Browse the database +pnpm db:studio +``` From a320e9aa43c9d1de29de4d7d3967291a30b37d42 Mon Sep 17 00:00:00 2001 From: Wes Bos Date: Fri, 6 Feb 2026 11:31:01 -0500 Subject: [PATCH 08/18] wIP bracket edge colours and animations --- src/components/bracket/Bracket.tsx | 317 +++++++++++++++++++++-- src/components/bracket/bracket.css | 31 ++- src/components/bracket/nodeGenerators.ts | 83 +----- src/routeTree.gen.ts | 12 +- 4 files changed, 343 insertions(+), 100 deletions(-) diff --git a/src/components/bracket/Bracket.tsx b/src/components/bracket/Bracket.tsx index 9809de7..8060ce3 100644 --- a/src/components/bracket/Bracket.tsx +++ b/src/components/bracket/Bracket.tsx @@ -35,6 +35,14 @@ export interface BracketProps { showPicks?: boolean; } +export type EdgeState = "winner" | "loser" | "pending" | "pickable" | "default"; + +export type EdgeHoverState = + | "none" + | "hovered-pick" + | "hovered-competitor" + | "hovered-incoming"; + function BracketEdge({ sourceX, sourceY, @@ -43,16 +51,65 @@ function BracketEdge({ sourcePosition, targetPosition, target, + data, }: EdgeProps) { + const edgeState = (data?.state as EdgeState) ?? "default"; + const hoverState = (data?.hoverState as EdgeHoverState) ?? "none"; + + let stroke = "#FFFFFF"; + let strokeOpacity = 1; + let strokeDasharray: string | undefined; + let className = ""; + + if (hoverState === "hovered-pick") { + stroke = "#22c55e"; + strokeDasharray = "18 6"; + className = "bracket-edge--hover-pick"; + } else if (hoverState === "hovered-competitor") { + stroke = "#ef4444"; + strokeDasharray = "18 6"; + className = "bracket-edge--hover-competitor"; + } else if (hoverState === "hovered-incoming") { + stroke = "#FFFFFF"; + strokeDasharray = "8 6"; + className = "bracket-edge--pickable"; + } else { + switch (edgeState) { + case "winner": + stroke = "#FFFFFF"; + strokeOpacity = 1; + break; + case "loser": + stroke = "#FFFFFF"; + strokeOpacity = 0.5; + break; + case "pending": + stroke = "#FFFFFF"; + strokeDasharray = "8 6"; + className = "bracket-edge--pending"; + break; + case "pickable": + stroke = "#FFFFFF"; + strokeDasharray = "8 6"; + className = "bracket-edge--pickable"; + break; + default: + break; + } + } + if (target === "championship") { const horizontalY = sourceY + 30; const edgePath = `M ${sourceX} ${sourceY} L ${sourceX} ${horizontalY} L ${targetX} ${horizontalY} L ${targetX} ${targetY - 35}`; return ( ); @@ -67,7 +124,17 @@ function BracketEdge({ targetPosition, borderRadius: 0, }); - return ; + return ( + + ); } const nodeTypes = { @@ -132,17 +199,143 @@ const edgeStyle: React.CSSProperties = { filter: "drop-shadow(0px 0px 7px black)", }; -function generateEdges(): Edge[] { +function parseSourceNodeId(sourceNodeId: string): { + gameId: string; + slot: "p1" | "p2"; +} { + const lastDash = sourceNodeId.lastIndexOf("-"); + const slot = sourceNodeId.slice(lastDash + 1) as "p1" | "p2"; + const gameId = sourceNodeId.slice(0, lastDash); + return { gameId, slot }; +} + +function getSourcePlayerId( + sourceNodeId: string, + nodes: Node[], +): string | undefined { + const node = nodes.find((n) => n.id === sourceNodeId); + if (!node) return undefined; + return (node.data as { playerId?: string })?.playerId; +} + +function computeEdgeState( + sourceNodeId: string, + targetNodeId: string, + tournamentResults: Record, + nodes: Node[], + allEdges: Edge[], +): EdgeState { + const { gameId } = parseSourceNodeId(sourceNodeId); + const playerId = getSourcePlayerId(sourceNodeId, nodes); + + // Finalist-to-championship edges: check if the final game is decided + if (sourceNodeId === "left-finalist" || sourceNodeId === "right-finalist") { + const finalWinner = tournamentResults.final; + if (finalWinner && playerId) { + return finalWinner === playerId ? "winner" : "loser"; + } + const targetNode = nodes.find((n) => n.id === targetNodeId); + const targetIsEmpty = targetNode?.type === "emptySlot"; + if (targetIsEmpty) { + const feedingEdges = allEdges.filter((e) => e.target === targetNodeId); + const allSourcesArePlayerNodes = feedingEdges.every((e) => { + const srcNode = nodes.find((n) => n.id === e.source); + return srcNode?.type === "playerNode"; + }); + if (feedingEdges.length >= 2 && allSourcesArePlayerNodes) { + return "pickable"; + } + return "pending"; + } + return "default"; + } + + const winner = tournamentResults[gameId]; + + if (winner && playerId) { + return winner === playerId ? "winner" : "loser"; + } + + // No result yet -- check if the target is an empty slot (pending/pickable) + const targetNode = nodes.find((n) => n.id === targetNodeId); + const targetIsEmpty = targetNode?.type === "emptySlot"; + + if (targetIsEmpty) { + const feedingEdges = allEdges.filter((e) => e.target === targetNodeId); + const allSourcesArePlayerNodes = feedingEdges.every((e) => { + const srcNode = nodes.find((n) => n.id === e.source); + return srcNode?.type === "playerNode"; + }); + if (feedingEdges.length >= 2 && allSourcesArePlayerNodes) { + return "pickable"; + } + return "pending"; + } + + return "default"; +} + +function computeEdgeHoverState( + edgeSource: string, + edgeTarget: string, + hoveredNodeId: string | null, + hoveredNodeType: "player" | "empty" | null, + edges: Edge[], +): EdgeHoverState { + if (!hoveredNodeId || !hoveredNodeType) return "none"; + + // Hovering an empty slot: highlight all edges flowing INTO it + if (hoveredNodeType === "empty") { + if (edgeTarget === hoveredNodeId) return "hovered-incoming"; + return "none"; + } + + // Hovering a player node: green ants on hovered player's edges, red ants on competitor + const hoveredEdges = edges.filter((e) => e.source === hoveredNodeId); + if (hoveredEdges.length === 0) return "none"; + + if (edgeSource === hoveredNodeId) return "hovered-pick"; + + for (const hoveredEdge of hoveredEdges) { + const siblingEdges = edges.filter( + (e) => e.target === hoveredEdge.target && e.source !== hoveredNodeId, + ); + if (siblingEdges.some((e) => e.source === edgeSource)) { + return "hovered-competitor"; + } + } + + return "none"; +} + +interface EdgeGeneratorContext { + tournamentResults: Record; + nodes: Node[]; + hoveredNodeId: string | null; + hoveredNodeType: "player" | "empty" | null; +} + +function generateEdges(ctx: EdgeGeneratorContext): Edge[] { const edges: Edge[] = []; const round1 = splitForDisplay(bracket.round1); const quarters = splitForDisplay(bracket.quarters); const semis = splitForDisplay(bracket.semis); + function pushEdge(edge: Omit) { + edges.push({ + ...edge, + data: { + state: "default" as EdgeState, + hoverState: "none" as EdgeHoverState, + }, + }); + } + // LEFT SIDE EDGES round1.left.forEach((game, gameIndex) => { const quarterGame = quarters.left[Math.floor(gameIndex / 2)]; - edges.push({ + pushEdge({ id: `${game.id}-p1-to-${quarterGame.id}`, source: `${game.id}-p1`, target: `${quarterGame.id}-p${(gameIndex % 2) + 1}`, @@ -152,7 +345,7 @@ function generateEdges(): Edge[] { targetHandle: "in-top", }); - edges.push({ + pushEdge({ id: `${game.id}-p2-to-${quarterGame.id}`, source: `${game.id}-p2`, target: `${quarterGame.id}-p${(gameIndex % 2) + 1}`, @@ -165,7 +358,7 @@ function generateEdges(): Edge[] { quarters.left.forEach((game, gameIndex) => { const semiGame = semis.left[0]; - edges.push({ + pushEdge({ id: `${game.id}-p1-to-${semiGame.id}`, source: `${game.id}-p1`, target: `${semiGame.id}-p${gameIndex + 1}`, @@ -175,7 +368,7 @@ function generateEdges(): Edge[] { targetHandle: "in-top", }); - edges.push({ + pushEdge({ id: `${game.id}-p2-to-${semiGame.id}`, source: `${game.id}-p2`, target: `${semiGame.id}-p${gameIndex + 1}`, @@ -187,7 +380,7 @@ function generateEdges(): Edge[] { }); semis.left.forEach((game) => { - edges.push({ + pushEdge({ id: `${game.id}-p1-to-left-finalist`, source: `${game.id}-p1`, target: `left-finalist`, @@ -197,7 +390,7 @@ function generateEdges(): Edge[] { targetHandle: "in-top", }); - edges.push({ + pushEdge({ id: `${game.id}-p2-to-left-finalist`, source: `${game.id}-p2`, target: `left-finalist`, @@ -208,7 +401,7 @@ function generateEdges(): Edge[] { }); }); - edges.push({ + pushEdge({ id: "left-finalist-to-champ", source: `left-finalist`, target: "championship", @@ -222,7 +415,7 @@ function generateEdges(): Edge[] { round1.right.forEach((game, gameIndex) => { const quarterGame = quarters.right[Math.floor(gameIndex / 2)]; - edges.push({ + pushEdge({ id: `${game.id}-p1-to-${quarterGame.id}`, source: `${game.id}-p1`, target: `${quarterGame.id}-p${(gameIndex % 2) + 1}`, @@ -232,7 +425,7 @@ function generateEdges(): Edge[] { targetHandle: "in-top", }); - edges.push({ + pushEdge({ id: `${game.id}-p2-to-${quarterGame.id}`, source: `${game.id}-p2`, target: `${quarterGame.id}-p${(gameIndex % 2) + 1}`, @@ -246,7 +439,7 @@ function generateEdges(): Edge[] { quarters.right.forEach((game, gameIndex) => { const semiGame = semis.right[0]; - edges.push({ + pushEdge({ id: `${game.id}-p1-to-${semiGame.id}`, source: `${game.id}-p1`, target: `${semiGame.id}-p${gameIndex + 1}`, @@ -256,7 +449,7 @@ function generateEdges(): Edge[] { targetHandle: "in-top", }); - edges.push({ + pushEdge({ id: `${game.id}-p2-to-${semiGame.id}`, source: `${game.id}-p2`, target: `${semiGame.id}-p${gameIndex + 1}`, @@ -268,7 +461,7 @@ function generateEdges(): Edge[] { }); semis.right.forEach((game) => { - edges.push({ + pushEdge({ id: `${game.id}-p1-to-right-finalist`, source: `${game.id}-p1`, target: `right-finalist`, @@ -278,7 +471,7 @@ function generateEdges(): Edge[] { targetHandle: "in-top", }); - edges.push({ + pushEdge({ id: `${game.id}-p2-to-right-finalist`, source: `${game.id}-p2`, target: `right-finalist`, @@ -289,7 +482,7 @@ function generateEdges(): Edge[] { }); }); - edges.push({ + pushEdge({ id: "right-finalist-to-champ", source: `right-finalist`, target: "championship", @@ -299,6 +492,30 @@ function generateEdges(): Edge[] { targetHandle: "in-bottom", }); + // Pass 1: compute edge states (needs full edge list to check siblings) + for (const edge of edges) { + const state = computeEdgeState( + edge.source, + edge.target, + ctx.tournamentResults, + ctx.nodes, + edges, + ); + (edge.data as { state: EdgeState }).state = state; + } + + // Pass 2: apply hover states + for (const edge of edges) { + const hoverState = computeEdgeHoverState( + edge.source, + edge.target, + ctx.hoveredNodeId, + ctx.hoveredNodeType, + edges, + ); + (edge.data as { hoverState: EdgeHoverState }).hoverState = hoverState; + } + return edges; } @@ -326,6 +543,7 @@ function BracketContent({ const isLocked = ctx?.isLocked ?? propsIsLocked ?? false; const isPickingEnabled = isInteractive && isAuthenticated && !isLocked; + const nodes = useMemo( () => generateNodes( @@ -347,7 +565,50 @@ function BracketContent({ isLocked, ], ); - const edges = useMemo(() => generateEdges(), []); + + const [hoveredNodeId, setHoveredNodeId] = useState(null); + const [hoveredNodeType, setHoveredNodeType] = useState< + "player" | "empty" | null + >(null); + + const edges = useMemo( + () => + generateEdges({ + tournamentResults, + nodes, + hoveredNodeId, + hoveredNodeType, + }), + [tournamentResults, nodes, hoveredNodeId, hoveredNodeType], + ); + + const styledNodes = useMemo(() => { + const emptySlotIds = new Set( + nodes.filter((n) => n.type === "emptySlot").map((n) => n.id), + ); + const pickableSlots = new Set(); + for (const slotId of emptySlotIds) { + const feedingEdges = edges.filter((e) => e.target === slotId); + const allPlayerSources = + feedingEdges.length >= 2 && + feedingEdges.every((e) => { + const src = nodes.find((n) => n.id === e.source); + return src?.type === "playerNode"; + }); + if (allPlayerSources) { + pickableSlots.add(slotId); + } + } + return nodes.map((node) => { + if (node.type === "emptySlot" && !pickableSlots.has(node.id)) { + return { + ...node, + style: { ...node.style, filter: "brightness(0.2)" }, + }; + } + return node; + }); + }, [nodes, edges]); const containerRef = useRef(null); const [containerHeight, setContainerHeight] = useState(null); const rfInstanceRef = useRef(null); @@ -429,7 +690,7 @@ function BracketContent({ onMouseLeave={handleMouseLeave} > { + if (node.type === "emptySlot") { + setHoveredNodeId(node.id); + setHoveredNodeType("empty"); + return; + } + const data = node.data as { + prediction?: { interactionMode?: string }; + }; + if (data.prediction?.interactionMode === "pickable") { + setHoveredNodeId(node.id); + setHoveredNodeType("player"); + } + }} + onNodeMouseLeave={() => { + setHoveredNodeId(null); + setHoveredNodeType(null); + }} onNodeClick={(_event, node) => { const data = node.data as { isPickable?: boolean; diff --git a/src/components/bracket/bracket.css b/src/components/bracket/bracket.css index fdabd02..0e163f3 100644 --- a/src/components/bracket/bracket.css +++ b/src/components/bracket/bracket.css @@ -102,6 +102,7 @@ flex-shrink: 0; transition: box-shadow 0.3s ease, + transform 0.3s ease, --ring-color 0.3s ease, --inner-ring-color 0.3s ease; } @@ -264,8 +265,8 @@ cursor: pointer; } -.player-node--pickable:hover .player-photo { - transform: translateY(calc(var(--brain-size) * -1)) scale(1.1); +.player-node--pickable:hover .player-photo-ring { + transform: scale(1.1); } .player-node--pickable:focus { @@ -344,6 +345,32 @@ color: var(--white); } +/* Edge State Animations */ +@keyframes marching-ants { + 0% { + stroke-dashoffset: 0; + } + 100% { + stroke-dashoffset: -28; + } +} + +.bracket-edge--pickable { + animation: marching-ants 0.6s linear infinite; +} + +.bracket-edge--hover-pick { + animation: marching-ants 0.4s linear infinite; +} + +.bracket-edge--hover-competitor { + animation: marching-ants 0.4s linear infinite; +} + +.bracket-edge--pending { + filter: brightness(0.2) !important; +} + /* React Flow Container */ .bracket-container { width: 100%; diff --git a/src/components/bracket/nodeGenerators.ts b/src/components/bracket/nodeGenerators.ts index 5a4e1c9..74747db 100644 --- a/src/components/bracket/nodeGenerators.ts +++ b/src/components/bracket/nodeGenerators.ts @@ -1,7 +1,6 @@ import type { Node } from "@xyflow/react"; import { bracket, - FEEDER_GAMES, type Game, isLoser, isWinner, @@ -20,29 +19,6 @@ import { } 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; } @@ -224,13 +200,6 @@ export function generateRound1Nodes({ 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; @@ -242,7 +211,7 @@ export function generateRound1Nodes({ ringColor, { x: xPos, y: baseY }, side, - gameLarge ? "round1" : "later", + "round1", undefined, { prediction: p1Options, showBio: true, isLoser: p1Loser }, ), @@ -258,7 +227,7 @@ export function generateRound1Nodes({ ringColor, { x: xPos, y: baseY + MATCH_GAP }, side, - gameLarge ? "round1" : "later", + "round1", undefined, { prediction: p2Options, showBio: true, isLoser: p2Loser }, ), @@ -279,19 +248,7 @@ export function generateQuarterNodes({ 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 qfOffset = MATCH_GAP * 0.637; const baseY = gameIndex * 4 * MATCH_GAP + qfOffset; let player1: Player | undefined; @@ -323,7 +280,7 @@ export function generateQuarterNodes({ ringColor, { x: xPos, y: baseY }, side, - p1Large ? "round1" : "later", + "later", "TBD", { prediction: p1Options, showBio: false, isLoser: p1Loser }, ), @@ -339,7 +296,7 @@ export function generateQuarterNodes({ ringColor, { x: xPos, y: baseY + 2 * MATCH_GAP }, side, - p2Large ? "round1" : "later", + "later", "TBD", { prediction: p2Options, showBio: false, isLoser: p2Loser }, ), @@ -360,20 +317,7 @@ export function generateSemiNodes({ 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; + const baseY = 1.5 * MATCH_GAP; let player1: Player | undefined; let player2: Player | undefined; @@ -404,7 +348,7 @@ export function generateSemiNodes({ ringColor, { x: xPos, y: baseY }, side, - p1Large ? "round1" : "later", + "later", "TBD", { prediction: p1Options, showBio: false, isLoser: p1Loser }, ), @@ -420,7 +364,7 @@ export function generateSemiNodes({ ringColor, { x: xPos, y: baseY + 4 * MATCH_GAP }, side, - p2Large ? "round1" : "later", + "later", "TBD", { prediction: p2Options, showBio: false, isLoser: p2Loser }, ), @@ -436,13 +380,6 @@ export function generateFinalistNode({ }: 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"; @@ -479,9 +416,9 @@ export function generateFinalistNode({ finalist, finalGame, ringColor, - { x: xPos, y: (finalistLarge ? 3.35 : 3.5) * MATCH_GAP }, + { x: xPos, y: 3.5 * MATCH_GAP }, side, - finalistLarge ? "round1" : "later", + "later", "Finalist TBD", { prediction: finalistOptions, showBio: false, isLoser: finalistLoser }, ); diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 07f62ff..e117205 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -107,8 +107,8 @@ export interface FileRoutesByFullPath { '/api/leaderboard/calculate': typeof ApiLeaderboardCalculateRoute '/api/og/$username': typeof ApiOgUsernameRoute '/api/predictions/lock': typeof ApiPredictionsLockRoute - '/api/leaderboard': typeof ApiLeaderboardIndexRoute - '/api/predictions': typeof ApiPredictionsIndexRoute + '/api/leaderboard/': typeof ApiLeaderboardIndexRoute + '/api/predictions/': typeof ApiPredictionsIndexRoute '/api/admin/brackets/unlock': typeof ApiAdminBracketsUnlockRoute } export interface FileRoutesByTo { @@ -158,8 +158,8 @@ export interface FileRouteTypes { | '/api/leaderboard/calculate' | '/api/og/$username' | '/api/predictions/lock' - | '/api/leaderboard' - | '/api/predictions' + | '/api/leaderboard/' + | '/api/predictions/' | '/api/admin/brackets/unlock' fileRoutesByTo: FileRoutesByTo to: @@ -245,14 +245,14 @@ declare module '@tanstack/react-router' { '/api/predictions/': { id: '/api/predictions/' path: '/api/predictions' - fullPath: '/api/predictions' + fullPath: '/api/predictions/' preLoaderRoute: typeof ApiPredictionsIndexRouteImport parentRoute: typeof rootRouteImport } '/api/leaderboard/': { id: '/api/leaderboard/' path: '/api/leaderboard' - fullPath: '/api/leaderboard' + fullPath: '/api/leaderboard/' preLoaderRoute: typeof ApiLeaderboardIndexRouteImport parentRoute: typeof rootRouteImport } From c83697a072346fbd30b622e5d46b052446fb9505 Mon Sep 17 00:00:00 2001 From: Wes Bos Date: Fri, 6 Feb 2026 12:00:09 -0500 Subject: [PATCH 09/18] adds STANDINGS/PICKS toggle --- src/components/bracket/Bracket.tsx | 226 +++++++++++++++-------- src/components/bracket/bracket.css | 61 +++++- src/components/bracket/nodeGenerators.ts | 8 +- src/routes/test.tsx | 1 + 4 files changed, 216 insertions(+), 80 deletions(-) diff --git a/src/components/bracket/Bracket.tsx b/src/components/bracket/Bracket.tsx index 8060ce3..54c330e 100644 --- a/src/components/bracket/Bracket.tsx +++ b/src/components/bracket/Bracket.tsx @@ -33,6 +33,36 @@ export interface BracketProps { getPickablePlayers?: (gameId: string) => string[]; tournamentResults?: Record; showPicks?: boolean; + onToggleShowPicks?: () => void; +} + +function BracketToggle({ + showPicks, + onToggle, +}: { + showPicks: boolean; + onToggle: () => void; +}) { + return ( +
+ + +
+ ); } export type EdgeState = "winner" | "loser" | "pending" | "pickable" | "default"; @@ -199,6 +229,46 @@ const edgeStyle: React.CSSProperties = { filter: "drop-shadow(0px 0px 7px black)", }; +const EDGE_SOURCE_MAP: Map = (() => { + const map = new Map(); + function add(source: string, target: string) { + const existing = map.get(target); + if (existing) { + existing.push(source); + } else { + map.set(target, [source]); + } + } + const r1 = splitForDisplay(bracket.round1); + const qf = splitForDisplay(bracket.quarters); + const sf = splitForDisplay(bracket.semis); + + for (const [side, r1Games, qfGames, sfGames] of [ + ["left", r1.left, qf.left, sf.left], + ["right", r1.right, qf.right, sf.right], + ] as const) { + r1Games.forEach((game, i) => { + const qfGame = qfGames[Math.floor(i / 2)]; + const target = `${qfGame.id}-p${(i % 2) + 1}`; + add(`${game.id}-p1`, target); + add(`${game.id}-p2`, target); + }); + qfGames.forEach((game, i) => { + const semiGame = sfGames[0]; + const target = `${semiGame.id}-p${i + 1}`; + add(`${game.id}-p1`, target); + add(`${game.id}-p2`, target); + }); + sfGames.forEach((game) => { + const target = `${side}-finalist`; + add(`${game.id}-p1`, target); + add(`${game.id}-p2`, target); + }); + add(`${side}-finalist`, "championship"); + } + return map; +})(); + function parseSourceNodeId(sourceNodeId: string): { gameId: string; slot: "p1" | "p2"; @@ -534,6 +604,7 @@ function BracketContent({ isAuthenticated = false, tournamentResults = {}, showPicks = false, + onToggleShowPicks, }: BracketProps) { const ctx = usePredictionsContext(); @@ -583,32 +654,27 @@ function BracketContent({ ); const styledNodes = useMemo(() => { - const emptySlotIds = new Set( - nodes.filter((n) => n.type === "emptySlot").map((n) => n.id), - ); + const nodeTypeMap = new Map(nodes.map((n) => [n.id, n.type])); const pickableSlots = new Set(); - for (const slotId of emptySlotIds) { - const feedingEdges = edges.filter((e) => e.target === slotId); - const allPlayerSources = - feedingEdges.length >= 2 && - feedingEdges.every((e) => { - const src = nodes.find((n) => n.id === e.source); - return src?.type === "playerNode"; - }); - if (allPlayerSources) { - pickableSlots.add(slotId); + for (const [targetId, sourceIds] of EDGE_SOURCE_MAP) { + if ( + nodeTypeMap.get(targetId) === "emptySlot" && + sourceIds.length >= 2 && + sourceIds.every((id) => nodeTypeMap.get(id) === "playerNode") + ) { + pickableSlots.add(targetId); } } return nodes.map((node) => { if (node.type === "emptySlot" && !pickableSlots.has(node.id)) { return { ...node, - style: { ...node.style, filter: "brightness(0.2)" }, + style: { ...node.style, filter: "brightness(0.5)" }, }; } return node; }); - }, [nodes, edges]); + }, [nodes]); const containerRef = useRef(null); const [containerHeight, setContainerHeight] = useState(null); const rfInstanceRef = useRef(null); @@ -681,67 +747,77 @@ function BracketContent({ }, [containerHeight]); return ( - // biome-ignore lint/a11y/noStaticElementInteractions: mouse events for scroll/zoom unlock UX -
- { - if (node.type === "emptySlot") { - setHoveredNodeId(node.id); - setHoveredNodeType("empty"); - return; - } - const data = node.data as { - prediction?: { interactionMode?: string }; - }; - if (data.prediction?.interactionMode === "pickable") { - setHoveredNodeId(node.id); - setHoveredNodeType("player"); - } - }} - onNodeMouseLeave={() => { - setHoveredNodeId(null); - setHoveredNodeType(null); - }} - onNodeClick={(_event, node) => { - 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); - } - }} + <> + {onToggleShowPicks && ( + + )} + {/* biome-ignore lint/a11y/noStaticElementInteractions: mouse events for scroll/zoom unlock UX */} +
- - -
+ { + if (node.type === "emptySlot") { + setHoveredNodeId(node.id); + setHoveredNodeType("empty"); + return; + } + const data = node.data as { + prediction?: { interactionMode?: string }; + }; + if (data.prediction?.interactionMode === "pickable") { + setHoveredNodeId(node.id); + setHoveredNodeType("player"); + } + }} + onNodeMouseLeave={() => { + setHoveredNodeId(null); + setHoveredNodeType(null); + }} + onNodeClick={(_event, node) => { + 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); + } + }} + > + + +
+ ); } diff --git a/src/components/bracket/bracket.css b/src/components/bracket/bracket.css index 0e163f3..0a6a24d 100644 --- a/src/components/bracket/bracket.css +++ b/src/components/bracket/bracket.css @@ -368,7 +368,66 @@ } .bracket-edge--pending { - filter: brightness(0.2) !important; + filter: brightness(0.5) !important; +} + +/* Standings mode: disable animations and brightness filters */ +.bracket-container--standings .bracket-edge--pickable, +.bracket-container--standings .bracket-edge--hover-pick, +.bracket-container--standings .bracket-edge--hover-competitor { + animation: none; +} + +.bracket-container--standings .bracket-edge--pending { + filter: none !important; +} + +.bracket-container--standings .react-flow__node { + filter: none !important; +} + +/* Bracket Toggle */ +.bracket-toggle { + display: flex; + justify-content: center; + gap: 0; + margin-bottom: 16px; +} + +.bracket-toggle__btn { + font-family: var(--font-block); + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 10px 24px; + border: 3px solid var(--black); + background: transparent; + color: var(--black); + cursor: pointer; + transition: + background 0.15s, + color 0.15s, + box-shadow 0.15s, + transform 0.1s; +} + +.bracket-toggle__btn:first-child { + border-right: none; +} + +.bracket-toggle__btn--active { + background: var(--yellow); + color: var(--black); + box-shadow: 4px 4px 0 var(--black); + cursor: default; +} + +.bracket-toggle__btn:not(.bracket-toggle__btn--active):hover { + background: var(--beige); +} + +.bracket-toggle__btn:not(.bracket-toggle__btn--active):active { + transform: translate(2px, 2px); } /* React Flow Container */ diff --git a/src/components/bracket/nodeGenerators.ts b/src/components/bracket/nodeGenerators.ts index 74747db..e317318 100644 --- a/src/components/bracket/nodeGenerators.ts +++ b/src/components/bracket/nodeGenerators.ts @@ -253,7 +253,7 @@ export function generateQuarterNodes({ let player1: Player | undefined; let player2: Player | undefined; - if (ctx.showPicks || (ctx.isInteractive && !ctx.isLocked)) { + if (ctx.showPicks) { const pickablePlayers = ctx.pickablePlayersCache[game.id]; player1 = pickablePlayers[0] ? players.find((p) => p.id === pickablePlayers[0]) @@ -321,7 +321,7 @@ export function generateSemiNodes({ let player1: Player | undefined; let player2: Player | undefined; - if (ctx.showPicks || (ctx.isInteractive && !ctx.isLocked)) { + if (ctx.showPicks) { const pickablePlayers = ctx.pickablePlayersCache[game.id]; player1 = pickablePlayers[0] ? players.find((p) => p.id === pickablePlayers[0]) @@ -384,7 +384,7 @@ export function generateFinalistNode({ let finalist: Player | undefined; const sfGameId = side === "left" ? "sf-0" : "sf-1"; - if (ctx.showPicks || (ctx.isInteractive && !ctx.isLocked)) { + if (ctx.showPicks) { const finalistId = ctx.predictions[sfGameId]; finalist = finalistId ? players.find((p) => p.id === finalistId) @@ -428,7 +428,7 @@ export function generateChampionshipNode(ctx: NodeContext): Node { const finalGame = bracket.finals[0]; let champion: Player | undefined; - if (ctx.showPicks || (ctx.isInteractive && !ctx.isLocked)) { + if (ctx.showPicks) { const championId = ctx.predictions.final; champion = championId ? players.find((p) => p.id === championId) diff --git a/src/routes/test.tsx b/src/routes/test.tsx index be87a65..e01df58 100644 --- a/src/routes/test.tsx +++ b/src/routes/test.tsx @@ -85,6 +85,7 @@ function TestPage() { isAuthenticated={isAuthenticated} tournamentResults={tournamentResults} showPicks={showPicks} + onToggleShowPicks={() => setShowPicks(!showPicks)} />
From 688c91b3cf151488484a70710c0c9adde5f2010a Mon Sep 17 00:00:00 2001 From: Wes Bos Date: Fri, 6 Feb 2026 12:41:29 -0500 Subject: [PATCH 10/18] Adds air dates to bracket --- src/components/bracket/Bracket.tsx | 4 +- src/components/bracket/PlayerNode.tsx | 56 +++++++++++++++ src/components/bracket/bracket.css | 92 ++++++++++++++---------- src/components/bracket/nodeGenerators.ts | 90 +++++++++++++++++++---- src/data/players.ts | 63 ++++++++++------ 5 files changed, 234 insertions(+), 71 deletions(-) diff --git a/src/components/bracket/Bracket.tsx b/src/components/bracket/Bracket.tsx index 54c330e..9327553 100644 --- a/src/components/bracket/Bracket.tsx +++ b/src/components/bracket/Bracket.tsx @@ -47,7 +47,7 @@ function BracketToggle({
); @@ -281,23 +295,51 @@ export const PlayerNodeFlow = memo(function PlayerNodeFlow({ prediction={data.prediction} playerId={data.playerId} gameId={data.gameId} + youtubeUrl={data.youtubeUrl} /> ); }); +function formatAirDate(isoDate: string): string { + const d = new Date(isoDate); + return d.toLocaleDateString("en-US", { + month: "long", + day: "numeric", + }); +} + +function YouTubeIcon() { + return ( + + ); +} + // Empty slot for matches not yet played export function EmptySlot({ text, side = "left", ringColor, round = "later", + airDate, + youtubeUrl, }: { text?: string; side?: "left" | "right"; ringColor?: string; round?: "round1" | "later"; + airDate?: string; + youtubeUrl?: string; }) { const classNames = [ "player-node", @@ -322,6 +364,16 @@ export function EmptySlot({

{text || "TBD"}

+ {airDate && ( + + {formatAirDate(airDate)} + + )}
); @@ -336,6 +388,8 @@ export const EmptySlotFlow = memo(function EmptySlotFlow({ side?: "left" | "right"; ringColor?: string; round?: "round1" | "later"; + airDate?: string; + youtubeUrl?: string; }; }) { return ( @@ -345,6 +399,8 @@ export const EmptySlotFlow = memo(function EmptySlotFlow({ side={data?.side} ringColor={data?.ringColor} round={data?.round} + airDate={data?.airDate} + youtubeUrl={data?.youtubeUrl} /> diff --git a/src/components/bracket/bracket.css b/src/components/bracket/bracket.css index 0a6a24d..6d5f67f 100644 --- a/src/components/bracket/bracket.css +++ b/src/components/bracket/bracket.css @@ -26,13 +26,13 @@ align-items: center; width: auto; .player-name { - padding-top: 20px; + /* padding-top: 20px; */ } .player-info { text-align: center; margin-left: 0; margin-right: 0; - padding: 10px 16px; + padding: 30px 16px 10px 16px; } .player-photo-ring { margin-left: 0; @@ -178,6 +178,11 @@ flex: 1; min-width: 200px; border: 2px solid var(--black); + &:has(a.player-youtube-link) { + display: flex; + align-items: center; + gap: 5px; + } } .player-name { @@ -186,9 +191,9 @@ color: var(--black); margin: 0; font-weight: 800; - line-height: 1.1; text-transform: uppercase; letter-spacing: 0.02em; + text-box: trim-both cap alphabetic; } .player-byline { @@ -199,6 +204,21 @@ white-space: nowrap; } +/* YouTube / air date links */ +.player-youtube-link { + display: inline-flex; + align-items: center; + gap: 3px; + color: black; + text-decoration: none; + font-family: var(--font-sans), sans-serif; + font-size: 12px; + transition: opacity 0.15s; + path { + color: red; + } +} + /* Winner State */ .player-node--winner .player-photo { filter: grayscale(0%); @@ -392,42 +412,42 @@ justify-content: center; gap: 0; margin-bottom: 16px; -} -.bracket-toggle__btn { - font-family: var(--font-block); - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.05em; - padding: 10px 24px; - border: 3px solid var(--black); - background: transparent; - color: var(--black); - cursor: pointer; - transition: - background 0.15s, - color 0.15s, - box-shadow 0.15s, - transform 0.1s; -} - -.bracket-toggle__btn:first-child { - border-right: none; -} + button { + font-family: var(--font-block); + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 10px 24px; + border: 3px solid var(--black); + background: transparent; + color: var(--black); + cursor: pointer; + transition: + background 0.15s, + color 0.15s, + box-shadow 0.15s, + transform 0.1s; + + &:first-child { + border-right: none; + } -.bracket-toggle__btn--active { - background: var(--yellow); - color: var(--black); - box-shadow: 4px 4px 0 var(--black); - cursor: default; -} + &.active { + background: var(--yellow); + color: var(--black); + box-shadow: 4px 4px 0 var(--black); + cursor: default; + } -.bracket-toggle__btn:not(.bracket-toggle__btn--active):hover { - background: var(--beige); -} + &:not(.active):hover { + background: var(--beige); + } -.bracket-toggle__btn:not(.bracket-toggle__btn--active):active { - transform: translate(2px, 2px); + &:not(.active):active { + transform: translate(2px, 2px); + } + } } /* React Flow Container */ @@ -453,7 +473,7 @@ z-index: 10; } -.bracket-container path { +.bracket-container path:not(a path) { filter: drop-shadow(0px 0px 7px black); } diff --git a/src/components/bracket/nodeGenerators.ts b/src/components/bracket/nodeGenerators.ts index e317318..a1cbbc1 100644 --- a/src/components/bracket/nodeGenerators.ts +++ b/src/components/bracket/nodeGenerators.ts @@ -1,12 +1,16 @@ import type { Node } from "@xyflow/react"; import { bracket, + FEEDER_GAMES, + GAME_LINKS, type Game, + getAirDateForGame, isLoser, isWinner, type Player, players, splitForDisplay, + YOUTUBE_CHANNEL, } from "@/data/players"; import { getPlayerById, getPlayersForGame } from "@/lib/simulation"; import type { NodeContext, RoundGeneratorOptions } from "./bracketTypes"; @@ -40,6 +44,7 @@ function playerToNodeData( prediction?: PredictionState; isLoser?: boolean; showBio?: boolean; + tournamentResults?: Record; }, ): { photo: string; @@ -55,15 +60,28 @@ function playerToNodeData( prediction?: PredictionState; playerId?: string; gameId?: string; + youtubeUrl?: string; } { const isEliminated = options?.isLoser !== undefined ? options.isLoser : isLoser(game, player); + const results = options?.tournamentResults ?? {}; + const playerIsWinner = + isWinner(game, player) || results[game.id] === player.id; + + let youtubeUrl: string | undefined; + if (!game.id.startsWith("r1-")) { + const feederGameId = getFeederGameForPlayer(game.id, player.id, results); + if (feederGameId) { + youtubeUrl = getYoutubeUrl(feederGameId); + } + } + return { photo: getPhotoPath(player, isEliminated), name: player.name, byline: player.byline, ringColor, - isWinner: isWinner(game, player), + isWinner: playerIsWinner, isEliminated, isLoser: isEliminated, showBio: options?.showBio ?? true, @@ -72,9 +90,26 @@ function playerToNodeData( prediction: options?.prediction, playerId: player.id, gameId: game.id, + youtubeUrl, }; } +function getYoutubeUrl(gameId: string): string { + const videoId = GAME_LINKS[gameId]; + if (videoId) return `https://www.youtube.com/watch?v=${videoId}`; + return YOUTUBE_CHANNEL; +} + +function getFeederGameForPlayer( + gameId: string, + playerId: string, + tournamentResults: Record, +): string | undefined { + const feeders = FEEDER_GAMES[gameId]; + if (!feeders) return undefined; + return feeders.find((fId) => tournamentResults[fId] === playerId); +} + function createNode( id: string, player: Player | undefined, @@ -89,20 +124,32 @@ function createNode( isLoser?: boolean; showBio?: boolean; }, + tournamentResults?: Record, ): Node { if (player) { return { id, type: "playerNode", position, - data: playerToNodeData(player, game, ringColor, side, round, nodeOptions), + data: playerToNodeData(player, game, ringColor, side, round, { + ...nodeOptions, + tournamentResults, + }), }; } + const airDate = getAirDateForGame(game.id); return { id, type: "emptySlot", position, - data: { text: emptyText, side, ringColor, round }, + data: { + text: emptyText, + side, + ringColor, + round, + airDate, + youtubeUrl: getYoutubeUrl(game.id), + }, }; } @@ -214,6 +261,7 @@ export function generateRound1Nodes({ "round1", undefined, { prediction: p1Options, showBio: true, isLoser: p1Loser }, + ctx.tournamentResults, ), ); @@ -230,6 +278,7 @@ export function generateRound1Nodes({ "round1", undefined, { prediction: p2Options, showBio: true, isLoser: p2Loser }, + ctx.tournamentResults, ), ); }); @@ -281,8 +330,9 @@ export function generateQuarterNodes({ { x: xPos, y: baseY }, side, "later", - "TBD", + "Quarter Finalist", { prediction: p1Options, showBio: false, isLoser: p1Loser }, + ctx.tournamentResults, ), ); @@ -297,8 +347,9 @@ export function generateQuarterNodes({ { x: xPos, y: baseY + 2 * MATCH_GAP }, side, "later", - "TBD", + "Quarter Finalist", { prediction: p2Options, showBio: false, isLoser: p2Loser }, + ctx.tournamentResults, ), ); }); @@ -349,8 +400,9 @@ export function generateSemiNodes({ { x: xPos, y: baseY }, side, "later", - "TBD", + "Semi Finalist", { prediction: p1Options, showBio: false, isLoser: p1Loser }, + ctx.tournamentResults, ), ); @@ -365,8 +417,9 @@ export function generateSemiNodes({ { x: xPos, y: baseY + 4 * MATCH_GAP }, side, "later", - "TBD", + "Semi Finalist", { prediction: p2Options, showBio: false, isLoser: p2Loser }, + ctx.tournamentResults, ), ); }); @@ -419,8 +472,9 @@ export function generateFinalistNode({ { x: xPos, y: 3.5 * MATCH_GAP }, side, "later", - "Finalist TBD", + "Finalist", { prediction: finalistOptions, showBio: false, isLoser: finalistLoser }, + ctx.tournamentResults, ); } @@ -447,10 +501,20 @@ export function generateChampionshipNode(ctx: NodeContext): Node { y: 0, }, data: champion - ? playerToNodeData(champion, finalGame, "#FFD700", "left", "later", { - isLoser: false, - showBio: false, - }) - : { text: "CHAMPION", side: "left", ringColor: "#FFD700" }, + ? { + ...playerToNodeData(champion, finalGame, "#FFD700", "left", "later", { + isLoser: false, + showBio: false, + tournamentResults: ctx.tournamentResults, + }), + youtubeUrl: getYoutubeUrl("final"), + } + : { + text: "CHAMPION", + side: "left", + ringColor: "#FFD700", + airDate: getAirDateForGame("final"), + youtubeUrl: getYoutubeUrl("final"), + }, }; } diff --git a/src/data/players.ts b/src/data/players.ts index 4563925..9743b06 100644 --- a/src/data/players.ts +++ b/src/data/players.ts @@ -8,13 +8,36 @@ 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", + "left-r1": "2026-03-06T13:00:00Z", + "right-r1": "2026-03-13T12:00:00Z", + qf: "2026-03-20T12:00:00Z", + sf: "2026-03-27T12:00:00Z", + final: "2026-04-03T12:00:00Z", } as const; +export type ScheduleKey = keyof typeof GAME_SCHEDULE; + +export function getScheduleKeyForGame(gameId: string): ScheduleKey { + if (gameId.startsWith("r1-")) { + const idx = Number.parseInt(gameId.split("-")[1], 10); + return idx < 4 ? "left-r1" : "right-r1"; + } + if (gameId.startsWith("qf-")) return "qf"; + if (gameId.startsWith("sf-")) return "sf"; + return "final"; +} + +export function getAirDateForGame(gameId: string): string { + return GAME_SCHEDULE[getScheduleKeyForGame(gameId)]; +} + +export const GAME_LINKS: Record = { + // YouTube video IDs keyed by game ID + // "r1-0": "dQw4w9WgXcQ", +}; + +export const YOUTUBE_CHANNEL = "https://www.youtube.com/@syntaxfm"; + // 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(); @@ -279,28 +302,28 @@ export const bracket: Bracket = { // Game 0: Jason Lengstorf vs Kyle Cook (Web Dev Simplified) { id: "r1-0", - date: "2026-02-01", + date: GAME_SCHEDULE["left-r1"], player1: jasonLengstorf, player2: kyleCook, }, // Game 1: Adam Wathan vs Julia Miocene { id: "r1-1", - date: "2026-02-01", + date: GAME_SCHEDULE["left-r1"], player1: adamWathan, player2: juliaMiocene, }, // Game 2: Chris Coyier vs Bree Hall { id: "r1-2", - date: "2026-02-02", + date: GAME_SCHEDULE["left-r1"], player1: chrisCoyier, player2: breeHall, }, // Game 3: Scott Tolinski vs Shaundai Person { id: "r1-3", - date: "2026-02-02", + date: GAME_SCHEDULE["left-r1"], player1: scottTolinski, player2: shaundaiPerson, }, @@ -310,28 +333,28 @@ export const bracket: Bracket = { // Game 4: Kevin Powell vs Amy Dutton { id: "r1-4", - date: "2026-02-01", + date: GAME_SCHEDULE["right-r1"], player1: kevinPowell, player2: amyDutton, }, // Game 5: Josh Comeau vs Cassidy Williams { id: "r1-5", - date: "2026-02-01", + date: GAME_SCHEDULE["right-r1"], player1: joshComeau, player2: cassidyWilliams, }, // Game 6: Wes Bos vs TBD { id: "r1-6", - date: "2026-02-02", + date: GAME_SCHEDULE["right-r1"], player1: wesBos, player2: benHong, }, // Game 7: Ania Kubow vs Adam Argyle { id: "r1-7", - date: "2026-02-02", + date: GAME_SCHEDULE["right-r1"], player1: aniaKubow, player2: adamArgyle, }, @@ -343,10 +366,10 @@ export const bracket: Bracket = { // Games 0-1: LEFT side | Games 2-3: RIGHT side quarters: [ - { id: "qf-0", date: "" }, - { id: "qf-1", date: "" }, - { id: "qf-2", date: "" }, - { id: "qf-3", date: "" }, + { id: "qf-0", date: GAME_SCHEDULE.qf }, + { id: "qf-1", date: GAME_SCHEDULE.qf }, + { id: "qf-2", date: GAME_SCHEDULE.qf }, + { id: "qf-3", date: GAME_SCHEDULE.qf }, ], // =========================================================================== @@ -355,15 +378,15 @@ export const bracket: Bracket = { // Game 0: LEFT side | Game 1: RIGHT side semis: [ - { id: "sf-0", date: "" }, - { id: "sf-1", date: "" }, + { id: "sf-0", date: GAME_SCHEDULE.sf }, + { id: "sf-1", date: GAME_SCHEDULE.sf }, ], // =========================================================================== // FINALS - 1 game, 2 players (CHAMPIONSHIP) // =========================================================================== - finals: [{ id: "final", date: "" }], + finals: [{ id: "final", date: GAME_SCHEDULE.final }], }; export const emptyBracket: Bracket = { From d965a79135e36ab93bbbc3b35face2c9f8be609f Mon Sep 17 00:00:00 2001 From: Wes Bos Date: Fri, 6 Feb 2026 14:45:37 -0500 Subject: [PATCH 11/18] clean up edge CSS --- src/components/bracket/Bracket.tsx | 129 ++++++++--------------- src/components/bracket/bracket.css | 94 ++++++++++++----- src/components/bracket/nodeGenerators.ts | 8 +- 3 files changed, 114 insertions(+), 117 deletions(-) diff --git a/src/components/bracket/Bracket.tsx b/src/components/bracket/Bracket.tsx index 9327553..cbc2143 100644 --- a/src/components/bracket/Bracket.tsx +++ b/src/components/bracket/Bracket.tsx @@ -86,63 +86,15 @@ function BracketEdge({ const edgeState = (data?.state as EdgeState) ?? "default"; const hoverState = (data?.hoverState as EdgeHoverState) ?? "none"; - let stroke = "#FFFFFF"; - let strokeOpacity = 1; - let strokeDasharray: string | undefined; - let className = ""; + const STROKE_WIDTH = 3; + let state: string = edgeState; if (hoverState === "hovered-pick") { - stroke = "#22c55e"; - strokeDasharray = "18 6"; - className = "bracket-edge--hover-pick"; + state = "hover-pick"; } else if (hoverState === "hovered-competitor") { - stroke = "#ef4444"; - strokeDasharray = "18 6"; - className = "bracket-edge--hover-competitor"; + state = "hover-competitor"; } else if (hoverState === "hovered-incoming") { - stroke = "#FFFFFF"; - strokeDasharray = "8 6"; - className = "bracket-edge--pickable"; - } else { - switch (edgeState) { - case "winner": - stroke = "#FFFFFF"; - strokeOpacity = 1; - break; - case "loser": - stroke = "#FFFFFF"; - strokeOpacity = 0.5; - break; - case "pending": - stroke = "#FFFFFF"; - strokeDasharray = "8 6"; - className = "bracket-edge--pending"; - break; - case "pickable": - stroke = "#FFFFFF"; - strokeDasharray = "8 6"; - className = "bracket-edge--pickable"; - break; - default: - break; - } - } - - if (target === "championship") { - const horizontalY = sourceY + 30; - const edgePath = `M ${sourceX} ${sourceY} L ${sourceX} ${horizontalY} L ${targetX} ${horizontalY} L ${targetX} ${targetY - 35}`; - return ( - - ); + state = "pickable"; } const [edgePath] = getSmoothStepPath({ @@ -154,16 +106,26 @@ function BracketEdge({ targetPosition, borderRadius: 0, }); + return ( - + <> + + + ); } @@ -223,12 +185,6 @@ function generateNodes( ]; } -const edgeStyle: React.CSSProperties = { - stroke: "#ffffff", - strokeWidth: 3, - filter: "drop-shadow(0px 0px 7px black)", -}; - const EDGE_SOURCE_MAP: Map = (() => { const map = new Map(); function add(source: string, target: string) { @@ -292,15 +248,18 @@ function computeEdgeState( sourceNodeId: string, targetNodeId: string, tournamentResults: Record, + predictions: Record, + showPicks: boolean, nodes: Node[], allEdges: Edge[], ): EdgeState { const { gameId } = parseSourceNodeId(sourceNodeId); const playerId = getSourcePlayerId(sourceNodeId, nodes); + const results = showPicks ? predictions : tournamentResults; // Finalist-to-championship edges: check if the final game is decided if (sourceNodeId === "left-finalist" || sourceNodeId === "right-finalist") { - const finalWinner = tournamentResults.final; + const finalWinner = results.final; if (finalWinner && playerId) { return finalWinner === playerId ? "winner" : "loser"; } @@ -320,7 +279,7 @@ function computeEdgeState( return "default"; } - const winner = tournamentResults[gameId]; + const winner = results[gameId]; if (winner && playerId) { return winner === playerId ? "winner" : "loser"; @@ -380,6 +339,8 @@ function computeEdgeHoverState( interface EdgeGeneratorContext { tournamentResults: Record; + predictions: Record; + showPicks: boolean; nodes: Node[]; hoveredNodeId: string | null; hoveredNodeType: "player" | "empty" | null; @@ -410,7 +371,6 @@ function generateEdges(ctx: EdgeGeneratorContext): Edge[] { source: `${game.id}-p1`, target: `${quarterGame.id}-p${(gameIndex % 2) + 1}`, type: "bracket", - style: edgeStyle, sourceHandle: "out-right", targetHandle: "in-top", }); @@ -420,7 +380,6 @@ function generateEdges(ctx: EdgeGeneratorContext): Edge[] { source: `${game.id}-p2`, target: `${quarterGame.id}-p${(gameIndex % 2) + 1}`, type: "bracket", - style: edgeStyle, targetHandle: "in-bottom", }); }); @@ -433,7 +392,6 @@ function generateEdges(ctx: EdgeGeneratorContext): Edge[] { source: `${game.id}-p1`, target: `${semiGame.id}-p${gameIndex + 1}`, type: "bracket", - style: edgeStyle, sourceHandle: "out-right", targetHandle: "in-top", }); @@ -443,7 +401,6 @@ function generateEdges(ctx: EdgeGeneratorContext): Edge[] { source: `${game.id}-p2`, target: `${semiGame.id}-p${gameIndex + 1}`, type: "bracket", - style: edgeStyle, sourceHandle: "out-right", targetHandle: "in-bottom", }); @@ -455,7 +412,6 @@ function generateEdges(ctx: EdgeGeneratorContext): Edge[] { source: `${game.id}-p1`, target: `left-finalist`, type: "bracket", - style: edgeStyle, sourceHandle: "out-right", targetHandle: "in-top", }); @@ -465,7 +421,6 @@ function generateEdges(ctx: EdgeGeneratorContext): Edge[] { source: `${game.id}-p2`, target: `left-finalist`, type: "bracket", - style: edgeStyle, sourceHandle: "out-right", targetHandle: "in-bottom", }); @@ -476,7 +431,6 @@ function generateEdges(ctx: EdgeGeneratorContext): Edge[] { source: `left-finalist`, target: "championship", type: "bracket", - style: edgeStyle, sourceHandle: "out-right", targetHandle: "in-bottom", }); @@ -490,7 +444,6 @@ function generateEdges(ctx: EdgeGeneratorContext): Edge[] { source: `${game.id}-p1`, target: `${quarterGame.id}-p${(gameIndex % 2) + 1}`, type: "bracket", - style: edgeStyle, sourceHandle: "out-left", targetHandle: "in-top", }); @@ -500,7 +453,6 @@ function generateEdges(ctx: EdgeGeneratorContext): Edge[] { source: `${game.id}-p2`, target: `${quarterGame.id}-p${(gameIndex % 2) + 1}`, type: "bracket", - style: edgeStyle, sourceHandle: "out-left", targetHandle: "in-bottom", }); @@ -514,7 +466,6 @@ function generateEdges(ctx: EdgeGeneratorContext): Edge[] { source: `${game.id}-p1`, target: `${semiGame.id}-p${gameIndex + 1}`, type: "bracket", - style: edgeStyle, sourceHandle: "out-left", targetHandle: "in-top", }); @@ -524,7 +475,6 @@ function generateEdges(ctx: EdgeGeneratorContext): Edge[] { source: `${game.id}-p2`, target: `${semiGame.id}-p${gameIndex + 1}`, type: "bracket", - style: edgeStyle, sourceHandle: "out-left", targetHandle: "in-bottom", }); @@ -536,7 +486,6 @@ function generateEdges(ctx: EdgeGeneratorContext): Edge[] { source: `${game.id}-p1`, target: `right-finalist`, type: "bracket", - style: edgeStyle, sourceHandle: "out-left", targetHandle: "in-top", }); @@ -546,7 +495,6 @@ function generateEdges(ctx: EdgeGeneratorContext): Edge[] { source: `${game.id}-p2`, target: `right-finalist`, type: "bracket", - style: edgeStyle, sourceHandle: "out-left", targetHandle: "in-bottom", }); @@ -557,7 +505,6 @@ function generateEdges(ctx: EdgeGeneratorContext): Edge[] { source: `right-finalist`, target: "championship", type: "bracket", - style: edgeStyle, sourceHandle: "out-left", targetHandle: "in-bottom", }); @@ -568,6 +515,8 @@ function generateEdges(ctx: EdgeGeneratorContext): Edge[] { edge.source, edge.target, ctx.tournamentResults, + ctx.predictions, + ctx.showPicks, ctx.nodes, edges, ); @@ -591,7 +540,6 @@ function generateEdges(ctx: EdgeGeneratorContext): Edge[] { const defaultEdgeOptions = { type: "bracket", - style: edgeStyle, }; const FIT_VIEW_PADDING = 0.05; @@ -646,11 +594,20 @@ function BracketContent({ () => generateEdges({ tournamentResults, + predictions, + showPicks, nodes, hoveredNodeId, hoveredNodeType, }), - [tournamentResults, nodes, hoveredNodeId, hoveredNodeType], + [ + tournamentResults, + predictions, + showPicks, + nodes, + hoveredNodeId, + hoveredNodeType, + ], ); const styledNodes = useMemo(() => { diff --git a/src/components/bracket/bracket.css b/src/components/bracket/bracket.css index 6d5f67f..1479384 100644 --- a/src/components/bracket/bracket.css +++ b/src/components/bracket/bracket.css @@ -24,15 +24,16 @@ flex-direction: column !important; justify-content: center; align-items: center; - width: auto; + width: 176px; .player-name { - /* padding-top: 20px; */ + white-space: nowrap; } .player-info { text-align: center; margin-left: 0; margin-right: 0; padding: 30px 16px 10px 16px; + flex-direction: column; } .player-photo-ring { margin-left: 0; @@ -181,6 +182,7 @@ &:has(a.player-youtube-link) { display: flex; align-items: center; + justify-content: space-between; gap: 5px; } } @@ -365,41 +367,80 @@ color: var(--white); } -/* Edge State Animations */ @keyframes marching-ants { 0% { stroke-dashoffset: 0; } 100% { - stroke-dashoffset: -28; + stroke-dashoffset: -100%; } } -.bracket-edge--pickable { - animation: marching-ants 0.6s linear infinite; -} +.bracket-edge { + stroke: #fff; -.bracket-edge--hover-pick { - animation: marching-ants 0.4s linear infinite; -} + &.track { + stroke-opacity: 0.2; + filter: none; + } -.bracket-edge--hover-competitor { - animation: marching-ants 0.4s linear infinite; -} + &.path { + stroke-opacity: 1; + } -.bracket-edge--pending { - filter: brightness(0.5) !important; -} + /* Animated states: marching ants */ + &[data-state="hover-pick"], + &[data-state="pickable"], + &[data-state="hover-competitor"] { + animation: marching-ants 4s linear infinite; + } -/* Standings mode: disable animations and brightness filters */ -.bracket-container--standings .bracket-edge--pickable, -.bracket-container--standings .bracket-edge--hover-pick, -.bracket-container--standings .bracket-edge--hover-competitor { - animation: none; -} + /* Dashed stroke */ + &[data-state="hover-pick"].path, + &[data-state="pickable"].path, + &[data-state="pending"].path { + stroke-dasharray: 6 9; + } -.bracket-container--standings .bracket-edge--pending { - filter: none !important; + &[data-state="hover-competitor"].path { + stroke-opacity: 0.2; + } + + &[data-state="loser"].path { + stroke-opacity: 0.5; + } + + &[data-state="pending"] { + filter: brightness(0.5) !important; + } + + &[data-state="winner"].track { + stroke: var(--orange); + stroke-opacity: 0.8; + } + + &[data-state="hover-pick"] { + &.path { + stroke: var(--white); + } + &.track { + stroke: var(--orange); + stroke-opacity: 0.8; + } + } + + /* Standings mode overrides */ + .bracket-container--standings & { + &[data-state="pickable"], + &[data-state="hover-pick"], + &[data-state="hover-competitor"] { + animation: none; + } + + &[data-state="pending"] { + filter: none !important; + } + } } .bracket-container--standings .react-flow__node { @@ -473,9 +514,9 @@ z-index: 10; } -.bracket-container path:not(a path) { +/* .bracket-container path:not(a path) { filter: drop-shadow(0px 0px 7px black); -} +} */ .bracket-handle { opacity: 0; /* Handles. Hidden, only used for debugging. */ @@ -484,6 +525,7 @@ background: red; border: 2px solid black; border-radius: 50%; + z-index: 100; } /* Custom bracket controls styling */ diff --git a/src/components/bracket/nodeGenerators.ts b/src/components/bracket/nodeGenerators.ts index a1cbbc1..957d823 100644 --- a/src/components/bracket/nodeGenerators.ts +++ b/src/components/bracket/nodeGenerators.ts @@ -27,11 +27,9 @@ function getRingColor(side: "left" | "right"): string { return side === "left" ? LEFT_RING_COLOR : RIGHT_RING_COLOR; } -function getPhotoPath(player: Player, isEliminated: boolean): string { +function getPhotoPath(player: Player): string { const filename = player.photo.replace("/avatars/", ""); - return isEliminated - ? `/avatars/bw/${filename}` - : `/avatars/color/${filename}`; + return `/avatars/color/${filename}`; } function playerToNodeData( @@ -77,7 +75,7 @@ function playerToNodeData( } return { - photo: getPhotoPath(player, isEliminated), + photo: getPhotoPath(player), name: player.name, byline: player.byline, ringColor, From 976195a080f7162eee833a563d35022762b7ec97 Mon Sep 17 00:00:00 2001 From: Wes Bos Date: Fri, 6 Feb 2026 15:49:03 -0500 Subject: [PATCH 12/18] Adds activity component. CSS --- src/components/LoginSection.tsx | 37 +---- src/components/bracket/Bracket.tsx | 44 +++--- src/components/bracket/bracket.css | 2 +- src/components/leaderboard/Activity.tsx | 110 ++++++++++++++ src/components/leaderboard/Leaderboard.tsx | 4 +- src/components/leaderboard/activity.css | 89 ++++++++++++ src/components/leaderboard/leaderboard.css | 4 + src/routeTree.gen.ts | 22 +++ src/routes/api/leaderboard/recent-pickers.ts | 145 +++++++++++++++++++ src/routes/index.tsx | 2 + src/routes/test.tsx | 2 + src/styles/login.css | 12 +- src/styles/styles.css | 12 ++ 13 files changed, 425 insertions(+), 60 deletions(-) create mode 100644 src/components/leaderboard/Activity.tsx create mode 100644 src/components/leaderboard/activity.css create mode 100644 src/routes/api/leaderboard/recent-pickers.ts diff --git a/src/components/LoginSection.tsx b/src/components/LoginSection.tsx index 88f8fd0..f860df9 100644 --- a/src/components/LoginSection.tsx +++ b/src/components/LoginSection.tsx @@ -217,7 +217,6 @@ function LoginSectionShare({ )} - {copied ? "Copied!" : "Copy Link"} {twitterShareUrl && ( - Share on X + Share on X )} {blueskyShareUrl && ( @@ -252,7 +251,7 @@ function LoginSectionShare({ > - Bluesky + Share on Bluesky )} @@ -361,17 +360,6 @@ export function LoginSection({ )} - {/* Toggle to show picks vs results */} - {onToggleShowPicks && ( - - )} - {/* Share section - only show when locked and username exists */} {shareUrl && ( - {/* Instructions */} -
- - - Click any player to pick them as the winner of that match - -
- { - if (node.type === "emptySlot") { - setHoveredNodeId(node.id); - setHoveredNodeType("empty"); - return; - } - const data = node.data as { - prediction?: { interactionMode?: string }; - }; - if (data.prediction?.interactionMode === "pickable") { - setHoveredNodeId(node.id); - setHoveredNodeType("player"); - } - }} - onNodeMouseLeave={() => { - setHoveredNodeId(null); - setHoveredNodeType(null); - }} + onNodeMouseEnter={ + isInteractive + ? (_event, node) => { + if (node.type === "emptySlot") { + setHoveredNodeId(node.id); + setHoveredNodeType("empty"); + return; + } + const data = node.data as { + prediction?: { interactionMode?: string }; + }; + if (data.prediction?.interactionMode === "pickable") { + setHoveredNodeId(node.id); + setHoveredNodeType("player"); + } + } + : undefined + } + onNodeMouseLeave={ + isInteractive + ? () => { + setHoveredNodeId(null); + setHoveredNodeType(null); + } + : undefined + } onNodeClick={(_event, node) => { const data = node.data as { isPickable?: boolean; diff --git a/src/components/bracket/bracket.css b/src/components/bracket/bracket.css index 1479384..738456e 100644 --- a/src/components/bracket/bracket.css +++ b/src/components/bracket/bracket.css @@ -461,7 +461,7 @@ letter-spacing: 0.05em; padding: 10px 24px; border: 3px solid var(--black); - background: transparent; + background: var(--beige); color: var(--black); cursor: pointer; transition: diff --git a/src/components/leaderboard/Activity.tsx b/src/components/leaderboard/Activity.tsx new file mode 100644 index 0000000..a3585ad --- /dev/null +++ b/src/components/leaderboard/Activity.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from "react"; +import type { ActivityItem } from "@/routes/api/leaderboard/recent-pickers"; +import "./activity.css"; + +const MAX_VISIBLE = 6; +const TICK_INTERVAL = 2500; + +function PickSentence({ item }: { item: ActivityItem }) { + const inner = ( + <> + {item.pickerImage && ( + + )} + {item.pickerName} + {" picks "} + + {item.predictedName} + {item.opponentName ? ( + <> + {" to beat "} + {item.opponentPhoto && ( + + )} + {item.opponentName} + + ) : ( + <>{` to ${item.label}`} + )} + + ); + + if (item.pickerUsername) { + return ( + + {inner} + + ); + } + return {inner}; +} + +export function Activity() { + const [visible, setVisible] = useState([]); + const queueRef = useRef([]); + const tickRef = useRef | null>(null); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + fetch("/api/leaderboard/recent-pickers") + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json() as Promise<{ activity?: ActivityItem[] }>; + }) + .then((data) => { + queueRef.current = data.activity || []; + setLoaded(true); + }) + .catch(() => {}); + }, []); + + useEffect(() => { + if (!loaded || queueRef.current.length === 0) return; + + const initial: ActivityItem[] = []; + for (let i = 0; i < 3 && queueRef.current.length > 0; i++) { + const item = queueRef.current.shift()!; + queueRef.current.push(item); + initial.push(item); + } + setVisible(initial); + + function tick() { + const queue = queueRef.current; + const next = queue.shift(); + if (!next) return; + queue.push(next); + + setVisible((prev) => { + const updated = [next, ...prev]; + if (updated.length > MAX_VISIBLE) { + return updated.slice(0, MAX_VISIBLE); + } + return updated; + }); + } + + tickRef.current = setInterval(tick, TICK_INTERVAL); + return () => { + if (tickRef.current) clearInterval(tickRef.current); + }; + }, [loaded]); + + if (visible.length === 0) return null; + + return ( +
+
    + {visible.map((item) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/src/components/leaderboard/Leaderboard.tsx b/src/components/leaderboard/Leaderboard.tsx index 6eb5b3e..466d7fc 100644 --- a/src/components/leaderboard/Leaderboard.tsx +++ b/src/components/leaderboard/Leaderboard.tsx @@ -11,7 +11,7 @@ export function Leaderboard() { fetch("/api/leaderboard/") .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json(); + return res.json() as Promise<{ leaderboard?: LeaderboardEntry[] }>; }) .then((data) => { setEntries(data.leaderboard || []); @@ -39,7 +39,7 @@ export function Leaderboard() {
Loading...
) : entries.length === 0 ? (
- No scores yet. Lock your bracket and check back after Round 1! +

No scores yet. Lock your bracket and check back after Round 1!

) : ( diff --git a/src/components/leaderboard/activity.css b/src/components/leaderboard/activity.css new file mode 100644 index 0000000..18329fe --- /dev/null +++ b/src/components/leaderboard/activity.css @@ -0,0 +1,89 @@ +.activity { + max-width: 600px; + margin: 0 auto; + padding: 10px 16px; + height: 155px; + mask-image: linear-gradient( + to bottom, + transparent, + black 5%, + black 95%, + transparent + ); +} + +.activity-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + gap: 4px; +} + +.activity-item { + background: var(--black); + border-radius: 500px; + opacity: 1; + transform: translateY(0); + max-height: 60px; + transition: + max-height 0.5s ease, + opacity 0.5s ease, + transform 0.5s ease; + + @starting-style { + max-height: 0; + opacity: 0; + transform: translateY(-10px); + } +} + +.pick-sentence { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + padding: 6px 10px; + font-family: var(--font-sans); + font-size: 0.825rem; + color: white; + text-decoration: none; + transition: color 0.15s; +} + +a.pick-sentence:hover { + color: var(--white); +} + +.pick-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + border: 1px solid #555; + object-fit: cover; + flex-shrink: 0; +} + +.pick-user-name { + color: var(--white); + font-weight: 600; +} + +.pick-player-name { + color: var(--yellow); + font-weight: 600; +} + +@media (max-width: 600px) { + .pick-sentence { + font-size: 0.75rem; + } + + .pick-avatar { + width: 18px; + height: 18px; + } +} diff --git a/src/components/leaderboard/leaderboard.css b/src/components/leaderboard/leaderboard.css index 154a713..30863f7 100644 --- a/src/components/leaderboard/leaderboard.css +++ b/src/components/leaderboard/leaderboard.css @@ -232,6 +232,10 @@ padding: 40px 20px; color: #666; font-style: italic; + + p { + margin: 0; + } } /* Mobile responsiveness */ diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index e117205..9de5418 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as ApiPredictionsIndexRouteImport } from './routes/api/prediction 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 ApiLeaderboardRecentPickersRouteImport } from './routes/api/leaderboard/recent-pickers' 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/$' @@ -64,6 +65,12 @@ const ApiOgUsernameRoute = ApiOgUsernameRouteImport.update({ path: '/api/og/$username', getParentRoute: () => rootRouteImport, } as any) +const ApiLeaderboardRecentPickersRoute = + ApiLeaderboardRecentPickersRouteImport.update({ + id: '/api/leaderboard/recent-pickers', + path: '/api/leaderboard/recent-pickers', + getParentRoute: () => rootRouteImport, + } as any) const ApiLeaderboardCalculateRoute = ApiLeaderboardCalculateRouteImport.update({ id: '/api/leaderboard/calculate', path: '/api/leaderboard/calculate', @@ -105,6 +112,7 @@ export interface FileRoutesByFullPath { '/api/auth/$': typeof ApiAuthSplatRoute '/api/bracket/$username': typeof ApiBracketUsernameRoute '/api/leaderboard/calculate': typeof ApiLeaderboardCalculateRoute + '/api/leaderboard/recent-pickers': typeof ApiLeaderboardRecentPickersRoute '/api/og/$username': typeof ApiOgUsernameRoute '/api/predictions/lock': typeof ApiPredictionsLockRoute '/api/leaderboard/': typeof ApiLeaderboardIndexRoute @@ -121,6 +129,7 @@ export interface FileRoutesByTo { '/api/auth/$': typeof ApiAuthSplatRoute '/api/bracket/$username': typeof ApiBracketUsernameRoute '/api/leaderboard/calculate': typeof ApiLeaderboardCalculateRoute + '/api/leaderboard/recent-pickers': typeof ApiLeaderboardRecentPickersRoute '/api/og/$username': typeof ApiOgUsernameRoute '/api/predictions/lock': typeof ApiPredictionsLockRoute '/api/leaderboard': typeof ApiLeaderboardIndexRoute @@ -138,6 +147,7 @@ export interface FileRoutesById { '/api/auth/$': typeof ApiAuthSplatRoute '/api/bracket/$username': typeof ApiBracketUsernameRoute '/api/leaderboard/calculate': typeof ApiLeaderboardCalculateRoute + '/api/leaderboard/recent-pickers': typeof ApiLeaderboardRecentPickersRoute '/api/og/$username': typeof ApiOgUsernameRoute '/api/predictions/lock': typeof ApiPredictionsLockRoute '/api/leaderboard/': typeof ApiLeaderboardIndexRoute @@ -156,6 +166,7 @@ export interface FileRouteTypes { | '/api/auth/$' | '/api/bracket/$username' | '/api/leaderboard/calculate' + | '/api/leaderboard/recent-pickers' | '/api/og/$username' | '/api/predictions/lock' | '/api/leaderboard/' @@ -172,6 +183,7 @@ export interface FileRouteTypes { | '/api/auth/$' | '/api/bracket/$username' | '/api/leaderboard/calculate' + | '/api/leaderboard/recent-pickers' | '/api/og/$username' | '/api/predictions/lock' | '/api/leaderboard' @@ -188,6 +200,7 @@ export interface FileRouteTypes { | '/api/auth/$' | '/api/bracket/$username' | '/api/leaderboard/calculate' + | '/api/leaderboard/recent-pickers' | '/api/og/$username' | '/api/predictions/lock' | '/api/leaderboard/' @@ -205,6 +218,7 @@ export interface RootRouteChildren { ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiBracketUsernameRoute: typeof ApiBracketUsernameRoute ApiLeaderboardCalculateRoute: typeof ApiLeaderboardCalculateRoute + ApiLeaderboardRecentPickersRoute: typeof ApiLeaderboardRecentPickersRoute ApiOgUsernameRoute: typeof ApiOgUsernameRoute ApiPredictionsLockRoute: typeof ApiPredictionsLockRoute ApiLeaderboardIndexRoute: typeof ApiLeaderboardIndexRoute @@ -270,6 +284,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiOgUsernameRouteImport parentRoute: typeof rootRouteImport } + '/api/leaderboard/recent-pickers': { + id: '/api/leaderboard/recent-pickers' + path: '/api/leaderboard/recent-pickers' + fullPath: '/api/leaderboard/recent-pickers' + preLoaderRoute: typeof ApiLeaderboardRecentPickersRouteImport + parentRoute: typeof rootRouteImport + } '/api/leaderboard/calculate': { id: '/api/leaderboard/calculate' path: '/api/leaderboard/calculate' @@ -325,6 +346,7 @@ const rootRouteChildren: RootRouteChildren = { ApiAuthSplatRoute: ApiAuthSplatRoute, ApiBracketUsernameRoute: ApiBracketUsernameRoute, ApiLeaderboardCalculateRoute: ApiLeaderboardCalculateRoute, + ApiLeaderboardRecentPickersRoute: ApiLeaderboardRecentPickersRoute, ApiOgUsernameRoute: ApiOgUsernameRoute, ApiPredictionsLockRoute: ApiPredictionsLockRoute, ApiLeaderboardIndexRoute: ApiLeaderboardIndexRoute, diff --git a/src/routes/api/leaderboard/recent-pickers.ts b/src/routes/api/leaderboard/recent-pickers.ts new file mode 100644 index 0000000..e646529 --- /dev/null +++ b/src/routes/api/leaderboard/recent-pickers.ts @@ -0,0 +1,145 @@ +import { env } from "cloudflare:workers"; +import { createFileRoute } from "@tanstack/react-router"; +import { desc, eq, inArray } from "drizzle-orm"; +import { createDb } from "@/db"; +import * as schema from "@/db/schema"; +import { bracket, players } from "@/data/players"; + +const playerMap = new Map(players.map((p) => [p.id, p])); + +const r1Opponents = new Map>(); +for (const game of bracket.round1) { + if (game.player1 && game.player2) { + const opponents = new Map(); + opponents.set(game.player1.id, game.player2.id); + opponents.set(game.player2.id, game.player1.id); + r1Opponents.set(game.id, opponents); + } +} + +function roundLabel(gameId: string): string { + if (gameId === "final") return "win it all"; + if (gameId.startsWith("sf-")) return "win the semis"; + if (gameId.startsWith("qf-")) return "win the quarters"; + return ""; +} + +export type ActivityItem = { + key: string; + pickerName: string; + pickerImage: string | null; + pickerUsername: string | null; + predictedName: string; + predictedPhoto: string; + opponentName: string | null; + opponentPhoto: string | null; + label: string; +}; + +export const Route = createFileRoute("/api/leaderboard/recent-pickers")({ + server: { + handlers: { + GET: async () => { + const db = createDb(env.DB); + + const lockedUsers = await db + .select({ + userId: schema.userBracketStatus.userId, + userName: schema.user.name, + userImage: schema.user.image, + username: schema.user.username, + }) + .from(schema.userBracketStatus) + .innerJoin( + schema.user, + eq(schema.userBracketStatus.userId, schema.user.id), + ) + .where(eq(schema.userBracketStatus.isLocked, true)) + .orderBy(desc(schema.userBracketStatus.lockedAt)) + .limit(40); + + if (lockedUsers.length === 0) { + return new Response(JSON.stringify({ activity: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + const userIds = lockedUsers.map((u) => u.userId); + 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)); + + const predictionsByUser = new Map< + string, + { gameId: string; predictedWinnerId: string }[] + >(); + for (const p of allPredictions) { + const list = predictionsByUser.get(p.userId) || []; + list.push({ + gameId: p.gameId, + predictedWinnerId: p.predictedWinnerId, + }); + predictionsByUser.set(p.userId, list); + } + + const items: ActivityItem[] = []; + + for (const user of lockedUsers) { + const predictions = predictionsByUser.get(user.userId) || []; + if (predictions.length === 0) continue; + + const shuffled = [...predictions]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + + for (const pred of shuffled.slice(0, 10)) { + const predicted = playerMap.get(pred.predictedWinnerId); + if (!predicted) continue; + + let opponentName: string | null = null; + let opponentPhoto: string | null = null; + const gameOpponents = r1Opponents.get(pred.gameId); + if (gameOpponents) { + const oppId = gameOpponents.get(pred.predictedWinnerId); + const opp = oppId ? playerMap.get(oppId) : undefined; + if (opp) { + opponentName = opp.name; + opponentPhoto = `/avatars/color/${opp.id}.png`; + } + } + + items.push({ + key: `${user.username ?? user.userName}-${pred.gameId}`, + pickerName: user.userName, + pickerImage: user.userImage, + pickerUsername: user.username, + predictedName: predicted.name, + predictedPhoto: `/avatars/color/${predicted.id}.png`, + opponentName, + opponentPhoto, + label: gameOpponents ? "" : roundLabel(pred.gameId), + }); + } + } + + for (let i = items.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [items[i], items[j]] = [items[j], items[i]]; + } + + return new Response(JSON.stringify({ activity: items }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }, + }, + }, +}); diff --git a/src/routes/index.tsx b/src/routes/index.tsx index db7dc9f..bec3ec6 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,5 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { Bracket } from "@/components/bracket/Bracket"; +import { Activity } from "@/components/leaderboard/Activity"; import { Roster } from "@/components/roster/Roster"; import { Rules } from "@/components/rules/Rules"; import { Ticket } from "@/components/Ticket"; @@ -15,6 +16,7 @@ function App() { +

The Bracket

diff --git a/src/routes/test.tsx b/src/routes/test.tsx index e01df58..2af98db 100644 --- a/src/routes/test.tsx +++ b/src/routes/test.tsx @@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { Bracket } from "@/components/bracket/Bracket"; import { LoginSection } from "@/components/LoginSection"; +import { Activity } from "@/components/leaderboard/Activity"; import { Leaderboard } from "@/components/leaderboard/Leaderboard"; import { Roster } from "@/components/roster/Roster"; import { Rules } from "@/components/rules/Rules"; @@ -72,6 +73,7 @@ function TestPage() {
+

The Bracket

diff --git a/src/styles/login.css b/src/styles/login.css index 453797d..9990314 100644 --- a/src/styles/login.css +++ b/src/styles/login.css @@ -715,6 +715,11 @@ .cta-share { margin-top: 16px; + display: flex; + align-items: center; + justify-content: center; + align-content: center; + gap: 10px; } .cta-share-label { @@ -723,11 +728,11 @@ text-transform: uppercase; letter-spacing: 0.05em; color: var(--black); - margin-bottom: 12px; display: flex; align-items: center; - gap: 8px; + gap: 10px; justify-content: center; + width: fit-content; } .cta-share-actions { @@ -738,7 +743,6 @@ .btn-share { flex: 1; - min-width: 120px; padding: 12px 16px; font-family: var(--font-block); font-size: 0.75rem; @@ -783,7 +787,7 @@ .btn-share--twitter { background: var(--black); color: var(--white); - border: 3px solid var(--yellow); + border: 3px solid var(--black); box-shadow: 4px 4px 0 var(--black); } diff --git a/src/styles/styles.css b/src/styles/styles.css index 39f3c2b..facc098 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -217,3 +217,15 @@ p { transform: translate(4px, 4px); box-shadow: none; } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} From 9b0c3ac0afcd54d844f5524e2b515b4abe9e8a48 Mon Sep 17 00:00:00 2001 From: Wes Bos Date: Fri, 6 Feb 2026 16:33:11 -0500 Subject: [PATCH 13/18] user menu, button css cleanup, bracket work --- src/components/Header.tsx | 2 + src/components/LoginSection.tsx | 183 ++--------------- src/components/NotFound.tsx | 2 +- src/components/UserMenu.tsx | 73 +++++++ src/components/bracket/Bracket.tsx | 146 +++++++++++--- src/components/bracket/bracket.css | 42 ++++ src/routes/test.tsx | 2 - src/styles/buttons.css | 136 +++++++++++++ src/styles/header.css | 1 + src/styles/login.css | 313 ----------------------------- src/styles/share-bracket.css | 40 +--- src/styles/styles.css | 27 +-- src/styles/user-menu.css | 110 ++++++++++ 13 files changed, 502 insertions(+), 575 deletions(-) create mode 100644 src/components/UserMenu.tsx create mode 100644 src/styles/buttons.css create mode 100644 src/styles/user-menu.css diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 5711497..b25fc03 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,4 +1,5 @@ import { Link } from "@tanstack/react-router"; +import { UserMenu } from "./UserMenu"; import "@/styles/header.css"; @@ -8,6 +9,7 @@ export function Header() { Mad CSS Logo + ); } diff --git a/src/components/LoginSection.tsx b/src/components/LoginSection.tsx index f860df9..0c2d40a 100644 --- a/src/components/LoginSection.tsx +++ b/src/components/LoginSection.tsx @@ -14,38 +14,6 @@ const ROUND_LABELS: Record = { 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, @@ -73,93 +41,6 @@ function LoginSectionProgress({ } // 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, @@ -195,7 +76,7 @@ function LoginSectionShare({
diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx new file mode 100644 index 0000000..adc9826 --- /dev/null +++ b/src/components/UserMenu.tsx @@ -0,0 +1,73 @@ +import { useRef, useState, useEffect } from "react"; +import { authClient } from "@/lib/auth-client"; +import "@/styles/user-menu.css"; + +export function UserMenu() { + const { data: session, isPending } = authClient.useSession(); + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + if (open) { + document.addEventListener("click", handleClickOutside); + } + return () => document.removeEventListener("click", handleClickOutside); + }, [open]); + + if (isPending || !session?.user) return null; + + const { name, image } = session.user; + + return ( +
+ + {open && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/bracket/Bracket.tsx b/src/components/bracket/Bracket.tsx index d6a363e..8a1e446 100644 --- a/src/components/bracket/Bracket.tsx +++ b/src/components/bracket/Bracket.tsx @@ -11,8 +11,9 @@ import { 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 { ALL_GAME_IDS, TOTAL_GAMES, bracket, splitForDisplay } from "@/data/players"; import { getPickablePlayersForGame } from "@/hooks/usePredictions"; +import { authClient } from "@/lib/auth-client"; import type { NodeContext } from "./bracketTypes"; import { generateChampionshipNode, @@ -43,6 +44,12 @@ function BracketToggle({ showPicks: boolean; onToggle: () => void; }) { + const { data: session } = authClient.useSession(); + const ctx = usePredictionsContext(); + const userImage = session?.user?.image; + const isLocked = ctx?.isLocked ?? false; + const pickCount = ctx?.pickCount ?? 0; + return (
); } +function BracketActions() { + const ctx = usePredictionsContext(); + const { data: session } = authClient.useSession(); + const [showLockConfirm, setShowLockConfirm] = useState(false); + + if (!session?.user || !ctx) return null; + + const { pickCount, isLocked, isSaving, hasChanges, isDeadlinePassed } = ctx; + const canLock = pickCount === TOTAL_GAMES && !isLocked && !isDeadlinePassed; + + if (isLocked || isDeadlinePassed) return null; + + return ( +
+ {showLockConfirm ? ( +
+

Lock your bracket? This cannot be undone.

+
+ + +
+
+ ) : ( + <> + + + {pickCount > 0 && ( + + )} + + )} +
+ ); +} + export type EdgeState = "winner" | "loser" | "pending" | "pickable" | "default"; export type EdgeHoverState = @@ -708,6 +803,7 @@ function BracketContent({ {onToggleShowPicks && ( )} + {/* biome-ignore lint/a11y/noStaticElementInteractions: mouse events for scroll/zoom unlock UX */}
{ - if (node.type === "emptySlot") { - setHoveredNodeId(node.id); - setHoveredNodeType("empty"); - return; + onNodeMouseEnter={ + isInteractive + ? (_event, node) => { + if (node.type === "emptySlot") { + setHoveredNodeId(node.id); + setHoveredNodeType("empty"); + return; + } + const data = node.data as { + prediction?: { interactionMode?: string }; + }; + if (data.prediction?.interactionMode === "pickable") { + setHoveredNodeId(node.id); + setHoveredNodeType("player"); + } } - const data = node.data as { - prediction?: { interactionMode?: string }; - }; - if (data.prediction?.interactionMode === "pickable") { - setHoveredNodeId(node.id); - setHoveredNodeType("player"); + : undefined + } + onNodeMouseLeave={ + isInteractive + ? () => { + setHoveredNodeId(null); + setHoveredNodeType(null); } - } - : undefined - } - onNodeMouseLeave={ - isInteractive - ? () => { - setHoveredNodeId(null); - setHoveredNodeType(null); - } - : undefined - } + : undefined + } onNodeClick={(_event, node) => { const data = node.data as { isPickable?: boolean; diff --git a/src/components/bracket/bracket.css b/src/components/bracket/bracket.css index 738456e..982fe15 100644 --- a/src/components/bracket/bracket.css +++ b/src/components/bracket/bracket.css @@ -488,9 +488,51 @@ &:not(.active):active { transform: translate(2px, 2px); } + &:has(.bracket-toggle-avatar) { + display: flex; + align-items: center; + gap: 2px; + } } } +.bracket-toggle-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + border: 2px solid var(--orange); + vertical-align: middle; + margin-right: 6px; + margin-top: -2px; +} + +.bracket-actions { + display: flex; + justify-content: center; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 16px; + max-width: 500px; + margin-inline: auto; +} + +.bracket-toggle-badge { + font-size: 0.625rem; + padding: 2px 6px; + margin-left: 6px; + border: 2px solid var(--black); + background: var(--white); + color: var(--black); + vertical-align: middle; + letter-spacing: 0.03em; +} + +.bracket-toggle-badge.locked { + background: var(--orange); + color: var(--white); + border-color: var(--orange); +} + /* React Flow Container */ .bracket-container { width: 100%; diff --git a/src/routes/test.tsx b/src/routes/test.tsx index 2af98db..a48434b 100644 --- a/src/routes/test.tsx +++ b/src/routes/test.tsx @@ -79,8 +79,6 @@ function TestPage() {

The Bracket

setShowPicks(!showPicks)} /> Date: Fri, 6 Feb 2026 16:56:50 -0500 Subject: [PATCH 14/18] styles and flow --- src/components/LoginSection.tsx | 111 +--------------- src/components/bracket/Bracket.tsx | 164 +++++++++++++++++------ src/components/bracket/bracket.css | 74 ++++++----- src/routes/test.tsx | 4 +- src/styles/buttons.css | 203 ++++++++++++++--------------- src/styles/login.css | 53 +++++--- 6 files changed, 308 insertions(+), 301 deletions(-) diff --git a/src/components/LoginSection.tsx b/src/components/LoginSection.tsx index 0c2d40a..0f80781 100644 --- a/src/components/LoginSection.tsx +++ b/src/components/LoginSection.tsx @@ -1,6 +1,5 @@ -import { useState } from "react"; import { usePredictionsContext } from "@/context/PredictionsContext"; -import { getNextGameTime, TOTAL_GAMES } from "@/data/players"; +import { getNextGameTime } from "@/data/players"; import { useCountdown } from "@/hooks/useCountdown"; import { authClient } from "@/lib/auth-client"; import { Scoreboard } from "./scoreboard/Scoreboard"; @@ -14,35 +13,8 @@ const ROUND_LABELS: Record = { final: "Finals", }; -// 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) // Sub-component: Share buttons (copy link, X, Bluesky) -function LoginSectionShare({ +export function LoginSectionShare({ twitterShareUrl, blueskyShareUrl, copied, @@ -140,20 +112,14 @@ function LoginSectionShare({ ); } -export interface LoginSectionProps { - username?: string | null; -} - -export function LoginSection({ username = null }: LoginSectionProps) { +export function LoginSection() { const ctx = usePredictionsContext(); - const pickCount = ctx?.pickCount ?? 0; const isLocked = ctx?.isLocked ?? false; const error = ctx?.error ?? null; const deadline = ctx?.deadline; const isDeadlinePassed = ctx?.isDeadlinePassed ?? false; const { data: session, isPending } = authClient.useSession(); - const [copied, setCopied] = useState(false); const countdown = useCountdown(deadline); const isUrgent = countdown.totalMs > 0 && countdown.totalMs < 24 * 60 * 60 * 1000; @@ -163,37 +129,6 @@ export function LoginSection({ username = null }: LoginSectionProps) { 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 (
@@ -203,46 +138,14 @@ export function LoginSection({ username = null }: LoginSectionProps) { } if (session?.user) { + const hasDeadlineMessage = isDeadlinePassed && !isLocked; + if (!hasDeadlineMessage && !error) return null; + return (
- {isLocked && ( - <> - {/* Next results countdown */} - {nextGame && nextGameCountdown.totalMs > 0 && ( -
- - {nextGameLabel} results in: - - -
- )} - - {/* Share section - only show when locked and username exists */} - {shareUrl && ( - - )} - - )} - - {isDeadlinePassed && !isLocked && ( + {hasDeadlineMessage && (
Deadline has passed
)} - - {/* Progress section - only show when not locked */} - {!isLocked && !isDeadlinePassed && ( - - )} - {error &&

{error}

}
); diff --git a/src/components/bracket/Bracket.tsx b/src/components/bracket/Bracket.tsx index 8a1e446..83c59bc 100644 --- a/src/components/bracket/Bracket.tsx +++ b/src/components/bracket/Bracket.tsx @@ -11,9 +11,18 @@ import { import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import "@xyflow/react/dist/style.css"; import { usePredictionsContext } from "@/context/PredictionsContext"; -import { ALL_GAME_IDS, TOTAL_GAMES, bracket, splitForDisplay } from "@/data/players"; +import { + ALL_GAME_IDS, + bracket, + getNextGameTime, + splitForDisplay, + TOTAL_GAMES, +} from "@/data/players"; +import { useCountdown } from "@/hooks/useCountdown"; +import { Scoreboard } from "@/components/scoreboard/Scoreboard"; import { getPickablePlayersForGame } from "@/hooks/usePredictions"; import { authClient } from "@/lib/auth-client"; +import { LoginSectionShare } from "@/components/LoginSection"; import type { NodeContext } from "./bracketTypes"; import { generateChampionshipNode, @@ -70,58 +79,96 @@ function BracketToggle({ )} MY PICKS - {session?.user && ( - isLocked ? ( + {session?.user && + (isLocked ? ( Locked In ) : ( - {pickCount}/{TOTAL_GAMES} - ) - )} + + {pickCount}/{TOTAL_GAMES} + + ))}
); } -function BracketActions() { +const ROUND_LABELS: Record = { + "left-r1": "Left R1", + "right-r1": "Right R1", + qf: "Quarterfinals", + sf: "Semifinals", + final: "Finals", +}; + +function NextResultsCountdown() { const ctx = usePredictionsContext(); const { data: session } = authClient.useSession(); - const [showLockConfirm, setShowLockConfirm] = useState(false); + const isLocked = ctx?.isLocked ?? false; + const nextGame = getNextGameTime(); + const nextGameCountdown = useCountdown(nextGame?.time); + const nextGameLabel = nextGame ? ROUND_LABELS[nextGame.round] : null; + + if (!session?.user || !isLocked || !nextGame || nextGameCountdown.totalMs <= 0) return null; + + return ( +
+ + {nextGameLabel} results in: + + +
+ ); +} - if (!session?.user || !ctx) return null; +function BracketToolbar() { + const ctx = usePredictionsContext(); + const { data: session } = authClient.useSession(); + const dialogRef = useRef(null); + const [copied, setCopied] = useState(false); - const { pickCount, isLocked, isSaving, hasChanges, isDeadlinePassed } = ctx; + const isLoggedIn = !!session?.user && !!ctx; + const pickCount = ctx?.pickCount ?? 0; + const isLocked = ctx?.isLocked ?? false; + const isSaving = ctx?.isSaving ?? false; + const hasChanges = ctx?.hasChanges ?? false; + const isDeadlinePassed = ctx?.isDeadlinePassed ?? false; const canLock = pickCount === TOTAL_GAMES && !isLocked && !isDeadlinePassed; + const showActions = isLoggedIn && !isLocked && !isDeadlinePassed; + + const username = (session?.user as { username?: string })?.username; + const shareUrl = username + ? `${typeof window !== "undefined" ? window.location.origin : ""}/bracket/${username}` + : null; + 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; + const showShare = isLoggedIn && isLocked && !!shareUrl; + + const handleCopyLink = async () => { + if (!shareUrl) return; + try { + await navigator.clipboard.writeText(shareUrl); + } catch { + 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); + }; - if (isLocked || isDeadlinePassed) return null; + if (!showActions && !showShare) return null; return ( -
- {showLockConfirm ? ( -
-

Lock your bracket? This cannot be undone.

-
- - -
-
- ) : ( - <> +
+ {showActions && ( +
)} - + +
+

Lock your bracket?

+

This cannot be undone.

+
+ + +
+
+
+
+ )} + {showShare && ( + )}
); @@ -803,7 +884,7 @@ function BracketContent({ {onToggleShowPicks && ( )} - + {/* biome-ignore lint/a11y/noStaticElementInteractions: mouse events for scroll/zoom unlock UX */}
+ ); } diff --git a/src/components/bracket/bracket.css b/src/components/bracket/bracket.css index 982fe15..920f63d 100644 --- a/src/components/bracket/bracket.css +++ b/src/components/bracket/bracket.css @@ -506,16 +506,54 @@ margin-top: -2px; } -.bracket-actions { +.bracket-toolbar { display: flex; justify-content: center; - gap: 10px; + align-items: center; + gap: 12px; flex-wrap: wrap; margin-bottom: 16px; - max-width: 500px; + max-width: 600px; margin-inline: auto; } +.bracket-toolbar-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.bracket-controls.react-flow__controls { + top: 0; + right: 0; + bottom: auto; + left: auto; + box-shadow: none; +} + +.bracket-controls .react-flow__controls-button { + background: var(--black); + border: 2px solid var(--yellow) !important; + border-radius: 0; + color: var(--yellow); + width: 32px; + height: 32px; + + &:hover { + background: var(--yellow); + } + + svg { + fill: var(--yellow); + max-width: 14px; + max-height: 14px; + } + + &:hover svg { + fill: var(--black); + } +} + .bracket-toggle-badge { font-size: 0.625rem; padding: 2px 6px; @@ -571,36 +609,6 @@ } /* Custom bracket controls styling */ -.bracket-controls.react-flow__controls { - top: 0; - right: 0; - bottom: auto; - left: auto; - box-shadow: none; -} - -.bracket-controls .react-flow__controls-button { - background: var(--black); - border: 2px solid var(--yellow) !important; - border-radius: 0; - color: var(--yellow); - width: 32px; - height: 32px; -} - -.bracket-controls .react-flow__controls-button:hover { - background: var(--yellow); -} - -.bracket-controls .react-flow__controls-button svg { - fill: var(--yellow); - max-width: 14px; - max-height: 14px; -} - -.bracket-controls .react-flow__controls-button:hover svg { - fill: var(--black); -} .bracket-container .react-flow__attribution { background: var(--black); diff --git a/src/routes/test.tsx b/src/routes/test.tsx index a48434b..d62f4e9 100644 --- a/src/routes/test.tsx +++ b/src/routes/test.tsx @@ -77,9 +77,7 @@ function TestPage() {

The Bracket

- + Date: Fri, 6 Feb 2026 16:59:54 -0500 Subject: [PATCH 15/18] hide the activity on prod for now --- src/routes/index.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/routes/index.tsx b/src/routes/index.tsx index bec3ec6..4afa8d5 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -12,11 +12,14 @@ function App() {
-
+
- + {/* */}

The Bracket

From 1bbac8aacdd88369dac58a0a952cabbd4cf69cb5 Mon Sep 17 00:00:00 2001 From: Wes Bos Date: Mon, 9 Feb 2026 10:42:35 -0500 Subject: [PATCH 16/18] allow admin to lock bracket --- src/routeTree.gen.ts | 21 +++++++ src/routes/admin.tsx | 90 +++++++++++++++++++++------ src/routes/api/admin/brackets/lock.ts | 68 ++++++++++++++++++++ src/styles/admin.css | 25 ++++++++ 4 files changed, 184 insertions(+), 20 deletions(-) create mode 100644 src/routes/api/admin/brackets/lock.ts diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 9de5418..7c2a3e9 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -24,6 +24,7 @@ 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' +import { Route as ApiAdminBracketsLockRouteImport } from './routes/api/admin/brackets/lock' const TestRoute = TestRouteImport.update({ id: '/test', @@ -101,6 +102,11 @@ const ApiAdminBracketsUnlockRoute = ApiAdminBracketsUnlockRouteImport.update({ path: '/api/admin/brackets/unlock', getParentRoute: () => rootRouteImport, } as any) +const ApiAdminBracketsLockRoute = ApiAdminBracketsLockRouteImport.update({ + id: '/api/admin/brackets/lock', + path: '/api/admin/brackets/lock', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -117,6 +123,7 @@ export interface FileRoutesByFullPath { '/api/predictions/lock': typeof ApiPredictionsLockRoute '/api/leaderboard/': typeof ApiLeaderboardIndexRoute '/api/predictions/': typeof ApiPredictionsIndexRoute + '/api/admin/brackets/lock': typeof ApiAdminBracketsLockRoute '/api/admin/brackets/unlock': typeof ApiAdminBracketsUnlockRoute } export interface FileRoutesByTo { @@ -134,6 +141,7 @@ export interface FileRoutesByTo { '/api/predictions/lock': typeof ApiPredictionsLockRoute '/api/leaderboard': typeof ApiLeaderboardIndexRoute '/api/predictions': typeof ApiPredictionsIndexRoute + '/api/admin/brackets/lock': typeof ApiAdminBracketsLockRoute '/api/admin/brackets/unlock': typeof ApiAdminBracketsUnlockRoute } export interface FileRoutesById { @@ -152,6 +160,7 @@ export interface FileRoutesById { '/api/predictions/lock': typeof ApiPredictionsLockRoute '/api/leaderboard/': typeof ApiLeaderboardIndexRoute '/api/predictions/': typeof ApiPredictionsIndexRoute + '/api/admin/brackets/lock': typeof ApiAdminBracketsLockRoute '/api/admin/brackets/unlock': typeof ApiAdminBracketsUnlockRoute } export interface FileRouteTypes { @@ -171,6 +180,7 @@ export interface FileRouteTypes { | '/api/predictions/lock' | '/api/leaderboard/' | '/api/predictions/' + | '/api/admin/brackets/lock' | '/api/admin/brackets/unlock' fileRoutesByTo: FileRoutesByTo to: @@ -188,6 +198,7 @@ export interface FileRouteTypes { | '/api/predictions/lock' | '/api/leaderboard' | '/api/predictions' + | '/api/admin/brackets/lock' | '/api/admin/brackets/unlock' id: | '__root__' @@ -205,6 +216,7 @@ export interface FileRouteTypes { | '/api/predictions/lock' | '/api/leaderboard/' | '/api/predictions/' + | '/api/admin/brackets/lock' | '/api/admin/brackets/unlock' fileRoutesById: FileRoutesById } @@ -223,6 +235,7 @@ export interface RootRouteChildren { ApiPredictionsLockRoute: typeof ApiPredictionsLockRoute ApiLeaderboardIndexRoute: typeof ApiLeaderboardIndexRoute ApiPredictionsIndexRoute: typeof ApiPredictionsIndexRoute + ApiAdminBracketsLockRoute: typeof ApiAdminBracketsLockRoute ApiAdminBracketsUnlockRoute: typeof ApiAdminBracketsUnlockRoute } @@ -333,6 +346,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAdminBracketsUnlockRouteImport parentRoute: typeof rootRouteImport } + '/api/admin/brackets/lock': { + id: '/api/admin/brackets/lock' + path: '/api/admin/brackets/lock' + fullPath: '/api/admin/brackets/lock' + preLoaderRoute: typeof ApiAdminBracketsLockRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -351,6 +371,7 @@ const rootRouteChildren: RootRouteChildren = { ApiPredictionsLockRoute: ApiPredictionsLockRoute, ApiLeaderboardIndexRoute: ApiLeaderboardIndexRoute, ApiPredictionsIndexRoute: ApiPredictionsIndexRoute, + ApiAdminBracketsLockRoute: ApiAdminBracketsLockRoute, ApiAdminBracketsUnlockRoute: ApiAdminBracketsUnlockRoute, } export const routeTree = rootRouteImport diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index e1a5416..7a1b8ea 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -271,6 +271,7 @@ function AdminPage() { const [searchInput, setSearchInput] = useState(""); const [searchQuery, setSearchQuery] = useState(""); const [unlockingUserId, setUnlockingUserId] = useState(null); + const [lockingUserId, setLockingUserId] = useState(null); // Fetch data function for subsequent requests (pagination, search) const fetchData = async (page: number, search: string) => { @@ -377,6 +378,44 @@ function AdminPage() { } }; + const handleLockBracket = async (userId: string, userName: string) => { + if (!confirm(`Lock bracket for ${userName}?`)) return; + + setLockingUserId(userId); + setMessage(null); + + try { + const response = await fetch("/api/admin/brackets/lock", { + 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: `Locked bracket for ${userName}`, + }); + invalidateAllPredictions(queryClient); + fetchData(pagination.page, searchQuery); + } else { + setMessage({ + type: "error", + text: data.error || "Failed to lock bracket", + }); + } + } catch { + setMessage({ type: "error", text: "Network error while locking" }); + } finally { + setLockingUserId(null); + } + }; + return (
@@ -477,26 +516,37 @@ function AdminPage() { : "-"}
diff --git a/src/routes/api/admin/brackets/lock.ts b/src/routes/api/admin/brackets/lock.ts new file mode 100644 index 0000000..137765c --- /dev/null +++ b/src/routes/api/admin/brackets/lock.ts @@ -0,0 +1,68 @@ +import { env } from "cloudflare:workers"; +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { createDb } from "@/db"; +import { userBracketStatus } from "@/db/schema"; +import { requireAdmin } from "@/lib/middleware/admin"; + +const lockRequestSchema = z.object({ + userId: z.string().min(1).max(50), +}); + +export const Route = createFileRoute("/api/admin/brackets/lock")({ + 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 = lockRequestSchema.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); + + // Upsert bracket status to locked + await db + .insert(userBracketStatus) + .values({ + id: crypto.randomUUID(), + userId, + isLocked: true, + lockedAt: new Date(), + }) + .onConflictDoUpdate({ + target: userBracketStatus.userId, + set: { isLocked: true, lockedAt: new Date() }, + }); + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }, + }, + }, +}); diff --git a/src/styles/admin.css b/src/styles/admin.css index 01f8db2..ae31d8d 100644 --- a/src/styles/admin.css +++ b/src/styles/admin.css @@ -372,6 +372,31 @@ cursor: not-allowed; } +.lock-btn { + padding: 6px 12px; + background: transparent; + border: 2px solid var(--yellow); + color: var(--yellow); + 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; +} + +.lock-btn:hover:not(:disabled) { + background: var(--yellow); + color: var(--black); +} + +.lock-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* ============================================ Messages ============================================ */ From 8fdeb573237271a29d0cddf8288a5d5e9a4a2694 Mon Sep 17 00:00:00 2001 From: Wes Bos Date: Mon, 9 Feb 2026 10:56:05 -0500 Subject: [PATCH 17/18] Move Acitivity component to Server functions --- src/components/leaderboard/Activity.tsx | 163 ++++++++++++++++--- src/routeTree.gen.ts | 22 --- src/routes/api/leaderboard/recent-pickers.ts | 145 ----------------- 3 files changed, 142 insertions(+), 188 deletions(-) delete mode 100644 src/routes/api/leaderboard/recent-pickers.ts diff --git a/src/components/leaderboard/Activity.tsx b/src/components/leaderboard/Activity.tsx index a3585ad..1964376 100644 --- a/src/components/leaderboard/Activity.tsx +++ b/src/components/leaderboard/Activity.tsx @@ -1,7 +1,138 @@ +import { useQuery } from "@tanstack/react-query"; +import { createServerFn } from "@tanstack/react-start"; import { useEffect, useRef, useState } from "react"; -import type { ActivityItem } from "@/routes/api/leaderboard/recent-pickers"; import "./activity.css"; +type ActivityItem = { + key: string; + pickerName: string; + pickerImage: string | null; + pickerUsername: string | null; + predictedName: string; + predictedPhoto: string; + opponentName: string | null; + opponentPhoto: string | null; + label: string; +}; + +const getRecentPickers = createServerFn({ method: "GET" }).handler(async () => { + const { env } = await import("cloudflare:workers"); + const { createDb } = await import("@/db"); + const { desc, eq, inArray } = await import("drizzle-orm"); + const schema = await import("@/db/schema"); + const { bracket, players } = await import("@/data/players"); + + const playerMap = new Map(players.map((p) => [p.id, p])); + + const r1Opponents = new Map>(); + for (const game of bracket.round1) { + if (game.player1 && game.player2) { + const opponents = new Map(); + opponents.set(game.player1.id, game.player2.id); + opponents.set(game.player2.id, game.player1.id); + r1Opponents.set(game.id, opponents); + } + } + + function roundLabel(gameId: string): string { + if (gameId === "final") return "win it all"; + if (gameId.startsWith("sf-")) return "win the semis"; + if (gameId.startsWith("qf-")) return "win the quarters"; + return ""; + } + + const db = createDb(env.DB); + + const lockedUsers = await db + .select({ + userId: schema.userBracketStatus.userId, + userName: schema.user.name, + userImage: schema.user.image, + username: schema.user.username, + }) + .from(schema.userBracketStatus) + .innerJoin(schema.user, eq(schema.userBracketStatus.userId, schema.user.id)) + .where(eq(schema.userBracketStatus.isLocked, true)) + .orderBy(desc(schema.userBracketStatus.lockedAt)) + .limit(40); + + if (lockedUsers.length === 0) { + return []; + } + + const userIds = lockedUsers.map((u) => u.userId); + 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)); + + const predictionsByUser = new Map< + string, + { gameId: string; predictedWinnerId: string }[] + >(); + for (const p of allPredictions) { + const list = predictionsByUser.get(p.userId) || []; + list.push({ + gameId: p.gameId, + predictedWinnerId: p.predictedWinnerId, + }); + predictionsByUser.set(p.userId, list); + } + + const items: ActivityItem[] = []; + + for (const user of lockedUsers) { + const predictions = predictionsByUser.get(user.userId) || []; + if (predictions.length === 0) continue; + + const shuffled = [...predictions]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + + for (const pred of shuffled.slice(0, 10)) { + const predicted = playerMap.get(pred.predictedWinnerId); + if (!predicted) continue; + + let opponentName: string | null = null; + let opponentPhoto: string | null = null; + const gameOpponents = r1Opponents.get(pred.gameId); + if (gameOpponents) { + const oppId = gameOpponents.get(pred.predictedWinnerId); + const opp = oppId ? playerMap.get(oppId) : undefined; + if (opp) { + opponentName = opp.name; + opponentPhoto = `/avatars/color/${opp.id}.png`; + } + } + + items.push({ + key: `${user.username ?? user.userName}-${pred.gameId}`, + pickerName: user.userName, + pickerImage: user.userImage, + pickerUsername: user.username, + predictedName: predicted.name, + predictedPhoto: `/avatars/color/${predicted.id}.png`, + opponentName, + opponentPhoto, + label: gameOpponents ? "" : roundLabel(pred.gameId), + }); + } + } + + for (let i = items.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [items[i], items[j]] = [items[j], items[i]]; + } + + return items; +}); + const MAX_VISIBLE = 6; const TICK_INTERVAL = 2500; @@ -19,11 +150,7 @@ function PickSentence({ item }: { item: ActivityItem }) { <> {" to beat "} {item.opponentPhoto && ( - + )} {item.opponentName} @@ -44,26 +171,20 @@ function PickSentence({ item }: { item: ActivityItem }) { } export function Activity() { + const { data } = useQuery({ + queryKey: ["recent-pickers"], + queryFn: () => getRecentPickers(), + staleTime: 1000 * 60 * 5, + }); + const [visible, setVisible] = useState([]); const queueRef = useRef([]); const tickRef = useRef | null>(null); - const [loaded, setLoaded] = useState(false); useEffect(() => { - fetch("/api/leaderboard/recent-pickers") - .then((res) => { - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json() as Promise<{ activity?: ActivityItem[] }>; - }) - .then((data) => { - queueRef.current = data.activity || []; - setLoaded(true); - }) - .catch(() => {}); - }, []); + if (!data || data.length === 0) return; - useEffect(() => { - if (!loaded || queueRef.current.length === 0) return; + queueRef.current = [...data]; const initial: ActivityItem[] = []; for (let i = 0; i < 3 && queueRef.current.length > 0; i++) { @@ -92,7 +213,7 @@ export function Activity() { return () => { if (tickRef.current) clearInterval(tickRef.current); }; - }, [loaded]); + }, [data]); if (visible.length === 0) return null; diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 7c2a3e9..843b681 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -17,7 +17,6 @@ import { Route as ApiPredictionsIndexRouteImport } from './routes/api/prediction 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 ApiLeaderboardRecentPickersRouteImport } from './routes/api/leaderboard/recent-pickers' 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/$' @@ -66,12 +65,6 @@ const ApiOgUsernameRoute = ApiOgUsernameRouteImport.update({ path: '/api/og/$username', getParentRoute: () => rootRouteImport, } as any) -const ApiLeaderboardRecentPickersRoute = - ApiLeaderboardRecentPickersRouteImport.update({ - id: '/api/leaderboard/recent-pickers', - path: '/api/leaderboard/recent-pickers', - getParentRoute: () => rootRouteImport, - } as any) const ApiLeaderboardCalculateRoute = ApiLeaderboardCalculateRouteImport.update({ id: '/api/leaderboard/calculate', path: '/api/leaderboard/calculate', @@ -118,7 +111,6 @@ export interface FileRoutesByFullPath { '/api/auth/$': typeof ApiAuthSplatRoute '/api/bracket/$username': typeof ApiBracketUsernameRoute '/api/leaderboard/calculate': typeof ApiLeaderboardCalculateRoute - '/api/leaderboard/recent-pickers': typeof ApiLeaderboardRecentPickersRoute '/api/og/$username': typeof ApiOgUsernameRoute '/api/predictions/lock': typeof ApiPredictionsLockRoute '/api/leaderboard/': typeof ApiLeaderboardIndexRoute @@ -136,7 +128,6 @@ export interface FileRoutesByTo { '/api/auth/$': typeof ApiAuthSplatRoute '/api/bracket/$username': typeof ApiBracketUsernameRoute '/api/leaderboard/calculate': typeof ApiLeaderboardCalculateRoute - '/api/leaderboard/recent-pickers': typeof ApiLeaderboardRecentPickersRoute '/api/og/$username': typeof ApiOgUsernameRoute '/api/predictions/lock': typeof ApiPredictionsLockRoute '/api/leaderboard': typeof ApiLeaderboardIndexRoute @@ -155,7 +146,6 @@ export interface FileRoutesById { '/api/auth/$': typeof ApiAuthSplatRoute '/api/bracket/$username': typeof ApiBracketUsernameRoute '/api/leaderboard/calculate': typeof ApiLeaderboardCalculateRoute - '/api/leaderboard/recent-pickers': typeof ApiLeaderboardRecentPickersRoute '/api/og/$username': typeof ApiOgUsernameRoute '/api/predictions/lock': typeof ApiPredictionsLockRoute '/api/leaderboard/': typeof ApiLeaderboardIndexRoute @@ -175,7 +165,6 @@ export interface FileRouteTypes { | '/api/auth/$' | '/api/bracket/$username' | '/api/leaderboard/calculate' - | '/api/leaderboard/recent-pickers' | '/api/og/$username' | '/api/predictions/lock' | '/api/leaderboard/' @@ -193,7 +182,6 @@ export interface FileRouteTypes { | '/api/auth/$' | '/api/bracket/$username' | '/api/leaderboard/calculate' - | '/api/leaderboard/recent-pickers' | '/api/og/$username' | '/api/predictions/lock' | '/api/leaderboard' @@ -211,7 +199,6 @@ export interface FileRouteTypes { | '/api/auth/$' | '/api/bracket/$username' | '/api/leaderboard/calculate' - | '/api/leaderboard/recent-pickers' | '/api/og/$username' | '/api/predictions/lock' | '/api/leaderboard/' @@ -230,7 +217,6 @@ export interface RootRouteChildren { ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiBracketUsernameRoute: typeof ApiBracketUsernameRoute ApiLeaderboardCalculateRoute: typeof ApiLeaderboardCalculateRoute - ApiLeaderboardRecentPickersRoute: typeof ApiLeaderboardRecentPickersRoute ApiOgUsernameRoute: typeof ApiOgUsernameRoute ApiPredictionsLockRoute: typeof ApiPredictionsLockRoute ApiLeaderboardIndexRoute: typeof ApiLeaderboardIndexRoute @@ -297,13 +283,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiOgUsernameRouteImport parentRoute: typeof rootRouteImport } - '/api/leaderboard/recent-pickers': { - id: '/api/leaderboard/recent-pickers' - path: '/api/leaderboard/recent-pickers' - fullPath: '/api/leaderboard/recent-pickers' - preLoaderRoute: typeof ApiLeaderboardRecentPickersRouteImport - parentRoute: typeof rootRouteImport - } '/api/leaderboard/calculate': { id: '/api/leaderboard/calculate' path: '/api/leaderboard/calculate' @@ -366,7 +345,6 @@ const rootRouteChildren: RootRouteChildren = { ApiAuthSplatRoute: ApiAuthSplatRoute, ApiBracketUsernameRoute: ApiBracketUsernameRoute, ApiLeaderboardCalculateRoute: ApiLeaderboardCalculateRoute, - ApiLeaderboardRecentPickersRoute: ApiLeaderboardRecentPickersRoute, ApiOgUsernameRoute: ApiOgUsernameRoute, ApiPredictionsLockRoute: ApiPredictionsLockRoute, ApiLeaderboardIndexRoute: ApiLeaderboardIndexRoute, diff --git a/src/routes/api/leaderboard/recent-pickers.ts b/src/routes/api/leaderboard/recent-pickers.ts deleted file mode 100644 index e646529..0000000 --- a/src/routes/api/leaderboard/recent-pickers.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { env } from "cloudflare:workers"; -import { createFileRoute } from "@tanstack/react-router"; -import { desc, eq, inArray } from "drizzle-orm"; -import { createDb } from "@/db"; -import * as schema from "@/db/schema"; -import { bracket, players } from "@/data/players"; - -const playerMap = new Map(players.map((p) => [p.id, p])); - -const r1Opponents = new Map>(); -for (const game of bracket.round1) { - if (game.player1 && game.player2) { - const opponents = new Map(); - opponents.set(game.player1.id, game.player2.id); - opponents.set(game.player2.id, game.player1.id); - r1Opponents.set(game.id, opponents); - } -} - -function roundLabel(gameId: string): string { - if (gameId === "final") return "win it all"; - if (gameId.startsWith("sf-")) return "win the semis"; - if (gameId.startsWith("qf-")) return "win the quarters"; - return ""; -} - -export type ActivityItem = { - key: string; - pickerName: string; - pickerImage: string | null; - pickerUsername: string | null; - predictedName: string; - predictedPhoto: string; - opponentName: string | null; - opponentPhoto: string | null; - label: string; -}; - -export const Route = createFileRoute("/api/leaderboard/recent-pickers")({ - server: { - handlers: { - GET: async () => { - const db = createDb(env.DB); - - const lockedUsers = await db - .select({ - userId: schema.userBracketStatus.userId, - userName: schema.user.name, - userImage: schema.user.image, - username: schema.user.username, - }) - .from(schema.userBracketStatus) - .innerJoin( - schema.user, - eq(schema.userBracketStatus.userId, schema.user.id), - ) - .where(eq(schema.userBracketStatus.isLocked, true)) - .orderBy(desc(schema.userBracketStatus.lockedAt)) - .limit(40); - - if (lockedUsers.length === 0) { - return new Response(JSON.stringify({ activity: [] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - - const userIds = lockedUsers.map((u) => u.userId); - 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)); - - const predictionsByUser = new Map< - string, - { gameId: string; predictedWinnerId: string }[] - >(); - for (const p of allPredictions) { - const list = predictionsByUser.get(p.userId) || []; - list.push({ - gameId: p.gameId, - predictedWinnerId: p.predictedWinnerId, - }); - predictionsByUser.set(p.userId, list); - } - - const items: ActivityItem[] = []; - - for (const user of lockedUsers) { - const predictions = predictionsByUser.get(user.userId) || []; - if (predictions.length === 0) continue; - - const shuffled = [...predictions]; - for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; - } - - for (const pred of shuffled.slice(0, 10)) { - const predicted = playerMap.get(pred.predictedWinnerId); - if (!predicted) continue; - - let opponentName: string | null = null; - let opponentPhoto: string | null = null; - const gameOpponents = r1Opponents.get(pred.gameId); - if (gameOpponents) { - const oppId = gameOpponents.get(pred.predictedWinnerId); - const opp = oppId ? playerMap.get(oppId) : undefined; - if (opp) { - opponentName = opp.name; - opponentPhoto = `/avatars/color/${opp.id}.png`; - } - } - - items.push({ - key: `${user.username ?? user.userName}-${pred.gameId}`, - pickerName: user.userName, - pickerImage: user.userImage, - pickerUsername: user.username, - predictedName: predicted.name, - predictedPhoto: `/avatars/color/${predicted.id}.png`, - opponentName, - opponentPhoto, - label: gameOpponents ? "" : roundLabel(pred.gameId), - }); - } - } - - for (let i = items.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [items[i], items[j]] = [items[j], items[i]]; - } - - return new Response(JSON.stringify({ activity: items }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }, - }, - }, -}); From 6d10af17d1468a71b4bd24a59dc6852b642ea289 Mon Sep 17 00:00:00 2001 From: Wes Bos Date: Mon, 9 Feb 2026 10:59:31 -0500 Subject: [PATCH 18/18] moves Leaderboard over to Tanstack server fns --- src/components/leaderboard/Leaderboard.tsx | 83 ++++++++++++++++------ src/routeTree.gen.ts | 21 ------ src/routes/api/leaderboard/index.ts | 63 ---------------- 3 files changed, 63 insertions(+), 104 deletions(-) delete mode 100644 src/routes/api/leaderboard/index.ts diff --git a/src/components/leaderboard/Leaderboard.tsx b/src/components/leaderboard/Leaderboard.tsx index 466d7fc..8ff54e5 100644 --- a/src/components/leaderboard/Leaderboard.tsx +++ b/src/components/leaderboard/Leaderboard.tsx @@ -1,26 +1,69 @@ -import { useEffect, useState } from "react"; -import type { LeaderboardEntry } from "@/routes/api/leaderboard/index"; +import { useQuery } from "@tanstack/react-query"; +import { createServerFn } from "@tanstack/react-start"; import { LeaderboardScore } from "./LeaderboardScore"; import "./leaderboard.css"; -export function Leaderboard() { - const [entries, setEntries] = useState([]); - const [loading, setLoading] = useState(true); +type LeaderboardEntry = { + rank: number; + userId: string; + userName: string; + userImage: string | null; + username: string | null; + round1Score: number; + round2Score: number; + round3Score: number; + round4Score: number; + totalScore: number; +}; + +const getLeaderboard = createServerFn({ method: "GET" }).handler(async () => { + const { env } = await import("cloudflare:workers"); + const { createDb } = await import("@/db"); + const { desc, eq } = await import("drizzle-orm"); + const schema = await import("@/db/schema"); + + 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); - useEffect(() => { - fetch("/api/leaderboard/") - .then((res) => { - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json() as Promise<{ leaderboard?: LeaderboardEntry[] }>; - }) - .then((data) => { - setEntries(data.leaderboard || []); - setLoading(false); - }) - .catch(() => { - setLoading(false); - }); - }, []); + return scores.map( + (score, index): LeaderboardEntry => ({ + 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, + }), + ); +}); + +export function Leaderboard() { + const { data, isLoading } = useQuery({ + queryKey: ["leaderboard"], + queryFn: () => getLeaderboard(), + staleTime: 1000 * 60 * 5, + }); + const entries = data ?? []; return (
@@ -35,7 +78,7 @@ export function Leaderboard() {

Top Predictors

- {loading ? ( + {isLoading ? (
Loading...
) : entries.length === 0 ? (
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 843b681..1372a02 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -14,7 +14,6 @@ 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' @@ -50,11 +49,6 @@ const ApiPredictionsIndexRoute = ApiPredictionsIndexRouteImport.update({ 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', @@ -113,7 +107,6 @@ export interface FileRoutesByFullPath { '/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/lock': typeof ApiAdminBracketsLockRoute '/api/admin/brackets/unlock': typeof ApiAdminBracketsUnlockRoute @@ -130,7 +123,6 @@ export interface FileRoutesByTo { '/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/lock': typeof ApiAdminBracketsLockRoute '/api/admin/brackets/unlock': typeof ApiAdminBracketsUnlockRoute @@ -148,7 +140,6 @@ export interface FileRoutesById { '/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/lock': typeof ApiAdminBracketsLockRoute '/api/admin/brackets/unlock': typeof ApiAdminBracketsUnlockRoute @@ -167,7 +158,6 @@ export interface FileRouteTypes { | '/api/leaderboard/calculate' | '/api/og/$username' | '/api/predictions/lock' - | '/api/leaderboard/' | '/api/predictions/' | '/api/admin/brackets/lock' | '/api/admin/brackets/unlock' @@ -184,7 +174,6 @@ export interface FileRouteTypes { | '/api/leaderboard/calculate' | '/api/og/$username' | '/api/predictions/lock' - | '/api/leaderboard' | '/api/predictions' | '/api/admin/brackets/lock' | '/api/admin/brackets/unlock' @@ -201,7 +190,6 @@ export interface FileRouteTypes { | '/api/leaderboard/calculate' | '/api/og/$username' | '/api/predictions/lock' - | '/api/leaderboard/' | '/api/predictions/' | '/api/admin/brackets/lock' | '/api/admin/brackets/unlock' @@ -219,7 +207,6 @@ export interface RootRouteChildren { ApiLeaderboardCalculateRoute: typeof ApiLeaderboardCalculateRoute ApiOgUsernameRoute: typeof ApiOgUsernameRoute ApiPredictionsLockRoute: typeof ApiPredictionsLockRoute - ApiLeaderboardIndexRoute: typeof ApiLeaderboardIndexRoute ApiPredictionsIndexRoute: typeof ApiPredictionsIndexRoute ApiAdminBracketsLockRoute: typeof ApiAdminBracketsLockRoute ApiAdminBracketsUnlockRoute: typeof ApiAdminBracketsUnlockRoute @@ -262,13 +249,6 @@ declare module '@tanstack/react-router' { 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' @@ -347,7 +327,6 @@ const rootRouteChildren: RootRouteChildren = { ApiLeaderboardCalculateRoute: ApiLeaderboardCalculateRoute, ApiOgUsernameRoute: ApiOgUsernameRoute, ApiPredictionsLockRoute: ApiPredictionsLockRoute, - ApiLeaderboardIndexRoute: ApiLeaderboardIndexRoute, ApiPredictionsIndexRoute: ApiPredictionsIndexRoute, ApiAdminBracketsLockRoute: ApiAdminBracketsLockRoute, ApiAdminBracketsUnlockRoute: ApiAdminBracketsUnlockRoute, diff --git a/src/routes/api/leaderboard/index.ts b/src/routes/api/leaderboard/index.ts deleted file mode 100644 index c4dee60..0000000 --- a/src/routes/api/leaderboard/index.ts +++ /dev/null @@ -1,63 +0,0 @@ -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" }, - }); - }, - }, - }, -});
- {user.username && user.isLocked && ( - <> - - View - - - + {user.username && ( + + View + + )} + {user.isLocked ? ( + + ) : ( + )}