This commit is contained in:
yohlo
2025-08-20 22:35:40 -05:00
commit f51c278cd3
169 changed files with 8173 additions and 0 deletions

View File

@@ -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;

21
.gitignore vendored Normal file
View File

@@ -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/

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
**/build
**/public
pnpm-lock.yaml
routeTree.gen.ts

11
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"search.exclude": {
"**/routeTree.gen.ts": true
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
}
}

16
Dockerfile.pocketbase Normal file
View File

@@ -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"]

72
README.md Normal file
View File

@@ -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!

69
docker-compose.yml Normal file
View File

@@ -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:

10
drizzle.config.ts Normal file
View File

@@ -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 ?? "",
},
});

66
package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,215 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("_pb_users_auth_");
return app.delete(collection);
}, (app) => {
const collection = new Collection({
"authAlert": {
"emailTemplate": {
"body": "<p>Hello,</p>\n<p>We noticed a login to your {APP_NAME} account from a new location.</p>\n<p>If this was you, you may disregard this email.</p>\n<p><strong>If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.</strong></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Login from a new location"
},
"enabled": true
},
"authRule": "",
"authToken": {
"duration": 604800
},
"confirmEmailChangeTemplate": {
"body": "<p>Hello,</p>\n<p>Click on the button below to confirm your new email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Confirm new email</a>\n</p>\n<p><i>If you didn't ask to change your email address, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"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": "<p>Hello,</p>\n<p>Your one-time password is: <strong>{OTP}</strong></p>\n<p><i>If you didn't ask for the one-time password, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "OTP for {APP_NAME}"
},
"enabled": false,
"length": 8
},
"passwordAuth": {
"enabled": true,
"identityFields": [
"email"
]
},
"passwordResetToken": {
"duration": 1800
},
"resetPasswordTemplate": {
"body": "<p>Hello,</p>\n<p>Click on the button below to reset your password.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Reset password</a>\n</p>\n<p><i>If you didn't ask to reset your password, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Reset your {APP_NAME} password"
},
"system": false,
"type": "auth",
"updateRule": "id = @request.auth.id",
"verificationTemplate": {
"body": "<p>Hello,</p>\n<p>Thank you for joining us at {APP_NAME}.</p>\n<p>Click on the button below to verify your email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Verify</a>\n</p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Verify your {APP_NAME} email"
},
"verificationToken": {
"duration": 259200
},
"viewRule": "id = @request.auth.id"
});
return app.save(collection);
})

View File

@@ -0,0 +1,99 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})

View File

@@ -0,0 +1,213 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})

View File

@@ -0,0 +1,154 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})

View File

@@ -0,0 +1,106 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,42 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,42 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,260 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,45 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,45 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,260 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})

View File

@@ -0,0 +1,71 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,38 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,45 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,42 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,71 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

15
postcss.config.mjs Normal file
View File

@@ -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',
},
},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

17
public/site.webmanifest Normal file
View File

@@ -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"
}

BIN
public/static/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

31
public/styles.css Normal file
View File

@@ -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);
}

391
src/app/routeTree.gen.ts Normal file
View File

@@ -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<FileRouteTypes>()
const rootServerRouteChildren: RootServerRouteChildren = {
ApiTestServerRoute: ApiTestServerRoute,
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute,
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute,
}
export const serverRouteTree = rootServerRouteImport
._addFileChildren(rootServerRouteChildren)
._addFileTypes<FileServerRouteTypes>()

37
src/app/router.tsx Normal file
View File

@@ -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<typeof createRouter>
}
}

107
src/app/routes/__root.tsx Normal file
View File

@@ -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 (
<RootDocument>
<DefaultCatchBoundary {...props} />
</RootDocument>
)
},
component: RootComponent,
notFoundComponent: () => <Navigate to="/" />,
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 (
<RootDocument>
<Providers>
<Outlet />
</Providers>
</RootDocument>
)
}
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html {...mantineHtmlProps} style={{ overflowX: 'hidden', overflowY: 'hidden', position: 'fixed', width: '100%' }}>
<head>
<HeadContent />
<ColorSchemeScript />
<link rel="stylesheet" href="/styles.css" />
</head>
<body style={{ overflowX: 'hidden', overflowY: 'hidden', position: 'fixed', width: '100%' }}>
<div className='app'>
{children}
</div>
<Scripts />
</body>
</html>
)
}

View File

@@ -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 (
<Layout>
<Outlet />
</Layout>
)
}
})

View File

@@ -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
},
};
}
})

View File

@@ -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 <Page>
<Title order={2} mb='md'>Players</Title>
<PlayerList players={players!} loading={isLoading} />
</Page>
}

View File

@@ -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 <PreviewBracketPage />
}

View File

@@ -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 (
<Page noPadding>
<Box h='60vh' p="md">
<Text m='16vh' fw={500}>Some Content Here</Text>
</Box>
<Box>
<Text pl='md'>Quick Links</Text>
<Divider />
<ListLink label="All Tournaments" to="/tournaments" Icon={TrophyIcon} />
</Box>
</Page>
);
}

