commit f51c278cd3393a7d503623eff572d0612e9f9b2c Author: yohlo Date: Wed Aug 20 22:35:40 2025 -0500 init diff --git a/.docker-postgres-init/01_init.sql b/.docker-postgres-init/01_init.sql new file mode 100644 index 0000000..c568e27 --- /dev/null +++ b/.docker-postgres-init/01_init.sql @@ -0,0 +1,6 @@ +-- Note: It's highly recommended to use more secure passwords + +-- create db/user for application data/drizzle orm +CREATE USER app_svc WITH PASSWORD 'password'; +CREATE DATABASE app_db; +GRANT ALL PRIVILEGES ON DATABASE app_db TO app_svc; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9f8fe7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +node_modules +package-lock.json +yarn.lock + +.DS_Store +.cache +.env +.env.docker +.vercel +.output +/build/ +/api/ +/server/build +/public/build# Sentry Config File +.env.sentry-build-plugin +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/scripts/ +/pb_data/ \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..2be5eaa --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +**/build +**/public +pnpm-lock.yaml +routeTree.gen.ts \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..00b5278 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "files.watcherExclude": { + "**/routeTree.gen.ts": true + }, + "search.exclude": { + "**/routeTree.gen.ts": true + }, + "files.readonlyInclude": { + "**/routeTree.gen.ts": true + } +} diff --git a/Dockerfile.pocketbase b/Dockerfile.pocketbase new file mode 100644 index 0000000..e45081b --- /dev/null +++ b/Dockerfile.pocketbase @@ -0,0 +1,16 @@ +FROM alpine:latest + +ARG PB_VERSION=0.29.2 + +RUN apk add --no-cache \ + unzip \ + ca-certificates + +# download and unzip PocketBase +ADD https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip /tmp/pb.zip +RUN unzip /tmp/pb.zip -d /pb/ + +EXPOSE 8090 + +# start PocketBase +CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..90cba4a --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# Welcome to TanStack.com! + +This site is built with TanStack Router! + +- [TanStack Router Docs](https://tanstack.com/router) + +It's deployed automagically with Netlify! + +- [Netlify](https://netlify.com/) + +## Development + +From your terminal: + +```sh +pnpm install +pnpm dev +``` + +This starts your app in development mode, rebuilding assets on file changes. + +## Editing and previewing the docs of TanStack projects locally + +The documentations for all TanStack projects except for `React Charts` are hosted on [https://tanstack.com](https://tanstack.com), powered by this TanStack Router app. +In production, the markdown doc pages are fetched from the GitHub repos of the projects, but in development they are read from the local file system. + +Follow these steps if you want to edit the doc pages of a project (in these steps we'll assume it's [`TanStack/form`](https://github.com/tanstack/form)) and preview them locally : + +1. Create a new directory called `tanstack`. + +```sh +mkdir tanstack +``` + +2. Enter the directory and clone this repo and the repo of the project there. + +```sh +cd tanstack +git clone git@github.com:TanStack/tanstack.com.git +git clone git@github.com:TanStack/form.git +``` + +> [!NOTE] +> Your `tanstack` directory should look like this: +> +> ``` +> tanstack/ +> | +> +-- form/ +> | +> +-- tanstack.com/ +> ``` + +> [!WARNING] +> Make sure the name of the directory in your local file system matches the name of the project's repo. For example, `tanstack/form` must be cloned into `form` (this is the default) instead of `some-other-name`, because that way, the doc pages won't be found. + +3. Enter the `tanstack/tanstack.com` directory, install the dependencies and run the app in dev mode: + +```sh +cd tanstack.com +pnpm i +# The app will run on https://localhost:3000 by default +pnpm dev +``` + +4. Now you can visit http://localhost:3000/form/latest/docs/overview in the browser and see the changes you make in `tanstack/form/docs`. + +> [!NOTE] +> The updated pages need to be manually reloaded in the browser. + +> [!WARNING] +> You will need to update the `docs/config.json` file (in the project's repo) if you add a new doc page! diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8d3c2b7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,69 @@ +services: + postgres: + image: postgres:14 + container_name: postgres-db + environment: + POSTGRES_USER: supertokens + POSTGRES_PASSWORD: password + POSTGRES_DB: supertokens + ports: + - "5432:5432" + env_file: + - .env + - .env.docker + volumes: + - postgres-data:/var/lib/postgresql/data + - ./.docker-postgres-init:/docker-entrypoint-initdb.d + networks: + - app-network + + pocketbase: + build: + context: . + dockerfile: Dockerfile.pocketbase + container_name: pocketbase-db + ports: + - "8090:8090" + volumes: + - ./pb_data:/pb/pb_data + - ./pb_migrations:/pb/pb_migrations + - ./pb_hooks:/pb/pb_hooks + networks: + - app-network + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: redis-cache + ports: + - "6379:6379" + command: redis-server --appendonly yes + volumes: + - redis-data:/data + networks: + - app-network + restart: unless-stopped + + supertokens: + image: registry.supertokens.io/supertokens/supertokens-postgresql + container_name: supertokens-core + depends_on: + - postgres + environment: + POSTGRESQL_CONNECTION_URI: postgresql://supertokens:password@postgres:5432/supertokens + ports: + - "3567:3567" + env_file: + - .env + - .env.docker + networks: + - app-network + +networks: + app-network: + driver: bridge + +volumes: + postgres-data: + pocketbase-data: + redis-data: \ No newline at end of file diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..cab06d8 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/lib/drizzle/schema", + out: "./src/lib/drizzle/migrations", + dialect: "postgresql", + dbCredentials: { + url: process.env.VITE_DATABASE_URL ?? "", + }, +}); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..2fa96c0 --- /dev/null +++ b/package.json @@ -0,0 +1,66 @@ +{ + "name": "tanstack-start-example-basic-react-query", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --host 0.0.0.0", + "build": "vite build && tsc --noEmit", + "start": "vite start", + "db:generate": "npx drizzle-kit generate --config drizzle.config.ts", + "db:push": "npx drizzle-kit push", + "db:studio": "npx drizzle-kit studio" + }, + "dependencies": { + "@hello-pangea/dnd": "^18.0.1", + "@mantine/charts": "^8.2.4", + "@mantine/core": "^8.2.4", + "@mantine/dates": "^8.2.4", + "@mantine/form": "^8.2.4", + "@mantine/hooks": "^8.2.4", + "@phosphor-icons/react": "^2.1.10", + "@tanstack/react-query": "^5.66.0", + "@tanstack/react-query-devtools": "^5.66.0", + "@tanstack/react-router": "^1.130.12", + "@tanstack/react-router-devtools": "^1.130.13", + "@tanstack/react-router-with-query": "^1.130.12", + "@tanstack/react-start": "^1.130.15", + "@types/ioredis": "^4.28.10", + "drizzle-orm": "^0.44.4", + "drizzle-zod": "^0.8.3", + "framer-motion": "^12.23.12", + "ioredis": "^5.7.0", + "pg": "^8.16.3", + "pocketbase": "^0.26.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-imask": "^7.6.1", + "react-use-draggable-scroll": "^0.4.7", + "recharts": "^3.1.2", + "redaxios": "^0.5.1", + "sonner": "^2.0.7", + "supertokens-node": "^23.0.1", + "supertokens-web-js": "^0.15.0", + "twilio": "^5.8.0", + "vaul": "^1.1.2", + "zod": "^4.0.15", + "zustand": "^5.0.7" + }, + "devDependencies": { + "@types/node": "^22.5.4", + "@types/pg": "^8.15.5", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^5.0.0", + "autoprefixer": "^10.4.20", + "dotenv-cli": "^10.0.0", + "drizzle-kit": "^0.31.4", + "postcss": "^8.5.1", + "postcss-preset-mantine": "^1.18.0", + "postcss-simple-vars": "^7.0.1", + "tsx": "^4.20.3", + "typescript": "^5.7.2", + "vite": "^6.3.5", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/pb_migrations/1755194038_deleted_users.js b/pb_migrations/1755194038_deleted_users.js new file mode 100644 index 0000000..598b9c1 --- /dev/null +++ b/pb_migrations/1755194038_deleted_users.js @@ -0,0 +1,215 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_"); + + return app.delete(collection); +}, (app) => { + const collection = new Collection({ + "authAlert": { + "emailTemplate": { + "body": "

Hello,

\n

We noticed a login to your {APP_NAME} account from a new location.

\n

If this was you, you may disregard this email.

\n

If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Login from a new location" + }, + "enabled": true + }, + "authRule": "", + "authToken": { + "duration": 604800 + }, + "confirmEmailChangeTemplate": { + "body": "

Hello,

\n

Click on the button below to confirm your new email address.

\n

\n Confirm new email\n

\n

If you didn't ask to change your email address, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Confirm your {APP_NAME} new email address" + }, + "createRule": "", + "deleteRule": "id = @request.auth.id", + "emailChangeToken": { + "duration": 1800 + }, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cost": 0, + "hidden": true, + "id": "password901924565", + "max": 0, + "min": 8, + "name": "password", + "pattern": "", + "presentable": false, + "required": true, + "system": true, + "type": "password" + }, + { + "autogeneratePattern": "[a-zA-Z0-9]{50}", + "hidden": true, + "id": "text2504183744", + "max": 60, + "min": 30, + "name": "tokenKey", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "exceptDomains": null, + "hidden": false, + "id": "email3885137012", + "name": "email", + "onlyDomains": null, + "presentable": false, + "required": true, + "system": true, + "type": "email" + }, + { + "hidden": false, + "id": "bool1547992806", + "name": "emailVisibility", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "hidden": false, + "id": "bool256245529", + "name": "verified", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 255, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "file376926767", + "maxSelect": 1, + "maxSize": 0, + "mimeTypes": [ + "image/jpeg", + "image/png", + "image/svg+xml", + "image/gif", + "image/webp" + ], + "name": "avatar", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": null, + "type": "file" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "fileToken": { + "duration": 180 + }, + "id": "_pb_users_auth_", + "indexes": [ + "CREATE UNIQUE INDEX `idx_tokenKey__pb_users_auth_` ON `users` (`tokenKey`)", + "CREATE UNIQUE INDEX `idx_email__pb_users_auth_` ON `users` (`email`) WHERE `email` != ''" + ], + "listRule": "id = @request.auth.id", + "manageRule": null, + "mfa": { + "duration": 1800, + "enabled": false, + "rule": "" + }, + "name": "users", + "oauth2": { + "enabled": false, + "mappedFields": { + "avatarURL": "avatar", + "id": "", + "name": "name", + "username": "" + } + }, + "otp": { + "duration": 180, + "emailTemplate": { + "body": "

Hello,

\n

Your one-time password is: {OTP}

\n

If you didn't ask for the one-time password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "OTP for {APP_NAME}" + }, + "enabled": false, + "length": 8 + }, + "passwordAuth": { + "enabled": true, + "identityFields": [ + "email" + ] + }, + "passwordResetToken": { + "duration": 1800 + }, + "resetPasswordTemplate": { + "body": "

Hello,

\n

Click on the button below to reset your password.

\n

\n Reset password\n

\n

If you didn't ask to reset your password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Reset your {APP_NAME} password" + }, + "system": false, + "type": "auth", + "updateRule": "id = @request.auth.id", + "verificationTemplate": { + "body": "

Hello,

\n

Thank you for joining us at {APP_NAME}.

\n

Click on the button below to verify your email address.

\n

\n Verify\n

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Verify your {APP_NAME} email" + }, + "verificationToken": { + "duration": 259200 + }, + "viewRule": "id = @request.auth.id" + }); + + return app.save(collection); +}) diff --git a/pb_migrations/1755194204_created_players.js b/pb_migrations/1755194204_created_players.js new file mode 100644 index 0000000..8276c33 --- /dev/null +++ b/pb_migrations/1755194204_created_players.js @@ -0,0 +1,99 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2849095986", + "max": 0, + "min": 0, + "name": "first_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3356015194", + "max": 0, + "min": 0, + "name": "last_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2156036508", + "max": 0, + "min": 0, + "name": "auth_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_3072146508", + "indexes": [], + "listRule": null, + "name": "players", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3072146508"); + + return app.delete(collection); +}) diff --git a/pb_migrations/1755194414_created_teams.js b/pb_migrations/1755194414_created_teams.js new file mode 100644 index 0000000..4edc0c1 --- /dev/null +++ b/pb_migrations/1755194414_created_teams.js @@ -0,0 +1,213 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "exceptDomains": null, + "hidden": false, + "id": "url156371623", + "name": "logo_url", + "onlyDomains": null, + "presentable": false, + "required": false, + "system": false, + "type": "url" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4081094964", + "max": 0, + "min": 0, + "name": "primary_color", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2916757198", + "max": 0, + "min": 0, + "name": "accent_color", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2696786675", + "max": 0, + "min": 0, + "name": "song_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text766552307", + "max": 0, + "min": 0, + "name": "song_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text119703309", + "max": 0, + "min": 0, + "name": "song_artist", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4097637126", + "max": 0, + "min": 0, + "name": "song_album", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number3356599746", + "max": null, + "min": null, + "name": "song_year", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1390264522", + "max": null, + "min": null, + "name": "song_start", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1329349942", + "max": null, + "min": null, + "name": "song_end", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "exceptDomains": null, + "hidden": false, + "id": "url3559948297", + "name": "song_image_url", + "onlyDomains": null, + "presentable": false, + "required": false, + "system": false, + "type": "url" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_1568971955", + "indexes": [], + "listRule": null, + "name": "teams", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1568971955"); + + return app.delete(collection); +}) diff --git a/pb_migrations/1755194489_created_tournaments.js b/pb_migrations/1755194489_created_tournaments.js new file mode 100644 index 0000000..e201b48 --- /dev/null +++ b/pb_migrations/1755194489_created_tournaments.js @@ -0,0 +1,154 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1587448267", + "max": 0, + "min": 0, + "name": "location", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text196455508", + "max": 0, + "min": 0, + "name": "desc", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "convertURLs": false, + "hidden": false, + "id": "editor2308610364", + "maxSize": 0, + "name": "rules", + "presentable": false, + "required": false, + "system": false, + "type": "editor" + }, + { + "exceptDomains": null, + "hidden": false, + "id": "url156371623", + "name": "logo_url", + "onlyDomains": null, + "presentable": false, + "required": false, + "system": false, + "type": "url" + }, + { + "hidden": false, + "id": "date2500268258", + "max": "", + "min": "", + "name": "enroll_time", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "date1345189255", + "max": "", + "min": "", + "name": "start_time", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "date1096160257", + "max": "", + "min": "", + "name": "end_time", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_340646327", + "indexes": [], + "listRule": null, + "name": "tournaments", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327"); + + return app.delete(collection); +}) diff --git a/pb_migrations/1755194557_updated_players.js b/pb_migrations/1755194557_updated_players.js new file mode 100644 index 0000000..e237094 --- /dev/null +++ b/pb_migrations/1755194557_updated_players.js @@ -0,0 +1,106 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3072146508") + + // update field + collection.fields.addAt(0, new Field({ + "autogeneratePattern": "[a-z0-9]{6}", + "hidden": false, + "id": "text3208210256", + "max": 6, + "min": 6, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + })) + + // update field + collection.fields.addAt(1, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text2849095986", + "max": 0, + "min": 0, + "name": "first_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + })) + + // update field + collection.fields.addAt(2, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text3356015194", + "max": 0, + "min": 0, + "name": "last_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3072146508") + + // update field + collection.fields.addAt(0, new Field({ + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + })) + + // update field + collection.fields.addAt(1, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text2849095986", + "max": 0, + "min": 0, + "name": "first_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + // update field + collection.fields.addAt(2, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text3356015194", + "max": 0, + "min": 0, + "name": "last_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}) diff --git a/pb_migrations/1755194570_updated_teams.js b/pb_migrations/1755194570_updated_teams.js new file mode 100644 index 0000000..215d143 --- /dev/null +++ b/pb_migrations/1755194570_updated_teams.js @@ -0,0 +1,42 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1568971955") + + // update field + collection.fields.addAt(1, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1568971955") + + // update field + collection.fields.addAt(1, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}) diff --git a/pb_migrations/1755194655_updated_tournaments.js b/pb_migrations/1755194655_updated_tournaments.js new file mode 100644 index 0000000..75396b2 --- /dev/null +++ b/pb_migrations/1755194655_updated_tournaments.js @@ -0,0 +1,42 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // update field + collection.fields.addAt(1, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // update field + collection.fields.addAt(1, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}) diff --git a/pb_migrations/1755194933_created_matches.js b/pb_migrations/1755194933_created_matches.js new file mode 100644 index 0000000..0508b24 --- /dev/null +++ b/pb_migrations/1755194933_created_matches.js @@ -0,0 +1,260 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1080860409", + "max": null, + "min": null, + "name": "lid", + "onlyInt": false, + "presentable": false, + "required": true, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "bool1352515405", + "name": "reset", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "number3320769076", + "max": null, + "min": null, + "name": "round", + "onlyInt": false, + "presentable": false, + "required": true, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number2650326517", + "max": null, + "min": null, + "name": "home_cups", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number766636452", + "max": null, + "min": null, + "name": "away_cups", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3404566555", + "max": null, + "min": null, + "name": "ot_count", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "date1345189255", + "max": "", + "min": "", + "name": "start_time", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number1096160257", + "max": null, + "min": null, + "name": "end_time", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "bool2000130356", + "name": "bye", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "number3642169398", + "max": null, + "min": null, + "name": "home_from_lid", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1662941821", + "max": null, + "min": null, + "name": "away_from_lid", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "bool1093314320", + "name": "home_from_loser", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "bool4045114275", + "name": "away_from_loser", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "bool1202828057", + "name": "losers_bracket", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1568971955", + "hidden": false, + "id": "relation1909853392", + "maxSelect": 1, + "minSelect": 0, + "name": "home", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1568971955", + "hidden": false, + "id": "relation2791285457", + "maxSelect": 1, + "minSelect": 0, + "name": "away", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_340646327", + "hidden": false, + "id": "relation3177167065", + "maxSelect": 1, + "minSelect": 0, + "name": "tournament", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_2541054544", + "indexes": [], + "listRule": null, + "name": "matches", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_2541054544"); + + return app.delete(collection); +}) diff --git a/pb_migrations/1755194955_updated_tournaments.js b/pb_migrations/1755194955_updated_tournaments.js new file mode 100644 index 0000000..88d303f --- /dev/null +++ b/pb_migrations/1755194955_updated_tournaments.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // add field + collection.fields.addAt(9, new Field({ + "cascadeDelete": false, + "collectionId": "pbc_1568971955", + "hidden": false, + "id": "relation2529305176", + "maxSelect": 999, + "minSelect": 0, + "name": "teams", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // remove field + collection.fields.removeById("relation2529305176") + + return app.save(collection) +}) diff --git a/pb_migrations/1755194970_updated_teams.js b/pb_migrations/1755194970_updated_teams.js new file mode 100644 index 0000000..4313eb7 --- /dev/null +++ b/pb_migrations/1755194970_updated_teams.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1568971955") + + // add field + collection.fields.addAt(13, new Field({ + "cascadeDelete": false, + "collectionId": "pbc_3072146508", + "hidden": false, + "id": "relation642663334", + "maxSelect": 999, + "minSelect": 0, + "name": "players", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1568971955") + + // remove field + collection.fields.removeById("relation642663334") + + return app.save(collection) +}) diff --git a/pb_migrations/1755375248_updated_tournaments.js b/pb_migrations/1755375248_updated_tournaments.js new file mode 100644 index 0000000..b3070d2 --- /dev/null +++ b/pb_migrations/1755375248_updated_tournaments.js @@ -0,0 +1,45 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // remove field + collection.fields.removeById("url156371623") + + // add field + collection.fields.addAt(5, new Field({ + "hidden": false, + "id": "file3834550803", + "maxSelect": 1, + "maxSize": 0, + "mimeTypes": [], + "name": "logo", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": [], + "type": "file" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // add field + collection.fields.addAt(5, new Field({ + "exceptDomains": null, + "hidden": false, + "id": "url156371623", + "name": "logo_url", + "onlyDomains": null, + "presentable": false, + "required": false, + "system": false, + "type": "url" + })) + + // remove field + collection.fields.removeById("file3834550803") + + return app.save(collection) +}) diff --git a/pb_migrations/1755379657_updated_tournaments.js b/pb_migrations/1755379657_updated_tournaments.js new file mode 100644 index 0000000..16853e7 --- /dev/null +++ b/pb_migrations/1755379657_updated_tournaments.js @@ -0,0 +1,45 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // remove field + collection.fields.removeById("file3834550803") + + // add field + collection.fields.addAt(9, new Field({ + "exceptDomains": null, + "hidden": false, + "id": "url156371623", + "name": "logo_url", + "onlyDomains": null, + "presentable": false, + "required": false, + "system": false, + "type": "url" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // add field + collection.fields.addAt(5, new Field({ + "hidden": false, + "id": "file3834550803", + "maxSelect": 1, + "maxSize": 0, + "mimeTypes": [], + "name": "logo", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": [], + "type": "file" + })) + + // remove field + collection.fields.removeById("url156371623") + + return app.save(collection) +}) diff --git a/pb_migrations/1755545075_deleted_matches.js b/pb_migrations/1755545075_deleted_matches.js new file mode 100644 index 0000000..931f1e9 --- /dev/null +++ b/pb_migrations/1755545075_deleted_matches.js @@ -0,0 +1,260 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_2541054544"); + + return app.delete(collection); +}, (app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1080860409", + "max": null, + "min": null, + "name": "lid", + "onlyInt": false, + "presentable": false, + "required": true, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "bool1352515405", + "name": "reset", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "number3320769076", + "max": null, + "min": null, + "name": "round", + "onlyInt": false, + "presentable": false, + "required": true, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number2650326517", + "max": null, + "min": null, + "name": "home_cups", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number766636452", + "max": null, + "min": null, + "name": "away_cups", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3404566555", + "max": null, + "min": null, + "name": "ot_count", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "date1345189255", + "max": "", + "min": "", + "name": "start_time", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number1096160257", + "max": null, + "min": null, + "name": "end_time", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "bool2000130356", + "name": "bye", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "number3642169398", + "max": null, + "min": null, + "name": "home_from_lid", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1662941821", + "max": null, + "min": null, + "name": "away_from_lid", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "bool1093314320", + "name": "home_from_loser", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "bool4045114275", + "name": "away_from_loser", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "bool1202828057", + "name": "losers_bracket", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1568971955", + "hidden": false, + "id": "relation1909853392", + "maxSelect": 1, + "minSelect": 0, + "name": "home", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1568971955", + "hidden": false, + "id": "relation2791285457", + "maxSelect": 1, + "minSelect": 0, + "name": "away", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_340646327", + "hidden": false, + "id": "relation3177167065", + "maxSelect": 1, + "minSelect": 0, + "name": "tournament", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_2541054544", + "indexes": [], + "listRule": null, + "name": "matches", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}) diff --git a/pb_migrations/1755545181_created_tables.js b/pb_migrations/1755545181_created_tables.js new file mode 100644 index 0000000..3249503 --- /dev/null +++ b/pb_migrations/1755545181_created_tables.js @@ -0,0 +1,71 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_3008659311", + "indexes": [], + "listRule": null, + "name": "tables", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3008659311"); + + return app.delete(collection); +}) diff --git a/pb_migrations/1755545239_updated_teams.js b/pb_migrations/1755545239_updated_teams.js new file mode 100644 index 0000000..53c702c --- /dev/null +++ b/pb_migrations/1755545239_updated_teams.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1568971955") + + // add field + collection.fields.addAt(14, new Field({ + "cascadeDelete": false, + "collectionId": "pbc_340646327", + "hidden": false, + "id": "relation3837590211", + "maxSelect": 999, + "minSelect": 0, + "name": "tournaments", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1568971955") + + // remove field + collection.fields.removeById("relation3837590211") + + return app.save(collection) +}) diff --git a/pb_migrations/1755546057_updated_teams.js b/pb_migrations/1755546057_updated_teams.js new file mode 100644 index 0000000..bc66ae2 --- /dev/null +++ b/pb_migrations/1755546057_updated_teams.js @@ -0,0 +1,38 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1568971955") + + // update field + collection.fields.addAt(11, new Field({ + "hidden": false, + "id": "number1329349942", + "max": null, + "min": null, + "name": "song_duration", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1568971955") + + // update field + collection.fields.addAt(11, new Field({ + "hidden": false, + "id": "number1329349942", + "max": null, + "min": null, + "name": "song_end", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + })) + + return app.save(collection) +}) diff --git a/pb_migrations/1755548588_updated_tournaments.js b/pb_migrations/1755548588_updated_tournaments.js new file mode 100644 index 0000000..1441d21 --- /dev/null +++ b/pb_migrations/1755548588_updated_tournaments.js @@ -0,0 +1,45 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // remove field + collection.fields.removeById("url156371623") + + // add field + collection.fields.addAt(9, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text156371623", + "max": 0, + "min": 0, + "name": "logo_url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // add field + collection.fields.addAt(9, new Field({ + "exceptDomains": null, + "hidden": false, + "id": "url156371623", + "name": "logo_url", + "onlyDomains": null, + "presentable": false, + "required": false, + "system": false, + "type": "url" + })) + + // remove field + collection.fields.removeById("text156371623") + + return app.save(collection) +}) diff --git a/pb_migrations/1755548718_updated_players.js b/pb_migrations/1755548718_updated_players.js new file mode 100644 index 0000000..66c7d5e --- /dev/null +++ b/pb_migrations/1755548718_updated_players.js @@ -0,0 +1,42 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3072146508") + + // update field + collection.fields.addAt(2, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text3356015194", + "max": 0, + "min": 0, + "name": "last_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3072146508") + + // update field + collection.fields.addAt(2, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text3356015194", + "max": 0, + "min": 0, + "name": "last_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + })) + + return app.save(collection) +}) diff --git a/pb_migrations/1755548899_deleted_tables.js b/pb_migrations/1755548899_deleted_tables.js new file mode 100644 index 0000000..5ba702a --- /dev/null +++ b/pb_migrations/1755548899_deleted_tables.js @@ -0,0 +1,71 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3008659311"); + + return app.delete(collection); +}, (app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_3008659311", + "indexes": [], + "listRule": null, + "name": "tables", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}) diff --git a/pb_migrations/1755630612_updated_players.js b/pb_migrations/1755630612_updated_players.js new file mode 100644 index 0000000..9d663b2 --- /dev/null +++ b/pb_migrations/1755630612_updated_players.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3072146508") + + // add field + collection.fields.addAt(4, new Field({ + "cascadeDelete": false, + "collectionId": "pbc_1568971955", + "hidden": false, + "id": "relation2529305176", + "maxSelect": 999, + "minSelect": 0, + "name": "teams", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3072146508") + + // remove field + collection.fields.removeById("relation2529305176") + + return app.save(collection) +}) diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..83a3d48 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,15 @@ +export default { + plugins: { + autoprefixer: {}, + 'postcss-preset-mantine': {}, + 'postcss-simple-vars': { + variables: { + 'mantine-breakpoint-xs': '36em', + 'mantine-breakpoint-sm': '48em', + 'mantine-breakpoint-md': '62em', + 'mantine-breakpoint-lg': '75em', + 'mantine-breakpoint-xl': '88em', + }, + }, + }, +} \ No newline at end of file diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png new file mode 100644 index 0000000..09c8324 Binary files /dev/null and b/public/android-chrome-192x192.png differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png new file mode 100644 index 0000000..11d626e Binary files /dev/null and b/public/android-chrome-512x512.png differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..5a9423c Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png new file mode 100644 index 0000000..e3389b0 Binary files /dev/null and b/public/favicon-16x16.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png new file mode 100644 index 0000000..900c77d Binary files /dev/null and b/public/favicon-32x32.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..1a17516 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..1e77bc0 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/site.webmanifest b/public/site.webmanifest new file mode 100644 index 0000000..d84fa29 --- /dev/null +++ b/public/site.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "display": "standalone" +} diff --git a/public/static/img/logo.png b/public/static/img/logo.png new file mode 100644 index 0000000..6c7e80d Binary files /dev/null and b/public/static/img/logo.png differ diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..833e0b0 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,31 @@ +* { + overscroll-behavior: none; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.app { + transition: transform 500ms cubic-bezier(0.32, 0.72, 0, 1), + border-radius 500ms cubic-bezier(0.32, 0.72, 0, 1); + transform: scale(1) translateY(0); + border-radius: 0; + will-change: transform; +} + +.app.drawer-scaling, +[data-drawer-level].drawer-scaling { + transform: scale(0.94) translateY(-8px); + border-radius: 12px; + overflow: hidden; +} + +[data-drawer-level="2"].drawer-scaling { + transform: scale(0.92) translateY(-6px); +} + +[data-drawer-level="3"].drawer-scaling { + transform: scale(0.90) translateY(-4px); +} \ No newline at end of file diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts new file mode 100644 index 0000000..eb6fa0a --- /dev/null +++ b/src/app/routeTree.gen.ts @@ -0,0 +1,391 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { createServerRootRoute } from '@tanstack/react-start/server' + +import { Route as rootRouteImport } from './routes/__root' +import { Route as LogoutRouteImport } from './routes/logout' +import { Route as LoginRouteImport } from './routes/login' +import { Route as AuthedRouteImport } from './routes/_authed' +import { Route as AuthedIndexRouteImport } from './routes/_authed/index' +import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings' +import { Route as AuthedAdminRouteImport } from './routes/_authed/admin' +import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index' +import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index' +import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId' +import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId' +import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId' +import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview' +import { ServerRoute as ApiTestServerRouteImport } from './routes/api/test' +import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$' +import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$' + +const rootServerRouteImport = createServerRootRoute() + +const LogoutRoute = LogoutRouteImport.update({ + id: '/logout', + path: '/logout', + getParentRoute: () => rootRouteImport, +} as any) +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) +const AuthedRoute = AuthedRouteImport.update({ + id: '/_authed', + getParentRoute: () => rootRouteImport, +} as any) +const AuthedIndexRoute = AuthedIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AuthedRoute, +} as any) +const AuthedSettingsRoute = AuthedSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => AuthedRoute, +} as any) +const AuthedAdminRoute = AuthedAdminRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => AuthedRoute, +} as any) +const AuthedTournamentsIndexRoute = AuthedTournamentsIndexRouteImport.update({ + id: '/tournaments/', + path: '/tournaments/', + getParentRoute: () => AuthedRoute, +} as any) +const AuthedAdminIndexRoute = AuthedAdminIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AuthedAdminRoute, +} as any) +const AuthedTournamentsTournamentIdRoute = + AuthedTournamentsTournamentIdRouteImport.update({ + id: '/tournaments/$tournamentId', + path: '/tournaments/$tournamentId', + getParentRoute: () => AuthedRoute, + } as any) +const AuthedTeamsTeamIdRoute = AuthedTeamsTeamIdRouteImport.update({ + id: '/teams/$teamId', + path: '/teams/$teamId', + getParentRoute: () => AuthedRoute, +} as any) +const AuthedProfilePlayerIdRoute = AuthedProfilePlayerIdRouteImport.update({ + id: '/profile/$playerId', + path: '/profile/$playerId', + getParentRoute: () => AuthedRoute, +} as any) +const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({ + id: '/preview', + path: '/preview', + getParentRoute: () => AuthedAdminRoute, +} as any) +const ApiTestServerRoute = ApiTestServerRouteImport.update({ + id: '/api/test', + path: '/api/test', + getParentRoute: () => rootServerRouteImport, +} as any) +const ApiEventsSplatServerRoute = ApiEventsSplatServerRouteImport.update({ + id: '/api/events/$', + path: '/api/events/$', + getParentRoute: () => rootServerRouteImport, +} as any) +const ApiAuthSplatServerRoute = ApiAuthSplatServerRouteImport.update({ + id: '/api/auth/$', + path: '/api/auth/$', + getParentRoute: () => rootServerRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/login': typeof LoginRoute + '/logout': typeof LogoutRoute + '/admin': typeof AuthedAdminRouteWithChildren + '/settings': typeof AuthedSettingsRoute + '/': typeof AuthedIndexRoute + '/admin/preview': typeof AuthedAdminPreviewRoute + '/profile/$playerId': typeof AuthedProfilePlayerIdRoute + '/teams/$teamId': typeof AuthedTeamsTeamIdRoute + '/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute + '/admin/': typeof AuthedAdminIndexRoute + '/tournaments': typeof AuthedTournamentsIndexRoute +} +export interface FileRoutesByTo { + '/login': typeof LoginRoute + '/logout': typeof LogoutRoute + '/settings': typeof AuthedSettingsRoute + '/': typeof AuthedIndexRoute + '/admin/preview': typeof AuthedAdminPreviewRoute + '/profile/$playerId': typeof AuthedProfilePlayerIdRoute + '/teams/$teamId': typeof AuthedTeamsTeamIdRoute + '/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute + '/admin': typeof AuthedAdminIndexRoute + '/tournaments': typeof AuthedTournamentsIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/_authed': typeof AuthedRouteWithChildren + '/login': typeof LoginRoute + '/logout': typeof LogoutRoute + '/_authed/admin': typeof AuthedAdminRouteWithChildren + '/_authed/settings': typeof AuthedSettingsRoute + '/_authed/': typeof AuthedIndexRoute + '/_authed/admin/preview': typeof AuthedAdminPreviewRoute + '/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute + '/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute + '/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute + '/_authed/admin/': typeof AuthedAdminIndexRoute + '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/login' + | '/logout' + | '/admin' + | '/settings' + | '/' + | '/admin/preview' + | '/profile/$playerId' + | '/teams/$teamId' + | '/tournaments/$tournamentId' + | '/admin/' + | '/tournaments' + fileRoutesByTo: FileRoutesByTo + to: + | '/login' + | '/logout' + | '/settings' + | '/' + | '/admin/preview' + | '/profile/$playerId' + | '/teams/$teamId' + | '/tournaments/$tournamentId' + | '/admin' + | '/tournaments' + id: + | '__root__' + | '/_authed' + | '/login' + | '/logout' + | '/_authed/admin' + | '/_authed/settings' + | '/_authed/' + | '/_authed/admin/preview' + | '/_authed/profile/$playerId' + | '/_authed/teams/$teamId' + | '/_authed/tournaments/$tournamentId' + | '/_authed/admin/' + | '/_authed/tournaments/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + AuthedRoute: typeof AuthedRouteWithChildren + LoginRoute: typeof LoginRoute + LogoutRoute: typeof LogoutRoute +} +export interface FileServerRoutesByFullPath { + '/api/test': typeof ApiTestServerRoute + '/api/auth/$': typeof ApiAuthSplatServerRoute + '/api/events/$': typeof ApiEventsSplatServerRoute +} +export interface FileServerRoutesByTo { + '/api/test': typeof ApiTestServerRoute + '/api/auth/$': typeof ApiAuthSplatServerRoute + '/api/events/$': typeof ApiEventsSplatServerRoute +} +export interface FileServerRoutesById { + __root__: typeof rootServerRouteImport + '/api/test': typeof ApiTestServerRoute + '/api/auth/$': typeof ApiAuthSplatServerRoute + '/api/events/$': typeof ApiEventsSplatServerRoute +} +export interface FileServerRouteTypes { + fileServerRoutesByFullPath: FileServerRoutesByFullPath + fullPaths: '/api/test' | '/api/auth/$' | '/api/events/$' + fileServerRoutesByTo: FileServerRoutesByTo + to: '/api/test' | '/api/auth/$' | '/api/events/$' + id: '__root__' | '/api/test' | '/api/auth/$' | '/api/events/$' + fileServerRoutesById: FileServerRoutesById +} +export interface RootServerRouteChildren { + ApiTestServerRoute: typeof ApiTestServerRoute + ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute + ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/logout': { + id: '/logout' + path: '/logout' + fullPath: '/logout' + preLoaderRoute: typeof LogoutRouteImport + parentRoute: typeof rootRouteImport + } + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } + '/_authed': { + id: '/_authed' + path: '' + fullPath: '' + preLoaderRoute: typeof AuthedRouteImport + parentRoute: typeof rootRouteImport + } + '/_authed/': { + id: '/_authed/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof AuthedIndexRouteImport + parentRoute: typeof AuthedRoute + } + '/_authed/settings': { + id: '/_authed/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof AuthedSettingsRouteImport + parentRoute: typeof AuthedRoute + } + '/_authed/admin': { + id: '/_authed/admin' + path: '/admin' + fullPath: '/admin' + preLoaderRoute: typeof AuthedAdminRouteImport + parentRoute: typeof AuthedRoute + } + '/_authed/tournaments/': { + id: '/_authed/tournaments/' + path: '/tournaments' + fullPath: '/tournaments' + preLoaderRoute: typeof AuthedTournamentsIndexRouteImport + parentRoute: typeof AuthedRoute + } + '/_authed/admin/': { + id: '/_authed/admin/' + path: '/' + fullPath: '/admin/' + preLoaderRoute: typeof AuthedAdminIndexRouteImport + parentRoute: typeof AuthedAdminRoute + } + '/_authed/tournaments/$tournamentId': { + id: '/_authed/tournaments/$tournamentId' + path: '/tournaments/$tournamentId' + fullPath: '/tournaments/$tournamentId' + preLoaderRoute: typeof AuthedTournamentsTournamentIdRouteImport + parentRoute: typeof AuthedRoute + } + '/_authed/teams/$teamId': { + id: '/_authed/teams/$teamId' + path: '/teams/$teamId' + fullPath: '/teams/$teamId' + preLoaderRoute: typeof AuthedTeamsTeamIdRouteImport + parentRoute: typeof AuthedRoute + } + '/_authed/profile/$playerId': { + id: '/_authed/profile/$playerId' + path: '/profile/$playerId' + fullPath: '/profile/$playerId' + preLoaderRoute: typeof AuthedProfilePlayerIdRouteImport + parentRoute: typeof AuthedRoute + } + '/_authed/admin/preview': { + id: '/_authed/admin/preview' + path: '/preview' + fullPath: '/admin/preview' + preLoaderRoute: typeof AuthedAdminPreviewRouteImport + parentRoute: typeof AuthedAdminRoute + } + } +} +declare module '@tanstack/react-start/server' { + interface ServerFileRoutesByPath { + '/api/test': { + id: '/api/test' + path: '/api/test' + fullPath: '/api/test' + preLoaderRoute: typeof ApiTestServerRouteImport + parentRoute: typeof rootServerRouteImport + } + '/api/events/$': { + id: '/api/events/$' + path: '/api/events/$' + fullPath: '/api/events/$' + preLoaderRoute: typeof ApiEventsSplatServerRouteImport + parentRoute: typeof rootServerRouteImport + } + '/api/auth/$': { + id: '/api/auth/$' + path: '/api/auth/$' + fullPath: '/api/auth/$' + preLoaderRoute: typeof ApiAuthSplatServerRouteImport + parentRoute: typeof rootServerRouteImport + } + } +} + +interface AuthedAdminRouteChildren { + AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute + AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute +} + +const AuthedAdminRouteChildren: AuthedAdminRouteChildren = { + AuthedAdminPreviewRoute: AuthedAdminPreviewRoute, + AuthedAdminIndexRoute: AuthedAdminIndexRoute, +} + +const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren( + AuthedAdminRouteChildren, +) + +interface AuthedRouteChildren { + AuthedAdminRoute: typeof AuthedAdminRouteWithChildren + AuthedSettingsRoute: typeof AuthedSettingsRoute + AuthedIndexRoute: typeof AuthedIndexRoute + AuthedProfilePlayerIdRoute: typeof AuthedProfilePlayerIdRoute + AuthedTeamsTeamIdRoute: typeof AuthedTeamsTeamIdRoute + AuthedTournamentsTournamentIdRoute: typeof AuthedTournamentsTournamentIdRoute + AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute +} + +const AuthedRouteChildren: AuthedRouteChildren = { + AuthedAdminRoute: AuthedAdminRouteWithChildren, + AuthedSettingsRoute: AuthedSettingsRoute, + AuthedIndexRoute: AuthedIndexRoute, + AuthedProfilePlayerIdRoute: AuthedProfilePlayerIdRoute, + AuthedTeamsTeamIdRoute: AuthedTeamsTeamIdRoute, + AuthedTournamentsTournamentIdRoute: AuthedTournamentsTournamentIdRoute, + AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute, +} + +const AuthedRouteWithChildren = + AuthedRoute._addFileChildren(AuthedRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + AuthedRoute: AuthedRouteWithChildren, + LoginRoute: LoginRoute, + LogoutRoute: LogoutRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() +const rootServerRouteChildren: RootServerRouteChildren = { + ApiTestServerRoute: ApiTestServerRoute, + ApiAuthSplatServerRoute: ApiAuthSplatServerRoute, + ApiEventsSplatServerRoute: ApiEventsSplatServerRoute, +} +export const serverRouteTree = rootServerRouteImport + ._addFileChildren(rootServerRouteChildren) + ._addFileTypes() diff --git a/src/app/router.tsx b/src/app/router.tsx new file mode 100644 index 0000000..a9f3570 --- /dev/null +++ b/src/app/router.tsx @@ -0,0 +1,37 @@ +import { QueryClient } from '@tanstack/react-query' +import { createRouter as createTanStackRouter } from '@tanstack/react-router' +import { routerWithQueryClient } from '@tanstack/react-router-with-query' +import { routeTree } from './routeTree.gen' +import { DefaultCatchBoundary } from '../components/DefaultCatchBoundary' +import { defaultHeaderConfig } from '@/features/core/hooks/use-header-config' + +export function createRouter() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, // 60 seconds + gcTime: 5 * 60 * 1000, // 5 minutes + refetchOnWindowFocus: false, + refetchOnReconnect: 'always', + retry: 3, + }, + }, + }) + + return routerWithQueryClient( + createTanStackRouter({ + routeTree, + context: { queryClient, auth: undefined!, header: defaultHeaderConfig, refresh: { toRefresh: [] } }, + defaultPreload: 'intent', + defaultErrorComponent: DefaultCatchBoundary, + scrollRestoration: true, + }), + queryClient, + ) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx new file mode 100644 index 0000000..26cd8b3 --- /dev/null +++ b/src/app/routes/__root.tsx @@ -0,0 +1,107 @@ +import '@mantine/core/styles.css'; +import '@mantine/dates/styles.css'; +import { + HeadContent, + Navigate, + Outlet, + Scripts, + createRootRouteWithContext +} from '@tanstack/react-router' +import * as React from 'react' +import { DefaultCatchBoundary } from '@/components/DefaultCatchBoundary' +import { type QueryClient } from '@tanstack/react-query' +import { ensureSuperTokensFrontend } from '@/lib/supertokens/client' +import { AuthContextType, authQueryConfig } from '@/contexts/auth-context' +import Providers from '@/components/providers' +import { ColorSchemeScript, mantineHtmlProps } from '@mantine/core'; +import { HeaderConfig } from '@/features/core/types/header-config'; + +export const Route = createRootRouteWithContext<{ + queryClient: QueryClient, + auth: AuthContextType, + header: HeaderConfig, + refresh: { toRefresh: string[] } +}>()({ + head: () => ({ + meta: [ + { + charSet: 'utf-8' + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=overlays-content', + } + ], + links: [ + { + rel: 'apple-touch-icon', + sizes: '180x180', + href: '/apple-touch-icon.png', + }, + { + rel: 'icon', + type: 'image/png', + sizes: '32x32', + href: '/favicon-32x32.png', + }, + { + rel: 'icon', + type: 'image/png', + sizes: '16x16', + href: '/favicon-16x16.png', + }, + { rel: 'manifest', href: '/site.webmanifest' }, + { rel: 'icon', href: '/favicon.ico' }, + ], + }), + errorComponent: (props) => { + return ( + + + + ) + }, + component: RootComponent, + notFoundComponent: () => , + beforeLoad: async ({ context }) => { + // I don't really like this. I wish there was some way before the router is rendered to useAuth() and pass context there. + // See: https://github.com/TanStack/router/discussions/3531 + const auth = await context.queryClient.ensureQueryData(authQueryConfig) + return { + auth + }; + } +}) + +function RootComponent() { + + React.useEffect(() => { + ensureSuperTokensFrontend() + }, []) + + return ( + + + + + + ) +} + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + + +
+ {children} +
+ + + + ) +} diff --git a/src/app/routes/_authed.tsx b/src/app/routes/_authed.tsx new file mode 100644 index 0000000..84ea040 --- /dev/null +++ b/src/app/routes/_authed.tsx @@ -0,0 +1,26 @@ +import { Outlet, redirect, createFileRoute } from "@tanstack/react-router"; +import Layout from "@/features/core/components/layout"; +import { useServerEvents } from "@/hooks/use-server-events"; + +export const Route = createFileRoute('/_authed')({ + beforeLoad: ({ context }) => { + if (!context.auth?.user) { + throw redirect({ to: '/login' }) + } + + return { + auth: { + ...context.auth, + user: context.auth.user + } + }; + }, + component: () => { + useServerEvents(); + return ( + + + + ) + } +}) diff --git a/src/app/routes/_authed/admin.tsx b/src/app/routes/_authed/admin.tsx new file mode 100644 index 0000000..44d7901 --- /dev/null +++ b/src/app/routes/_authed/admin.tsx @@ -0,0 +1,18 @@ +import { Outlet, redirect, createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute('/_authed/admin')({ + component: Outlet, + beforeLoad: ({ context }) => { + if (!context.auth?.roles?.includes('Admin')) { + throw redirect({ to: '/' }) + } + + return { + header: { + ...context.header, + title: 'Admin', + withBackButton: true + }, + }; + } +}) diff --git a/src/app/routes/_authed/admin/index.tsx b/src/app/routes/_authed/admin/index.tsx new file mode 100644 index 0000000..8968db2 --- /dev/null +++ b/src/app/routes/_authed/admin/index.tsx @@ -0,0 +1,22 @@ +import { createFileRoute } from "@tanstack/react-router" +import { Title } from "@mantine/core"; +import Page from "@/components/page"; +import { playerQueries } from "@/features/players/queries"; +import { useQuery } from "@tanstack/react-query"; +import PlayerList from "@/features/players/components/player-list"; + +export const Route = createFileRoute("/_authed/admin/")({ + loader: async ({ context }) => { + const { queryClient } = context; + await queryClient.ensureQueryData(playerQueries.list()) + }, + component: RouteComponent, +}) + +function RouteComponent() { + const { data: players, isLoading } = useQuery(playerQueries.list()); + return + Players + + +} diff --git a/src/app/routes/_authed/admin/preview.tsx b/src/app/routes/_authed/admin/preview.tsx new file mode 100644 index 0000000..d1af781 --- /dev/null +++ b/src/app/routes/_authed/admin/preview.tsx @@ -0,0 +1,10 @@ +import { PreviewBracketPage } from '@/features/bracket/components/bracket-page' +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_authed/admin/preview')({ + component: RouteComponent, +}) + +function RouteComponent() { + return +} diff --git a/src/app/routes/_authed/index.tsx b/src/app/routes/_authed/index.tsx new file mode 100644 index 0000000..8c62a38 --- /dev/null +++ b/src/app/routes/_authed/index.tsx @@ -0,0 +1,29 @@ +import { createFileRoute } from "@tanstack/react-router"; +import Page from "@/components/page"; +import { TrophyIcon } from "@phosphor-icons/react"; +import ListLink from "@/components/list-link"; +import { tournamentQueries } from "@/features/tournaments/queries"; +import { Box, Divider, Text } from "@mantine/core"; + +export const Route = createFileRoute("/_authed/")({ + component: Home, + beforeLoad: async ({ context }) => { + await context.queryClient.ensureQueryData(tournamentQueries.list()); + }, +}); + +function Home() { + return ( + + + Some Content Here + + + + Quick Links + + + + + ); +} diff --git a/src/app/routes/_authed/profile.$playerId.tsx b/src/app/routes/_authed/profile.$playerId.tsx new file mode 100644 index 0000000..74eb2a3 --- /dev/null +++ b/src/app/routes/_authed/profile.$playerId.tsx @@ -0,0 +1,28 @@ +import Page from "@/components/page"; +import Profile from "@/features/players/components/profile"; +import { playerQueries } from "@/features/players/queries"; +import { redirect, createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/_authed/profile/$playerId")({ + beforeLoad: async ({ params, context }) => { + const { queryClient } = context; + const player = await queryClient.ensureQueryData(playerQueries.details(params.playerId)) + if (!player) throw redirect({ to: '/' }); + return { + player + } + }, + loader: ({ params }) => ({ + header: { + collapsed: true, + withBackButton: true + }, + refresh: { + toRefresh: [playerQueries.details(params.playerId).queryKey], + } + }), + component: () => { + const { player } = Route.useRouteContext(); + return + }, +}) diff --git a/src/app/routes/_authed/settings.tsx b/src/app/routes/_authed/settings.tsx new file mode 100644 index 0000000..052699f --- /dev/null +++ b/src/app/routes/_authed/settings.tsx @@ -0,0 +1,34 @@ +import { createFileRoute } from "@tanstack/react-router" +import { Box, Title, Stack } from "@mantine/core" +import { ColorSchemePicker } from "@/features/settings/components/color-scheme-picker" +import AccentColorPicker from "@/features/settings/components/accent-color-picker" +import { SignOutIcon } from "@phosphor-icons/react" +import ListLink from "@/components/list-link" +import Page from "@/components/page" + +export const Route = createFileRoute("/_authed/settings")({ + loader: () => ({ + header: { + title: 'Settings', + withBackButton: true, + }, + }), + component: RouteComponent, +}) + +function RouteComponent() { + return + + Appearance + + + + + + + +} diff --git a/src/app/routes/_authed/teams.$teamId.tsx b/src/app/routes/_authed/teams.$teamId.tsx new file mode 100644 index 0000000..4394379 --- /dev/null +++ b/src/app/routes/_authed/teams.$teamId.tsx @@ -0,0 +1,26 @@ +import Page from "@/components/page"; +import TeamProfile from "@/features/teams/components/team-profile"; +import { teamQueries } from "@/features/teams/queries"; +import { redirect, createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/_authed/teams/$teamId")({ + beforeLoad: async ({ params, context }) => { + const { queryClient } = context; + const team = await queryClient.ensureQueryData(teamQueries.details(params.teamId)) + if (!team) throw redirect({ to: '/' }); + return { team } + }, + loader: ({ params }) => ({ + header: { + collapsed: true, + withBackButton: true + }, + refresh: { + toRefresh: [teamQueries.details(params.teamId).queryKey], + } + }), + component: () => { + const { team } = Route.useRouteContext(); + return + }, +}) diff --git a/src/app/routes/_authed/tournaments/$tournamentId.tsx b/src/app/routes/_authed/tournaments/$tournamentId.tsx new file mode 100644 index 0000000..44c1714 --- /dev/null +++ b/src/app/routes/_authed/tournaments/$tournamentId.tsx @@ -0,0 +1,67 @@ +import { createFileRoute } from '@tanstack/react-router' +import { tournamentQueries } from '@/features/tournaments/queries'; +import Page from '@/components/page' +import { useQuery } from '@tanstack/react-query'; +import { Box, Button } from '@mantine/core'; +import { useSheet } from '@/hooks/use-sheet'; +import Sheet from '@/components/sheet/sheet'; +import { Tournament } from '@/features/tournaments/types'; +import { UsersIcon } from '@phosphor-icons/react'; +import ListButton from '@/components/list-button'; +import TeamList from '@/features/teams/components/team-list'; + +export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({ + beforeLoad: async ({ context, params }) => { + const { queryClient } = context; + await queryClient.ensureQueryData(tournamentQueries.details(params.tournamentId)) + }, + loader: ({ params }) => ({ + header: { + collapsed: true, + withBackButton: true + }, + refresh: { + toRefresh: tournamentQueries.details(params.tournamentId).queryKey, + } + }), + component: RouteComponent, +}) + +function RouteComponent() { + const { data: tournament } = useQuery(tournamentQueries.details(Route.useParams().tournamentId)); + + const sheet = useSheet() + + return + + +

+ {tournament?.name} +

+ + + +
+ + sheet.open()} + Icon={UsersIcon} + /> + + + + +
+} + +const TeamDrawer = ({ tournament }: { tournament: Tournament }) => { + return ( + + ); +} diff --git a/src/app/routes/_authed/tournaments/index.tsx b/src/app/routes/_authed/tournaments/index.tsx new file mode 100644 index 0000000..c9db7c9 --- /dev/null +++ b/src/app/routes/_authed/tournaments/index.tsx @@ -0,0 +1,54 @@ +import Page from '@/components/page' +import { Button, Stack } from '@mantine/core' +import { createFileRoute } from '@tanstack/react-router' +import { TournamentCard } from '@/features/tournaments/components/tournament-card' +import { tournamentQueries } from '@/features/tournaments/queries' +import { useQuery } from '@tanstack/react-query' +import { useAuth } from '@/contexts/auth-context' +import { useSheet } from '@/hooks/use-sheet' +import Sheet from '@/components/sheet/sheet' +import CreateTournament from '@/features/admin/components/create-tournament' +import { PlusIcon } from '@phosphor-icons/react' + +export const Route = createFileRoute('/_authed/tournaments/')({ + beforeLoad: async ({ context }) => { + const { queryClient } = context; + await queryClient.ensureQueryData(tournamentQueries.list()) + }, + loader: () => ({ + header: { + withBackButton: true, + title: 'Tournaments', + }, + refresh: { + toRefresh: tournamentQueries.list().queryKey, + } + }), + component: RouteComponent, +}) + +function RouteComponent() { + const { data: tournaments } = useQuery(tournamentQueries.list()); + const { roles } = useAuth(); + const sheet = useSheet(); + + return + + { + roles?.includes("Admin") ? ( + <> + + + + + + ) : null + } + { + tournaments?.map((tournament: any) => ( + + )) + } + + +} diff --git a/src/app/routes/api/auth.$.ts b/src/app/routes/api/auth.$.ts new file mode 100644 index 0000000..d3af08e --- /dev/null +++ b/src/app/routes/api/auth.$.ts @@ -0,0 +1,19 @@ +// API file that handles all supertokens auth routes +import { createServerFileRoute } from '@tanstack/react-start/server'; +import { handleAuthAPIRequest } from 'supertokens-node/custom' +import { ensureSuperTokensBackend } from '@/lib/supertokens/server' + +ensureSuperTokensBackend(); + +// forwards all supertokens api methods to our API +const superTokensHandler = handleAuthAPIRequest(); +const handleRequest = async ({ request }: {request: Request}) => superTokensHandler(request); +export const ServerRoute = createServerFileRoute('/api/auth/$').methods({ + GET: handleRequest, + POST: handleRequest, + PUT: handleRequest, + DELETE: handleRequest, + PATCH: handleRequest, + OPTIONS: handleRequest, + HEAD: handleRequest, +}) diff --git a/src/app/routes/api/events.$.ts b/src/app/routes/api/events.$.ts new file mode 100644 index 0000000..2584ed4 --- /dev/null +++ b/src/app/routes/api/events.$.ts @@ -0,0 +1,67 @@ +import { createServerFileRoute } from "@tanstack/react-start/server"; +import { serverEvents, type ServerEvent } from "@/lib/events/emitter"; +import { logger } from "@/lib/logger"; +import { superTokensRequestMiddleware } from "@/utils/supertokens"; + +export const ServerRoute = createServerFileRoute("/api/events/$").middleware([superTokensRequestMiddleware]).methods({ + GET: ({ request, context }) => { + logger.info('ServerEvents | New connection', (context as any)?.userAuthId); + + const stream = new ReadableStream({ + start(controller) { + // Send initial connection messages + const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`; + controller.enqueue(new TextEncoder().encode(connectMessage)); + + // Listen for events and broadcast to all connections + const handleEvent = (event: ServerEvent) => { + logger.info('ServerEvents | Event received', event); + const message = `data: ${JSON.stringify(event)}\n\n`; + try { + controller.enqueue(new TextEncoder().encode(message)); + } catch (error) { + logger.error("ServerEvents | Error sending SSE message", error); + } + }; + + serverEvents.on("test", handleEvent); + + // Keep alive ping every 30 seconds + const pingInterval = setInterval(() => { + try { + const pingMessage = `data: ${JSON.stringify({ type: "ping" })}\n\n`; + controller.enqueue(new TextEncoder().encode(pingMessage)); + } catch (e) { + clearInterval(pingInterval); + controller.close(); + } + }, 30000); + + const cleanup = () => { + serverEvents.off("test", handleEvent); + clearInterval(pingInterval); + try { + logger.info('ServerEvents | Closing connection', (context as any)?.userAuthId); + controller.close(); + } catch (e) { + logger.error('ServerEvents | Error closing controller', e); + } + }; + + request.signal?.addEventListener("abort", cleanup); + + return cleanup; + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Cache-Control", + }, + }); + }, +}); \ No newline at end of file diff --git a/src/app/routes/api/test.ts b/src/app/routes/api/test.ts new file mode 100644 index 0000000..8986448 --- /dev/null +++ b/src/app/routes/api/test.ts @@ -0,0 +1,9 @@ +import { createServerFileRoute } from '@tanstack/react-start/server'; +import { superTokensRequestMiddleware } from '@/utils/supertokens'; + +// Simple test route for testing the auth middleware +export const ServerRoute = createServerFileRoute('/api/test').middleware([superTokensRequestMiddleware]).methods({ + GET: () => { + return new Response('Hello from the authenticated API!') + }, +}) diff --git a/src/app/routes/login.tsx b/src/app/routes/login.tsx new file mode 100644 index 0000000..6fca9fe --- /dev/null +++ b/src/app/routes/login.tsx @@ -0,0 +1,27 @@ +import LoginLayout from "@/features/login/components/layout"; +import LoginFlow from "@/features/login/components/login-flow"; +import { redirect, createFileRoute } from "@tanstack/react-router"; +import z from "zod"; + +const loginSearchSchema = z.object({ + stage: z.enum(['code', 'name']).optional(), + number: z.string().optional(), + callback: z.string().optional() +}); + + +export const Route = createFileRoute("/login")({ + validateSearch: loginSearchSchema, + beforeLoad: async ({ context }) => { + if (context.auth?.user) { + throw redirect({ to: '/' }) + } + }, + component: () => { + return ( + + + + ) + } +}) diff --git a/src/app/routes/logout.tsx b/src/app/routes/logout.tsx new file mode 100644 index 0000000..f38c489 --- /dev/null +++ b/src/app/routes/logout.tsx @@ -0,0 +1,14 @@ +import { LoadingOverlay } from '@mantine/core' +import { signOut } from 'supertokens-web-js/recipe/passwordless' +import { redirect, createFileRoute } from '@tanstack/react-router' +import { authQueryConfig, defaultAuthData } from '@/contexts/auth-context' + +export const Route = createFileRoute('/logout')({ + preload: false, + loader: async ({ context }) => { + await context.queryClient.setQueryData(authQueryConfig.queryKey, defaultAuthData); + await signOut(); + throw redirect({ to: '/login' }); + }, + pendingComponent: () => +}) diff --git a/src/app/tanstack-start.d.ts b/src/app/tanstack-start.d.ts new file mode 100644 index 0000000..df36e6d --- /dev/null +++ b/src/app/tanstack-start.d.ts @@ -0,0 +1,2 @@ +/// +import '../../.tanstack-start/server-routes/routeTree.gen' diff --git a/src/components/DefaultCatchBoundary.tsx b/src/components/DefaultCatchBoundary.tsx new file mode 100644 index 0000000..f750e7b --- /dev/null +++ b/src/components/DefaultCatchBoundary.tsx @@ -0,0 +1,53 @@ +import { + ErrorComponent, + Link, + rootRouteId, + useMatch, + useRouter, +} from '@tanstack/react-router' +import type { ErrorComponentProps } from '@tanstack/react-router' + +export function DefaultCatchBoundary({ error }: ErrorComponentProps) { + const router = useRouter() + const isRoot = useMatch({ + strict: false, + select: (state) => state.id === rootRouteId, + }) + + console.error('DefaultCatchBoundary Error:', error) + + return ( +
+ +
+ + {isRoot ? ( + + Home + + ) : ( + { + e.preventDefault() + window.history.back() + }} + > + Go Back + + )} +
+
+ ) +} diff --git a/src/components/avatar.tsx b/src/components/avatar.tsx new file mode 100644 index 0000000..5e4b40d --- /dev/null +++ b/src/components/avatar.tsx @@ -0,0 +1,15 @@ +import { Avatar as MantineAvatar, AvatarProps as MantineAvatarProps, Paper } from '@mantine/core'; + +interface AvatarProps extends Omit { + name: string; + size?: number; + radius?: string | number; +} + +const Avatar = ({ name, size = 35, radius = '100%', ...props }: AvatarProps) => { + return + + +} + +export default Avatar; diff --git a/src/components/list-link.tsx b/src/components/list-link.tsx new file mode 100644 index 0000000..b5353a9 --- /dev/null +++ b/src/components/list-link.tsx @@ -0,0 +1,27 @@ +import { NavLink, Text } from "@mantine/core"; +import { CaretRightIcon, Icon } from "@phosphor-icons/react"; +import { Link, useNavigate } from "@tanstack/react-router"; + +interface ListLinkProps { + label: string; + to: string; + Icon: Icon; +} + +const ListLink = ({ label, to, Icon }: ListLinkProps) => { + const navigate = useNavigate(); + + return ( + navigate({ to })} + label={{label}} + leftSection={} + rightSection={} + /> + ) +} + +export default ListLink; diff --git a/src/components/page.tsx b/src/components/page.tsx new file mode 100644 index 0000000..cdf1f8a --- /dev/null +++ b/src/components/page.tsx @@ -0,0 +1,15 @@ +import { Container, ContainerProps } from "@mantine/core"; +import useHeaderConfig from "@/features/core/hooks/use-header-config"; + +interface PageProps extends ContainerProps, React.PropsWithChildren { + noPadding?: boolean; +} + +const Page = ({ children, noPadding, ...props }: PageProps) => { + const headerConfig = useHeaderConfig(); + return + {children} + +} + +export default Page; diff --git a/src/components/phone-number-input.tsx b/src/components/phone-number-input.tsx new file mode 100644 index 0000000..3989bdb --- /dev/null +++ b/src/components/phone-number-input.tsx @@ -0,0 +1,34 @@ +import { Input, InputProps, Group, Text } from '@mantine/core'; +import { CheckFat, Phone } from '@phosphor-icons/react'; +import { IMaskInput } from 'react-imask'; + +interface PhoneNumberInputProps extends InputProps { + id: string; + value?: string; + onChange: (value: string) => void; + label: string; + description?: string; + error?: string; +} + +const PhoneNumberInput: React.FC = ({ id, value, onChange, label, description, error, ...props }) => { + return ( + +   +1} + leftSectionWidth={50} + leftSectionProps={{ style: { padding: 0 } }} + placeholder="(713) 867-5309" + onAccept={(_, mask) => onChange(mask.unmaskedValue)} + rightSection={value?.length === 10 && } + value={value} + {...props} + /> + + ); +} + +export default PhoneNumberInput; diff --git a/src/components/providers.tsx b/src/components/providers.tsx new file mode 100644 index 0000000..2a95a0f --- /dev/null +++ b/src/components/providers.tsx @@ -0,0 +1,16 @@ +import { AuthProvider } from "@/contexts/auth-context" +import MantineProvider from "@/lib/mantine/mantine-provider" +import { Toaster } from "sonner" + +const Providers = ({ children }: { children: React.ReactNode }) => { + return ( + + + + {children} + + + ) +} + +export default Providers; diff --git a/src/components/sheet/drawer.tsx b/src/components/sheet/drawer.tsx new file mode 100644 index 0000000..cd3830d --- /dev/null +++ b/src/components/sheet/drawer.tsx @@ -0,0 +1,73 @@ +import { Box, Container } from "@mantine/core"; +import { PropsWithChildren, useEffect } from "react"; +import { Drawer as VaulDrawer } from 'vaul'; +import { useMantineColorScheme } from '@mantine/core'; +import styles from './styles.module.css'; + +interface DrawerProps extends PropsWithChildren { + title?: string; + opened: boolean; + onChange: (next: boolean) => void; +} + +const Drawer: React.FC = ({ title, children, opened, onChange }) => { + const { colorScheme } = useMantineColorScheme(); + + useEffect(() => { + const appElement = document.querySelector('.app') as HTMLElement; + + if (!appElement) return; + + let themeColorMeta = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement; + if (!themeColorMeta) { + themeColorMeta = document.createElement('meta'); + themeColorMeta.name = 'theme-color'; + document.head.appendChild(themeColorMeta); + } + + const colors = { + light: { + normal: 'rgb(255,255,255)', + overlay: 'rgb(153,153,153)' + }, + dark: { + normal: 'rgb(36,36,36)', + overlay: 'rgb(22,22,22)' + } + }; + + const currentColors = colors[colorScheme] || colors.light; + + if (opened) { + appElement.classList.add('drawer-scaling'); + themeColorMeta.content = currentColors.overlay; + } else { + appElement.classList.remove('drawer-scaling'); + themeColorMeta.content = currentColors.normal; + } + + return () => { + appElement.classList.remove('drawer-scaling'); + themeColorMeta.content = currentColors.normal; + }; + }, [opened, colorScheme]); + + return ( + + + + + + + + {title} + {children} + + + + + + ) +} + +export default Drawer; diff --git a/src/components/sheet/index.ts b/src/components/sheet/index.ts new file mode 100644 index 0000000..6f4cd42 --- /dev/null +++ b/src/components/sheet/index.ts @@ -0,0 +1 @@ +export * from './sheet'; diff --git a/src/components/sheet/modal.tsx b/src/components/sheet/modal.tsx new file mode 100644 index 0000000..07873f2 --- /dev/null +++ b/src/components/sheet/modal.tsx @@ -0,0 +1,16 @@ +import { Modal as MantineModal, Title } from "@mantine/core"; +import { PropsWithChildren } from "react"; + +interface ModalProps extends PropsWithChildren { + title?: string; + opened: boolean; + onClose: () => void; +} + +const Modal: React.FC = ({ title, children, opened, onClose }) => ( + {title}}> + {children} + +) + +export default Modal; diff --git a/src/components/sheet/sheet.tsx b/src/components/sheet/sheet.tsx new file mode 100644 index 0000000..dca26c9 --- /dev/null +++ b/src/components/sheet/sheet.tsx @@ -0,0 +1,30 @@ +import { PropsWithChildren, useCallback } from "react"; +import { useIsMobile } from "@/hooks/use-is-mobile"; +import Drawer from "./drawer"; +import Modal from "./modal"; +import { Box, ScrollArea } from "@mantine/core"; + +interface SheetProps extends PropsWithChildren { + title?: string; + opened: boolean; + onChange: (next: boolean) => void; +} + +const Sheet: React.FC = ({ title, children, opened, onChange }) => { + const isMobile = useIsMobile(); + const handleClose = useCallback(() => onChange(false), [onChange]); + + const SheetComponent = isMobile ? Drawer : Modal; + + return ( + + + + {children} + + + + ); +}; + +export default Sheet; \ No newline at end of file diff --git a/src/components/sheet/slide-panel/index.ts b/src/components/sheet/slide-panel/index.ts new file mode 100644 index 0000000..d09b09c --- /dev/null +++ b/src/components/sheet/slide-panel/index.ts @@ -0,0 +1,7 @@ +import { SlidePanel } from './slide-panel'; + +export * from './slide-panel'; +export * from './slide-panel-field'; +export * from './slide-panel-context'; + +export default SlidePanel; \ No newline at end of file diff --git a/src/components/sheet/slide-panel/slide-panel-context.tsx b/src/components/sheet/slide-panel/slide-panel-context.tsx new file mode 100644 index 0000000..feea7de --- /dev/null +++ b/src/components/sheet/slide-panel/slide-panel-context.tsx @@ -0,0 +1,18 @@ +import { ComponentType, createContext } from "react"; + +interface SlidePanelContextType { + openPanel: (config: PanelConfig) => void; + closePanel: () => void; +} + +interface PanelConfig { + title: string; + Component: ComponentType; + value: any; + onChange: (value: any) => void; + componentProps?: Record; +} + +const SlidePanelContext = createContext(null); + +export { SlidePanelContext, type SlidePanelContextType, type PanelConfig }; diff --git a/src/components/sheet/slide-panel/slide-panel-field.tsx b/src/components/sheet/slide-panel/slide-panel-field.tsx new file mode 100644 index 0000000..23bcc6b --- /dev/null +++ b/src/components/sheet/slide-panel/slide-panel-field.tsx @@ -0,0 +1,89 @@ +import { Box, Text, UnstyledButton, Flex, Stack } from "@mantine/core"; +import { CaretRightIcon } from "@phosphor-icons/react"; +import { ComponentType, useContext } from "react"; +import { SlidePanelContext } from "./slide-panel-context"; + +interface SlidePanelFieldProps { + key: string; + value?: any; + onChange?: (value: any) => void; + Component: ComponentType; + title: string; + label?: string; + placeholder?: string; + formatValue?: (value: any) => string; + componentProps?: Record; + withAsterisk?: boolean; + error?: string; +} + +const SlidePanelField = ({ + value, + onChange, + Component, + title, + label, + placeholder = "Select value", + withAsterisk = false, + formatValue, + componentProps, + error, +}: SlidePanelFieldProps) => { + const context = useContext(SlidePanelContext); + + if (!context) { + throw new Error('SlidePanelField must be used within a SlidePanel'); + } + + const handleClick = () => { + if (!onChange) return; + + context.openPanel({ + title, + Component, + value, + onChange, + componentProps, + }); + }; + + const displayValue = () => { + if (formatValue && value != null) { + return formatValue(value); + } + if (value != null) { + if (value instanceof Date) { + return value.toLocaleDateString(); + } + return String(value); + } + return placeholder; + }; + + return ( + + + + + {label}{withAsterisk && *} + {displayValue()} + + + + + {error && {error}} + + ); +}; + +export { SlidePanelField }; \ No newline at end of file diff --git a/src/components/sheet/slide-panel/slide-panel.tsx b/src/components/sheet/slide-panel/slide-panel.tsx new file mode 100644 index 0000000..46212d3 --- /dev/null +++ b/src/components/sheet/slide-panel/slide-panel.tsx @@ -0,0 +1,164 @@ +import { Box, Text, Group, ActionIcon, Button, ScrollArea, Divider } from "@mantine/core"; +import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react"; +import { useState, ReactNode} from "react"; +import { SlidePanelContext, type PanelConfig } from "./slide-panel-context"; + +interface SlidePanelProps { + children: ReactNode; + onSubmit: (event: React.FormEvent) => void; + onCancel?: () => void; + submitText?: string; + cancelText?: string; + maxHeight?: string; + formProps?: Record; + loading?: boolean; +} + +/** + * SlidePanel is a form component meant to be used inside a drawer/modal + * It is used to create a form with multiple views/panels that slides in from the side + * Use with SlidePanelField for an extra panel + */ +const SlidePanel = ({ + children, + onSubmit, + onCancel, + submitText = "Submit", + cancelText = "Cancel", + maxHeight = "70vh", + formProps = {}, + loading = false +}: SlidePanelProps) => { + const [isOpen, setIsOpen] = useState(false); + const [panelConfig, setPanelConfig] = useState(null); + const [tempValue, setTempValue] = useState(null); + + const openPanel = (config: PanelConfig) => { + setPanelConfig(config); + setTempValue(config.value); + setIsOpen(true); + }; + + const closePanel = () => { + setIsOpen(false); + }; + + const handleConfirm = () => { + if (panelConfig) { + panelConfig.onChange(tempValue); + } + setIsOpen(false); + }; + + const handleFormSubmit = (event: React.FormEvent) => { + event.preventDefault(); + onSubmit(event); + }; + + return ( + + + +
+ + + {children} + + + + + + + {onCancel && ( + + )} + + +
+
+ + + {panelConfig && ( + <> + + + + + {panelConfig.title} + + + + + + + + + + + + )} + +
+
+ ); +}; + +export { SlidePanel }; \ No newline at end of file diff --git a/src/components/sheet/styles.module.css b/src/components/sheet/styles.module.css new file mode 100644 index 0000000..90179a3 --- /dev/null +++ b/src/components/sheet/styles.module.css @@ -0,0 +1,20 @@ +.drawerOverlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.4); + z-index: 101; +} + +.drawerContent { + z-index: 999; + background-color: var(--mantine-color-body); + border-top-left-radius: 20px; + border-top-right-radius: 20px; + margin-top: 24px; + height: fit-content; + position: fixed; + bottom: 0; + left: 0; + right: 0; + outline: none; +} diff --git a/src/contexts/auth-context.tsx b/src/contexts/auth-context.tsx new file mode 100644 index 0000000..471d0c4 --- /dev/null +++ b/src/contexts/auth-context.tsx @@ -0,0 +1,73 @@ +import { createContext, PropsWithChildren, useCallback, useContext, useMemo } from "react"; +import { MantineColor, MantineColorScheme } from "@mantine/core"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { fetchMe } from "@/features/players/server"; + +const queryKey = ['auth']; +export const authQueryConfig = { + queryKey, + queryFn: fetchMe +} + +interface AuthData { + user: any; + metadata: { accentColor: MantineColor; colorScheme: MantineColorScheme }; + roles: string[]; +} + +export const defaultAuthData: AuthData = { + user: undefined, + metadata: { accentColor: 'blue', colorScheme: 'auto' }, + roles: [], +} + +export interface AuthContextType extends AuthData { + set: ({ user, metadata, roles }: Partial) => void; +} + +const AuthContext = createContext({ + ...defaultAuthData, + set: () => {}, +}); + +export const AuthProvider: React.FC = ({ children }) => { + const queryClient = useQueryClient(); + const { data, isLoading } = useQuery(authQueryConfig); + + const set = useCallback((updates: Partial) => { + queryClient.setQueryData(queryKey, (oldData: AuthData | undefined) => { + const currentData = oldData || defaultAuthData; + return { + ...currentData, + ...updates, + metadata: updates.metadata + ? { ...currentData.metadata, ...updates.metadata } + : currentData.metadata + }; + }); + }, [queryClient]); + + if (isLoading) { + return

Loading...

+ } + + return ( + + {children} + + ) +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/src/features/admin/components/create-tournament.tsx b/src/features/admin/components/create-tournament.tsx new file mode 100644 index 0000000..98ca0a1 --- /dev/null +++ b/src/features/admin/components/create-tournament.tsx @@ -0,0 +1,118 @@ +import { Stack, TextInput, Textarea } from "@mantine/core"; +import { useForm, UseFormInput } from "@mantine/form"; +import { LinkIcon } from "@phosphor-icons/react"; +import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel"; +import { TournamentFormInput } from "@/features/tournaments/types"; +import { DateTimePicker } from "./date-time-picker"; +import { isNotEmpty } from "@mantine/form"; +import useCreateTournament from "../hooks/use-create-tournament"; + +const CreateTournament = ({ close }: { close: () => void }) => { + + const config: UseFormInput = { + initialValues: { // TODO : Remove fake initial values + name: 'Test Tournament', + location: 'Test Location', + desc: 'Test Description', + logo_url: 'https://en.wikipedia.org/wiki/Trophy#/media/File:1934_Melbourne_Cup,_National_Museum_of_Australia.jpg', + start_time: '2025-01-01T00:00:00Z', + enroll_time: '2025-01-01T00:00:00Z', + }, + onSubmitPreventDefault: 'always', + validate: { + name: isNotEmpty('Name is required'), + location: isNotEmpty('Location is required'), + start_time: isNotEmpty('Start time is required'), + enroll_time: isNotEmpty('Enrollment time is required'), + } + } + + const form = useForm(config); + + const { mutate: createTournament, isPending } = useCreateTournament(); + + const handleSubmit = async (values: TournamentFormInput) => { + createTournament(values, { + onSuccess: () => { + close(); + } + }); + } + + return ( + + + + + + + + } + {...form.getInputProps('logo_url')} + /> + + new Date(date).toLocaleDateString('en-US', { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + hour12: true + })} + /> + + new Date(date).toLocaleDateString('en-US', { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + hour12: true + })} + /> + + + ); +}; + +export default CreateTournament; \ No newline at end of file diff --git a/src/features/admin/components/date-time-picker.tsx b/src/features/admin/components/date-time-picker.tsx new file mode 100644 index 0000000..d947391 --- /dev/null +++ b/src/features/admin/components/date-time-picker.tsx @@ -0,0 +1,90 @@ +import { DatePicker, TimeInput } from "@mantine/dates"; +import { ActionIcon, Stack } from "@mantine/core"; +import { useRef } from "react"; +import { ClockIcon } from "@phosphor-icons/react"; + +interface DateTimePickerProps { + value: Date | null; + onChange: (date: string | null) => void; + label?: string; + [key: string]: any; +} + +const DateTimePicker = ({ value, onChange, label, ...rest }: DateTimePickerProps) => { + const timeRef = useRef(null); + const currentDate = value ? new Date(value) : null; + + const formatDate = (date: Date | null): string => { + if (!date) return ""; + return date.toISOString().split('T')[0]; + }; + + const formatTime = (date: Date | null): string => { + if (!date) return ""; + return date.toTimeString().slice(0, 5); + }; + + const handleDateChange = (dateString: string | null) => { + if (!dateString) { + onChange(''); + return; + } + + const newDate = new Date(dateString + 'T00:00:00'); + + if (currentDate) { + newDate.setHours(currentDate.getHours()); + newDate.setMinutes(currentDate.getMinutes()); + } + + onChange(newDate.toISOString()); + }; + + const handleTimeChange = (event: React.ChangeEvent) => { + const timeValue = event.target.value; + if (!timeValue) return; + + const [hours, minutes] = timeValue.split(':').map(Number); + if (isNaN(hours) || isNaN(minutes)) return; + + const baseDate = currentDate || new Date(); + const newDate = new Date(baseDate); + + newDate.setHours(hours); + newDate.setMinutes(minutes); + newDate.setSeconds(0); + newDate.setMilliseconds(0); + + onChange(newDate.toISOString()); + }; + + return ( + + + timeRef.current?.showPicker()} + > + + + } + {...rest} + /> + + ); +}; + +export { DateTimePicker }; \ No newline at end of file diff --git a/src/features/admin/hooks/use-create-tournament.ts b/src/features/admin/hooks/use-create-tournament.ts new file mode 100644 index 0000000..09aad39 --- /dev/null +++ b/src/features/admin/hooks/use-create-tournament.ts @@ -0,0 +1,37 @@ +import { useMutation } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { createTournament } from "@/features/tournaments/server"; +import toast from '@/lib/sonner'; +import { TournamentInput } from "@/features/tournaments/types"; +import { logger } from "../"; + +const useCreateTournament = () => { + const navigate = useNavigate(); + + return useMutation({ + mutationFn: (data: TournamentInput) => createTournament({ data }), + onMutate: (data) => { + logger.info('Creating tournament', data); + }, + onSuccess: (data) => { + if (!data) { + toast.error('There was an issue creating your tournament. Please try again later.'); + logger.error('Error creating tournament', data); + } else { + toast.success('Tournament created successfully!'); + logger.info('Tournament created successfully', data); + navigate({ to: '/tournaments' }); + } + }, + onError: (error: any) => { + logger.error('Error creating tournament', error); + if (error.message) { + toast.error(error.message); + } else { + toast.error('An unexpected error occurred when trying to create a tournament. Please try again later.'); + } + }, + }); +}; + +export default useCreateTournament; diff --git a/src/features/admin/index.ts b/src/features/admin/index.ts new file mode 100644 index 0000000..add9192 --- /dev/null +++ b/src/features/admin/index.ts @@ -0,0 +1,3 @@ +import { Logger } from "@/lib/logger"; + +export const logger = new Logger('Admin'); \ No newline at end of file diff --git a/src/features/bracket/bracket.ts b/src/features/bracket/bracket.ts new file mode 100644 index 0000000..536a5ed --- /dev/null +++ b/src/features/bracket/bracket.ts @@ -0,0 +1,412 @@ +// Type definitions +interface TMatchSlot {} + +interface Seed extends TMatchSlot { + seed: number; +} + +interface TBD extends TMatchSlot { + parent: TMatchBase; + loser: boolean; + ifNecessary?: boolean; +} + +interface TMatchBase { + lid: number; // local id + round: number; + order?: number | null; +} + +interface TMatch extends TMatchBase { + home: Seed | TBD; + away: Seed | TBD; + reset?: boolean; +} + +interface TBye extends TMatchBase { + home: Seed | TBD; +} + +type MatchType = TMatch | TBye; + +// Utility functions +function innerOuter(ls: T[]): T[] { + if (ls.length === 2) return ls; + + const size = Math.floor(ls.length / 4); + + const innerPart = [ls.slice(size, 2 * size), ls.slice(2 * size, 3 * size)]; + const outerPart = [ls.slice(0, size), ls.slice(3 * size)]; + + const inner = (part: T[][]): T[] => [part[0].pop()!, part[1].shift()!]; + const outer = (part: T[][]): T[] => [part[0].shift()!, part[1].pop()!]; + + const quads: T[][] = Array(Math.floor(size / 2)).fill(null).map(() => []); + + const push = (part: T[][], method: (p: T[][]) => T[], arr: T[]) => { + if (part[0].length && part[1].length) { + arr.push(...method(part)); + } + }; + + for (let i = 0; i < Math.floor(size / 2); i++) { + push(outerPart, outer, quads[i]); + push(innerPart, inner, quads[i]); + push(outerPart, inner, quads[i]); + push(innerPart, outer, quads[i]); + } + + const result: T[] = []; + for (let i = 0; i < quads.length; i++) { + const curr = i % 2 === 0 ? quads.shift()! : quads.pop()!; + result.push(...curr); + } + + return result; +} + +function reverseHalfShift(ls: T[]): T[] { + const halfLength = Math.floor(ls.length / 2); + return [...ls.slice(-halfLength), ...ls.slice(0, -halfLength)]; +} + +export class BracketGenerator { + private _bracket: MatchType[][] = []; + private _losersBracket: MatchType[][] = []; + private _order: number = 0; + private _floatingLosers: TBD[] = []; + private _lid: number = 0; + private _matches: Map = new Map(); + + public n: number; + public doubleElim: boolean; + private _nearestPowerOf2: number; + private _m: number; + private _byes: number; + + constructor(n: number, doubleElim: boolean = false) { + if (n < 8 || n > 64) { + throw new Error("The number of teams must be greater than or equal to 8 and less than or equal to 64"); + } + + this.n = n; + this.doubleElim = doubleElim; + this._nearestPowerOf2 = Math.pow(2, Math.ceil(Math.log2(n))); + this._m = this._nearestPowerOf2; + this._byes = this._m - n; + + this._generateSingleElim(); + } + + private _makeMatch(round: number, home: Seed | TBD, away: Seed | TBD, order: number): TMatch { + const match: TMatch = { + lid: this._lid, + round: round, + home: home, + away: away, + order: order + }; + this._matches.set(this._lid, match); + this._lid += 1; + return match; + } + + private _makeBye(round: number, home: Seed | TBD): TBye { + const bye: TBye = { + lid: this._lid, + round: round, + home: home + }; + this._matches.set(this._lid, bye); + this._lid += 1; + return bye; + } + + private _makeTBD(parent: TMatchBase, loser: boolean = false): TBD { + return { + parent: parent, + loser: loser + }; + } + + private _makeSeed(seed: number): Seed { + return { seed: seed }; + } + + private _parseQuad(quad: MatchType[]): MatchType[] { + // Used to generate losers bracket by iterating over the first round of the bracket, 4 matches/byes at a time + + const pop = (): TBye => this._makeBye(0, this._floatingLosers.pop()!); + const popAt = (i: number) => (): TBye => this._makeBye(0, this._floatingLosers.splice(i, 1)[0]); + const shift = (): TBye => this._makeBye(0, this._floatingLosers.shift()!); + const popShift = (): TMatch => this._makeMatch(0, this._floatingLosers.pop()!, this._floatingLosers.shift()!, this._orderIncrement()); + const pairShift = (): TMatch => this._makeMatch(0, this._floatingLosers.shift()!, this._floatingLosers.shift()!, this._orderIncrement()); + + // Actions to perform based on number of byes in the winners bracket quad + const actions: { [key: number]: (() => MatchType)[] } = { + 0: [pop, pairShift, pop, pairShift], + 1: [pop, shift, pop, pairShift], + 2: [pop, shift, pop, shift], + 3: [popAt(-2), popShift], + 4: [pop, pop] + }; + + // Count the number of byes in the quad + const b = quad.filter(m => 'home' in m && !('away' in m)).length; + + const result = actions[b].map(action => action()); + return result; + } + + private _flattenRound(round: MatchType[], roundNumber: number = 0): MatchType[] { + // Check if all matches are byes + if (round.every(m => 'home' in m && !('away' in m))) { + const result: MatchType[] = []; + for (let i = 0; i < round.length; i += 2) { + result.push(this._makeMatch( + roundNumber, + (round[i] as TBye).home, + (round[i + 1] as TBye).home, + this._orderIncrement() + )); + } + return result; + } + + return round; + } + + private _startsWithBringInRound(): boolean { + // Start at 8, first block of size 4 returns 0 + let start = 8; + const blockSizes = [4, 5, 7, 9, 15, 17]; // Sizes of blocks + let result = 0; // First block returns 0 + + // Loop through predefined block sizes + for (const blockSize of blockSizes) { + const end = start + blockSize - 1; + if (start <= this.n && this.n <= end) { + return result === 0; + } + start = end + 1; + result = 1 - result; // Alternate between 0 and 1 + } + + return false; + } + + private _generateStartingRounds(): void { + this._floatingLosers = []; + + // Generate Pairings based on seeding + const seeds: (Seed | null)[] = []; + for (let i = 1; i <= this.n; i++) { + seeds.push(this._makeSeed(i)); + } + for (let i = 0; i < this._byes; i++) { + seeds.push(null); + } + + const pairings: [Seed | null, Seed | null][] = []; + const innerOuterResult = innerOuter(seeds); + for (let i = 0; i < innerOuterResult.length; i += 2) { + pairings.push([innerOuterResult[i], innerOuterResult[i + 1]]); + } + + // First Round + let round: MatchType[] = []; + for (const [home, away] of pairings) { + if (away === null) { + round.push(this._makeBye(0, home!)); + } else { + const match = this._makeMatch(0, home!, away, this._orderIncrement()); + round.push(match); + this._floatingLosers.push(this._makeTBD(match, true)); + } + } + + this._bracket = [round]; + + // Second Round + const prev = round; + round = []; + + const getSlot = (m: MatchType): Seed | TBD => { + return 'away' in m ? this._makeTBD(m) : (m as TBye).home; + }; + + const startOrder = this._orderIncrement(); + const orderDelta = Math.abs(this._byes - (this._m / 4)); + const orderSplit = [startOrder + orderDelta, startOrder]; + + for (let i = 0; i < prev.length; i += 2) { + const home = getSlot(prev[i]); + const away = getSlot(prev[i + 1]); + + let order: number; + if ('parent' in away) { + order = orderSplit[0]; + orderSplit[0] += 1; + } else { + order = orderSplit[1]; + orderSplit[1] += 1; + } + + const match = this._makeMatch(1, home, away, order); + round.push(match); + this._floatingLosers.push(this._makeTBD(match, true)); + } + + this._bracket.push(round); + this._order = orderSplit[0] - 1; + + // Generate losers bracket if double elim + if (this.doubleElim) { + // Round one + this._floatingLosers = innerOuter(this._floatingLosers); + this._losersBracket = []; + let roundOne: MatchType[] = []; + for (let i = 0; i < prev.length; i += 4) { + roundOne.push(...this._parseQuad(prev.slice(i, i + 4))); + } + this._losersBracket.push(this._flattenRound(roundOne)); + + // Round two + const roundTwo: MatchType[] = []; + for (let i = 0; i < roundOne.length; i += 2) { + roundTwo.push(this._makeMatch( + 1, + getSlot(roundOne[i]), + getSlot(roundOne[i + 1]), + this._orderIncrement() + )); + } + + this._losersBracket.push(roundTwo); + } + } + + private _orderIncrement(): number { + this._order += 1; + return this._order; + } + + private _generateBringInRound(roundNumber: number): void { + console.log('generating bring in round', roundNumber); + const bringIns = reverseHalfShift(this._floatingLosers); + this._floatingLosers = []; + const round: MatchType[] = []; + + const prev = this._losersBracket[this._losersBracket.length - 1]; + for (const match of prev) { + const bringIn = bringIns.pop()!; + const newMatch = this._makeMatch( + roundNumber, + bringIn, + this._makeTBD(match), + this._orderIncrement() + ); + round.push(newMatch); + } + + this._losersBracket.push(round); + } + + private _generateLosersRound(roundNumber: number): void { + console.log('generating losers round', roundNumber); + const round: MatchType[] = []; + const prev = this._losersBracket[this._losersBracket.length - 1]; + + if (prev.length < 2) return; + + for (let i = 0; i < prev.length; i += 2) { + const newMatch = this._makeMatch( + roundNumber, + this._makeTBD(prev[i]), + this._makeTBD(prev[i + 1]), + this._orderIncrement() + ); + round.push(newMatch); + } + + this._losersBracket.push(round); + } + + private _generateSingleElim(): void { + this._generateStartingRounds(); + let prev = this._bracket[this._bracket.length - 1]; + + const add = ( + round: MatchType[], + prevSlot: TBD | null, + match: MatchType + ): [MatchType[], TBD | null] => { + if (prevSlot === null) return [round, this._makeTBD(match)]; + const newMatch = this._makeMatch( + this._bracket.length, + prevSlot, + this._makeTBD(match), + this._orderIncrement() + ); + this._floatingLosers.push(this._makeTBD(newMatch, true)); + return [[...round, newMatch], null]; + }; + + while (prev.length > 1) { + let round: MatchType[] = []; + let prevSlot: TBD | null = null; + for (const match of prev) { + [round, prevSlot] = add(round, prevSlot, match); + } + this._bracket.push(round); + prev = round; + + if (this.doubleElim) { + const r = this._losersBracket.length; + if (this._startsWithBringInRound()) { + this._generateBringInRound(r); + this._generateLosersRound(r + 1); + } else { + this._generateLosersRound(r); + this._generateBringInRound(r + 1); + } + } + } + + // Grand Finals and bracket reset + if (this.doubleElim) { + const winnersFinal = this._bracket[this._bracket.length - 1][this._bracket[this._bracket.length - 1].length - 1]; + const losersFinal = this._losersBracket[this._losersBracket.length - 1][this._losersBracket[this._losersBracket.length - 1].length - 1]; + + const grandFinal = this._makeMatch( + this._bracket.length, + this._makeTBD(winnersFinal), + this._makeTBD(losersFinal), + this._orderIncrement() + ); + + const resetMatch = this._makeMatch( + this._bracket.length + 1, + this._makeTBD(grandFinal), + this._makeTBD(grandFinal, true), + this._orderIncrement() + ); + resetMatch.reset = true; + + this._bracket.push([grandFinal], [resetMatch]); + } + } + + // Public getters for accessing the brackets + get bracket(): MatchType[][] { + return this._bracket; + } + + get losersBracket(): MatchType[][] { + return this._losersBracket; + } + + get matches(): Map { + return this._matches; + } +} diff --git a/src/features/bracket/components/bracket-page.tsx b/src/features/bracket/components/bracket-page.tsx new file mode 100644 index 0000000..8fc3181 --- /dev/null +++ b/src/features/bracket/components/bracket-page.tsx @@ -0,0 +1,146 @@ +import { Text, Container, Flex, ScrollArea } from "@mantine/core"; +import { SeedList } from "./seed-list"; +import BracketView from "./bracket-view"; +import { MutableRefObject, RefObject, useEffect, useRef, useState } from "react"; +import { bracketQueries } from "../queries"; +import { useQuery } from "@tanstack/react-query"; +import { useDraggable } from "react-use-draggable-scroll"; +import { ref } from "process"; +import './styles.module.css'; +import { useIsMobile } from "@/hooks/use-is-mobile"; +import useAppShellHeight from "@/hooks/use-appshell-height"; + +interface Team { + id: string; + name: string; +} + +interface BracketData { + n: number; + doubleElim: boolean; + matches: { [key: string]: any }; + winnersBracket: number[][]; + losersBracket: number[][]; +} + +export const PreviewBracketPage: React.FC = () => { + const isMobile = useIsMobile(); + const height = useAppShellHeight(); + const refDraggable = useRef(null); + const { events } = useDraggable(refDraggable as RefObject, { isMounted: !!refDraggable.current }); + + const teamCount = 20; + const { data, isLoading, error } = useQuery(bracketQueries.preview(teamCount)); + + // Create teams with proper structure + const [teams, setTeams] = useState( + Array.from({ length: teamCount }, (_, i) => ({ + id: `team-${i + 1}`, + name: `Team ${i + 1}` + })) + ); + + const [seededWinnersBracket, setSeededWinnersBracket] = useState([]); + const [seededLosersBracket, setSeededLosersBracket] = useState([]); + + useEffect(() => { + if (!data) return; + + // Map match IDs to actual match objects with team names + const mapBracket = (bracketIds: number[][]) => { + return bracketIds.map(roundIds => + roundIds.map(lid => { + const match = data.matches[lid]; + if (!match) return null; + + const mappedMatch = { ...match }; + + // Map home slot - handle both uppercase and lowercase type names + if (match.home?.type?.toLowerCase() === 'seed') { + mappedMatch.home = { + ...match.home, + team: teams[match.home.seed - 1] + }; + } + + // Map away slot if it exists - handle both uppercase and lowercase type names + if (match.away?.type?.toLowerCase() === 'seed') { + mappedMatch.away = { + ...match.away, + team: teams[match.away.seed - 1] + }; + } + + return mappedMatch; + }).filter(m => m !== null) + ); + }; + + setSeededWinnersBracket(mapBracket(data.winnersBracket)); + setSeededLosersBracket(mapBracket(data.losersBracket)); + }, [teams, data]); + + const handleSeedChange = (teamIndex: number, newSeedIndex: number) => { + const newTeams = [...teams]; + const movingTeam = newTeams[teamIndex]; + + // Remove the team from its current position + newTeams.splice(teamIndex, 1); + + // Insert it at the new position + newTeams.splice(newSeedIndex, 0, movingTeam); + + setTeams(newTeams); + }; + + if (isLoading) return

Loading...

; + if (error) return

Error loading bracket

; + if (!data) return

No data available

; + + return ( + + + + Preview Bracket ({data.n} teams, {data.doubleElim ? 'Double' : 'Single'} Elimination) + + + +
+ + Seed Teams + + +
+ +
+ + Winners Bracket + + +
+
+ + Losers Bracket + + +
+
+
+
+ ); +}; diff --git a/src/features/bracket/components/bracket-view.tsx b/src/features/bracket/components/bracket-view.tsx new file mode 100644 index 0000000..5c79afb --- /dev/null +++ b/src/features/bracket/components/bracket-view.tsx @@ -0,0 +1,119 @@ +import { ActionIcon, Card, Container, Flex, Text } from '@mantine/core'; +import { PlayIcon } from '@phosphor-icons/react'; +import React from 'react'; + +interface BracketViewProps { + bracket: any[][]; + matches: { [key: string]: any }; + onAnnounce?: (teamOne: any, teamTwo: any) => void; +} + +const BracketView: React.FC = ({ bracket, matches, onAnnounce }) => { + // Helper to check match type (handle both uppercase and lowercase) + const isMatchType = (type: string, expected: string) => { + return type?.toLowerCase() === expected.toLowerCase(); + }; + + // Helper to check slot type (handle both uppercase and lowercase) + const isSlotType = (type: string, expected: string) => { + return type?.toLowerCase() === expected.toLowerCase(); + }; + + // Helper to get parent match order number + const getParentMatchOrder = (parentId: number): number | string => { + const parentMatch = matches[parentId]; + if (parentMatch && parentMatch.order !== null && parentMatch.order !== undefined) { + return parentMatch.order; + } + // If no order (like for byes), return the parentId with a different prefix + return `Match ${parentId}`; + }; + + return ( + + {bracket.map((round, roundIndex) => ( + + {round.map((match, matchIndex) => { + if (!match) return null; + + // Handle bye matches (no away slot) - check both 'TBye' and 'bye' + if (isMatchType(match.type, 'bye') || isMatchType(match.type, 'tbye')) { + return ( + + + ); + } + + // Regular matches with both home and away + return ( + + {match.order} + + + {isSlotType(match.home?.type, 'seed') && ( + <> + Seed {match.home.seed} + {match.home.team && {match.home.team.name}} + + )} + {isSlotType(match.home?.type, 'tbd') && ( + + {match.home.loser ? 'Loser' : 'Winner'} of Match {getParentMatchOrder(match.home.parentId || match.home.parent)} + + )} + {!match.home && TBD} + + + {isSlotType(match.away?.type, 'seed') && ( + <> + Seed {match.away.seed} + {match.away.team && {match.away.team.name}} + + )} + {isSlotType(match.away?.type, 'tbd') && ( + + {match.away.loser ? 'Loser' : 'Winner'} of Match {getParentMatchOrder(match.away.parentId || match.away.parent)} + + )} + {!match.away && TBD} + + {match.reset && ( + + IF NECESSARY + + )} + {onAnnounce && match.home?.team && match.away?.team && ( + { + onAnnounce(match.home.team, match.away.team); + }} + bd='none' + style={{ boxShadow: 'none' }} + size='xs' + > + + + )} + + + ); + })} + + ))} + + ); +}; + +export default BracketView; \ No newline at end of file diff --git a/src/features/bracket/components/seed-list.tsx b/src/features/bracket/components/seed-list.tsx new file mode 100644 index 0000000..32eed54 --- /dev/null +++ b/src/features/bracket/components/seed-list.tsx @@ -0,0 +1,49 @@ +import { Flex, Text, Select, Card } from '@mantine/core'; +import React from 'react'; + +interface Team { + id: string; + name: string; +} + +interface SeedListProps { + teams: Team[]; + onSeedChange: (currentIndex: number, newIndex: number) => void; +} + +export function SeedList({ teams, onSeedChange }: SeedListProps) { + const seedOptions = teams.map((_, index) => ({ + value: index.toString(), + label: `Seed ${index + 1}` + })); + + return ( + + {teams.map((team, index) => ( + + + +