init
6
.docker-postgres-init/01_init.sql
Normal 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
@@ -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
@@ -0,0 +1,4 @@
|
|||||||
|
**/build
|
||||||
|
**/public
|
||||||
|
pnpm-lock.yaml
|
||||||
|
routeTree.gen.ts
|
||||||
11
.vscode/settings.json
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
215
pb_migrations/1755194038_deleted_users.js
Normal 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);
|
||||||
|
})
|
||||||
99
pb_migrations/1755194204_created_players.js
Normal 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);
|
||||||
|
})
|
||||||
213
pb_migrations/1755194414_created_teams.js
Normal 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);
|
||||||
|
})
|
||||||
154
pb_migrations/1755194489_created_tournaments.js
Normal 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);
|
||||||
|
})
|
||||||
106
pb_migrations/1755194557_updated_players.js
Normal 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)
|
||||||
|
})
|
||||||
42
pb_migrations/1755194570_updated_teams.js
Normal 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)
|
||||||
|
})
|
||||||
42
pb_migrations/1755194655_updated_tournaments.js
Normal 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)
|
||||||
|
})
|
||||||
260
pb_migrations/1755194933_created_matches.js
Normal 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);
|
||||||
|
})
|
||||||
28
pb_migrations/1755194955_updated_tournaments.js
Normal 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)
|
||||||
|
})
|
||||||
28
pb_migrations/1755194970_updated_teams.js
Normal 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)
|
||||||
|
})
|
||||||
45
pb_migrations/1755375248_updated_tournaments.js
Normal 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)
|
||||||
|
})
|
||||||
45
pb_migrations/1755379657_updated_tournaments.js
Normal 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)
|
||||||
|
})
|
||||||
260
pb_migrations/1755545075_deleted_matches.js
Normal 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);
|
||||||
|
})
|
||||||
71
pb_migrations/1755545181_created_tables.js
Normal 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);
|
||||||
|
})
|
||||||
28
pb_migrations/1755545239_updated_teams.js
Normal 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)
|
||||||
|
})
|
||||||
38
pb_migrations/1755546057_updated_teams.js
Normal 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)
|
||||||
|
})
|
||||||
45
pb_migrations/1755548588_updated_tournaments.js
Normal 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)
|
||||||
|
})
|
||||||
42
pb_migrations/1755548718_updated_players.js
Normal 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)
|
||||||
|
})
|
||||||
71
pb_migrations/1755548899_deleted_tables.js
Normal 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);
|
||||||
|
})
|
||||||
28
pb_migrations/1755630612_updated_players.js
Normal 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
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
BIN
public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 832 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
17
public/site.webmanifest
Normal 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
|
After Width: | Height: | Size: 747 KiB |
31
public/styles.css
Normal 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
@@ -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
@@ -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
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
src/app/routes/_authed.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
18
src/app/routes/_authed/admin.tsx
Normal 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
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
22
src/app/routes/_authed/admin/index.tsx
Normal 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>
|
||||||
|
}
|
||||||
10
src/app/routes/_authed/admin/preview.tsx
Normal 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 />
|
||||||
|
}
|
||||||
29
src/app/routes/_authed/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/app/routes/_authed/profile.$playerId.tsx
Normal 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>
|
||||||
|
},
|
||||||
|
})
|
||||||
34
src/app/routes/_authed/settings.tsx
Normal 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>
|
||||||
|
}
|
||||||
26
src/app/routes/_authed/teams.$teamId.tsx
Normal 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>
|
||||||
|
},
|
||||||
|
})
|
||||||
67
src/app/routes/_authed/tournaments/$tournamentId.tsx
Normal 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!} />
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/app/routes/_authed/tournaments/index.tsx
Normal 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>
|
||||||
|
}
|
||||||
19
src/app/routes/api/auth.$.ts
Normal 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,
|
||||||
|
})
|
||||||
67
src/app/routes/api/events.$.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
9
src/app/routes/api/test.ts
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
import '../../.tanstack-start/server-routes/routeTree.gen'
|
||||||
53
src/components/DefaultCatchBoundary.tsx
Normal 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
@@ -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;
|
||||||
27
src/components/list-link.tsx
Normal 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
@@ -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;
|
||||||
34
src/components/phone-number-input.tsx
Normal 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} /> <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;
|
||||||
16
src/components/providers.tsx
Normal 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;
|
||||||
73
src/components/sheet/drawer.tsx
Normal 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;
|
||||||
1
src/components/sheet/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './sheet';
|
||||||
16
src/components/sheet/modal.tsx
Normal 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;
|
||||||
30
src/components/sheet/sheet.tsx
Normal 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;
|
||||||
7
src/components/sheet/slide-panel/index.ts
Normal 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;
|
||||||
18
src/components/sheet/slide-panel/slide-panel-context.tsx
Normal 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 };
|
||||||
89
src/components/sheet/slide-panel/slide-panel-field.tsx
Normal 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 };
|
||||||
164
src/components/sheet/slide-panel/slide-panel.tsx
Normal 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 };
|
||||||
20
src/components/sheet/styles.module.css
Normal 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;
|
||||||
|
}
|
||||||
73
src/contexts/auth-context.tsx
Normal 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;
|
||||||
|
};
|
||||||
118
src/features/admin/components/create-tournament.tsx
Normal 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;
|
||||||
90
src/features/admin/components/date-time-picker.tsx
Normal 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 };
|
||||||
37
src/features/admin/hooks/use-create-tournament.ts
Normal 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;
|
||||||
3
src/features/admin/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { Logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
export const logger = new Logger('Admin');
|
||||||
412
src/features/bracket/bracket.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
146
src/features/bracket/components/bracket-page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
119
src/features/bracket/components/bracket-view.tsx
Normal 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;
|
||||||
49
src/features/bracket/components/seed-list.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/features/bracket/components/styles.module.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/features/bracket/queries.ts
Normal 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 }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
30
src/features/bracket/server.ts
Normal 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]);
|
||||||
|
});
|
||||||
24
src/features/bracket/utils/index.ts
Normal 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,
|
||||||
|
}
|
||||||
31
src/features/core/components/animated-outlet.tsx
Normal 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;
|
||||||
25
src/features/core/components/back-button.tsx
Normal 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;
|
||||||
26
src/features/core/components/header.tsx
Normal 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;
|
||||||
53
src/features/core/components/layout.tsx
Normal 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;
|
||||||
1
src/features/core/components/nav-link/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './nav-link';
|
||||||
28
src/features/core/components/nav-link/nav-link.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
6
src/features/core/components/nav-link/styles.module.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.navLinkBox {
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--mantine-radius-md);
|
||||||
|
color: unset;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
39
src/features/core/components/navbar.tsx
Normal 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;
|
||||||
124
src/features/core/components/pullable.tsx
Normal 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;
|
||||||
27
src/features/core/hooks/use-header-config.ts
Normal 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;
|
||||||
38
src/features/core/hooks/use-links.ts
Normal 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]);
|
||||||
24
src/features/core/hooks/use-refresh-config.ts
Normal 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;
|
||||||
31
src/features/core/hooks/use-visual-viewport-size.ts
Normal 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;
|
||||||