View File

@@ -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 <Page><Profile player={player} /></Page>
},
})

View File

@@ -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 <Page noPadding>
<Box px='md' py='sm' style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
<Title order={3}>Appearance</Title>
<Stack>
<AccentColorPicker />
<ColorSchemePicker />
</Stack>
</Box>
<ListLink
label='Sign Out'
to='/logout'
Icon={SignOutIcon}
/>
</Page>
}

View File

@@ -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 <Page><TeamProfile team={team} /></Page>
},
})

View File

@@ -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 <Page noPadding>
<Box mt='xl' p='md'>
<h3 style={{ marginTop: 0 }}>
{tournament?.name}
</h3>
<Button onClick={() => sheet.open()}>
View Teams
</Button>
</Box>
<ListButton
label='Teams'
onClick={() => sheet.open()}
Icon={UsersIcon}
/>
<Sheet
{...sheet.props}
title='Teams'
>
<TeamDrawer tournament={tournament!} />
</Sheet>
</Page>
}
const TeamDrawer = ({ tournament }: { tournament: Tournament }) => {
return (
<TeamList teams={tournament?.teams!} />
);
}

View File

@@ -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 <Page>
<Stack>
{
roles?.includes("Admin") ? (
<>
<Button leftSection={<PlusIcon />} variant='subtle' onClick={sheet.open}>Create Tournament</Button>
<Sheet {...sheet.props} title='Create Tournament'>
<CreateTournament close={sheet.close} />
</Sheet>
</>
) : null
}
{
tournaments?.map((tournament: any) => (
<TournamentCard key={tournament.id} tournament={tournament} />
))
}
</Stack>
</Page>
}

View File

@@ -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,
})

View File

@@ -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",
},
});
},
});

View File

@@ -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!')
},
})

27
src/app/routes/login.tsx Normal file
View File

@@ -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 (
<LoginLayout>
<LoginFlow />
</LoginLayout>
)
}
})

14
src/app/routes/logout.tsx Normal file
View File

@@ -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: () => <LoadingOverlay visible />
})

2
src/app/tanstack-start.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
import '../../.tanstack-start/server-routes/routeTree.gen'

View File

@@ -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 (
<div className="min-w-0 flex-1 p-4 flex flex-col items-center justify-center gap-6">
<ErrorComponent error={error} />
<div className="flex gap-2 items-center flex-wrap">
<button
onClick={() => {
router.invalidate()
}}
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
>
Try Again
</button>
{isRoot ? (
<Link
to="/"
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
>
Home
</Link>
) : (
<Link
to="/"
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
onClick={(e) => {
e.preventDefault()
window.history.back()
}}
>
Go Back
</Link>
)}
</div>
</div>
)
}

15
src/components/avatar.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { Avatar as MantineAvatar, AvatarProps as MantineAvatarProps, Paper } from '@mantine/core';
interface AvatarProps extends Omit<MantineAvatarProps, 'radius' | 'color' | 'size'> {
name: string;
size?: number;
radius?: string | number;
}
const Avatar = ({ name, size = 35, radius = '100%', ...props }: AvatarProps) => {
return <Paper p={size / 20} radius={radius} withBorder>
<MantineAvatar alt={name} key={name} name={name} color='initials' size={size} radius={radius} {...props} />
</Paper>
}
export default Avatar;

View File

@@ -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 (
<NavLink
w='100%'
p='md'
component={'button'}
onClick={() => navigate({ to })}
label={<Text fw={500} size='md'>{label}</Text>}
leftSection={<Icon weight='bold' size={20} />}
rightSection={<CaretRightIcon size={20} />}
/>
)
}
export default ListLink;

15
src/components/page.tsx Normal file
View File

@@ -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 <Container px={noPadding ? 0 : 'md'} pt={headerConfig.collapsed ? 60 : 0} pb={20} m={0} maw={600} mx='auto' {...props}>
{children}
</Container>
}
export default Page;

View File

@@ -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<PhoneNumberInputProps> = ({ id, value, onChange, label, description, error, ...props }) => {
return (
<Input.Wrapper id={id} label={label} description={description} error={error}>
<Input
id={id}
component={IMaskInput}
mask="(000) 000-0000"
leftSection={<Group gap={2}><Phone size={20} /> &nbsp; <Text c='dimmed' size='sm'>+1</Text></Group>}
leftSectionWidth={50}
leftSectionProps={{ style: { padding: 0 } }}
placeholder="(713) 867-5309"
onAccept={(_, mask) => onChange(mask.unmaskedValue)}
rightSection={value?.length === 10 && <CheckFat color='green' size={20} weight='fill' />}
value={value}
{...props}
/>
</Input.Wrapper>
);
}
export default PhoneNumberInput;

View File

@@ -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 (
<AuthProvider>
<MantineProvider>
<Toaster position='top-center' />
{children}
</MantineProvider>
</AuthProvider>
)
}
export default Providers;

