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