diff --git a/CLAUDE.md b/CLAUDE.md
index ee9e0b5..246dc85 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,43 @@ 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.
## Code Style
- Biome for linting/formatting (tabs, double quotes)
- TypeScript strict mode
+- XY Flow library for tournament bracket visualization
+
+## 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_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/0000_wonderful_warpath.sql b/drizzle/0000_wonderful_warpath.sql
new file mode 100644
index 0000000..8c18358
--- /dev/null
+++ b/drizzle/0000_wonderful_warpath.sql
@@ -0,0 +1,104 @@
+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 `tournament_result` (
+ `id` text PRIMARY KEY NOT NULL,
+ `game_id` text NOT NULL,
+ `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
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `tournament_result_game_id_unique` ON `tournament_result` (`game_id`);--> statement-breakpoint
+CREATE INDEX `tournament_result_gameId_idx` ON `tournament_result` (`game_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 INDEX `user_prediction_userId_gameId_idx` 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/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json
index 55bd2c8..779c3e8 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": "0dd9c063-5d8e-49d0-bb27-76fd0763885a",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"account": {
@@ -224,6 +224,68 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
+ "tournament_result": {
+ "name": "tournament_result",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "winner_id": {
+ "name": "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": {
+ "tournament_result_game_id_unique": {
+ "name": "tournament_result_game_id_unique",
+ "columns": [
+ "game_id"
+ ],
+ "isUnique": true
+ },
+ "tournament_result_gameId_idx": {
+ "name": "tournament_result_gameId_idx",
+ "columns": [
+ "game_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
"user": {
"name": "user",
"columns": {
@@ -263,6 +325,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 +356,13 @@
"email"
],
"isUnique": true
+ },
+ "user_username_unique": {
+ "name": "user_username_unique",
+ "columns": [
+ "username"
+ ],
+ "isUnique": true
}
},
"foreignKeys": {},
@@ -294,6 +370,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_idx": {
+ "name": "user_prediction_userId_gameId_idx",
+ "columns": [
+ "user_id",
+ "game_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "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..a8a2c0a 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": 1769443421416,
+ "tag": "0000_wonderful_warpath",
"breakpoints": true
}
]
diff --git a/package.json b/package.json
index 05a8747..321369e 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,8 @@
"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"
},
"devDependencies": {
"@biomejs/biome": "2.3.11",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2d9bd84..ffb6735 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -56,6 +56,9 @@ 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
devDependencies:
'@biomejs/biome':
specifier: 2.3.11
@@ -1164,6 +1167,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 +1302,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'}
@@ -1783,6 +1795,10 @@ packages:
babel-dead-code-elimination@1.0.11:
resolution: {integrity: sha512-mwq3W3e/pKSI6TG8lXMiDWvEi1VXYlSBlJlB3l+I0bAb5u1RNUl88udos85eOPNK3m5EXK9uO7d2g08pesTySQ==}
+ 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 +1911,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==}
@@ -1951,9 +1970,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}
@@ -2161,6 +2197,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==}
@@ -2214,6 +2254,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 +2289,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==}
@@ -2302,6 +2348,10 @@ 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}
@@ -2389,6 +2439,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 +2523,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 +2603,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 +2638,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}
@@ -2654,6 +2719,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'}
@@ -2746,6 +2815,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 +2846,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==}
@@ -2857,6 +2932,9 @@ packages:
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 +3080,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 +3134,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==}
@@ -3804,6 +3888,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 +3969,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)':
@@ -4489,6 +4580,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ base64-js@0.0.8: {}
+
base64-js@1.5.1: {}
baseline-browser-mapping@2.9.11: {}
@@ -4570,6 +4663,8 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
+ camelize@1.0.1: {}
+
caniuse-lite@1.0.30001762: {}
chai@6.2.2: {}
@@ -4639,6 +4734,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 +4750,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
@@ -4761,6 +4870,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
@@ -4904,6 +5015,8 @@ snapshots:
escalade@3.2.0: {}
+ escape-html@1.0.3: {}
+
esprima@4.0.1: {}
estree-walker@3.0.3:
@@ -4922,6 +5035,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
+ fflate@0.7.4: {}
+
file-uri-to-path@1.0.0: {}
fill-range@7.1.1:
@@ -4962,6 +5077,8 @@ snapshots:
rou3: 0.7.12
srvx: 0.10.0
+ hex-rgb@4.3.0: {}
+
html-encoding-sniffer@6.0.0:
dependencies:
'@exodus/bytes': 1.8.0
@@ -5059,6 +5176,8 @@ snapshots:
json5@2.2.3: {}
+ just-camel-case@6.2.0: {}
+
kleur@4.1.5: {}
kysely@0.28.9: {}
@@ -5117,6 +5236,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 +5311,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 +5345,8 @@ snapshots:
picomatch@4.0.3: {}
+ postcss-value-parser@4.2.0: {}
+
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
@@ -5327,6 +5460,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
@@ -5418,6 +5565,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 +5596,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: {}
@@ -5511,6 +5662,11 @@ snapshots:
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 +5788,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 +5830,8 @@ snapshots:
yallist@3.1.1: {}
+ yoga-wasm-web@0.3.3: {}
+
youch-core@0.3.3:
dependencies:
'@poppinss/exception': 1.2.3
diff --git a/public/fonts/DSEG7Classic-Bold.woff2 b/public/fonts/DSEG7Classic-Bold.woff2
new file mode 100644
index 0000000..558eec4
Binary files /dev/null and b/public/fonts/DSEG7Classic-Bold.woff2 differ
diff --git a/scripts/seed-leaderboard.sql b/scripts/seed-leaderboard.sql
new file mode 100644
index 0000000..d5fd3e7
--- /dev/null
+++ b/scripts/seed-leaderboard.sql
@@ -0,0 +1,110 @@
+-- Seed tournament results (Round 1 only for testing)
+INSERT OR REPLACE INTO tournament_result (id, game_id, winner_id, created_at, updated_at) VALUES
+ ('tr-r1-0', 'r1-0', 'wes-bos', 1737331200000, 1737331200000),
+ ('tr-r1-1', 'r1-1', 'kevin-powell', 1737331200000, 1737331200000),
+ ('tr-r1-2', 'r1-2', 'adam-wathan', 1737331200000, 1737331200000),
+ ('tr-r1-3', 'r1-3', 'josh-comeau', 1737331200000, 1737331200000),
+ ('tr-r1-4', 'r1-4', 'kyle-cook', 1737331200000, 1737331200000),
+ ('tr-r1-5', 'r1-5', 'bramus', 1737331200000, 1737331200000),
+ ('tr-r1-6', 'r1-6', 'cassie-evans', 1737331200000, 1737331200000),
+ ('tr-r1-7', 'r1-7', 'miriam-suzanne', 1737331200000, 1737331200000);
+
+-- Add some quarterfinal results
+INSERT OR REPLACE INTO tournament_result (id, game_id, winner_id, created_at, updated_at) VALUES
+ ('tr-qf-0', 'qf-0', 'kevin-powell', 1737331200000, 1737331200000),
+ ('tr-qf-1', 'qf-1', 'josh-comeau', 1737331200000, 1737331200000);
+
+-- Create dummy test users (for demo purposes)
+-- Note: In production, users come from GitHub OAuth
+INSERT OR REPLACE INTO user (id, name, email, email_verified, image, created_at, updated_at) VALUES
+ ('test-user-1', 'CSS Wizard', 'wizard@test.com', 1, 'https://avatars.githubusercontent.com/u/1?v=4', 1737331200000, 1737331200000),
+ ('test-user-2', 'Flexbox Fan', 'flex@test.com', 1, 'https://avatars.githubusercontent.com/u/2?v=4', 1737331200000, 1737331200000),
+ ('test-user-3', 'Grid Master', 'grid@test.com', 1, 'https://avatars.githubusercontent.com/u/3?v=4', 1737331200000, 1737331200000),
+ ('test-user-4', 'Animation Ace', 'anim@test.com', 1, 'https://avatars.githubusercontent.com/u/4?v=4', 1737331200000, 1737331200000),
+ ('test-user-5', 'Selector Savant', 'select@test.com', 1, 'https://avatars.githubusercontent.com/u/5?v=4', 1737331200000, 1737331200000);
+
+-- Mark test users' brackets as locked
+INSERT OR REPLACE INTO user_bracket_status (id, user_id, is_locked, locked_at, created_at) VALUES
+ ('bs-1', 'test-user-1', 1, 1737331200000, 1737331200000),
+ ('bs-2', 'test-user-2', 1, 1737331200000, 1737331200000),
+ ('bs-3', 'test-user-3', 1, 1737331200000, 1737331200000),
+ ('bs-4', 'test-user-4', 1, 1737331200000, 1737331200000),
+ ('bs-5', 'test-user-5', 1, 1737331200000, 1737331200000);
+
+-- User 1 predictions (perfect Round 1, 1/2 QF = 80 + 20 = 100)
+INSERT OR REPLACE INTO user_prediction (id, user_id, game_id, predicted_winner_id, created_at, updated_at) VALUES
+ ('p1-r1-0', 'test-user-1', 'r1-0', 'wes-bos', 1737331200000, 1737331200000),
+ ('p1-r1-1', 'test-user-1', 'r1-1', 'kevin-powell', 1737331200000, 1737331200000),
+ ('p1-r1-2', 'test-user-1', 'r1-2', 'adam-wathan', 1737331200000, 1737331200000),
+ ('p1-r1-3', 'test-user-1', 'r1-3', 'josh-comeau', 1737331200000, 1737331200000),
+ ('p1-r1-4', 'test-user-1', 'r1-4', 'kyle-cook', 1737331200000, 1737331200000),
+ ('p1-r1-5', 'test-user-1', 'r1-5', 'bramus', 1737331200000, 1737331200000),
+ ('p1-r1-6', 'test-user-1', 'r1-6', 'cassie-evans', 1737331200000, 1737331200000),
+ ('p1-r1-7', 'test-user-1', 'r1-7', 'miriam-suzanne', 1737331200000, 1737331200000),
+ ('p1-qf-0', 'test-user-1', 'qf-0', 'kevin-powell', 1737331200000, 1737331200000),
+ ('p1-qf-1', 'test-user-1', 'qf-1', 'adam-wathan', 1737331200000, 1737331200000);
+
+-- User 2 predictions (6/8 Round 1, 2/2 QF = 60 + 40 = 100)
+INSERT OR REPLACE INTO user_prediction (id, user_id, game_id, predicted_winner_id, created_at, updated_at) VALUES
+ ('p2-r1-0', 'test-user-2', 'r1-0', 'wes-bos', 1737331200000, 1737331200000),
+ ('p2-r1-1', 'test-user-2', 'r1-1', 'kevin-powell', 1737331200000, 1737331200000),
+ ('p2-r1-2', 'test-user-2', 'r1-2', 'ania-kubow', 1737331200000, 1737331200000),
+ ('p2-r1-3', 'test-user-2', 'r1-3', 'josh-comeau', 1737331200000, 1737331200000),
+ ('p2-r1-4', 'test-user-2', 'r1-4', 'kyle-cook', 1737331200000, 1737331200000),
+ ('p2-r1-5', 'test-user-2', 'r1-5', 'bramus', 1737331200000, 1737331200000),
+ ('p2-r1-6', 'test-user-2', 'r1-6', 'stephanie-eckles', 1737331200000, 1737331200000),
+ ('p2-r1-7', 'test-user-2', 'r1-7', 'miriam-suzanne', 1737331200000, 1737331200000),
+ ('p2-qf-0', 'test-user-2', 'qf-0', 'kevin-powell', 1737331200000, 1737331200000),
+ ('p2-qf-1', 'test-user-2', 'qf-1', 'josh-comeau', 1737331200000, 1737331200000);
+
+-- User 3 predictions (5/8 Round 1, 1/2 QF = 50 + 20 = 70)
+INSERT OR REPLACE INTO user_prediction (id, user_id, game_id, predicted_winner_id, created_at, updated_at) VALUES
+ ('p3-r1-0', 'test-user-3', 'r1-0', 'scott-tolinski', 1737331200000, 1737331200000),
+ ('p3-r1-1', 'test-user-3', 'r1-1', 'kevin-powell', 1737331200000, 1737331200000),
+ ('p3-r1-2', 'test-user-3', 'r1-2', 'adam-wathan', 1737331200000, 1737331200000),
+ ('p3-r1-3', 'test-user-3', 'r1-3', 'josh-comeau', 1737331200000, 1737331200000),
+ ('p3-r1-4', 'test-user-3', 'r1-4', 'jen-simmons', 1737331200000, 1737331200000),
+ ('p3-r1-5', 'test-user-3', 'r1-5', 'rachel-andrew', 1737331200000, 1737331200000),
+ ('p3-r1-6', 'test-user-3', 'r1-6', 'cassie-evans', 1737331200000, 1737331200000),
+ ('p3-r1-7', 'test-user-3', 'r1-7', 'miriam-suzanne', 1737331200000, 1737331200000),
+ ('p3-qf-0', 'test-user-3', 'qf-0', 'wes-bos', 1737331200000, 1737331200000),
+ ('p3-qf-1', 'test-user-3', 'qf-1', 'josh-comeau', 1737331200000, 1737331200000);
+
+-- User 4 predictions (4/8 Round 1, 0/2 QF = 40 + 0 = 40)
+INSERT OR REPLACE INTO user_prediction (id, user_id, game_id, predicted_winner_id, created_at, updated_at) VALUES
+ ('p4-r1-0', 'test-user-4', 'r1-0', 'wes-bos', 1737331200000, 1737331200000),
+ ('p4-r1-1', 'test-user-4', 'r1-1', 'adam-argyle', 1737331200000, 1737331200000),
+ ('p4-r1-2', 'test-user-4', 'r1-2', 'adam-wathan', 1737331200000, 1737331200000),
+ ('p4-r1-3', 'test-user-4', 'r1-3', 'cassidy-williams', 1737331200000, 1737331200000),
+ ('p4-r1-4', 'test-user-4', 'r1-4', 'kyle-cook', 1737331200000, 1737331200000),
+ ('p4-r1-5', 'test-user-4', 'r1-5', 'rachel-andrew', 1737331200000, 1737331200000),
+ ('p4-r1-6', 'test-user-4', 'r1-6', 'stephanie-eckles', 1737331200000, 1737331200000),
+ ('p4-r1-7', 'test-user-4', 'r1-7', 'css-ninja', 1737331200000, 1737331200000),
+ ('p4-qf-0', 'test-user-4', 'qf-0', 'wes-bos', 1737331200000, 1737331200000),
+ ('p4-qf-1', 'test-user-4', 'qf-1', 'adam-wathan', 1737331200000, 1737331200000);
+
+-- User 5 predictions (3/8 Round 1, 1/2 QF = 30 + 20 = 50)
+INSERT OR REPLACE INTO user_prediction (id, user_id, game_id, predicted_winner_id, created_at, updated_at) VALUES
+ ('p5-r1-0', 'test-user-5', 'r1-0', 'scott-tolinski', 1737331200000, 1737331200000),
+ ('p5-r1-1', 'test-user-5', 'r1-1', 'adam-argyle', 1737331200000, 1737331200000),
+ ('p5-r1-2', 'test-user-5', 'r1-2', 'ania-kubow', 1737331200000, 1737331200000),
+ ('p5-r1-3', 'test-user-5', 'r1-3', 'josh-comeau', 1737331200000, 1737331200000),
+ ('p5-r1-4', 'test-user-5', 'r1-4', 'jen-simmons', 1737331200000, 1737331200000),
+ ('p5-r1-5', 'test-user-5', 'r1-5', 'bramus', 1737331200000, 1737331200000),
+ ('p5-r1-6', 'test-user-5', 'r1-6', 'stephanie-eckles', 1737331200000, 1737331200000),
+ ('p5-r1-7', 'test-user-5', 'r1-7', 'css-ninja', 1737331200000, 1737331200000),
+ ('p5-qf-0', 'test-user-5', 'qf-0', 'kevin-powell', 1737331200000, 1737331200000),
+ ('p5-qf-1', 'test-user-5', 'qf-1', 'adam-wathan', 1737331200000, 1737331200000);
+
+-- Insert user scores (calculated based on above predictions)
+-- User 1: R1=80, R2=20 (1 correct of 2 played), Total=100
+-- User 2: R1=60, R2=40 (2 correct), Total=100
+-- User 3: R1=50, R2=20 (1 correct), Total=70
+-- User 4: R1=40, R2=0, Total=40
+-- User 5: R1=30, R2=20 (1 correct), Total=50
+INSERT OR REPLACE INTO user_score (id, user_id, round1_score, round2_score, round3_score, round4_score, total_score, created_at, updated_at) VALUES
+ ('score-1', 'test-user-1', 80, 20, 0, 0, 100, 1737331200000, 1737331200000),
+ ('score-2', 'test-user-2', 60, 40, 0, 0, 100, 1737331200000, 1737331200000),
+ ('score-3', 'test-user-3', 50, 20, 0, 0, 70, 1737331200000, 1737331200000),
+ ('score-5', 'test-user-5', 30, 20, 0, 0, 50, 1737331200000, 1737331200000),
+ ('score-4', 'test-user-4', 40, 0, 0, 0, 40, 1737331200000, 1737331200000);
diff --git a/src/components/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 */}
diff --git a/src/components/LoginSection.tsx b/src/components/LoginSection.tsx
index 08b68c9..bce02b1 100644
--- a/src/components/LoginSection.tsx
+++ b/src/components/LoginSection.tsx
@@ -1,55 +1,322 @@
+import { useState } from "react";
+import { 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() {
+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;
+}
+
+export function LoginSection({
+ pickCount = 0,
+ isLocked = false,
+ isSaving = false,
+ hasChanges = false,
+ error = null,
+ deadline,
+ isDeadlinePassed = false,
+ username = null,
+ onSave,
+ onLock,
+ onReset,
+}: 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;
+
+ 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;
if (isPending) {
return (
- ...
+ Loading...
);
}
if (session?.user) {
+ const canLock = pickCount === TOTAL_GAMES && !isLocked && !isDeadlinePassed;
+
return (
-

-
+ {/* 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!
+
+
+ {/* Share section - only show when locked and username exists */}
+ {shareUrl && (
+
+
+
+ Share your bracket
+
+
+
+ {twitterShareUrl && (
+
+
+ Share on X
+
+ )}
+
+
+ )}
+ >
+ )}
+
+ {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 && (
+
+ )}