View File

@@ -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<DrawerProps> = ({ 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 (
<VaulDrawer.Root open={opened} onOpenChange={onChange}>
<VaulDrawer.Portal>
<VaulDrawer.Overlay className={styles.drawerOverlay} />
<VaulDrawer.Content className={styles.drawerContent}>
<Container flex={1} p='md'>
<Box mb='sm' bg='var(--mantine-color-gray-4)' w='3rem' h='0.375rem' ml='auto' mr='auto' style={{ borderRadius: '9999px' }} />
<Container mah='fit-content' mx='auto' maw='28rem' px={0}>
<VaulDrawer.Title>{title}</VaulDrawer.Title>
{children}
</Container>
</Container>
</VaulDrawer.Content>
</VaulDrawer.Portal>
</VaulDrawer.Root>
)
}
export default Drawer;

View File

@@ -0,0 +1 @@
export * from './sheet';

View File

@@ -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<ModalProps> = ({ title, children, opened, onClose }) => (
<MantineModal opened={opened} onClose={onClose} title={<Title order={3}>{title}</Title>}>
{children}
</MantineModal>
)
export default Modal;

View File

@@ -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<SheetProps> = ({ title, children, opened, onChange }) => {
const isMobile = useIsMobile();
const handleClose = useCallback(() => onChange(false), [onChange]);
const SheetComponent = isMobile ? Drawer : Modal;
return (
<SheetComponent title={title} opened={opened} onChange={onChange} onClose={handleClose}>
<ScrollArea style={{ flex: 1 }} scrollbarSize={8} scrollbars='y' type='scroll'>
<Box mah='70vh'>
{children}
</Box>
</ScrollArea>
</SheetComponent>
);
};
export default Sheet;

View File

@@ -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;

View File

@@ -0,0 +1,18 @@
import { ComponentType, createContext } from "react";
interface SlidePanelContextType {
openPanel: (config: PanelConfig) => void;
closePanel: () => void;
}
interface PanelConfig {
title: string;
Component: ComponentType<any>;
value: any;
onChange: (value: any) => void;
componentProps?: Record<string, any>;
}
const SlidePanelContext = createContext<SlidePanelContextType | null>(null);
export { SlidePanelContext, type SlidePanelContextType, type PanelConfig };

View File

@@ -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<any>;
title: string;
label?: string;
placeholder?: string;
formatValue?: (value: any) => string;
componentProps?: Record<string, any>;
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 (
<Box>
<UnstyledButton
onClick={handleClick}
p='sm'
style={{
width: '100%',
border: error ? '1px solid var(--mantine-color-error)' : '1px solid var(--mantine-color-dimmed)',
borderRadius: 'var(--mantine-radius-lg)',
backgroundColor: 'var(--mantine-color-body)',
textAlign: 'left',
}}
>
<Flex justify="space-between" align="center">
<Stack>
<Text size="sm" fw={500}>{label}{withAsterisk && <Text span size="sm" c='var(--mantine-color-error)' fw={500} ml={4}>*</Text>}</Text>
<Text size="sm" c='dimmed'>{displayValue()}</Text>
</Stack>
<CaretRightIcon size={24} weight='thin' style={{ marginRight: '12px' }} />
</Flex>
</UnstyledButton>
{error && <Text size="xs" c='var(--mantine-color-error)' fw={500} ml={4} mt={4}>{error}</Text>}
</Box>
);
};
export { SlidePanelField };

View File

@@ -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<HTMLFormElement>) => void;
onCancel?: () => void;
submitText?: string;
cancelText?: string;
maxHeight?: string;
formProps?: Record<string, any>;
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<PanelConfig | null>(null);
const [tempValue, setTempValue] = useState<any>(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<HTMLFormElement>) => {
event.preventDefault();
onSubmit(event);
};
return (
<SlidePanelContext.Provider value={{ openPanel, closePanel }}>
<Box
style={{
position: 'relative',
height: maxHeight,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Box
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
transform: isOpen ? 'translateX(-100%)' : 'translateX(0)',
transition: 'transform 0.3s ease-in-out',
display: 'flex',
flexDirection: 'column'
}}
>
<form
{...formProps}
onSubmit={handleFormSubmit}
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
...formProps.style,
}}
>
<ScrollArea style={{ flex: 1 }} scrollbarSize={8} scrollbars='y' type='always'>
<Box p="md">
{children}
</Box>
</ScrollArea>
<Box p="sm">
<Group gap="md">
<Button type="submit" fullWidth loading={loading} disabled={loading}>
{submitText}
</Button>
{onCancel && (
<Button
variant="subtle"
color="red"
fullWidth
onClick={onCancel}
type="button"
disabled={loading}
>
{cancelText}
</Button>
)}
</Group>
</Box>
</form>
</Box>
<Box
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
transform: isOpen ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 0.3s ease-in-out',
backgroundColor: 'var(--mantine-color-body)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
{panelConfig && (
<>
<Group justify="space-between" p="md" align="center" w='100%'>
<ActionIcon variant="transparent" onClick={closePanel}>
<ArrowLeftIcon size={24} />
</ActionIcon>
<Text fw={500}>{panelConfig.title}</Text>
<ActionIcon variant="transparent" color="green" onClick={handleConfirm}>
<CheckIcon size={24} />
</ActionIcon>
</Group>
<Divider h='1px' w='100%' bg='var(--mantine-color-dimmed)' my='xs'/>
<Box>
<panelConfig.Component
value={tempValue}
onChange={setTempValue}
{...(panelConfig.componentProps || {})}
/>
</Box>
</>
)}
</Box>
</Box>
</SlidePanelContext.Provider>
);
};
export { SlidePanel };

View File

@@ -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;
}

View File

@@ -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<AuthContextType>) => void;
}
const AuthContext = createContext<AuthContextType>({
...defaultAuthData,
set: () => {},
});
export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<AuthData>(authQueryConfig);
const set = useCallback((updates: Partial<AuthData>) => {
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 <p>Loading...</p>
}
return (
<AuthContext
value={{
user: data?.user || defaultAuthData.user,
metadata: data?.metadata || defaultAuthData.metadata,
roles: data?.roles || defaultAuthData.roles,
set
}}>
{children}
</AuthContext>
)
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -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<TournamentFormInput> = {
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 (
<SlidePanel
onSubmit={form.onSubmit(handleSubmit)}
onCancel={close}
submitText="Create Tournament"
cancelText="Cancel"
loading={isPending}
>
<Stack>
<TextInput
label="Name"
withAsterisk
key={form.key('name')}
{...form.getInputProps('name')}
/>
<TextInput
label="Location"
withAsterisk
key={form.key('location')}
{...form.getInputProps('location')}
/>
<TextInput
label="Short Description"
key={form.key('desc')}
{...form.getInputProps('desc')}
/>
<TextInput
key={form.key('logo_url')}
accept="image/*"
label="Logo"
leftSection={<LinkIcon size={16} />}
{...form.getInputProps('logo_url')}
/>
<SlidePanelField
key={form.key('start_time')}
{...form.getInputProps('start_time')}
Component={DateTimePicker}
title="Select Start Date"
label="Start Date"
withAsterisk
formatValue={(date) => new Date(date).toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
})}
/>
<SlidePanelField
key={form.key('enroll_time')}
{...form.getInputProps('enroll_time')}
Component={DateTimePicker}
title="Select Enrollment Due Date"
label="Enrollment Due"
withAsterisk
formatValue={(date) => new Date(date).toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
})}
/>
</Stack>
</SlidePanel>
);
};
export default CreateTournament;

View File

@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<Stack>
<DatePicker
size="md"
value={formatDate(currentDate)}
onChange={handleDateChange}
{...rest}
/>
<TimeInput
ref={timeRef}
label="Time"
size="md"
value={formatTime(currentDate)}
onChange={handleTimeChange}
rightSection={
<ActionIcon
variant="subtle"
color="gray"
onClick={() => timeRef.current?.showPicker()}
>
<ClockIcon size={16} />
</ActionIcon>
}
{...rest}
/>
</Stack>
);
};
export { DateTimePicker };

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
import { Logger } from "@/lib/logger";
export const logger = new Logger('Admin');

View File

@@ -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<T>(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<T>(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<number, MatchType> = 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<number, MatchType> {
return this._matches;
}
}

View File

@@ -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<HTMLDivElement>(null);
const { events } = useDraggable(refDraggable as RefObject<HTMLDivElement>, { isMounted: !!refDraggable.current });
const teamCount = 20;
const { data, isLoading, error } = useQuery<BracketData>(bracketQueries.preview(teamCount));
// Create teams with proper structure
const [teams, setTeams] = useState<Team[]>(
Array.from({ length: teamCount }, (_, i) => ({
id: `team-${i + 1}`,
name: `Team ${i + 1}`
}))
);
const [seededWinnersBracket, setSeededWinnersBracket] = useState<any[][]>([]);
const [seededLosersBracket, setSeededLosersBracket] = useState<any[][]>([]);
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 <p>Loading...</p>;
if (error) return <p>Error loading bracket</p>;
if (!data) return <p>No data available</p>;
return (
<Container p={0} w="100%" style={{ userSelect: "none" }}>
<Flex w="100%" justify="space-between" h='3rem'>
<Text fw={600} size="lg" mb={16}>
Preview Bracket ({data.n} teams, {data.doubleElim ? 'Double' : 'Single'} Elimination)
</Text>
</Flex>
<Flex w="100%" gap={24}>
<div style={{ minWidth: 250, display: 'none' }}>
<Text fw={600} pb={16}>
Seed Teams
</Text>
<SeedList teams={teams} onSeedChange={handleSeedChange} />
</div>
<ScrollArea
px='xs'
viewportRef={refDraggable}
viewportProps={events}
h={`calc(${height} - 4rem)`}
className="bracket-container"
styles={{
root: { overflow: "auto", flex: 1, gap: 24, display: 'flex', flexDirection: 'column' }
}}
>
<div>
<Text fw={600} size="md" mb={16}>
Winners Bracket
</Text>
<BracketView
bracket={seededWinnersBracket}
matches={data.matches}
/>
</div>
<div>
<Text fw={600} size="md" mb={16}>
Losers Bracket
</Text>
<BracketView
bracket={seededLosersBracket}
matches={data.matches}
/>
</div>
</ScrollArea>
</Flex>
</Container>
);
};

View File

@@ -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<BracketViewProps> = ({ 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 (
<Flex direction='row' gap={24} justify='left' pos='relative' p='xl'>
{bracket.map((round, roundIndex) => (
<Flex direction='column' key={roundIndex} gap={24} justify='space-around'>
{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 (
<Flex key={matchIndex}>
</Flex>
);
}
// Regular matches with both home and away
return (
<Flex direction='row' key={matchIndex} align='center' justify='end' gap={8}>
<Text c='dimmed' fw='bolder'>{match.order}</Text>
<Card withBorder pos='relative' w={200} style={{ overflow: 'visible' }}>
<Card.Section withBorder p={4}>
{isSlotType(match.home?.type, 'seed') && (
<>
<Text c='dimmed' size='xs'>Seed {match.home.seed}</Text>
{match.home.team && <Text size='xs'>{match.home.team.name}</Text>}
</>
)}
{isSlotType(match.home?.type, 'tbd') && (
<Text c='dimmed' size='xs'>
{match.home.loser ? 'Loser' : 'Winner'} of Match {getParentMatchOrder(match.home.parentId || match.home.parent)}
</Text>
)}
{!match.home && <Text c='dimmed' size='xs' fs='italic'>TBD</Text>}
</Card.Section>
<Card.Section p={4} mb={-16}>
{isSlotType(match.away?.type, 'seed') && (
<>
<Text c='dimmed' size='xs'>Seed {match.away.seed}</Text>
{match.away.team && <Text size='xs'>{match.away.team.name}</Text>}
</>
)}
{isSlotType(match.away?.type, 'tbd') && (
<Text c='dimmed' size='xs'>
{match.away.loser ? 'Loser' : 'Winner'} of Match {getParentMatchOrder(match.away.parentId || match.away.parent)}
</Text>
)}
{!match.away && <Text c='dimmed' size='xs' fs='italic'>TBD</Text>}
</Card.Section>
{match.reset && (
<Text
pos='absolute'
top={-8}
left={8}
size='xs'
c='orange'
fw='bold'
>
IF NECESSARY
</Text>
)}
{onAnnounce && match.home?.team && match.away?.team && (
<ActionIcon
pos='absolute'
variant='filled'
color='green'
top={-20}
right={-12}
onClick={() => {
onAnnounce(match.home.team, match.away.team);
}}
bd='none'
style={{ boxShadow: 'none' }}
size='xs'
>
<PlayIcon size={12} />
</ActionIcon>
)}
</Card>
</Flex>
);
})}
</Flex>
))}
</Flex>
);
};
export default BracketView;

View File

@@ -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 (
<Flex direction='column' gap={8}>
{teams.map((team, index) => (
<Card key={team.id} withBorder p="xs">
<Flex align="center" gap="xs" justify="space-between">
<Flex align="center" gap="xs">
<Select
value={index.toString()}
data={seedOptions}
onChange={(value) => {
if (value !== null) {
const newIndex = parseInt(value);
if (newIndex !== index) {
onSeedChange(index, newIndex);
}
}
}}
size="xs"
w={100}
/>
<Text size="sm" fw={500}>
{team.name}
</Text>
</Flex>
</Flex>
</Card>
))}
</Flex>
);
}

View File

@@ -0,0 +1,35 @@
/* Hide scrollbars but keep functionality */
.bracket-container::-webkit-scrollbar {
display: none;
}
.bracket-container {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Cursor states for draggable area */
.bracket-container:active {
cursor: grabbing;
}
/* Smooth scrolling on mobile */
.bracket-container {
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
}
/* Prevent text selection while dragging */
.bracket-container * {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Optional: Add subtle shadows for depth on desktop */
@media (min-width: 768px) {
.bracket-container {
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
}
}

View File

@@ -0,0 +1,13 @@
import { queryOptions } from "@tanstack/react-query";
import { previewBracket } from "./server";
const bracketKeys = {
preview: (teams: number) => ['bracket-preview', teams] as const,
};
export const bracketQueries = {
preview: (teams: number) => queryOptions({
queryKey: bracketKeys.preview(teams),
queryFn: () => previewBracket({ data: teams }),
}),
};

View File

@@ -0,0 +1,30 @@
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { Logger } from "@/lib/logger";
import brackets from './utils';
const logger = new Logger("Bracket Generation")
// Transform the imported JSON to match the expected format
function transformBracketData(bracketData: any) {
return {
n: bracketData.config.teams,
doubleElim: bracketData.config.doubleElimination,
matches: bracketData.matches,
winnersBracket: bracketData.structure.winners,
losersBracket: bracketData.structure.losers
};
}
export const previewBracket = createServerFn()
.validator(z.number())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teams }) => {
logger.info('Generating bracket', teams);
if (!Object.keys(brackets).includes(teams.toString()))
throw Error("Bracket not available")
// Transform the imported data to match expected format
return transformBracketData(brackets[teams]);
});

View File

@@ -0,0 +1,24 @@
/**
* Imports saved json dumps of bracket generation from a python script that I didn't prioritize converting to TS
*/
import b12 from '../../../../scripts/brackets/12.json';
import b13 from '../../../../scripts/brackets/13.json';
import b14 from '../../../../scripts/brackets/14.json';
import b15 from '../../../../scripts/brackets/15.json';
import b16 from '../../../../scripts/brackets/16.json';
import b17 from '../../../../scripts/brackets/17.json';
import b18 from '../../../../scripts/brackets/18.json';
import b19 from '../../../../scripts/brackets/19.json';
import b20 from '../../../../scripts/brackets/20.json';
export default {
12: b12,
13: b13,
14: b14,
15: b15,
16: b16,
17: b17,
18: b18,
19: b19,
20: b20,
}

View File

@@ -0,0 +1,31 @@
import { Outlet, useRouter } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
const AnimatedOutlet = () => {
const router = useRouter();
return (
<AnimatePresence mode="wait">
<motion.div
key={router.state.location.pathname}
initial={{ x: '100%', opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: '-100%', opacity: 0 }}
transition={{
type: 'tween',
duration: 0.3,
ease: 'easeInOut'
}}
style={{
position: 'absolute',
width: '100%',
height: '100%'
}}
>
<Outlet />
</motion.div>
</AnimatePresence>
);
}
export default AnimatedOutlet;

View File

@@ -0,0 +1,25 @@
import { Box } from "@mantine/core"
import { ArrowLeftIcon } from "@phosphor-icons/react"
import { useRouter } from "@tanstack/react-router"
interface BackButtonProps {
offsetY: number;
}
const BackButton = ({ offsetY }: BackButtonProps) => {
const router = useRouter()
return (
<Box
style={{ cursor: 'pointer', zIndex: 1000, transform: `translateY(-${offsetY}px)` }}
onClick={() => router.history.back()}
pos='absolute'
left={{ base: 0, sm: 100, md: 200, lg: 300 }}
m={20}
>
<ArrowLeftIcon weight='bold' size={20} />
</Box>
);
}
export default BackButton;

View File

@@ -0,0 +1,26 @@
import { Title, AppShell, Flex } from "@mantine/core";
import { HeaderConfig } from "../types/header-config";
import BackButton from "./back-button";
import { useMemo } from "react";
interface HeaderProps extends HeaderConfig {
scrollPosition: { x: number, y: number };
}
const Header = ({ withBackButton, collapsed, title, scrollPosition }: HeaderProps) => {
const offsetY = useMemo(() => {
return collapsed ? scrollPosition.y : 0;
}, [collapsed, scrollPosition.y]);
return (
<>
{withBackButton && <BackButton offsetY={offsetY} />}
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
<Flex justify='center' align='center' h='100%' px='md'>
<Title order={2}>{title}</Title>
</Flex>
</AppShell.Header>
</>
);
}
export default Header;

View File

@@ -0,0 +1,53 @@
import { AppShell } from '@mantine/core';
import { PropsWithChildren, useState } from 'react';
import Header from './header';
import Navbar from './navbar';
import useHeaderConfig from '../hooks/use-header-config';
import Pullable from './pullable';
import useVisualViewportSize from '../hooks/use-visual-viewport-size';
const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const headerConfig = useHeaderConfig();
const viewport = useVisualViewportSize();
const [scrollPosition, setScrollPosition] = useState({ x: 0, y: 0 });
return (
<AppShell
id='app-shell'
layout='alt'
header={{ height: 60, collapsed: headerConfig.collapsed }}
navbar={{
width: { base: 0, sm: 100, md: 200, lg: 300 },
breakpoint: 'sm',
collapsed: { mobile: true },
}}
aside={{
width: { base: 0, sm: 100, md: 200, lg: 300 },
breakpoint: 'sm',
collapsed: { desktop: false, mobile: true }
}}
pos='relative'
h='100dvh'
mah='100dvh'
style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
>
<Header scrollPosition={scrollPosition} {...headerConfig} />
<AppShell.Main
pos='relative'
h='100%'
mah='100%'
pb={{ base: 70, md: 0 }}
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
style={{ transition: 'none' }}
>
<Pullable scrollPosition={scrollPosition} onScrollPositionChange={setScrollPosition}>
{children}
</Pullable>
</AppShell.Main>
<Navbar />
<AppShell.Aside withBorder />
</AppShell>
);
}
export default Layout;

View File

@@ -0,0 +1 @@
export * from './nav-link';

View File

@@ -0,0 +1,28 @@
import { Flex, Box, Text } from "@mantine/core";
import { Link, useRouterState } from "@tanstack/react-router";
import styles from './styles.module.css';
import { Icon } from "@phosphor-icons/react";
import { useMemo } from "react";
interface NavLinkProps {
href: string;
label: string;
Icon: Icon;
}
export const NavLink = ({ href, label, Icon }: NavLinkProps) => {
const router = useRouterState();
const isActive = useMemo(() => router.location.pathname === href || (router.location.pathname.includes(href) && href !== '/'), [router.location.pathname, href]);
return (
<Box component={Link} to={href}
className={styles.navLinkBox}
p={{ base: 0, sm: 8 }}
>
<Flex direction={{ base: 'column', md: 'row' }} align='center' gap={{ base: 0, md: 'xs' }}>
<Icon weight={isActive ? 'fill' : 'regular'} size={28} style={{ color: isActive ? 'var(--mantine-primary-color-filled)' : undefined }} />
<Text visibleFrom='md' ta='center' size='md' fw={isActive ? 800 : 500} c={isActive ? 'var(--mantine-primary-color-filled)' : undefined}>{label}</Text>
</Flex>
</Box>
)
}

View File

@@ -0,0 +1,6 @@
.navLinkBox {
text-decoration: none;
border-radius: var(--mantine-radius-md);
color: unset;
width: fit-content;
}

View File

@@ -0,0 +1,39 @@
import { AppShell, ScrollArea, Stack, Group, Paper } from "@mantine/core";
import { Link } from "@tanstack/react-router";
import { NavLink } from "./nav-link";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useAuth } from "@/contexts/auth-context";
import { useLinks } from "../hooks/use-links";
const Navbar = () => {
const { user, roles } = useAuth()
const isMobile = useIsMobile();
const links = useLinks(user?.id, roles);
console.log('rendered')
if (isMobile) return (
<Paper component='nav' role='navigation' withBorder radius='lg' h='4rem' w='calc(100% - 2rem)' shadow='sm' pos='fixed' m='1rem' bottom='0' style={{ zIndex: 10 }}>
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
{links.map((link) => (
<NavLink key={link.href} {...link} />
))}
</Group>
</Paper>
)
return <AppShell.Navbar p="xs" role='navigation'>
<AppShell.Section grow component={ScrollArea}>
<Stack gap='xs' mx='auto' w='fit-content' justify='end' mt='md'>
{links.map((link) => (
<NavLink key={link.href} {...link} />
))}
</Stack>
</AppShell.Section>
<AppShell.Section>
<Link to="/logout">Logout</Link>
</AppShell.Section>
</AppShell.Navbar>
}
export default Navbar;

View File

@@ -0,0 +1,124 @@
import { ActionIcon, Box, Button, Flex, ScrollArea } from "@mantine/core";
import { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from "react";
import useAppShellHeight from "@/hooks/use-appshell-height";
import useRefreshConfig from "@/features/core/hooks/use-refresh-config";
import { ArrowClockwiseIcon, SpinnerIcon } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
const THRESHOLD = 80;
interface PullableProps extends PropsWithChildren {
scrollPosition: { x: number, y: number };
onScrollPositionChange: (position: { x: number, y: number }) => void;
}
/**
* Pullable is a component that allows the user to pull down to refresh the page
* TODO: Need to figure out why it isn't disabled when onRefresh is undefined
*/
const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollPositionChange }) => {
const height = useAppShellHeight();
const [isRefreshing, setIsRefreshing] = useState(false);
const [scrolling, setScrolling] = useState(false);
const { toRefresh } = useRefreshConfig();
const queryClient = useQueryClient();
const scrollY = useMemo(() => scrollPosition.y < 0 && scrolling ? Math.abs(scrollPosition.y) : 0, [scrollPosition.y, scrolling]);
const onTrigger = useCallback(async () => {
setIsRefreshing(true);
if (toRefresh.length > 0) {
// TODO: Remove this after testing - or does the delay help ux?
await new Promise(resolve => setTimeout(resolve, 1000));
await queryClient.refetchQueries({ queryKey: toRefresh, exact: true});
}
setIsRefreshing(false);
}, [toRefresh]);
useEffect(() => {
if (!isRefreshing && scrollY > THRESHOLD) {
onTrigger();
}
}, [scrollY, isRefreshing, onTrigger]);
const iconOpacity = useMemo(() => {
if (isRefreshing) return 1;
if (toRefresh.length === 0) return 0;
const clampedValue = Math.max(5, Math.min(THRESHOLD, scrollY));
const min = 5;
const max = THRESHOLD;
const range = max - min;
return (clampedValue - min) / range;
}, [scrollY, isRefreshing])
useEffect(() => {
const scrollWrapper = document.getElementById('scroll-wrapper');
if (scrollWrapper) {
scrollWrapper.addEventListener('touchstart', () => {
setScrolling(true);
});
scrollWrapper.addEventListener('touchend', () => {
setScrolling(false);
});
}
}, []);
useEffect(() => {
if (typeof window === 'undefined') return;
const ac = new AbortController();
const options = {
passive: true,
signal: ac.signal
};
window.addEventListener('touchstart', () => setScrolling(true), options);
window.addEventListener('touchend', () => setScrolling(false), options);
return () => void ac.abort();
}, []);
return (
<>
<Flex
pos='absolute'
justify='center'
align='center'
w='100%'
display={scrollY > 20 || isRefreshing ? 'flex' : 'none'}
opacity={iconOpacity}
style={{ zIndex: 10 }}
>
<SpinnerIcon
weight="bold"
size={iconOpacity * 28}
color='var(--mantine-color-dimmed)'
style={{
marginTop: 8,
transform: iconOpacity === 1 ? undefined : `rotate(${iconOpacity * 360}deg)`,
animation: iconOpacity === 1 ? 'spin 1s linear infinite' : undefined,
}}
/>
</Flex>
<ScrollArea
id='scroll-wrapper'
onScrollPositionChange={onScrollPositionChange}
type='never' mah='100%' h='100%'
pt={(scrolling || scrollY > 40) || !isRefreshing ? 0 : 40 - scrollY}
>
<Box pt='1rem'pb='0.285rem' mih={height} style={{ boxSizing: 'content-box' }}>
{ /* TODO: Remove this debug button */}
<ActionIcon style={{ zIndex: 1000 }} pos='absolute' top={8} left='calc(50% - 24px)' onClick={onTrigger} variant='filled' color='var(--mantine-color-dimmed)'>
<ArrowClockwiseIcon />
</ActionIcon>
{children}
</Box>
</ScrollArea>
</>
)
}
export default Pullable;

View File

@@ -0,0 +1,27 @@
import { isMatch, useMatches } from "@tanstack/react-router";
import { HeaderConfig } from "../types/header-config";
export const defaultHeaderConfig: HeaderConfig = {
title: 'Starter App',
withBackButton: false,
collapsed: false,
}
const useHeaderConfig = () => {
const matches = useMatches();
const matchesWithHeader = matches.filter((match) =>
isMatch(match, 'loaderData.header'),
)
const config = matchesWithHeader.reduce((acc, match) => {
return {
...acc,
...match?.loaderData?.header,
}
}, defaultHeaderConfig) as HeaderConfig;
return config;
}
export default useHeaderConfig;

View File

@@ -0,0 +1,38 @@
import { GearIcon, HouseIcon, QuestionIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
export const useLinks = (userId: number, roles: string[]) =>
useMemo(() => {
const links = [
{
label: 'Home',
href: '/',
Icon: HouseIcon
},
{
label: 'Tournaments',
href: '/tournaments',
Icon: TrophyIcon
},
{
label: 'Profile',
href: `/profile/${userId}`,
Icon: UserCircleIcon
},
{
label: 'Settings',
href: '/settings',
Icon: GearIcon
}
]
if (roles.includes('Admin')) {
links.push({
label: 'Admin',
href: '/admin',
Icon: ShieldIcon
})
}
return links;
}, [userId, roles]);

View File

@@ -0,0 +1,24 @@
import { isMatch, useMatches } from "@tanstack/react-router";
export const defaultRefreshConfig: { toRefresh: string[] } = {
toRefresh: [],
}
const useRefreshConfig = () => {
const matches = useMatches();
const matchesWithRefresh = matches.filter((match) =>
isMatch(match, 'loaderData.refresh'),
)
const config = matchesWithRefresh.reduce((acc, match) => {
return {
...acc,
...match?.loaderData?.refresh,
}
}, defaultRefreshConfig) as { toRefresh: string[] };
return config;
}
export default useRefreshConfig;

View File

@@ -0,0 +1,31 @@
import { useCallback, useEffect, useState } from 'react';
const eventListerOptions = {
passive: true,
};
const useVisualViewportSize = () => {
const windowExists = typeof window !== 'undefined';
const [windowSize, setWindowSize] = useState({
width: windowExists ? window.visualViewport?.width || 0 : 0,
height: windowExists ? window.visualViewport?.height || 0 : 0,
top: windowExists ? window.visualViewport?.offsetTop || 0 : 0,
});
const setSize = useCallback(() => {
if (!windowExists) return;
setWindowSize({ width: window.visualViewport?.width || 0, height: window.visualViewport?.height || 0, top: window.visualViewport?.offsetTop || 0 });
}, []);
useEffect(() => {
if (!windowExists) return;
window.visualViewport?.addEventListener('resize', setSize, eventListerOptions);
return () => {
window.visualViewport?.removeEventListener('resize', setSize);
}
}, []);
return windowSize;
}
export default useVisualViewportSize;

Some files were not shown because too many files have changed in this diff Show More