Compare commits
21 Commits
5e20b94a1f
...
upgrade
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
732afaf623 | ||
|
|
48aeaabeea | ||
|
|
a4b9fe9065 | ||
|
|
31e50af593 | ||
|
|
39053cadaa | ||
|
|
ea6656aa33 | ||
|
|
92c4987372 | ||
|
|
b3ebf46afa | ||
|
|
c0ef535001 | ||
|
|
81329e4354 | ||
|
|
36f3bb77d4 | ||
|
|
6760ea46f9 | ||
|
|
e4164cbc71 | ||
|
|
94ea44c66e | ||
|
|
7441d1ac58 | ||
|
|
7ff26229d9 | ||
|
|
b93ce38d48 | ||
|
|
ae934e77f4 | ||
|
|
cae5fa1c71 | ||
|
|
fc3f626313 | ||
|
|
1027b49258 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,3 +20,4 @@ yarn.lock
|
|||||||
/scripts/
|
/scripts/
|
||||||
/pb_data/
|
/pb_data/
|
||||||
/.tanstack/
|
/.tanstack/
|
||||||
|
/dist/
|
||||||
@@ -32,17 +32,17 @@ services:
|
|||||||
- app-network
|
- app-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
redis:
|
#redis:
|
||||||
image: redis:7-alpine
|
# image: redis:7-alpine
|
||||||
container_name: redis-cache
|
# container_name: redis-cache
|
||||||
ports:
|
# ports:
|
||||||
- "6379:6379"
|
# - "6379:6379"
|
||||||
command: redis-server --appendonly yes
|
# command: redis-server --appendonly yes
|
||||||
volumes:
|
# volumes:
|
||||||
- redis-data:/data
|
# - redis-data:/data
|
||||||
networks:
|
# networks:
|
||||||
- app-network
|
# - app-network
|
||||||
restart: unless-stopped
|
# restart: unless-stopped
|
||||||
|
|
||||||
supertokens:
|
supertokens:
|
||||||
image: registry.supertokens.io/supertokens/supertokens-postgresql
|
image: registry.supertokens.io/supertokens/supertokens-postgresql
|
||||||
@@ -51,6 +51,7 @@ services:
|
|||||||
- postgres
|
- postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRESQL_CONNECTION_URI: postgresql://supertokens:password@postgres:5432/supertokens
|
POSTGRESQL_CONNECTION_URI: postgresql://supertokens:password@postgres:5432/supertokens
|
||||||
|
ACCESS_TOKEN_VALIDITY: 360000
|
||||||
ports:
|
ports:
|
||||||
- "3567:3567"
|
- "3567:3567"
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --host 0.0.0.0",
|
"dev": "vite dev --host 0.0.0.0",
|
||||||
"build": "vite build && tsc --noEmit",
|
"build": "vite build && tsc --noEmit",
|
||||||
"start": "vite start"
|
"start": "bun run .output/server/index.mjs",
|
||||||
|
"start:node": "node .output/server/index.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
@@ -24,12 +25,15 @@
|
|||||||
"@tanstack/react-router": "^1.130.12",
|
"@tanstack/react-router": "^1.130.12",
|
||||||
"@tanstack/react-router-devtools": "^1.130.13",
|
"@tanstack/react-router-devtools": "^1.130.13",
|
||||||
"@tanstack/react-router-with-query": "^1.130.12",
|
"@tanstack/react-router-with-query": "^1.130.12",
|
||||||
"@tanstack/react-start": "^1.130.15",
|
"@tanstack/react-start": "^1.132.2",
|
||||||
"@tanstack/react-virtual": "^3.13.12",
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"@tiptap/pm": "^3.4.3",
|
"@tiptap/pm": "^3.4.3",
|
||||||
"@tiptap/react": "^3.4.3",
|
"@tiptap/react": "^3.4.3",
|
||||||
"@tiptap/starter-kit": "^3.4.3",
|
"@tiptap/starter-kit": "^3.4.3",
|
||||||
|
"@types/bun": "^1.2.22",
|
||||||
"@types/ioredis": "^4.28.10",
|
"@types/ioredis": "^4.28.10",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
|
"dotenv": "^17.2.2",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.23.12",
|
"framer-motion": "^12.23.12",
|
||||||
"ioredis": "^5.7.0",
|
"ioredis": "^5.7.0",
|
||||||
@@ -51,6 +55,8 @@
|
|||||||
"zustand": "^5.0.7"
|
"zustand": "^5.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tanstack/react-router-ssr-query": "^1.132.2",
|
||||||
|
"@tanstack/router-plugin": "^1.132.2",
|
||||||
"@types/node": "^22.5.4",
|
"@types/node": "^22.5.4",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
"@types/react": "^19.0.8",
|
"@types/react": "^19.0.8",
|
||||||
@@ -63,7 +69,7 @@
|
|||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.3.5",
|
"vite": "^7.1.7",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
pb_migrations/1758379630_created_badges.js
Normal file
85
pb_migrations/1758379630_created_badges.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/// <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": "text1843675174",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "description",
|
||||||
|
"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_1340419796",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "badges",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1340419796");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
28
pb_migrations/1758380013_updated_players.js
Normal file
28
pb_migrations/1758380013_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(5, new Field({
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1340419796",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2029409178",
|
||||||
|
"maxSelect": 999,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "badges",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_3072146508")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("relation2029409178")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
28
pb_migrations/1758385120_updated_players.js
Normal file
28
pb_migrations/1758385120_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(6, new Field({
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1340419796",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2813965191",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "featured_badge",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_3072146508")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("relation2813965191")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
84
pb_migrations/1758388728_created_free_agents.js
Normal file
84
pb_migrations/1758388728_created_free_agents.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/// <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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2551806565",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "player",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1146066909",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "phone",
|
||||||
|
"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_2929550049",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "free_agents",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2929550049");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
28
pb_migrations/1758402128_updated_free_agents.js
Normal file
28
pb_migrations/1758402128_updated_free_agents.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2929550049")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(3, new Field({
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_340646327",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation3177167065",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "tournament",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2929550049")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("relation3177167065")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
28
pb_migrations/1758402424_updated_tournaments.js
Normal file
28
pb_migrations/1758402424_updated_tournaments.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("relation1584152981")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(11, new Field({
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation1584152981",
|
||||||
|
"maxSelect": 999,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "free_agents",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
28
pb_migrations/1758575563_updated_free_agents.js
Normal file
28
pb_migrations/1758575563_updated_free_agents.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2929550049")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": "",
|
||||||
|
"deleteRule": "",
|
||||||
|
"listRule": "",
|
||||||
|
"updateRule": "",
|
||||||
|
"viewRule": ""
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2929550049")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"listRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
28
pb_migrations/1758575597_updated_free_agents.js
Normal file
28
pb_migrations/1758575597_updated_free_agents.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2929550049")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"listRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2929550049")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": "",
|
||||||
|
"deleteRule": "",
|
||||||
|
"listRule": "",
|
||||||
|
"updateRule": "",
|
||||||
|
"viewRule": ""
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
364
server.ts
Normal file
364
server.ts
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
/**
|
||||||
|
* TanStack Start Production Server with Bun
|
||||||
|
*
|
||||||
|
* A high-performance production server for TanStack Start applications that
|
||||||
|
* implements intelligent static asset loading with configurable memory management.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Hybrid loading strategy (preload small files, serve large files on-demand)
|
||||||
|
* - Configurable file filtering with include/exclude patterns
|
||||||
|
* - Memory-efficient response generation
|
||||||
|
* - Production-ready caching headers
|
||||||
|
*
|
||||||
|
* Environment Variables:
|
||||||
|
*
|
||||||
|
* PORT (number)
|
||||||
|
* - Server port number
|
||||||
|
* - Default: 3000
|
||||||
|
*
|
||||||
|
* STATIC_PRELOAD_MAX_BYTES (number)
|
||||||
|
* - Maximum file size in bytes to preload into memory
|
||||||
|
* - Files larger than this will be served on-demand from disk
|
||||||
|
* - Default: 5242880 (5MB)
|
||||||
|
* - Example: STATIC_PRELOAD_MAX_BYTES=5242880 (5MB)
|
||||||
|
*
|
||||||
|
* STATIC_PRELOAD_INCLUDE (string)
|
||||||
|
* - Comma-separated list of glob patterns for files to include
|
||||||
|
* - If specified, only matching files are eligible for preloading
|
||||||
|
* - Patterns are matched against filenames only, not full paths
|
||||||
|
* - Example: STATIC_PRELOAD_INCLUDE="*.js,*.css,*.woff2"
|
||||||
|
*
|
||||||
|
* STATIC_PRELOAD_EXCLUDE (string)
|
||||||
|
* - Comma-separated list of glob patterns for files to exclude
|
||||||
|
* - Applied after include patterns
|
||||||
|
* - Patterns are matched against filenames only, not full paths
|
||||||
|
* - Example: STATIC_PRELOAD_EXCLUDE="*.map,*.txt"
|
||||||
|
*
|
||||||
|
* STATIC_PRELOAD_VERBOSE (boolean)
|
||||||
|
* - Enable detailed logging of loaded and skipped files
|
||||||
|
* - Default: false
|
||||||
|
* - Set to "true" to enable verbose output
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun run server.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readdir } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const PORT = Number(process.env.PORT ?? 3000)
|
||||||
|
const CLIENT_DIR = './dist/client'
|
||||||
|
const SERVER_ENTRY = './dist/server/server.js'
|
||||||
|
|
||||||
|
// Preloading configuration from environment variables
|
||||||
|
const MAX_PRELOAD_BYTES = Number(
|
||||||
|
process.env.STATIC_PRELOAD_MAX_BYTES ?? 5 * 1024 * 1024, // 5MB default
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse comma-separated include patterns (no defaults)
|
||||||
|
const INCLUDE_PATTERNS = (process.env.STATIC_PRELOAD_INCLUDE ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(globToRegExp)
|
||||||
|
|
||||||
|
// Parse comma-separated exclude patterns (no defaults)
|
||||||
|
const EXCLUDE_PATTERNS = (process.env.STATIC_PRELOAD_EXCLUDE ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(globToRegExp)
|
||||||
|
|
||||||
|
// Verbose logging flag
|
||||||
|
const VERBOSE = process.env.STATIC_PRELOAD_VERBOSE === 'true'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a simple glob pattern to a regular expression
|
||||||
|
* Supports * wildcard for matching any characters
|
||||||
|
*/
|
||||||
|
function globToRegExp(glob: string): RegExp {
|
||||||
|
// Escape regex special chars except *, then replace * with .*
|
||||||
|
const escaped = glob
|
||||||
|
.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&')
|
||||||
|
.replace(/\*/g, '.*')
|
||||||
|
return new RegExp(`^${escaped}$`, 'i')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for preloaded static assets
|
||||||
|
*/
|
||||||
|
interface AssetMetadata {
|
||||||
|
route: string
|
||||||
|
size: number
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of static asset preloading process
|
||||||
|
*/
|
||||||
|
interface PreloadResult {
|
||||||
|
routes: Record<string, () => Response>
|
||||||
|
loaded: Array<AssetMetadata>
|
||||||
|
skipped: Array<AssetMetadata>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file should be included based on configured patterns
|
||||||
|
*/
|
||||||
|
function shouldInclude(relativePath: string): boolean {
|
||||||
|
const fileName = relativePath.split(/[/\\]/).pop() ?? relativePath
|
||||||
|
|
||||||
|
// If include patterns are specified, file must match at least one
|
||||||
|
if (INCLUDE_PATTERNS.length > 0) {
|
||||||
|
if (!INCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If exclude patterns are specified, file must not match any
|
||||||
|
if (EXCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build static routes with intelligent preloading strategy
|
||||||
|
* Small files are loaded into memory, large files are served on-demand
|
||||||
|
*/
|
||||||
|
async function buildStaticRoutes(clientDir: string): Promise<PreloadResult> {
|
||||||
|
const routes: Record<string, () => Response> = {}
|
||||||
|
const loaded: Array<AssetMetadata> = []
|
||||||
|
const skipped: Array<AssetMetadata> = []
|
||||||
|
|
||||||
|
console.log(`📦 Loading static assets from ${clientDir}...`)
|
||||||
|
console.log(
|
||||||
|
` Max preload size: ${(MAX_PRELOAD_BYTES / 1024 / 1024).toFixed(2)} MB`,
|
||||||
|
)
|
||||||
|
if (INCLUDE_PATTERNS.length > 0) {
|
||||||
|
console.log(
|
||||||
|
` Include patterns: ${process.env.STATIC_PRELOAD_INCLUDE ?? ''}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (EXCLUDE_PATTERNS.length > 0) {
|
||||||
|
console.log(
|
||||||
|
` Exclude patterns: ${process.env.STATIC_PRELOAD_EXCLUDE ?? ''}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalPreloadedBytes = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read all files recursively
|
||||||
|
const files = await readdir(clientDir, { recursive: true })
|
||||||
|
|
||||||
|
for (const relativePath of files) {
|
||||||
|
const filepath = join(clientDir, relativePath)
|
||||||
|
const route = '/' + relativePath.replace(/\\/g, '/') // Handle Windows paths
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get file metadata
|
||||||
|
const file = Bun.file(filepath)
|
||||||
|
|
||||||
|
// Skip if file doesn't exist or is empty
|
||||||
|
if (!(await file.exists()) || file.size === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata: AssetMetadata = {
|
||||||
|
route,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type || 'application/octet-stream',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if file should be preloaded
|
||||||
|
const matchesPattern = shouldInclude(relativePath)
|
||||||
|
const withinSizeLimit = file.size <= MAX_PRELOAD_BYTES
|
||||||
|
|
||||||
|
if (matchesPattern && withinSizeLimit) {
|
||||||
|
// Preload small files into memory
|
||||||
|
const bytes = await file.bytes()
|
||||||
|
|
||||||
|
routes[route] = () =>
|
||||||
|
new Response(bytes, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': metadata.type,
|
||||||
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
loaded.push({ ...metadata, size: bytes.byteLength })
|
||||||
|
totalPreloadedBytes += bytes.byteLength
|
||||||
|
} else {
|
||||||
|
// Serve large or filtered files on-demand
|
||||||
|
routes[route] = () => {
|
||||||
|
const fileOnDemand = Bun.file(filepath)
|
||||||
|
return new Response(fileOnDemand, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': metadata.type,
|
||||||
|
'Cache-Control': 'public, max-age=3600',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
skipped.push(metadata)
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error && error.name !== 'EISDIR') {
|
||||||
|
console.error(`❌ Failed to load ${filepath}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always show file overview in Vite-like format first
|
||||||
|
if (loaded.length > 0 || skipped.length > 0) {
|
||||||
|
const allFiles = [...loaded, ...skipped].sort((a, b) =>
|
||||||
|
a.route.localeCompare(b.route),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Calculate max path length for alignment
|
||||||
|
const maxPathLength = Math.min(
|
||||||
|
Math.max(...allFiles.map((f) => f.route.length)),
|
||||||
|
60,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Format file size with KB and gzip estimation
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
const kb = bytes / 1024
|
||||||
|
// Rough gzip estimation (typically 30-70% compression)
|
||||||
|
const gzipKb = kb * 0.35
|
||||||
|
return {
|
||||||
|
size: kb < 100 ? kb.toFixed(2) : kb.toFixed(1),
|
||||||
|
gzip: gzipKb < 100 ? gzipKb.toFixed(2) : gzipKb.toFixed(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loaded.length > 0) {
|
||||||
|
console.log('\n📁 Preloaded into memory:')
|
||||||
|
loaded
|
||||||
|
.sort((a, b) => a.route.localeCompare(b.route))
|
||||||
|
.forEach((file) => {
|
||||||
|
const { size, gzip } = formatFileSize(file.size)
|
||||||
|
const paddedPath = file.route.padEnd(maxPathLength)
|
||||||
|
const sizeStr = `${size.padStart(7)} kB`
|
||||||
|
const gzipStr = `gzip: ${gzip.padStart(6)} kB`
|
||||||
|
console.log(` ${paddedPath} ${sizeStr} │ ${gzipStr}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipped.length > 0) {
|
||||||
|
console.log('\n💾 Served on-demand:')
|
||||||
|
skipped
|
||||||
|
.sort((a, b) => a.route.localeCompare(b.route))
|
||||||
|
.forEach((file) => {
|
||||||
|
const { size, gzip } = formatFileSize(file.size)
|
||||||
|
const paddedPath = file.route.padEnd(maxPathLength)
|
||||||
|
const sizeStr = `${size.padStart(7)} kB`
|
||||||
|
const gzipStr = `gzip: ${gzip.padStart(6)} kB`
|
||||||
|
console.log(` ${paddedPath} ${sizeStr} │ ${gzipStr}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show detailed verbose info if enabled
|
||||||
|
if (VERBOSE) {
|
||||||
|
console.log('\n📊 Detailed file information:')
|
||||||
|
allFiles.forEach((file) => {
|
||||||
|
const isPreloaded = loaded.includes(file)
|
||||||
|
const status = isPreloaded ? '[MEMORY]' : '[ON-DEMAND]'
|
||||||
|
const reason =
|
||||||
|
!isPreloaded && file.size > MAX_PRELOAD_BYTES
|
||||||
|
? ' (too large)'
|
||||||
|
: !isPreloaded
|
||||||
|
? ' (filtered)'
|
||||||
|
: ''
|
||||||
|
console.log(
|
||||||
|
` ${status.padEnd(12)} ${file.route} - ${file.type}${reason}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log summary after the file list
|
||||||
|
console.log() // Empty line for separation
|
||||||
|
if (loaded.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`✅ Preloaded ${String(loaded.length)} files (${(totalPreloadedBytes / 1024 / 1024).toFixed(2)} MB) into memory`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
console.log('ℹ️ No files preloaded into memory')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipped.length > 0) {
|
||||||
|
const tooLarge = skipped.filter((f) => f.size > MAX_PRELOAD_BYTES).length
|
||||||
|
const filtered = skipped.length - tooLarge
|
||||||
|
console.log(
|
||||||
|
`ℹ️ ${String(skipped.length)} files will be served on-demand (${String(tooLarge)} too large, ${String(filtered)} filtered)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Failed to load static files from ${clientDir}:`, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { routes, loaded, skipped }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the production server
|
||||||
|
*/
|
||||||
|
async function startServer() {
|
||||||
|
console.log('🚀 Starting production server...')
|
||||||
|
|
||||||
|
// Load TanStack Start server handler
|
||||||
|
let handler: { fetch: (request: Request) => Response | Promise<Response> }
|
||||||
|
try {
|
||||||
|
const serverModule = (await import(SERVER_ENTRY)) as {
|
||||||
|
default: { fetch: (request: Request) => Response | Promise<Response> }
|
||||||
|
}
|
||||||
|
handler = serverModule.default
|
||||||
|
console.log('✅ TanStack Start handler loaded')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to load server handler:', error)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build static routes with intelligent preloading
|
||||||
|
const { routes } = await buildStaticRoutes(CLIENT_DIR)
|
||||||
|
|
||||||
|
// Create Bun server
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: PORT,
|
||||||
|
|
||||||
|
routes: {
|
||||||
|
// Serve static assets (preloaded or on-demand)
|
||||||
|
...routes,
|
||||||
|
|
||||||
|
// Fallback to TanStack Start handler for all other routes
|
||||||
|
'/*': (request) => {
|
||||||
|
try {
|
||||||
|
return handler.fetch(request)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Server handler error:', error)
|
||||||
|
return new Response('Internal Server Error', { status: 500 })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Global error handler
|
||||||
|
error(error) {
|
||||||
|
console.error('Uncaught server error:', error)
|
||||||
|
return new Response('Internal Server Error', { status: 500 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\n🚀 Server running at http://localhost:${String(server.port)}\n`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
startServer().catch((error: unknown) => {
|
||||||
|
console.error('Failed to start server:', error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -8,8 +8,6 @@
|
|||||||
// You should NOT make any changes in this file as it will be overwritten.
|
// 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.
|
// 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 rootRouteImport } from './routes/__root'
|
||||||
import { Route as RefreshSessionRouteImport } from './routes/refresh-session'
|
import { Route as RefreshSessionRouteImport } from './routes/refresh-session'
|
||||||
import { Route as LogoutRouteImport } from './routes/logout'
|
import { Route as LogoutRouteImport } from './routes/logout'
|
||||||
@@ -21,27 +19,26 @@ import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings'
|
|||||||
import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
|
import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
|
||||||
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
|
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
|
||||||
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
|
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
|
||||||
|
import { Route as ApiTournamentsUploadLogoRouteImport } from './routes/api/tournaments/upload-logo'
|
||||||
|
import { Route as ApiTeamsUploadLogoRouteImport } from './routes/api/teams/upload-logo'
|
||||||
|
import { Route as ApiSpotifyTokenRouteImport } from './routes/api/spotify/token'
|
||||||
|
import { Route as ApiSpotifySearchRouteImport } from './routes/api/spotify/search'
|
||||||
|
import { Route as ApiSpotifyResumeRouteImport } from './routes/api/spotify/resume'
|
||||||
|
import { Route as ApiSpotifyPlaybackRouteImport } from './routes/api/spotify/playback'
|
||||||
|
import { Route as ApiSpotifyCaptureRouteImport } from './routes/api/spotify/capture'
|
||||||
|
import { Route as ApiSpotifyCallbackRouteImport } from './routes/api/spotify/callback'
|
||||||
|
import { Route as ApiEventsSplatRouteImport } from './routes/api/events.$'
|
||||||
|
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
|
||||||
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
|
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
|
||||||
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
|
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
|
||||||
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
|
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
|
||||||
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
|
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
|
||||||
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
||||||
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
||||||
import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id'
|
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
|
||||||
|
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
|
||||||
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
||||||
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
|
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
|
||||||
import { ServerRoute as ApiTeamsUploadLogoServerRouteImport } from './routes/api/teams/upload-logo'
|
|
||||||
import { ServerRoute as ApiSpotifyTokenServerRouteImport } from './routes/api/spotify/token'
|
|
||||||
import { ServerRoute as ApiSpotifySearchServerRouteImport } from './routes/api/spotify/search'
|
|
||||||
import { ServerRoute as ApiSpotifyResumeServerRouteImport } from './routes/api/spotify/resume'
|
|
||||||
import { ServerRoute as ApiSpotifyPlaybackServerRouteImport } from './routes/api/spotify/playback'
|
|
||||||
import { ServerRoute as ApiSpotifyCaptureServerRouteImport } from './routes/api/spotify/capture'
|
|
||||||
import { ServerRoute as ApiSpotifyCallbackServerRouteImport } from './routes/api/spotify/callback'
|
|
||||||
import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$'
|
|
||||||
import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$'
|
|
||||||
import { ServerRoute as ApiFilesCollectionRecordIdFileServerRouteImport } from './routes/api/files/$collection/$recordId/$file'
|
|
||||||
|
|
||||||
const rootServerRouteImport = createServerRootRoute()
|
|
||||||
|
|
||||||
const RefreshSessionRoute = RefreshSessionRouteImport.update({
|
const RefreshSessionRoute = RefreshSessionRouteImport.update({
|
||||||
id: '/refresh-session',
|
id: '/refresh-session',
|
||||||
@@ -92,6 +89,57 @@ const AuthedAdminIndexRoute = AuthedAdminIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => AuthedAdminRoute,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiTournamentsUploadLogoRoute =
|
||||||
|
ApiTournamentsUploadLogoRouteImport.update({
|
||||||
|
id: '/api/tournaments/upload-logo',
|
||||||
|
path: '/api/tournaments/upload-logo',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiTeamsUploadLogoRoute = ApiTeamsUploadLogoRouteImport.update({
|
||||||
|
id: '/api/teams/upload-logo',
|
||||||
|
path: '/api/teams/upload-logo',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiSpotifyTokenRoute = ApiSpotifyTokenRouteImport.update({
|
||||||
|
id: '/api/spotify/token',
|
||||||
|
path: '/api/spotify/token',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiSpotifySearchRoute = ApiSpotifySearchRouteImport.update({
|
||||||
|
id: '/api/spotify/search',
|
||||||
|
path: '/api/spotify/search',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiSpotifyResumeRoute = ApiSpotifyResumeRouteImport.update({
|
||||||
|
id: '/api/spotify/resume',
|
||||||
|
path: '/api/spotify/resume',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiSpotifyPlaybackRoute = ApiSpotifyPlaybackRouteImport.update({
|
||||||
|
id: '/api/spotify/playback',
|
||||||
|
path: '/api/spotify/playback',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiSpotifyCaptureRoute = ApiSpotifyCaptureRouteImport.update({
|
||||||
|
id: '/api/spotify/capture',
|
||||||
|
path: '/api/spotify/capture',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiSpotifyCallbackRoute = ApiSpotifyCallbackRouteImport.update({
|
||||||
|
id: '/api/spotify/callback',
|
||||||
|
path: '/api/spotify/callback',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiEventsSplatRoute = ApiEventsSplatRouteImport.update({
|
||||||
|
id: '/api/events/$',
|
||||||
|
path: '/api/events/$',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||||
|
id: '/api/auth/$',
|
||||||
|
path: '/api/auth/$',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AuthedTournamentsTournamentIdRoute =
|
const AuthedTournamentsTournamentIdRoute =
|
||||||
AuthedTournamentsTournamentIdRouteImport.update({
|
AuthedTournamentsTournamentIdRouteImport.update({
|
||||||
id: '/tournaments/$tournamentId',
|
id: '/tournaments/$tournamentId',
|
||||||
@@ -125,77 +173,29 @@ const AuthedTournamentsIdBracketRoute =
|
|||||||
path: '/tournaments/$id/bracket',
|
path: '/tournaments/$id/bracket',
|
||||||
getParentRoute: () => AuthedRoute,
|
getParentRoute: () => AuthedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthedAdminTournamentsIdRoute =
|
const AuthedAdminTournamentsIdIndexRoute =
|
||||||
AuthedAdminTournamentsIdRouteImport.update({
|
AuthedAdminTournamentsIdIndexRouteImport.update({
|
||||||
id: '/tournaments/$id',
|
id: '/tournaments/$id/',
|
||||||
path: '/tournaments/$id',
|
path: '/tournaments/$id/',
|
||||||
getParentRoute: () => AuthedAdminRoute,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiFilesCollectionRecordIdFileRoute =
|
||||||
|
ApiFilesCollectionRecordIdFileRouteImport.update({
|
||||||
|
id: '/api/files/$collection/$recordId/$file',
|
||||||
|
path: '/api/files/$collection/$recordId/$file',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AuthedAdminTournamentsRunIdRoute =
|
const AuthedAdminTournamentsRunIdRoute =
|
||||||
AuthedAdminTournamentsRunIdRouteImport.update({
|
AuthedAdminTournamentsRunIdRouteImport.update({
|
||||||
id: '/tournaments/run/$id',
|
id: '/tournaments/run/$id',
|
||||||
path: '/tournaments/run/$id',
|
path: '/tournaments/run/$id',
|
||||||
getParentRoute: () => AuthedAdminRoute,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const ApiTournamentsUploadLogoServerRoute =
|
const AuthedAdminTournamentsIdTeamsRoute =
|
||||||
ApiTournamentsUploadLogoServerRouteImport.update({
|
AuthedAdminTournamentsIdTeamsRouteImport.update({
|
||||||
id: '/api/tournaments/upload-logo',
|
id: '/tournaments/$id/teams',
|
||||||
path: '/api/tournaments/upload-logo',
|
path: '/tournaments/$id/teams',
|
||||||
getParentRoute: () => rootServerRouteImport,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
|
||||||
const ApiTeamsUploadLogoServerRoute =
|
|
||||||
ApiTeamsUploadLogoServerRouteImport.update({
|
|
||||||
id: '/api/teams/upload-logo',
|
|
||||||
path: '/api/teams/upload-logo',
|
|
||||||
getParentRoute: () => rootServerRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ApiSpotifyTokenServerRoute = ApiSpotifyTokenServerRouteImport.update({
|
|
||||||
id: '/api/spotify/token',
|
|
||||||
path: '/api/spotify/token',
|
|
||||||
getParentRoute: () => rootServerRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ApiSpotifySearchServerRoute = ApiSpotifySearchServerRouteImport.update({
|
|
||||||
id: '/api/spotify/search',
|
|
||||||
path: '/api/spotify/search',
|
|
||||||
getParentRoute: () => rootServerRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ApiSpotifyResumeServerRoute = ApiSpotifyResumeServerRouteImport.update({
|
|
||||||
id: '/api/spotify/resume',
|
|
||||||
path: '/api/spotify/resume',
|
|
||||||
getParentRoute: () => rootServerRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ApiSpotifyPlaybackServerRoute =
|
|
||||||
ApiSpotifyPlaybackServerRouteImport.update({
|
|
||||||
id: '/api/spotify/playback',
|
|
||||||
path: '/api/spotify/playback',
|
|
||||||
getParentRoute: () => rootServerRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ApiSpotifyCaptureServerRoute = ApiSpotifyCaptureServerRouteImport.update({
|
|
||||||
id: '/api/spotify/capture',
|
|
||||||
path: '/api/spotify/capture',
|
|
||||||
getParentRoute: () => rootServerRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ApiSpotifyCallbackServerRoute =
|
|
||||||
ApiSpotifyCallbackServerRouteImport.update({
|
|
||||||
id: '/api/spotify/callback',
|
|
||||||
path: '/api/spotify/callback',
|
|
||||||
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)
|
|
||||||
const ApiFilesCollectionRecordIdFileServerRoute =
|
|
||||||
ApiFilesCollectionRecordIdFileServerRouteImport.update({
|
|
||||||
id: '/api/files/$collection/$recordId/$file',
|
|
||||||
path: '/api/files/$collection/$recordId/$file',
|
|
||||||
getParentRoute: () => rootServerRouteImport,
|
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
@@ -210,12 +210,24 @@ export interface FileRoutesByFullPath {
|
|||||||
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
|
'/api/events/$': typeof ApiEventsSplatRoute
|
||||||
|
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
||||||
|
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||||
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||||
|
'/api/spotify/resume': typeof ApiSpotifyResumeRoute
|
||||||
|
'/api/spotify/search': typeof ApiSpotifySearchRoute
|
||||||
|
'/api/spotify/token': typeof ApiSpotifyTokenRoute
|
||||||
|
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
|
||||||
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
||||||
'/admin/': typeof AuthedAdminIndexRoute
|
'/admin/': typeof AuthedAdminIndexRoute
|
||||||
'/tournaments': typeof AuthedTournamentsIndexRoute
|
'/tournaments': typeof AuthedTournamentsIndexRoute
|
||||||
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
|
|
||||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||||
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
||||||
|
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||||
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||||
|
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
@@ -228,12 +240,24 @@ export interface FileRoutesByTo {
|
|||||||
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
|
'/api/events/$': typeof ApiEventsSplatRoute
|
||||||
|
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
||||||
|
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||||
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||||
|
'/api/spotify/resume': typeof ApiSpotifyResumeRoute
|
||||||
|
'/api/spotify/search': typeof ApiSpotifySearchRoute
|
||||||
|
'/api/spotify/token': typeof ApiSpotifyTokenRoute
|
||||||
|
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
|
||||||
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
||||||
'/admin': typeof AuthedAdminIndexRoute
|
'/admin': typeof AuthedAdminIndexRoute
|
||||||
'/tournaments': typeof AuthedTournamentsIndexRoute
|
'/tournaments': typeof AuthedTournamentsIndexRoute
|
||||||
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
|
|
||||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||||
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
||||||
|
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||||
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||||
|
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -249,12 +273,24 @@ export interface FileRoutesById {
|
|||||||
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||||
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
|
'/api/events/$': typeof ApiEventsSplatRoute
|
||||||
|
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
||||||
|
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||||
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||||
|
'/api/spotify/resume': typeof ApiSpotifyResumeRoute
|
||||||
|
'/api/spotify/search': typeof ApiSpotifySearchRoute
|
||||||
|
'/api/spotify/token': typeof ApiSpotifyTokenRoute
|
||||||
|
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
|
||||||
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
||||||
'/_authed/admin/': typeof AuthedAdminIndexRoute
|
'/_authed/admin/': typeof AuthedAdminIndexRoute
|
||||||
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
|
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||||
'/_authed/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
|
|
||||||
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||||
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||||
|
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||||
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||||
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||||
|
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
@@ -270,12 +306,24 @@ export interface FileRouteTypes {
|
|||||||
| '/profile/$playerId'
|
| '/profile/$playerId'
|
||||||
| '/teams/$teamId'
|
| '/teams/$teamId'
|
||||||
| '/tournaments/$tournamentId'
|
| '/tournaments/$tournamentId'
|
||||||
|
| '/api/auth/$'
|
||||||
|
| '/api/events/$'
|
||||||
|
| '/api/spotify/callback'
|
||||||
|
| '/api/spotify/capture'
|
||||||
|
| '/api/spotify/playback'
|
||||||
|
| '/api/spotify/resume'
|
||||||
|
| '/api/spotify/search'
|
||||||
|
| '/api/spotify/token'
|
||||||
|
| '/api/teams/upload-logo'
|
||||||
|
| '/api/tournaments/upload-logo'
|
||||||
| '/admin/'
|
| '/admin/'
|
||||||
| '/tournaments'
|
| '/tournaments'
|
||||||
| '/admin/tournaments/$id'
|
|
||||||
| '/tournaments/$id/bracket'
|
| '/tournaments/$id/bracket'
|
||||||
| '/admin/tournaments'
|
| '/admin/tournaments'
|
||||||
|
| '/admin/tournaments/$id/teams'
|
||||||
| '/admin/tournaments/run/$id'
|
| '/admin/tournaments/run/$id'
|
||||||
|
| '/api/files/$collection/$recordId/$file'
|
||||||
|
| '/admin/tournaments/$id'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/login'
|
| '/login'
|
||||||
@@ -288,12 +336,24 @@ export interface FileRouteTypes {
|
|||||||
| '/profile/$playerId'
|
| '/profile/$playerId'
|
||||||
| '/teams/$teamId'
|
| '/teams/$teamId'
|
||||||
| '/tournaments/$tournamentId'
|
| '/tournaments/$tournamentId'
|
||||||
|
| '/api/auth/$'
|
||||||
|
| '/api/events/$'
|
||||||
|
| '/api/spotify/callback'
|
||||||
|
| '/api/spotify/capture'
|
||||||
|
| '/api/spotify/playback'
|
||||||
|
| '/api/spotify/resume'
|
||||||
|
| '/api/spotify/search'
|
||||||
|
| '/api/spotify/token'
|
||||||
|
| '/api/teams/upload-logo'
|
||||||
|
| '/api/tournaments/upload-logo'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/tournaments'
|
| '/tournaments'
|
||||||
| '/admin/tournaments/$id'
|
|
||||||
| '/tournaments/$id/bracket'
|
| '/tournaments/$id/bracket'
|
||||||
| '/admin/tournaments'
|
| '/admin/tournaments'
|
||||||
|
| '/admin/tournaments/$id/teams'
|
||||||
| '/admin/tournaments/run/$id'
|
| '/admin/tournaments/run/$id'
|
||||||
|
| '/api/files/$collection/$recordId/$file'
|
||||||
|
| '/admin/tournaments/$id'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/_authed'
|
| '/_authed'
|
||||||
@@ -308,12 +368,24 @@ export interface FileRouteTypes {
|
|||||||
| '/_authed/profile/$playerId'
|
| '/_authed/profile/$playerId'
|
||||||
| '/_authed/teams/$teamId'
|
| '/_authed/teams/$teamId'
|
||||||
| '/_authed/tournaments/$tournamentId'
|
| '/_authed/tournaments/$tournamentId'
|
||||||
|
| '/api/auth/$'
|
||||||
|
| '/api/events/$'
|
||||||
|
| '/api/spotify/callback'
|
||||||
|
| '/api/spotify/capture'
|
||||||
|
| '/api/spotify/playback'
|
||||||
|
| '/api/spotify/resume'
|
||||||
|
| '/api/spotify/search'
|
||||||
|
| '/api/spotify/token'
|
||||||
|
| '/api/teams/upload-logo'
|
||||||
|
| '/api/tournaments/upload-logo'
|
||||||
| '/_authed/admin/'
|
| '/_authed/admin/'
|
||||||
| '/_authed/tournaments/'
|
| '/_authed/tournaments/'
|
||||||
| '/_authed/admin/tournaments/$id'
|
|
||||||
| '/_authed/tournaments/$id/bracket'
|
| '/_authed/tournaments/$id/bracket'
|
||||||
| '/_authed/admin/tournaments/'
|
| '/_authed/admin/tournaments/'
|
||||||
|
| '/_authed/admin/tournaments/$id/teams'
|
||||||
| '/_authed/admin/tournaments/run/$id'
|
| '/_authed/admin/tournaments/run/$id'
|
||||||
|
| '/api/files/$collection/$recordId/$file'
|
||||||
|
| '/_authed/admin/tournaments/$id/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -321,101 +393,17 @@ export interface RootRouteChildren {
|
|||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
LogoutRoute: typeof LogoutRoute
|
LogoutRoute: typeof LogoutRoute
|
||||||
RefreshSessionRoute: typeof RefreshSessionRoute
|
RefreshSessionRoute: typeof RefreshSessionRoute
|
||||||
}
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
export interface FileServerRoutesByFullPath {
|
ApiEventsSplatRoute: typeof ApiEventsSplatRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
|
||||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
ApiSpotifyCaptureRoute: typeof ApiSpotifyCaptureRoute
|
||||||
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
|
ApiSpotifyPlaybackRoute: typeof ApiSpotifyPlaybackRoute
|
||||||
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
ApiSpotifyResumeRoute: typeof ApiSpotifyResumeRoute
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute
|
||||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute
|
||||||
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
|
ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute
|
||||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
|
||||||
}
|
|
||||||
export interface FileServerRoutesByTo {
|
|
||||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
|
||||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
|
||||||
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
|
|
||||||
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
|
||||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
|
||||||
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
|
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
|
||||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
|
||||||
}
|
|
||||||
export interface FileServerRoutesById {
|
|
||||||
__root__: typeof rootServerRouteImport
|
|
||||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
|
||||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
|
||||||
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
|
|
||||||
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
|
||||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
|
||||||
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
|
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
|
||||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
|
||||||
}
|
|
||||||
export interface FileServerRouteTypes {
|
|
||||||
fileServerRoutesByFullPath: FileServerRoutesByFullPath
|
|
||||||
fullPaths:
|
|
||||||
| '/api/auth/$'
|
|
||||||
| '/api/events/$'
|
|
||||||
| '/api/spotify/callback'
|
|
||||||
| '/api/spotify/capture'
|
|
||||||
| '/api/spotify/playback'
|
|
||||||
| '/api/spotify/resume'
|
|
||||||
| '/api/spotify/search'
|
|
||||||
| '/api/spotify/token'
|
|
||||||
| '/api/teams/upload-logo'
|
|
||||||
| '/api/tournaments/upload-logo'
|
|
||||||
| '/api/files/$collection/$recordId/$file'
|
|
||||||
fileServerRoutesByTo: FileServerRoutesByTo
|
|
||||||
to:
|
|
||||||
| '/api/auth/$'
|
|
||||||
| '/api/events/$'
|
|
||||||
| '/api/spotify/callback'
|
|
||||||
| '/api/spotify/capture'
|
|
||||||
| '/api/spotify/playback'
|
|
||||||
| '/api/spotify/resume'
|
|
||||||
| '/api/spotify/search'
|
|
||||||
| '/api/spotify/token'
|
|
||||||
| '/api/teams/upload-logo'
|
|
||||||
| '/api/tournaments/upload-logo'
|
|
||||||
| '/api/files/$collection/$recordId/$file'
|
|
||||||
id:
|
|
||||||
| '__root__'
|
|
||||||
| '/api/auth/$'
|
|
||||||
| '/api/events/$'
|
|
||||||
| '/api/spotify/callback'
|
|
||||||
| '/api/spotify/capture'
|
|
||||||
| '/api/spotify/playback'
|
|
||||||
| '/api/spotify/resume'
|
|
||||||
| '/api/spotify/search'
|
|
||||||
| '/api/spotify/token'
|
|
||||||
| '/api/teams/upload-logo'
|
|
||||||
| '/api/tournaments/upload-logo'
|
|
||||||
| '/api/files/$collection/$recordId/$file'
|
|
||||||
fileServerRoutesById: FileServerRoutesById
|
|
||||||
}
|
|
||||||
export interface RootServerRouteChildren {
|
|
||||||
ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute
|
|
||||||
ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute
|
|
||||||
ApiSpotifyCallbackServerRoute: typeof ApiSpotifyCallbackServerRoute
|
|
||||||
ApiSpotifyCaptureServerRoute: typeof ApiSpotifyCaptureServerRoute
|
|
||||||
ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute
|
|
||||||
ApiSpotifyResumeServerRoute: typeof ApiSpotifyResumeServerRoute
|
|
||||||
ApiSpotifySearchServerRoute: typeof ApiSpotifySearchServerRoute
|
|
||||||
ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute
|
|
||||||
ApiTeamsUploadLogoServerRoute: typeof ApiTeamsUploadLogoServerRoute
|
|
||||||
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
|
|
||||||
ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
@@ -490,6 +478,76 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedAdminIndexRouteImport
|
preLoaderRoute: typeof AuthedAdminIndexRouteImport
|
||||||
parentRoute: typeof AuthedAdminRoute
|
parentRoute: typeof AuthedAdminRoute
|
||||||
}
|
}
|
||||||
|
'/api/tournaments/upload-logo': {
|
||||||
|
id: '/api/tournaments/upload-logo'
|
||||||
|
path: '/api/tournaments/upload-logo'
|
||||||
|
fullPath: '/api/tournaments/upload-logo'
|
||||||
|
preLoaderRoute: typeof ApiTournamentsUploadLogoRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/teams/upload-logo': {
|
||||||
|
id: '/api/teams/upload-logo'
|
||||||
|
path: '/api/teams/upload-logo'
|
||||||
|
fullPath: '/api/teams/upload-logo'
|
||||||
|
preLoaderRoute: typeof ApiTeamsUploadLogoRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/spotify/token': {
|
||||||
|
id: '/api/spotify/token'
|
||||||
|
path: '/api/spotify/token'
|
||||||
|
fullPath: '/api/spotify/token'
|
||||||
|
preLoaderRoute: typeof ApiSpotifyTokenRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/spotify/search': {
|
||||||
|
id: '/api/spotify/search'
|
||||||
|
path: '/api/spotify/search'
|
||||||
|
fullPath: '/api/spotify/search'
|
||||||
|
preLoaderRoute: typeof ApiSpotifySearchRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/spotify/resume': {
|
||||||
|
id: '/api/spotify/resume'
|
||||||
|
path: '/api/spotify/resume'
|
||||||
|
fullPath: '/api/spotify/resume'
|
||||||
|
preLoaderRoute: typeof ApiSpotifyResumeRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/spotify/playback': {
|
||||||
|
id: '/api/spotify/playback'
|
||||||
|
path: '/api/spotify/playback'
|
||||||
|
fullPath: '/api/spotify/playback'
|
||||||
|
preLoaderRoute: typeof ApiSpotifyPlaybackRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/spotify/capture': {
|
||||||
|
id: '/api/spotify/capture'
|
||||||
|
path: '/api/spotify/capture'
|
||||||
|
fullPath: '/api/spotify/capture'
|
||||||
|
preLoaderRoute: typeof ApiSpotifyCaptureRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/spotify/callback': {
|
||||||
|
id: '/api/spotify/callback'
|
||||||
|
path: '/api/spotify/callback'
|
||||||
|
fullPath: '/api/spotify/callback'
|
||||||
|
preLoaderRoute: typeof ApiSpotifyCallbackRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/events/$': {
|
||||||
|
id: '/api/events/$'
|
||||||
|
path: '/api/events/$'
|
||||||
|
fullPath: '/api/events/$'
|
||||||
|
preLoaderRoute: typeof ApiEventsSplatRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/auth/$': {
|
||||||
|
id: '/api/auth/$'
|
||||||
|
path: '/api/auth/$'
|
||||||
|
fullPath: '/api/auth/$'
|
||||||
|
preLoaderRoute: typeof ApiAuthSplatRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/_authed/tournaments/$tournamentId': {
|
'/_authed/tournaments/$tournamentId': {
|
||||||
id: '/_authed/tournaments/$tournamentId'
|
id: '/_authed/tournaments/$tournamentId'
|
||||||
path: '/tournaments/$tournamentId'
|
path: '/tournaments/$tournamentId'
|
||||||
@@ -532,13 +590,20 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport
|
preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport
|
||||||
parentRoute: typeof AuthedRoute
|
parentRoute: typeof AuthedRoute
|
||||||
}
|
}
|
||||||
'/_authed/admin/tournaments/$id': {
|
'/_authed/admin/tournaments/$id/': {
|
||||||
id: '/_authed/admin/tournaments/$id'
|
id: '/_authed/admin/tournaments/$id/'
|
||||||
path: '/tournaments/$id'
|
path: '/tournaments/$id'
|
||||||
fullPath: '/admin/tournaments/$id'
|
fullPath: '/admin/tournaments/$id'
|
||||||
preLoaderRoute: typeof AuthedAdminTournamentsIdRouteImport
|
preLoaderRoute: typeof AuthedAdminTournamentsIdIndexRouteImport
|
||||||
parentRoute: typeof AuthedAdminRoute
|
parentRoute: typeof AuthedAdminRoute
|
||||||
}
|
}
|
||||||
|
'/api/files/$collection/$recordId/$file': {
|
||||||
|
id: '/api/files/$collection/$recordId/$file'
|
||||||
|
path: '/api/files/$collection/$recordId/$file'
|
||||||
|
fullPath: '/api/files/$collection/$recordId/$file'
|
||||||
|
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/_authed/admin/tournaments/run/$id': {
|
'/_authed/admin/tournaments/run/$id': {
|
||||||
id: '/_authed/admin/tournaments/run/$id'
|
id: '/_authed/admin/tournaments/run/$id'
|
||||||
path: '/tournaments/run/$id'
|
path: '/tournaments/run/$id'
|
||||||
@@ -546,86 +611,12 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedAdminTournamentsRunIdRouteImport
|
preLoaderRoute: typeof AuthedAdminTournamentsRunIdRouteImport
|
||||||
parentRoute: typeof AuthedAdminRoute
|
parentRoute: typeof AuthedAdminRoute
|
||||||
}
|
}
|
||||||
}
|
'/_authed/admin/tournaments/$id/teams': {
|
||||||
}
|
id: '/_authed/admin/tournaments/$id/teams'
|
||||||
declare module '@tanstack/react-start/server' {
|
path: '/tournaments/$id/teams'
|
||||||
interface ServerFileRoutesByPath {
|
fullPath: '/admin/tournaments/$id/teams'
|
||||||
'/api/tournaments/upload-logo': {
|
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
|
||||||
id: '/api/tournaments/upload-logo'
|
parentRoute: typeof AuthedAdminRoute
|
||||||
path: '/api/tournaments/upload-logo'
|
|
||||||
fullPath: '/api/tournaments/upload-logo'
|
|
||||||
preLoaderRoute: typeof ApiTournamentsUploadLogoServerRouteImport
|
|
||||||
parentRoute: typeof rootServerRouteImport
|
|
||||||
}
|
|
||||||
'/api/teams/upload-logo': {
|
|
||||||
id: '/api/teams/upload-logo'
|
|
||||||
path: '/api/teams/upload-logo'
|
|
||||||
fullPath: '/api/teams/upload-logo'
|
|
||||||
preLoaderRoute: typeof ApiTeamsUploadLogoServerRouteImport
|
|
||||||
parentRoute: typeof rootServerRouteImport
|
|
||||||
}
|
|
||||||
'/api/spotify/token': {
|
|
||||||
id: '/api/spotify/token'
|
|
||||||
path: '/api/spotify/token'
|
|
||||||
fullPath: '/api/spotify/token'
|
|
||||||
preLoaderRoute: typeof ApiSpotifyTokenServerRouteImport
|
|
||||||
parentRoute: typeof rootServerRouteImport
|
|
||||||
}
|
|
||||||
'/api/spotify/search': {
|
|
||||||
id: '/api/spotify/search'
|
|
||||||
path: '/api/spotify/search'
|
|
||||||
fullPath: '/api/spotify/search'
|
|
||||||
preLoaderRoute: typeof ApiSpotifySearchServerRouteImport
|
|
||||||
parentRoute: typeof rootServerRouteImport
|
|
||||||
}
|
|
||||||
'/api/spotify/resume': {
|
|
||||||
id: '/api/spotify/resume'
|
|
||||||
path: '/api/spotify/resume'
|
|
||||||
fullPath: '/api/spotify/resume'
|
|
||||||
preLoaderRoute: typeof ApiSpotifyResumeServerRouteImport
|
|
||||||
parentRoute: typeof rootServerRouteImport
|
|
||||||
}
|
|
||||||
'/api/spotify/playback': {
|
|
||||||
id: '/api/spotify/playback'
|
|
||||||
path: '/api/spotify/playback'
|
|
||||||
fullPath: '/api/spotify/playback'
|
|
||||||
preLoaderRoute: typeof ApiSpotifyPlaybackServerRouteImport
|
|
||||||
parentRoute: typeof rootServerRouteImport
|
|
||||||
}
|
|
||||||
'/api/spotify/capture': {
|
|
||||||
id: '/api/spotify/capture'
|
|
||||||
path: '/api/spotify/capture'
|
|
||||||
fullPath: '/api/spotify/capture'
|
|
||||||
preLoaderRoute: typeof ApiSpotifyCaptureServerRouteImport
|
|
||||||
parentRoute: typeof rootServerRouteImport
|
|
||||||
}
|
|
||||||
'/api/spotify/callback': {
|
|
||||||
id: '/api/spotify/callback'
|
|
||||||
path: '/api/spotify/callback'
|
|
||||||
fullPath: '/api/spotify/callback'
|
|
||||||
preLoaderRoute: typeof ApiSpotifyCallbackServerRouteImport
|
|
||||||
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
|
|
||||||
}
|
|
||||||
'/api/files/$collection/$recordId/$file': {
|
|
||||||
id: '/api/files/$collection/$recordId/$file'
|
|
||||||
path: '/api/files/$collection/$recordId/$file'
|
|
||||||
fullPath: '/api/files/$collection/$recordId/$file'
|
|
||||||
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileServerRouteImport
|
|
||||||
parentRoute: typeof rootServerRouteImport
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -633,17 +624,19 @@ declare module '@tanstack/react-start/server' {
|
|||||||
interface AuthedAdminRouteChildren {
|
interface AuthedAdminRouteChildren {
|
||||||
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
||||||
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
||||||
AuthedAdminTournamentsIdRoute: typeof AuthedAdminTournamentsIdRoute
|
|
||||||
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
|
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
|
||||||
|
AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute
|
||||||
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
|
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
|
||||||
|
AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
||||||
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
||||||
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
||||||
AuthedAdminTournamentsIdRoute: AuthedAdminTournamentsIdRoute,
|
|
||||||
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
||||||
|
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
|
||||||
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
|
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
|
||||||
|
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren(
|
const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren(
|
||||||
@@ -682,24 +675,27 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
LogoutRoute: LogoutRoute,
|
LogoutRoute: LogoutRoute,
|
||||||
RefreshSessionRoute: RefreshSessionRoute,
|
RefreshSessionRoute: RefreshSessionRoute,
|
||||||
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
|
ApiEventsSplatRoute: ApiEventsSplatRoute,
|
||||||
|
ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute,
|
||||||
|
ApiSpotifyCaptureRoute: ApiSpotifyCaptureRoute,
|
||||||
|
ApiSpotifyPlaybackRoute: ApiSpotifyPlaybackRoute,
|
||||||
|
ApiSpotifyResumeRoute: ApiSpotifyResumeRoute,
|
||||||
|
ApiSpotifySearchRoute: ApiSpotifySearchRoute,
|
||||||
|
ApiSpotifyTokenRoute: ApiSpotifyTokenRoute,
|
||||||
|
ApiTeamsUploadLogoRoute: ApiTeamsUploadLogoRoute,
|
||||||
|
ApiTournamentsUploadLogoRoute: ApiTournamentsUploadLogoRoute,
|
||||||
|
ApiFilesCollectionRecordIdFileRoute: ApiFilesCollectionRecordIdFileRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
._addFileTypes<FileRouteTypes>()
|
._addFileTypes<FileRouteTypes>()
|
||||||
const rootServerRouteChildren: RootServerRouteChildren = {
|
|
||||||
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute,
|
import type { getRouter } from './router.tsx'
|
||||||
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute,
|
import type { createStart } from '@tanstack/react-start'
|
||||||
ApiSpotifyCallbackServerRoute: ApiSpotifyCallbackServerRoute,
|
declare module '@tanstack/react-start' {
|
||||||
ApiSpotifyCaptureServerRoute: ApiSpotifyCaptureServerRoute,
|
interface Register {
|
||||||
ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute,
|
ssr: true
|
||||||
ApiSpotifyResumeServerRoute: ApiSpotifyResumeServerRoute,
|
router: Awaited<ReturnType<typeof getRouter>>
|
||||||
ApiSpotifySearchServerRoute: ApiSpotifySearchServerRoute,
|
}
|
||||||
ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute,
|
|
||||||
ApiTeamsUploadLogoServerRoute: ApiTeamsUploadLogoServerRoute,
|
|
||||||
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
|
|
||||||
ApiFilesCollectionRecordIdFileServerRoute:
|
|
||||||
ApiFilesCollectionRecordIdFileServerRoute,
|
|
||||||
}
|
}
|
||||||
export const serverRouteTree = rootServerRouteImport
|
|
||||||
._addFileChildren(rootServerRouteChildren)
|
|
||||||
._addFileTypes<FileServerRouteTypes>()
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
||||||
import { routerWithQueryClient } from "@tanstack/react-router-with-query";
|
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
|
||||||
import { routeTree } from "./routeTree.gen";
|
import { routeTree } from "./routeTree.gen";
|
||||||
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
|
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
|
||||||
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
|
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
|
||||||
|
|
||||||
export function createRouter() {
|
export function getRouter() {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
@@ -18,8 +18,7 @@ export function createRouter() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return routerWithQueryClient(
|
const router = createTanStackRouter({
|
||||||
createTanStackRouter({
|
|
||||||
routeTree,
|
routeTree,
|
||||||
context: {
|
context: {
|
||||||
queryClient,
|
queryClient,
|
||||||
@@ -33,13 +32,18 @@ export function createRouter() {
|
|||||||
defaultErrorComponent: DefaultCatchBoundary,
|
defaultErrorComponent: DefaultCatchBoundary,
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
defaultViewTransition: false,
|
defaultViewTransition: false,
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
setupRouterSsrQueryIntegration({
|
||||||
|
router,
|
||||||
queryClient
|
queryClient
|
||||||
);
|
})
|
||||||
|
|
||||||
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "@tanstack/react-router" {
|
declare module "@tanstack/react-router" {
|
||||||
interface Register {
|
interface Register {
|
||||||
router: ReturnType<typeof createRouter>;
|
router: ReturnType<typeof getRouter>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
import "@mantine/core/styles.css";
|
|
||||||
import "@mantine/dates/styles.css";
|
|
||||||
import "@mantine/carousel/styles.css";
|
|
||||||
import '@mantine/tiptap/styles.css';
|
|
||||||
import {
|
import {
|
||||||
HeadContent,
|
HeadContent,
|
||||||
Navigate,
|
Navigate,
|
||||||
@@ -18,9 +14,12 @@ import Providers from "@/features/core/components/providers";
|
|||||||
import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
|
import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
|
||||||
import { HeaderConfig } from "@/features/core/types/header-config";
|
import { HeaderConfig } from "@/features/core/types/header-config";
|
||||||
import { playerQueries } from "@/features/players/queries";
|
import { playerQueries } from "@/features/players/queries";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
import FullScreenLoader from "@/components/full-screen-loader";
|
import FullScreenLoader from "@/components/full-screen-loader";
|
||||||
|
import mantineCssUrl from '@mantine/core/styles.css?url'
|
||||||
|
import mantineDatesCssUrl from '@mantine/dates/styles.css?url'
|
||||||
|
import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url'
|
||||||
|
import mantineTiptapCssUrl from '@mantine/tiptap/styles.css?url'
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<{
|
export const Route = createRootRouteWithContext<{
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
@@ -61,6 +60,10 @@ export const Route = createRootRouteWithContext<{
|
|||||||
},
|
},
|
||||||
{ rel: "manifest", href: "/site.webmanifest" },
|
{ rel: "manifest", href: "/site.webmanifest" },
|
||||||
{ rel: "icon", href: "/favicon.ico" },
|
{ rel: "icon", href: "/favicon.ico" },
|
||||||
|
{ rel: 'stylesheet', href: mantineCssUrl },
|
||||||
|
{ rel: 'stylesheet', href: mantineCarouselCssUrl },
|
||||||
|
{ rel: 'stylesheet', href: mantineDatesCssUrl },
|
||||||
|
{ rel: 'stylesheet', href: mantineTiptapCssUrl }
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
errorComponent: (props) => {
|
errorComponent: (props) => {
|
||||||
@@ -80,12 +83,20 @@ export const Route = createRootRouteWithContext<{
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (location.pathname === '/login' || location.pathname === '/logout') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// https://github.com/TanStack/router/discussions/3531
|
// https://github.com/TanStack/router/discussions/3531
|
||||||
const auth = await ensureServerQueryData(
|
const auth = await ensureServerQueryData(
|
||||||
context.queryClient,
|
context.queryClient,
|
||||||
playerQueries.auth()
|
playerQueries.auth()
|
||||||
);
|
);
|
||||||
return { auth };
|
return { auth };
|
||||||
|
} catch (error) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
pendingComponent: () => <Providers><FullScreenLoader /></Providers>,
|
pendingComponent: () => <Providers><FullScreenLoader /></Providers>,
|
||||||
});
|
});
|
||||||
@@ -131,7 +142,6 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||||||
>
|
>
|
||||||
<div className="app">{children}</div>
|
<div className="app">{children}</div>
|
||||||
<Scripts />
|
<Scripts />
|
||||||
<ReactQueryDevtools />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { tournamentQueries } from "@/features/tournaments/queries";
|
|||||||
import ManageTournament from "@/features/tournaments/components/manage-tournament";
|
import ManageTournament from "@/features/tournaments/components/manage-tournament";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/tournaments/$id")({
|
export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
|
||||||
beforeLoad: async ({ context, params }) => {
|
beforeLoad: async ({ context, params }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
const tournament = await ensureServerQueryData(
|
const tournament = await ensureServerQueryData(
|
||||||
32
src/app/routes/_authed/admin/tournaments/$id/teams.tsx
Normal file
32
src/app/routes/_authed/admin/tournaments/$id/teams.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||||
|
import ManageTeams from "@/features/teams/components/manage-teams";
|
||||||
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
|
||||||
|
beforeLoad: async ({ context, params }) => {
|
||||||
|
const { queryClient } = context;
|
||||||
|
const tournament = await ensureServerQueryData(
|
||||||
|
queryClient,
|
||||||
|
tournamentQueries.details(params.id)
|
||||||
|
);
|
||||||
|
if (!tournament) throw redirect({ to: "/admin/tournaments" });
|
||||||
|
return {
|
||||||
|
tournament,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
loader: ({ context }) => ({
|
||||||
|
header: {
|
||||||
|
withBackButton: true,
|
||||||
|
title: `${context.tournament.name} Teams`,
|
||||||
|
},
|
||||||
|
withPadding: false,
|
||||||
|
}),
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { id } = Route.useParams();
|
||||||
|
const { tournament } = Route.useRouteContext();
|
||||||
|
return <ManageTeams tournament={tournament} />;
|
||||||
|
}
|
||||||
@@ -3,9 +3,13 @@ import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/
|
|||||||
import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament";
|
import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
import StartedTournament from "@/features/tournaments/components/started-tournament";
|
import StartedTournament from "@/features/tournaments/components/started-tournament";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import UpcomingTournamentSkeleton from "@/features/tournaments/components/upcoming-tournament/skeleton";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/")({
|
export const Route = createFileRoute("/_authed/")({
|
||||||
component: Home,
|
component: () => <Suspense fallback={<UpcomingTournamentSkeleton />}>
|
||||||
|
<Home />
|
||||||
|
</Suspense>,
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async ({ context }) => {
|
||||||
const queryClient = context.queryClient;
|
const queryClient = context.queryClient;
|
||||||
const tournament = await ensureServerQueryData(queryClient, tournamentQueries.current())
|
const tournament = await ensureServerQueryData(queryClient, tournamentQueries.current())
|
||||||
@@ -18,11 +22,11 @@ export const Route = createFileRoute("/_authed/")({
|
|||||||
title: context.tournament.name || "FLXN"
|
title: context.tournament.name || "FLXN"
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
pendingComponent: () => <UpcomingTournamentSkeleton />
|
||||||
});
|
});
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const { data: tournament } = useCurrentTournament();
|
const { data: tournament } = useCurrentTournament();
|
||||||
|
|
||||||
if (!tournament.matches || tournament.matches.length === 0) {
|
if (!tournament.matches || tournament.matches.length === 0) {
|
||||||
return <UpcomingTournament tournament={tournament} />;
|
return <UpcomingTournament tournament={tournament} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { playerQueries, useAllPlayerStats } from "@/features/players/queries";
|
import { playerQueries } from "@/features/players/queries";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
|
||||||
import PlayerStatsTable from "@/features/players/components/player-stats-table";
|
import PlayerStatsTable from "@/features/players/components/player-stats-table";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
|
||||||
|
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/stats")({
|
export const Route = createFileRoute("/_authed/stats")({
|
||||||
component: Stats,
|
component: Stats,
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
const queryClient = context.queryClient;
|
const queryClient = context.queryClient;
|
||||||
await ensureServerQueryData(queryClient, playerQueries.allStats());
|
prefetchServerQuery(queryClient, playerQueries.allStats());
|
||||||
},
|
},
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
withPadding: false,
|
withPadding: false,
|
||||||
@@ -20,7 +22,7 @@ export const Route = createFileRoute("/_authed/stats")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Stats() {
|
function Stats() {
|
||||||
const { data: playerStats } = useAllPlayerStats();
|
return <Suspense fallback={<PlayerStatsTableSkeleton />}>
|
||||||
|
<PlayerStatsTable />
|
||||||
return <PlayerStatsTable playerStats={playerStats} />;
|
</Suspense>;
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import TeamProfile from "@/features/teams/components/team-profile";
|
import TeamProfile from "@/features/teams/components/team-profile";
|
||||||
|
import ProfileSkeleton from "@/features/teams/components/team-profile/skeleton";
|
||||||
import { teamKeys, teamQueries } from "@/features/teams/queries";
|
import { teamKeys, teamQueries } from "@/features/teams/queries";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
|
||||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||||
import { redirect, createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const searchSchema = z.object({
|
const searchSchema = z.object({
|
||||||
@@ -25,6 +26,8 @@ export const Route = createFileRoute("/_authed/teams/$teamId")({
|
|||||||
}),
|
}),
|
||||||
component: () => {
|
component: () => {
|
||||||
const { teamId } = Route.useParams();
|
const { teamId } = Route.useParams();
|
||||||
return <TeamProfile id={teamId} />;
|
return <Suspense fallback={<ProfileSkeleton />}>
|
||||||
|
<TeamProfile id={teamId} />
|
||||||
|
</Suspense>;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { tournamentQueries } from '@/features/tournaments/queries';
|
|||||||
import Profile from '@/features/tournaments/components/profile';
|
import Profile from '@/features/tournaments/components/profile';
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
|
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import ProfileSkeleton from '@/features/tournaments/components/profile/skeleton';
|
||||||
|
|
||||||
const searchSchema = z.object({
|
const searchSchema = z.object({
|
||||||
tab: z.string().optional(),
|
tab: z.string().optional(),
|
||||||
@@ -10,9 +12,9 @@ const searchSchema = z.object({
|
|||||||
|
|
||||||
export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
|
export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
|
||||||
validateSearch: searchSchema,
|
validateSearch: searchSchema,
|
||||||
beforeLoad: async ({ context, params }) => {
|
beforeLoad: ({ context, params }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
await prefetchServerQuery(queryClient, tournamentQueries.details(params.tournamentId))
|
prefetchServerQuery(queryClient, tournamentQueries.details(params.tournamentId))
|
||||||
},
|
},
|
||||||
loader: ({ params, context }) => ({
|
loader: ({ params, context }) => ({
|
||||||
header: {
|
header: {
|
||||||
@@ -28,5 +30,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
|
|||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const tournamentId = Route.useParams().tournamentId;
|
const tournamentId = Route.useParams().tournamentId;
|
||||||
return <Profile id={tournamentId} />
|
return <Suspense fallback={<ProfileSkeleton />}>
|
||||||
|
<Profile id={tournamentId} />
|
||||||
|
</Suspense>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
import Page from '@/components/page'
|
|
||||||
import { Stack } from '@mantine/core'
|
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { TournamentCard } from '@/features/tournaments/components/tournament-card'
|
import { tournamentQueries } from '@/features/tournaments/queries'
|
||||||
import { tournamentQueries, useTournaments } from '@/features/tournaments/queries'
|
|
||||||
import { useAuth } from '@/contexts/auth-context'
|
|
||||||
import { useSheet } from '@/hooks/use-sheet'
|
|
||||||
import Sheet from '@/components/sheet/sheet'
|
|
||||||
import TournamentForm from '@/features/tournaments/components/tournament-form'
|
|
||||||
import { PlusIcon } from '@phosphor-icons/react'
|
|
||||||
import Button from '@/components/button'
|
|
||||||
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch'
|
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch'
|
||||||
|
import { Suspense } from 'react'
|
||||||
|
import TournamentCardList from '@/features/tournaments/components/tournament-card-list'
|
||||||
|
import { Skeleton, Stack } from '@mantine/core'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/tournaments/')({
|
export const Route = createFileRoute('/_authed/tournaments/')({
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async ({ context }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
await prefetchServerQuery(queryClient, tournamentQueries.list())
|
prefetchServerQuery(queryClient, tournamentQueries.list())
|
||||||
},
|
},
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
header: {
|
header: {
|
||||||
@@ -27,27 +21,11 @@ export const Route = createFileRoute('/_authed/tournaments/')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { data: tournaments } = useTournaments();
|
return <Suspense fallback={<Stack gap="md">
|
||||||
const { roles } = useAuth();
|
{Array(10).fill(null).map((_, index) => (
|
||||||
const sheet = useSheet();
|
<Skeleton height="120px" w="100%" />
|
||||||
|
))}
|
||||||
return (
|
</Stack>}>
|
||||||
<Stack>
|
<TournamentCardList />
|
||||||
{
|
</Suspense>
|
||||||
roles?.includes("Admin") ? (
|
|
||||||
<>
|
|
||||||
<Button leftSection={<PlusIcon />} variant='subtle' onClick={sheet.open}>Create Tournament</Button>
|
|
||||||
<Sheet {...sheet.props} title='Create Tournament'>
|
|
||||||
<TournamentForm close={sheet.close} />
|
|
||||||
</Sheet>
|
|
||||||
</>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
{
|
|
||||||
tournaments?.map((tournament: any) => (
|
|
||||||
<TournamentCard key={tournament.id} tournament={tournament} />
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// API file that handles all supertokens auth routes
|
// API file that handles all supertokens auth routes
|
||||||
import { createServerFileRoute } from '@tanstack/react-start/server';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { handleAuthAPIRequest } from 'supertokens-node/custom'
|
import { handleAuthAPIRequest } from 'supertokens-node/custom'
|
||||||
import { ensureSuperTokensBackend } from '@/lib/supertokens/server'
|
import { ensureSuperTokensBackend } from '@/lib/supertokens/server'
|
||||||
|
|
||||||
@@ -12,7 +12,9 @@ const handleRequest = async ({ request }: {request: Request}) => {
|
|||||||
console.log("Handling auth request:", request.method, request.url);
|
console.log("Handling auth request:", request.method, request.url);
|
||||||
return superTokensHandler(request);
|
return superTokensHandler(request);
|
||||||
};
|
};
|
||||||
export const ServerRoute = createServerFileRoute('/api/auth/$').methods({
|
export const Route = createFileRoute('/api/auth/$')({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
GET: handleRequest,
|
GET: handleRequest,
|
||||||
POST: handleRequest,
|
POST: handleRequest,
|
||||||
PUT: handleRequest,
|
PUT: handleRequest,
|
||||||
@@ -20,4 +22,6 @@ export const ServerRoute = createServerFileRoute('/api/auth/$').methods({
|
|||||||
PATCH: handleRequest,
|
PATCH: handleRequest,
|
||||||
OPTIONS: handleRequest,
|
OPTIONS: handleRequest,
|
||||||
HEAD: handleRequest,
|
HEAD: handleRequest,
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import { createServerFileRoute } from "@tanstack/react-start/server";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { serverEvents, type ServerEvent } from "@/lib/events/emitter";
|
import { serverEvents, type ServerEvent } from "@/lib/events/emitter";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute("/api/events/$").middleware([superTokensRequestMiddleware]).methods({
|
export const Route = createFileRoute("/api/events/$")({
|
||||||
|
server: {
|
||||||
|
middleware: [superTokensRequestMiddleware],
|
||||||
|
handlers: {
|
||||||
GET: ({ request, context }) => {
|
GET: ({ request, context }) => {
|
||||||
logger.info('ServerEvents | New connection', context?.userAuthId);
|
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
|
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
|
||||||
controller.enqueue(new TextEncoder().encode(connectMessage));
|
controller.enqueue(new TextEncoder().encode(connectMessage));
|
||||||
|
|
||||||
const handleEvent = (event: ServerEvent) => {
|
const handleEvent = (event: ServerEvent) => {
|
||||||
logger.info('ServerEvents | Event received', event);
|
logger.info("ServerEvents | Event received", event);
|
||||||
const message = `data: ${JSON.stringify(event)}\n\n`;
|
const message = `data: ${JSON.stringify(event)}\n\n`;
|
||||||
try {
|
try {
|
||||||
controller.enqueue(new TextEncoder().encode(message));
|
controller.enqueue(new TextEncoder().encode(message));
|
||||||
@@ -32,23 +33,15 @@ export const ServerRoute = createServerFileRoute("/api/events/$").middleware([su
|
|||||||
controller.enqueue(new TextEncoder().encode(pingMessage));
|
controller.enqueue(new TextEncoder().encode(pingMessage));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
clearInterval(pingInterval);
|
clearInterval(pingInterval);
|
||||||
controller.close();
|
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
serverEvents.off("test", handleEvent);
|
serverEvents.off("test", handleEvent);
|
||||||
clearInterval(pingInterval);
|
clearInterval(pingInterval);
|
||||||
try {
|
|
||||||
logger.info('ServerEvents | Closing connection', context?.userAuthId);
|
|
||||||
controller.close();
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('ServerEvents | Error closing controller', e);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
request.signal?.addEventListener("abort", cleanup);
|
request.signal?.addEventListener("abort", cleanup);
|
||||||
|
|
||||||
return cleanup;
|
return cleanup;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -63,4 +56,6 @@ export const ServerRoute = createServerFileRoute("/api/events/$").middleware([su
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -1,84 +1,100 @@
|
|||||||
import { createServerFileRoute } from "@tanstack/react-start/server";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute("/api/files/$collection/$recordId/$file").methods({
|
export const Route = createFileRoute(
|
||||||
|
"/api/files/$collection/$recordId/$file"
|
||||||
|
)({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
GET: async ({ params, request }) => {
|
GET: async ({ params, request }) => {
|
||||||
try {
|
try {
|
||||||
const { collection, recordId, file } = params;
|
const { collection, recordId, file } = params;
|
||||||
const pocketbaseUrl = process.env.POCKETBASE_URL || 'http://127.0.0.1:8090';
|
const pocketbaseUrl =
|
||||||
|
process.env.POCKETBASE_URL || "http://127.0.0.1:8090";
|
||||||
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
|
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
|
||||||
|
|
||||||
logger.info('File proxy', {
|
logger.info("File proxy", {
|
||||||
collection,
|
collection,
|
||||||
recordId,
|
recordId,
|
||||||
file,
|
file,
|
||||||
targetUrl: fileUrl
|
targetUrl: fileUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(fileUrl, {
|
const response = await fetch(fileUrl, {
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
...(request.headers.get('range') && { 'Range': request.headers.get('range')! }),
|
...(request.headers.get("range") && {
|
||||||
...(request.headers.get('if-none-match') && { 'If-None-Match': request.headers.get('if-none-match')! }),
|
Range: request.headers.get("range")!,
|
||||||
...(request.headers.get('if-modified-since') && { 'If-Modified-Since': request.headers.get('if-modified-since')! }),
|
}),
|
||||||
|
...(request.headers.get("if-none-match") && {
|
||||||
|
"If-None-Match": request.headers.get("if-none-match")!,
|
||||||
|
}),
|
||||||
|
...(request.headers.get("if-modified-since") && {
|
||||||
|
"If-Modified-Since": request.headers.get("if-modified-since")!,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
logger.error('PocketBase file request failed', {
|
logger.error("PocketBase file request failed", {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
url: fileUrl
|
url: fileUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
return new Response('File not found', { status: 404 });
|
return new Response("File not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(`PocketBase error: ${response.statusText}`, {
|
return new Response(`PocketBase error: ${response.statusText}`, {
|
||||||
status: response.status
|
status: response.status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = response.body;
|
const body = response.body;
|
||||||
const responseHeaders = new Headers();
|
const responseHeaders = new Headers();
|
||||||
const headers = [
|
const headers = [
|
||||||
'content-type',
|
"content-type",
|
||||||
'content-length',
|
"content-length",
|
||||||
'content-disposition',
|
"content-disposition",
|
||||||
'etag',
|
"etag",
|
||||||
'last-modified',
|
"last-modified",
|
||||||
'cache-control',
|
"cache-control",
|
||||||
'accept-ranges',
|
"accept-ranges",
|
||||||
'content-range'
|
"content-range",
|
||||||
];
|
];
|
||||||
|
|
||||||
headers.forEach(header => {
|
headers.forEach((header) => {
|
||||||
const value = response.headers.get(header);
|
const value = response.headers.get(header);
|
||||||
if (value) {
|
if (value) {
|
||||||
responseHeaders.set(header, value);
|
responseHeaders.set(header, value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
responseHeaders.set('Access-Control-Allow-Origin', '*');
|
responseHeaders.set("Access-Control-Allow-Origin", "*");
|
||||||
responseHeaders.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
responseHeaders.set(
|
||||||
responseHeaders.set('Access-Control-Allow-Headers', 'Range, If-None-Match, If-Modified-Since');
|
"Access-Control-Allow-Methods",
|
||||||
|
"GET, HEAD, OPTIONS"
|
||||||
|
);
|
||||||
|
responseHeaders.set(
|
||||||
|
"Access-Control-Allow-Headers",
|
||||||
|
"Range, If-None-Match, If-Modified-Since"
|
||||||
|
);
|
||||||
|
|
||||||
logger.info('File proxy response', {
|
logger.info("File proxy response", {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
contentType: response.headers.get('content-type'),
|
contentType: response.headers.get("content-type"),
|
||||||
contentLength: response.headers.get('content-length')
|
contentLength: response.headers.get("content-length"),
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
headers: responseHeaders
|
headers: responseHeaders,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('File proxy error', error);
|
logger.error("File proxy error", error);
|
||||||
return new Response('Internal server error', { status: 500 });
|
return new Response("Internal server error", { status: 500 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -86,10 +102,12 @@ export const ServerRoute = createServerFileRoute("/api/files/$collection/$record
|
|||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Access-Control-Allow-Origin': '*',
|
"Access-Control-Allow-Origin": "*",
|
||||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
'Access-Control-Max-Age': '86400',
|
"Access-Control-Max-Age": "86400",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -1,127 +1,145 @@
|
|||||||
import { createServerFileRoute } from '@tanstack/react-start/server'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { SpotifyAuth } from '@/lib/spotify/auth'
|
|
||||||
|
|
||||||
const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID!
|
const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!;
|
||||||
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!
|
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
|
||||||
const SPOTIFY_REDIRECT_URI = import.meta.env.VITE_SPOTIFY_REDIRECT_URI!
|
const SPOTIFY_REDIRECT_URI = process.env.VITE_SPOTIFY_REDIRECT_URI!;
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute('/api/spotify/callback').methods({
|
export const Route = createFileRoute("/api/spotify/callback")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
GET: async ({ request }: { request: Request }) => {
|
GET: async ({ request }: { request: Request }) => {
|
||||||
const getReturnPath = (state: string | null): string => {
|
const getReturnPath = (state: string | null): string => {
|
||||||
if (!state) return '/';
|
if (!state) return "/";
|
||||||
try {
|
try {
|
||||||
const decodedState = JSON.parse(atob(state));
|
const decodedState = JSON.parse(atob(state));
|
||||||
return decodedState.returnPath || '/';
|
return decodedState.returnPath || "/";
|
||||||
} catch {
|
} catch {
|
||||||
return '/';
|
return "/";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url);
|
||||||
const code = url.searchParams.get('code')
|
const code = url.searchParams.get("code");
|
||||||
const state = url.searchParams.get('state')
|
const state = url.searchParams.get("state");
|
||||||
const error = url.searchParams.get('error')
|
const error = url.searchParams.get("error");
|
||||||
|
|
||||||
const returnPath = getReturnPath(state);
|
const returnPath = getReturnPath(state);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Spotify OAuth error:', error)
|
console.error("Spotify OAuth error:", error);
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
'Location': returnPath + '?spotify_error=' + encodeURIComponent(error),
|
Location:
|
||||||
|
returnPath + "?spotify_error=" + encodeURIComponent(error),
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code || !state) {
|
if (!code || !state) {
|
||||||
console.error('Missing code or state:', { code: !!code, state: !!state })
|
console.error("Missing code or state:", {
|
||||||
|
code: !!code,
|
||||||
|
state: !!state,
|
||||||
|
});
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
'Location': returnPath + '?spotify_error=missing_code_or_state',
|
Location: returnPath + "?spotify_error=missing_code_or_state",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Token exchange attempt:', {
|
console.log("Token exchange attempt:", {
|
||||||
client_id: SPOTIFY_CLIENT_ID,
|
client_id: SPOTIFY_CLIENT_ID,
|
||||||
redirect_uri: SPOTIFY_REDIRECT_URI,
|
redirect_uri: SPOTIFY_REDIRECT_URI,
|
||||||
has_code: !!code,
|
has_code: !!code,
|
||||||
has_state: !!state,
|
has_state: !!state,
|
||||||
})
|
});
|
||||||
|
|
||||||
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
|
const tokenResponse = await fetch(
|
||||||
method: 'POST',
|
"https://accounts.spotify.com/api/token",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`,
|
Authorization: `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString("base64")}`,
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
grant_type: 'authorization_code',
|
grant_type: "authorization_code",
|
||||||
code,
|
code,
|
||||||
redirect_uri: SPOTIFY_REDIRECT_URI,
|
redirect_uri: SPOTIFY_REDIRECT_URI,
|
||||||
}),
|
}),
|
||||||
})
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!tokenResponse.ok) {
|
if (!tokenResponse.ok) {
|
||||||
const errorText = await tokenResponse.text()
|
const errorText = await tokenResponse.text();
|
||||||
console.error('Token exchange error:', {
|
console.error("Token exchange error:", {
|
||||||
status: tokenResponse.status,
|
status: tokenResponse.status,
|
||||||
statusText: tokenResponse.statusText,
|
statusText: tokenResponse.statusText,
|
||||||
body: errorText,
|
body: errorText,
|
||||||
redirect_uri: SPOTIFY_REDIRECT_URI,
|
redirect_uri: SPOTIFY_REDIRECT_URI,
|
||||||
})
|
});
|
||||||
|
|
||||||
const errorParam = encodeURIComponent(`${tokenResponse.status}: ${errorText}`)
|
const errorParam = encodeURIComponent(
|
||||||
|
`${tokenResponse.status}: ${errorText}`
|
||||||
|
);
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
'Location': `${returnPath}?spotify_error=token_exchange_failed&details=${errorParam}`,
|
Location: `${returnPath}?spotify_error=token_exchange_failed&details=${errorParam}`,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await tokenResponse.json()
|
const tokens = await tokenResponse.json();
|
||||||
|
|
||||||
console.log('Token exchange successful:', {
|
console.log("Token exchange successful:", {
|
||||||
has_access_token: !!tokens.access_token,
|
has_access_token: !!tokens.access_token,
|
||||||
has_refresh_token: !!tokens.refresh_token,
|
has_refresh_token: !!tokens.refresh_token,
|
||||||
expires_in: tokens.expires_in,
|
expires_in: tokens.expires_in,
|
||||||
})
|
});
|
||||||
|
|
||||||
console.log('Decoded return path:', returnPath);
|
console.log("Decoded return path:", returnPath);
|
||||||
|
|
||||||
const response = new Response(null, {
|
const response = new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
'Location': returnPath + '?spotify_auth=success',
|
Location: returnPath + "?spotify_auth=success",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const isSecure = process.env.NODE_ENV === 'production'
|
const isSecure = import.meta.env.NODE_ENV === "production";
|
||||||
const cookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${tokens.expires_in}`
|
const cookieOptions = `HttpOnly; ${isSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${tokens.expires_in}`;
|
||||||
|
|
||||||
response.headers.append('Set-Cookie', `spotify_access_token=${tokens.access_token}; ${cookieOptions}`)
|
response.headers.append(
|
||||||
|
"Set-Cookie",
|
||||||
|
`spotify_access_token=${tokens.access_token}; ${cookieOptions}`
|
||||||
|
);
|
||||||
|
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
const refreshCookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${60 * 60 * 24 * 30}` // 30 days
|
const refreshCookieOptions = `HttpOnly; ${isSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${60 * 60 * 24 * 30}`; // 30 days
|
||||||
response.headers.append('Set-Cookie', `spotify_refresh_token=${tokens.refresh_token}; ${refreshCookieOptions}`)
|
response.headers.append(
|
||||||
|
"Set-Cookie",
|
||||||
|
`spotify_refresh_token=${tokens.refresh_token}; ${refreshCookieOptions}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Spotify callback error:', error)
|
console.error("Spotify callback error:", error);
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const state = url.searchParams.get('state');
|
const state = url.searchParams.get("state");
|
||||||
const returnPath = getReturnPath(state);
|
const returnPath = getReturnPath(state);
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
'Location': returnPath + '?spotify_error=callback_failed',
|
Location: returnPath + "?spotify_error=callback_failed",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,59 +1,60 @@
|
|||||||
import { createServerFileRoute } from '@tanstack/react-start/server'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { SpotifyWebApiClient } from '@/lib/spotify/client'
|
import { SpotifyWebApiClient } from "@/lib/spotify/client";
|
||||||
import type { SpotifyPlaybackSnapshot } from '@/lib/spotify/types'
|
import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute('/api/spotify/capture').methods({
|
export const Route = createFileRoute("/api/spotify/capture")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
POST: async ({ request }: { request: Request }) => {
|
POST: async ({ request }: { request: Request }) => {
|
||||||
try {
|
try {
|
||||||
// Get access token from cookies
|
const cookies = request.headers.get("Cookie") || "";
|
||||||
const cookies = request.headers.get('Cookie') || ''
|
const accessTokenMatch = cookies.match(
|
||||||
const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/)
|
/spotify_access_token=([^;]+)/
|
||||||
|
);
|
||||||
|
|
||||||
if (!accessTokenMatch) {
|
if (!accessTokenMatch) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'No access token found' }),
|
JSON.stringify({ error: "No access token found" }),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = decodeURIComponent(accessTokenMatch[1])
|
const accessToken = decodeURIComponent(accessTokenMatch[1]);
|
||||||
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
const spotifyClient = new SpotifyWebApiClient(accessToken);
|
||||||
|
|
||||||
// Create a snapshot of the current playback state
|
const snapshot = await spotifyClient.createPlaybackSnapshot();
|
||||||
const snapshot = await spotifyClient.createPlaybackSnapshot()
|
|
||||||
|
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'No active playback to capture' }),
|
JSON.stringify({ error: "No active playback to capture" }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(
|
return new Response(JSON.stringify({ snapshot }), {
|
||||||
JSON.stringify({ snapshot }),
|
|
||||||
{
|
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
});
|
||||||
)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Spotify capture error:', error)
|
console.error("Spotify capture error:", error);
|
||||||
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Failed to capture playback state'
|
const errorMessage =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to capture playback state";
|
||||||
|
|
||||||
return new Response(
|
return new Response(JSON.stringify({ error: errorMessage }), {
|
||||||
JSON.stringify({ error: errorMessage }),
|
|
||||||
{
|
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
});
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,195 +1,203 @@
|
|||||||
import { createServerFileRoute } from '@tanstack/react-start/server'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { SpotifyWebApiClient } from '@/lib/spotify/client'
|
import { SpotifyWebApiClient } from "@/lib/spotify/client";
|
||||||
|
|
||||||
// Helper function to get access token from cookies
|
|
||||||
function getAccessTokenFromCookies(request: Request): string | null {
|
function getAccessTokenFromCookies(request: Request): string | null {
|
||||||
const cookieHeader = request.headers.get('cookie')
|
const cookieHeader = request.headers.get("cookie");
|
||||||
if (!cookieHeader) return null
|
if (!cookieHeader) return null;
|
||||||
|
|
||||||
const cookies = Object.fromEntries(
|
const cookies = Object.fromEntries(
|
||||||
cookieHeader.split('; ').map(c => c.split('='))
|
cookieHeader.split("; ").map((c) => c.split("="))
|
||||||
)
|
);
|
||||||
|
|
||||||
return cookies.spotify_access_token || null
|
return cookies.spotify_access_token || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute('/api/spotify/playback').methods({
|
export const Route = createFileRoute("/api/spotify/playback")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
POST: async ({ request }: { request: Request }) => {
|
POST: async ({ request }: { request: Request }) => {
|
||||||
try {
|
try {
|
||||||
const accessToken = getAccessTokenFromCookies(request)
|
const accessToken = getAccessTokenFromCookies(request);
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'No access token found' }),
|
JSON.stringify({ error: "No access token found" }),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json();
|
||||||
const { action, deviceId, volumePercent } = body
|
const { action, deviceId, volumePercent, trackId, positionMs } = body;
|
||||||
|
|
||||||
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
const spotifyClient = new SpotifyWebApiClient(accessToken);
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'play':
|
case "play":
|
||||||
await spotifyClient.play(deviceId)
|
await spotifyClient.play(deviceId);
|
||||||
break
|
break;
|
||||||
case 'pause':
|
case "playTrack":
|
||||||
await spotifyClient.pause()
|
if (!trackId) {
|
||||||
break
|
|
||||||
case 'next':
|
|
||||||
await spotifyClient.skipToNext()
|
|
||||||
break
|
|
||||||
case 'previous':
|
|
||||||
await spotifyClient.skipToPrevious()
|
|
||||||
break
|
|
||||||
case 'volume':
|
|
||||||
if (typeof volumePercent !== 'number') {
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'volumePercent must be a number' }),
|
JSON.stringify({
|
||||||
|
error: "trackId is required for playTrack action",
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
await spotifyClient.setVolume(volumePercent)
|
await spotifyClient.playTrack(trackId, deviceId, positionMs);
|
||||||
break
|
break;
|
||||||
case 'transfer':
|
case "pause":
|
||||||
|
await spotifyClient.pause();
|
||||||
|
break;
|
||||||
|
case "next":
|
||||||
|
await spotifyClient.skipToNext();
|
||||||
|
break;
|
||||||
|
case "previous":
|
||||||
|
await spotifyClient.skipToPrevious();
|
||||||
|
break;
|
||||||
|
case "volume":
|
||||||
|
if (typeof volumePercent !== "number") {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "volumePercent must be a number" }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await spotifyClient.setVolume(volumePercent);
|
||||||
|
break;
|
||||||
|
case "transfer":
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'deviceId is required for transfer action' }),
|
JSON.stringify({
|
||||||
|
error: "deviceId is required for transfer action",
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
await spotifyClient.transferPlayback(deviceId)
|
await spotifyClient.transferPlayback(deviceId);
|
||||||
break
|
break;
|
||||||
default:
|
default:
|
||||||
return new Response(
|
return new Response(JSON.stringify({ error: "Invalid action" }), {
|
||||||
JSON.stringify({ error: 'Invalid action' }),
|
|
||||||
{
|
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
});
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
JSON.stringify({ success: true }),
|
|
||||||
{
|
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
});
|
||||||
)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Playback control error:', error)
|
console.error("Playback control error:", error);
|
||||||
|
|
||||||
// Handle specific Spotify API errors
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
if (error.message.includes('NO_ACTIVE_DEVICE')) {
|
if (error.message.includes("NO_ACTIVE_DEVICE")) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'No active device found. Please select a device first.' }),
|
JSON.stringify({
|
||||||
|
error:
|
||||||
|
"No active device found. Please select a device first.",
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.message.includes('PREMIUM_REQUIRED')) {
|
if (error.message.includes("PREMIUM_REQUIRED")) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Spotify Premium is required for playback control.' }),
|
JSON.stringify({
|
||||||
|
error: "Spotify Premium is required for playback control.",
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 403,
|
status: 403,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the full error details for debugging
|
console.error("Full error details:", {
|
||||||
console.error('Full error details:', {
|
|
||||||
message: error.message,
|
message: error.message,
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
name: error.name,
|
name: error.name,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Playback control failed', details: error instanceof Error ? error.message : 'Unknown error' }),
|
JSON.stringify({
|
||||||
|
error: "Playback control failed",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// GET endpoint for retrieving current playback state and devices
|
|
||||||
GET: async ({ request }: { request: Request }) => {
|
GET: async ({ request }: { request: Request }) => {
|
||||||
try {
|
try {
|
||||||
const accessToken = getAccessTokenFromCookies(request)
|
const accessToken = getAccessTokenFromCookies(request);
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'No access token found' }),
|
JSON.stringify({ error: "No access token found" }),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url);
|
||||||
const type = url.searchParams.get('type') // 'state' or 'devices'
|
const type = url.searchParams.get("type");
|
||||||
|
|
||||||
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
const spotifyClient = new SpotifyWebApiClient(accessToken);
|
||||||
|
|
||||||
if (type === 'devices') {
|
if (type === "devices") {
|
||||||
const devices = await spotifyClient.getDevices()
|
const devices = await spotifyClient.getDevices();
|
||||||
return new Response(
|
return new Response(JSON.stringify({ devices }), {
|
||||||
JSON.stringify({ devices }),
|
|
||||||
{
|
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
});
|
||||||
)
|
} else if (type === "state") {
|
||||||
} else if (type === 'state') {
|
const playbackState = await spotifyClient.getPlaybackState();
|
||||||
const playbackState = await spotifyClient.getPlaybackState()
|
return new Response(JSON.stringify({ playbackState }), {
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ playbackState }),
|
|
||||||
{
|
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
});
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
// Return both by default
|
|
||||||
const [devices, playbackState] = await Promise.all([
|
const [devices, playbackState] = await Promise.all([
|
||||||
spotifyClient.getDevices(),
|
spotifyClient.getDevices(),
|
||||||
spotifyClient.getPlaybackState(),
|
spotifyClient.getPlaybackState(),
|
||||||
])
|
]);
|
||||||
|
|
||||||
return new Response(
|
return new Response(JSON.stringify({ devices, playbackState }), {
|
||||||
JSON.stringify({ devices, playbackState }),
|
|
||||||
{
|
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
});
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get playback data error:', error)
|
console.error("Get playback data error:", error);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Failed to get playback data' }),
|
JSON.stringify({ error: "Failed to get playback data" }),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,72 +1,71 @@
|
|||||||
import { createServerFileRoute } from '@tanstack/react-start/server'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { SpotifyWebApiClient } from '@/lib/spotify/client'
|
import { SpotifyWebApiClient } from "@/lib/spotify/client";
|
||||||
import type { SpotifyPlaybackSnapshot } from '@/lib/spotify/types'
|
import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute('/api/spotify/resume').methods({
|
export const Route = createFileRoute("/api/spotify/resume")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
POST: async ({ request }: { request: Request }) => {
|
POST: async ({ request }: { request: Request }) => {
|
||||||
try {
|
try {
|
||||||
// Get access token from cookies
|
const cookies = request.headers.get("Cookie") || "";
|
||||||
const cookies = request.headers.get('Cookie') || ''
|
const accessTokenMatch = cookies.match(
|
||||||
const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/)
|
/spotify_access_token=([^;]+)/
|
||||||
|
);
|
||||||
|
|
||||||
if (!accessTokenMatch) {
|
if (!accessTokenMatch) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'No access token found' }),
|
JSON.stringify({ error: "No access token found" }),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = decodeURIComponent(accessTokenMatch[1])
|
const accessToken = decodeURIComponent(accessTokenMatch[1]);
|
||||||
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
const spotifyClient = new SpotifyWebApiClient(accessToken);
|
||||||
|
|
||||||
// Parse the request body to get the snapshot
|
const body = await request.json();
|
||||||
const body = await request.json()
|
const { snapshot } = body as { snapshot: SpotifyPlaybackSnapshot };
|
||||||
const { snapshot } = body as { snapshot: SpotifyPlaybackSnapshot }
|
|
||||||
|
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'No snapshot provided' }),
|
JSON.stringify({ error: "No snapshot provided" }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore the playback state from the snapshot
|
await spotifyClient.restorePlaybackSnapshot(snapshot);
|
||||||
await spotifyClient.restorePlaybackSnapshot(snapshot)
|
|
||||||
|
|
||||||
return new Response(
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
JSON.stringify({ success: true }),
|
|
||||||
{
|
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
});
|
||||||
)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Spotify resume error:', error)
|
console.error("Spotify resume error:", error);
|
||||||
|
|
||||||
let errorMessage = 'Failed to resume playback state'
|
let errorMessage = "Failed to resume playback state";
|
||||||
|
|
||||||
// Handle common Spotify Premium requirement error
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
if (error.message.includes('Premium') || error.message.includes('403')) {
|
if (
|
||||||
errorMessage = 'Spotify Premium required for playback control'
|
error.message.includes("Premium") ||
|
||||||
|
error.message.includes("403")
|
||||||
|
) {
|
||||||
|
errorMessage = "Spotify premium required";
|
||||||
} else {
|
} else {
|
||||||
errorMessage = error.message
|
errorMessage = error.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(
|
return new Response(JSON.stringify({ error: errorMessage }), {
|
||||||
JSON.stringify({ error: errorMessage }),
|
|
||||||
{
|
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
});
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,81 +1,87 @@
|
|||||||
import { createServerFileRoute } from '@tanstack/react-start/server'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
// Function to get Client Credentials access token
|
|
||||||
async function getClientCredentialsToken(): Promise<string> {
|
async function getClientCredentialsToken(): Promise<string> {
|
||||||
const clientId = process.env.VITE_SPOTIFY_CLIENT_ID
|
const clientId = process.env.VITE_SPOTIFY_CLIENT_ID;
|
||||||
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET
|
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
|
||||||
|
|
||||||
if (!clientId || !clientSecret) {
|
if (!clientId || !clientSecret) {
|
||||||
throw new Error('Missing Spotify client credentials')
|
throw new Error("Missing Spotify client credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('https://accounts.spotify.com/api/token', {
|
const response = await fetch("https://accounts.spotify.com/api/token", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
'Authorization': `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
|
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
|
||||||
},
|
},
|
||||||
body: 'grant_type=client_credentials',
|
body: "grant_type=client_credentials",
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to get Spotify access token')
|
throw new Error("Failed to get Spotify access token");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json();
|
||||||
return data.access_token
|
return data.access_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute('/api/spotify/search').methods({
|
export const Route = createFileRoute("/api/spotify/search")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
GET: async ({ request }: { request: Request }) => {
|
GET: async ({ request }: { request: Request }) => {
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url);
|
||||||
const query = url.searchParams.get('q')
|
const query = url.searchParams.get("q");
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Query parameter q is required' }),
|
JSON.stringify({ error: "Query parameter q is required" }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get client credentials access token
|
// Get client credentials access token
|
||||||
const accessToken = await getClientCredentialsToken()
|
const accessToken = await getClientCredentialsToken();
|
||||||
|
|
||||||
// Search using Spotify API directly
|
// Search using Spotify API directly
|
||||||
const searchUrl = `https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=20`
|
const searchUrl = `https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=20`;
|
||||||
|
|
||||||
const searchResponse = await fetch(searchUrl, {
|
const searchResponse = await fetch(searchUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!searchResponse.ok) {
|
if (!searchResponse.ok) {
|
||||||
throw new Error('Spotify search request failed')
|
throw new Error("Spotify search request failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchResult = await searchResponse.json()
|
const searchResult = await searchResponse.json();
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ tracks: searchResult.tracks.items }),
|
JSON.stringify({ tracks: searchResult.tracks.items }),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search error:', error)
|
console.error("Search error:", error);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Search failed', details: error instanceof Error ? error.message : 'Unknown error' }),
|
JSON.stringify({
|
||||||
|
error: "Search failed",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,52 +1,58 @@
|
|||||||
import { createServerFileRoute } from '@tanstack/react-start/server'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID!
|
const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!;
|
||||||
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!
|
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute('/api/spotify/token').methods({
|
export const Route = createFileRoute("/api/spotify/token")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
POST: async ({ request }: { request: Request }) => {
|
POST: async ({ request }: { request: Request }) => {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json();
|
||||||
const { refresh_token } = body
|
const { refresh_token } = body;
|
||||||
|
|
||||||
if (!refresh_token) {
|
if (!refresh_token) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'refresh_token is required' }),
|
JSON.stringify({ error: "refresh_token is required" }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh access token
|
const tokenResponse = await fetch(
|
||||||
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
|
"https://accounts.spotify.com/api/token",
|
||||||
method: 'POST',
|
{
|
||||||
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`,
|
Authorization: `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString("base64")}`,
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
grant_type: 'refresh_token',
|
grant_type: "refresh_token",
|
||||||
refresh_token,
|
refresh_token,
|
||||||
}),
|
}),
|
||||||
})
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!tokenResponse.ok) {
|
if (!tokenResponse.ok) {
|
||||||
const error = await tokenResponse.json()
|
const error = await tokenResponse.json();
|
||||||
console.error('Token refresh error:', error)
|
console.error("Token refresh error:", error);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Failed to refresh token', details: error }),
|
JSON.stringify({
|
||||||
|
error: "Failed to refresh token",
|
||||||
|
details: error,
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: tokenResponse.status,
|
status: tokenResponse.status,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await tokenResponse.json()
|
const tokens = await tokenResponse.json();
|
||||||
|
|
||||||
// Return new tokens
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
@@ -56,50 +62,46 @@ export const ServerRoute = createServerFileRoute('/api/spotify/token').methods({
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Token refresh endpoint error:', error)
|
console.error("Token refresh endpoint error:", error);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Internal server error' }),
|
JSON.stringify({ error: "Internal server error" }),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// GET endpoint to retrieve current tokens from cookies
|
|
||||||
GET: async ({ request }: { request: Request }) => {
|
GET: async ({ request }: { request: Request }) => {
|
||||||
try {
|
try {
|
||||||
const cookieHeader = request.headers.get('cookie')
|
const cookieHeader = request.headers.get("cookie");
|
||||||
if (!cookieHeader) {
|
if (!cookieHeader) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({ error: "No cookies found" }), {
|
||||||
JSON.stringify({ error: 'No cookies found' }),
|
|
||||||
{
|
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
});
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookies = Object.fromEntries(
|
const cookies = Object.fromEntries(
|
||||||
cookieHeader.split('; ').map((c: string) => c.split('='))
|
cookieHeader.split("; ").map((c: string) => c.split("="))
|
||||||
)
|
);
|
||||||
|
|
||||||
const accessToken = cookies.spotify_access_token
|
const accessToken = cookies.spotify_access_token;
|
||||||
const refreshToken = cookies.spotify_refresh_token
|
const refreshToken = cookies.spotify_refresh_token;
|
||||||
|
|
||||||
if (!accessToken && !refreshToken) {
|
if (!accessToken && !refreshToken) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'No Spotify tokens found' }),
|
JSON.stringify({ error: "No Spotify tokens found" }),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
@@ -110,18 +112,20 @@ export const ServerRoute = createServerFileRoute('/api/spotify/token').methods({
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get tokens endpoint error:', error)
|
console.error("Get tokens endpoint error:", error);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Internal server error' }),
|
JSON.stringify({ error: "Internal server error" }),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,116 +1,148 @@
|
|||||||
import { createServerFileRoute } from '@tanstack/react-start/server';
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { superTokensRequestMiddleware } from '@/utils/supertokens';
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
import { pbAdmin } from '@/lib/pocketbase/client';
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from "@/lib/logger";
|
||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
|
|
||||||
const uploadSchema = z.object({
|
const uploadSchema = z.object({
|
||||||
teamId: z.string().min(1, 'Team ID is required'),
|
teamId: z.string().min(1, "Team ID is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute('/api/teams/upload-logo')
|
export const Route = createFileRoute("/api/teams/upload-logo")({
|
||||||
.middleware([superTokensRequestMiddleware])
|
server: {
|
||||||
.methods({
|
middleware: [superTokensRequestMiddleware],
|
||||||
|
handlers: {
|
||||||
POST: async ({ request, context }) => {
|
POST: async ({ request, context }) => {
|
||||||
try {
|
try {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const isAdmin = context.roles.includes("Admin");
|
||||||
|
|
||||||
if (!userId) return new Response('Unauthenticated', { status: 401 });
|
if (!userId) return new Response("Unauthenticated", { status: 401 });
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const teamId = formData.get('teamId') as string;
|
const teamId = formData.get("teamId") as string;
|
||||||
const logoFile = formData.get('logo') as File;
|
const logoFile = formData.get("logo") as File;
|
||||||
|
|
||||||
const validationResult = uploadSchema.safeParse({ teamId });
|
const validationResult = uploadSchema.safeParse({ teamId });
|
||||||
if (!validationResult.success) {
|
if (!validationResult.success) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(
|
||||||
error: 'Invalid input',
|
JSON.stringify({
|
||||||
details: validationResult.error.issues
|
error: "Invalid input",
|
||||||
}), {
|
details: validationResult.error.issues,
|
||||||
|
}),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!logoFile || logoFile.size === 0) {
|
if (!logoFile || logoFile.size === 0) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(
|
||||||
error: 'Logo file is required'
|
JSON.stringify({
|
||||||
}), {
|
error: "Logo file is required",
|
||||||
|
}),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
|
const allowedTypes = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
];
|
||||||
if (!allowedTypes.includes(logoFile.type)) {
|
if (!allowedTypes.includes(logoFile.type)) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(
|
||||||
error: 'Invalid file type. Only JPEG, PNG and GIF are allowed.'
|
JSON.stringify({
|
||||||
}), {
|
error: "Invalid file type. Only JPEG, PNG and GIF are allowed.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxSize = 10 * 1024 * 1024;
|
const maxSize = 10 * 1024 * 1024;
|
||||||
if (logoFile.size > maxSize) {
|
if (logoFile.size > maxSize) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(
|
||||||
error: 'File too large. Maximum size is 10MB.'
|
JSON.stringify({
|
||||||
}), {
|
error: "File too large. Maximum size is 10MB.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const team = await pbAdmin.getTeam(teamId);
|
const team = await pbAdmin.getTeam(teamId);
|
||||||
if (!team) {
|
if (!team) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(
|
||||||
error: 'Team not found'
|
JSON.stringify({
|
||||||
}), {
|
error: "Team not found",
|
||||||
|
}),
|
||||||
|
{
|
||||||
status: 404,
|
status: 404,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!team.players.map(p => p.id).includes(context.userId) && !isAdmin)
|
const user = await pbAdmin.getPlayerByAuthId(userId);
|
||||||
return new Response('Unauthorized', { status: 403 });
|
if (!team.players.map((p) => p.id).includes(user?.id!) && !isAdmin)
|
||||||
|
return new Response("Unauthorized", { status: 403 });
|
||||||
|
|
||||||
logger.info('Uploading team logo', {
|
logger.info("Uploading team logo", {
|
||||||
teamId,
|
teamId,
|
||||||
fileName: logoFile.name,
|
fileName: logoFile.name,
|
||||||
fileSize: logoFile.size,
|
fileSize: logoFile.size,
|
||||||
userId
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pbFormData = new FormData();
|
const pbFormData = new FormData();
|
||||||
pbFormData.append('logo', logoFile);
|
pbFormData.append("logo", logoFile);
|
||||||
|
|
||||||
const updatedTeam= await pbAdmin.updateTeam(teamId, pbFormData as any);
|
const updatedTeam = await pbAdmin.updateTeam(
|
||||||
|
|
||||||
logger.info('Team logo uploaded successfully', {
|
|
||||||
teamId,
|
teamId,
|
||||||
logo: updatedTeam.logo
|
pbFormData as any
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("Team logo uploaded successfully", {
|
||||||
|
teamId,
|
||||||
|
logo: updatedTeam.logo,
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
team: updatedTeam,
|
team: updatedTeam,
|
||||||
message: 'Logo uploaded successfully'
|
message: "Logo uploaded successfully",
|
||||||
}), {
|
}),
|
||||||
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
}
|
||||||
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error uploading team logo:', error);
|
logger.error("Error uploading team logo:", error);
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(
|
||||||
error: 'Failed to upload logo',
|
JSON.stringify({
|
||||||
message: error.message || 'Unknown error occurred'
|
error: "Failed to upload logo",
|
||||||
}), {
|
message: error.message || "Unknown error occurred",
|
||||||
|
}),
|
||||||
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -1,115 +1,145 @@
|
|||||||
import { createServerFileRoute } from '@tanstack/react-start/server';
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { superTokensRequestMiddleware } from '@/utils/supertokens';
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
import { pbAdmin } from '@/lib/pocketbase/client';
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from "@/lib/logger";
|
||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
|
|
||||||
const uploadSchema = z.object({
|
const uploadSchema = z.object({
|
||||||
tournamentId: z.string().min(1, 'Tournament ID is required'),
|
tournamentId: z.string().min(1, "Tournament ID is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute('/api/tournaments/upload-logo')
|
export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
||||||
.middleware([superTokensRequestMiddleware])
|
server: {
|
||||||
.methods({
|
middleware: [superTokensRequestMiddleware],
|
||||||
|
handlers: {
|
||||||
POST: async ({ request, context }) => {
|
POST: async ({ request, context }) => {
|
||||||
try {
|
try {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const isAdmin = context.roles.includes("Admin");
|
||||||
|
|
||||||
if (!userId) return new Response('Unauthenticated', { status: 401 });
|
if (!userId) return new Response("Unauthenticated", { status: 401 });
|
||||||
if (!isAdmin) return new Response('Unauthorized', { status: 403 });
|
if (!isAdmin) return new Response("Unauthorized", { status: 403 });
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const tournamentId = formData.get('tournamentId') as string;
|
const tournamentId = formData.get("tournamentId") as string;
|
||||||
const logoFile = formData.get('logo') as File;
|
const logoFile = formData.get("logo") as File;
|
||||||
|
|
||||||
const validationResult = uploadSchema.safeParse({ tournamentId });
|
const validationResult = uploadSchema.safeParse({ tournamentId });
|
||||||
if (!validationResult.success) {
|
if (!validationResult.success) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(
|
||||||
error: 'Invalid input',
|
JSON.stringify({
|
||||||
details: validationResult.error.issues
|
error: "Invalid input",
|
||||||
}), {
|
details: validationResult.error.issues,
|
||||||
|
}),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!logoFile || logoFile.size === 0) {
|
if (!logoFile || logoFile.size === 0) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(
|
||||||
error: 'Logo file is required'
|
JSON.stringify({
|
||||||
}), {
|
error: "Logo file is required",
|
||||||
|
}),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
|
const allowedTypes = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
];
|
||||||
if (!allowedTypes.includes(logoFile.type)) {
|
if (!allowedTypes.includes(logoFile.type)) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(
|
||||||
error: 'Invalid file type. Only JPEG, PNG and GIF are allowed.'
|
JSON.stringify({
|
||||||
}), {
|
error: "Invalid file type. Only JPEG, PNG and GIF are allowed.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxSize = 10 * 1024 * 1024;
|
const maxSize = 10 * 1024 * 1024;
|
||||||
if (logoFile.size > maxSize) {
|
if (logoFile.size > maxSize) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(
|
||||||
error: 'File too large. Maximum size is 10MB.'
|
JSON.stringify({
|
||||||
}), {
|
error: "File too large. Maximum size is 10MB.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tournament = await pbAdmin.getTournament(tournamentId);
|
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||||
if (!tournament) {
|
if (!tournament) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(
|
||||||
error: 'Tournament not found'
|
JSON.stringify({
|
||||||
}), {
|
error: "Tournament not found",
|
||||||
|
}),
|
||||||
|
{
|
||||||
status: 404,
|
status: 404,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info("Uploading tournament logo", {
|
||||||
logger.info('Uploading tournament logo', {
|
|
||||||
tournamentId,
|
tournamentId,
|
||||||
fileName: logoFile.name,
|
fileName: logoFile.name,
|
||||||
fileSize: logoFile.size,
|
fileSize: logoFile.size,
|
||||||
userId
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pbFormData = new FormData();
|
const pbFormData = new FormData();
|
||||||
pbFormData.append('logo', logoFile);
|
pbFormData.append("logo", logoFile);
|
||||||
|
|
||||||
const updatedTournament = await pbAdmin.updateTournament(tournamentId, pbFormData as any);
|
const updatedTournament = await pbAdmin.updateTournament(
|
||||||
|
|
||||||
logger.info('Tournament logo uploaded successfully', {
|
|
||||||
tournamentId,
|
tournamentId,
|
||||||
logo: updatedTournament.logo
|
pbFormData as any
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("Tournament logo uploaded successfully", {
|
||||||
|
tournamentId,
|
||||||
|
logo: updatedTournament.logo,
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
tournament: updatedTournament,
|
tournament: updatedTournament,
|
||||||
message: 'Logo uploaded successfully'
|
message: "Logo uploaded successfully",
|
||||||
}), {
|
}),
|
||||||
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
}
|
||||||
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error uploading tournament logo:', error);
|
logger.error("Error uploading tournament logo:", error);
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(
|
||||||
error: 'Failed to upload logo',
|
JSON.stringify({
|
||||||
message: error.message || 'Unknown error occurred'
|
error: "Failed to upload logo",
|
||||||
}), {
|
message: error.message || "Unknown error occurred",
|
||||||
|
}),
|
||||||
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -1,38 +1,33 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import FullScreenLoader from '@/components/full-screen-loader'
|
import FullScreenLoader from '@/components/full-screen-loader'
|
||||||
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'
|
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'
|
||||||
|
import { resetRefreshFlag } from '@/lib/supertokens/client'
|
||||||
|
|
||||||
export const Route = createFileRoute('/refresh-session')({
|
export const Route = createFileRoute('/refresh-session')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
})
|
})
|
||||||
|
|
||||||
// https://supertokens.com/docs/additional-verification/session-verification/ssr?uiType=custom
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
|
const hasAttemptedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (hasAttemptedRef.current) return;
|
||||||
|
hasAttemptedRef.current = true;
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
try {
|
try {
|
||||||
|
resetRefreshFlag();
|
||||||
const refreshed = await attemptRefreshingSession()
|
const refreshed = await attemptRefreshingSession()
|
||||||
|
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
const redirect = urlParams.get('redirect')
|
const redirect = urlParams.get('redirect')
|
||||||
|
|
||||||
const isServerFunction = redirect && (
|
if (redirect && !redirect.includes('_serverFn') && !redirect.includes('/api/')) {
|
||||||
redirect.startsWith('_serverFn') ||
|
|
||||||
redirect.startsWith('api/') ||
|
|
||||||
redirect.includes('_serverFn')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (redirect && !isServerFunction) {
|
|
||||||
window.location.href = decodeURIComponent(redirect)
|
window.location.href = decodeURIComponent(redirect)
|
||||||
} else {
|
} else {
|
||||||
const referrer = document.referrer;
|
window.location.href = '/';
|
||||||
const referrerUrl = referrer && !referrer.includes('/_serverFn') && !referrer.includes('/api/')
|
|
||||||
? referrer
|
|
||||||
: '/';
|
|
||||||
|
|
||||||
window.location.href = referrerUrl;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
@@ -42,8 +37,7 @@ function RouteComponent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeout = setTimeout(handleRefresh, 100)
|
setTimeout(handleRefresh, 100)
|
||||||
return () => clearTimeout(timeout)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return <FullScreenLoader />
|
return <FullScreenLoader />
|
||||||
|
|||||||
@@ -11,19 +11,18 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button as MantineButton,
|
Button as MantineButton,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
|
||||||
Stack,
|
Stack,
|
||||||
Group,
|
Group,
|
||||||
Alert,
|
|
||||||
Collapse,
|
Collapse,
|
||||||
Code,
|
Code,
|
||||||
ThemeIcon
|
Container,
|
||||||
|
Center
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { useDisclosure } from '@mantine/hooks'
|
import { useDisclosure } from '@mantine/hooks'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import toast from '@/lib/sonner'
|
import toast from '@/lib/sonner'
|
||||||
import { logger } from '@/lib/logger'
|
import { logger } from '@/lib/logger'
|
||||||
import { ExclamationMarkIcon, XCircleIcon } from '@phosphor-icons/react'
|
import { XCircleIcon, WarningIcon } from '@phosphor-icons/react'
|
||||||
import Button from './button'
|
import Button from './button'
|
||||||
|
|
||||||
export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||||
@@ -50,25 +49,15 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
|||||||
|
|
||||||
if (errorMessage.toLowerCase().includes('unauthorized')) {
|
if (errorMessage.toLowerCase().includes('unauthorized')) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Container size="sm" py="xl">
|
||||||
style={{
|
<Center>
|
||||||
display: 'flex',
|
<Stack align="center" gap="md">
|
||||||
flexDirection: 'column',
|
<XCircleIcon size={64} color="var(--mantine-color-red-6)" />
|
||||||
alignItems: 'center',
|
<Text size="xl" fw={600}>Access Denied</Text>
|
||||||
justifyContent: 'center',
|
<Text c="dimmed" ta="center">
|
||||||
minHeight: '50vh',
|
You don't have permission to access this page.
|
||||||
padding: 'var(--mantine-spacing-xl)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack align="center" gap="lg">
|
|
||||||
<ThemeIcon color="red" size={80} radius="xl">
|
|
||||||
<XCircleIcon size={48} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Title order={2} ta="center">Access Denied</Title>
|
|
||||||
<Text size="lg" c="dimmed" ta="center">
|
|
||||||
You don't have permission to access this.
|
|
||||||
</Text>
|
</Text>
|
||||||
<Group>
|
<Group gap="sm" mt="md">
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
onClick={() => window.history.back()}
|
onClick={() => window.history.back()}
|
||||||
@@ -84,59 +73,46 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
|||||||
</MantineButton>
|
</MantineButton>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Center>
|
||||||
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Container size="sm" py="xl">
|
||||||
style={{
|
<Center>
|
||||||
display: 'flex',
|
<Stack align="center" gap="md" w="100%">
|
||||||
flexDirection: 'column',
|
<WarningIcon size={64} color="var(--mantine-color-red-6)" />
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
minHeight: '50vh',
|
|
||||||
padding: 'var(--mantine-spacing-xl)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack align="center" gap="lg" maw={600}>
|
|
||||||
<ThemeIcon color="red" size={80} radius="xl">
|
|
||||||
<ExclamationMarkIcon size={48} />
|
|
||||||
</ThemeIcon>
|
|
||||||
|
|
||||||
<Title order={2} ta="center">Something went wrong</Title>
|
<Text size="xl" fw={600}>Something went wrong</Text>
|
||||||
|
|
||||||
<Text size="lg" c="dimmed" ta="center">
|
<Text c="dimmed" ta="center">
|
||||||
There was an unexpected error. Please try again later.
|
An error occurred while loading this page.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Alert
|
<Box w="100%" mt="md">
|
||||||
variant="light"
|
<Text size="sm" c="dimmed" mb="xs">Error: {errorMessage}</Text>
|
||||||
color="red"
|
|
||||||
title="Error Details"
|
|
||||||
w="100%"
|
|
||||||
>
|
|
||||||
<Text mb="sm">{errorMessage}</Text>
|
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
onClick={toggleDetails}
|
onClick={toggleDetails}
|
||||||
|
fullWidth
|
||||||
>
|
>
|
||||||
{detailsOpened ? 'Hide' : 'Show'} stack trace
|
{detailsOpened ? 'Hide' : 'Show'} details
|
||||||
</Button>
|
</Button>
|
||||||
<Collapse in={detailsOpened}>
|
<Collapse in={detailsOpened}>
|
||||||
<Code block mt="md" p="md">
|
<Code block mt="sm" p="sm" style={{ fontSize: '11px' }}>
|
||||||
{errorStack}
|
{errorStack}
|
||||||
</Code>
|
</Code>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Alert>
|
</Box>
|
||||||
|
|
||||||
<Group>
|
<Group gap="sm" mt="lg">
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
onClick={() => router.invalidate()}
|
onClick={() => router.invalidate()}
|
||||||
>
|
>
|
||||||
Try Again
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
{isRoot ? (
|
{isRoot ? (
|
||||||
<MantineButton
|
<MantineButton
|
||||||
@@ -156,6 +132,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Center>
|
||||||
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import {
|
|||||||
Avatar as MantineAvatar,
|
Avatar as MantineAvatar,
|
||||||
AvatarProps as MantineAvatarProps,
|
AvatarProps as MantineAvatarProps,
|
||||||
Paper,
|
Paper,
|
||||||
|
Modal,
|
||||||
|
Image,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
ActionIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { XIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
interface AvatarProps
|
interface AvatarProps
|
||||||
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
||||||
@@ -10,6 +17,8 @@ interface AvatarProps
|
|||||||
size?: number;
|
size?: number;
|
||||||
radius?: string | number;
|
radius?: string | number;
|
||||||
withBorder?: boolean;
|
withBorder?: boolean;
|
||||||
|
disableFullscreen?: boolean;
|
||||||
|
contain?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Avatar = ({
|
const Avatar = ({
|
||||||
@@ -17,10 +26,39 @@ const Avatar = ({
|
|||||||
size = 35,
|
size = 35,
|
||||||
radius = "100%",
|
radius = "100%",
|
||||||
withBorder = true,
|
withBorder = true,
|
||||||
|
disableFullscreen = false,
|
||||||
|
contain = false,
|
||||||
...props
|
...props
|
||||||
}: AvatarProps) => {
|
}: AvatarProps) => {
|
||||||
|
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
||||||
|
const hasImage = Boolean(props.src);
|
||||||
|
|
||||||
|
const handleAvatarClick = () => {
|
||||||
|
if (hasImage && !disableFullscreen) {
|
||||||
|
setIsFullscreenOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper p={size / 20} radius={radius} withBorder={withBorder}>
|
<>
|
||||||
|
<Paper
|
||||||
|
p={size / 20}
|
||||||
|
radius={radius}
|
||||||
|
withBorder={withBorder}
|
||||||
|
style={{
|
||||||
|
cursor: hasImage && !disableFullscreen ? 'pointer' : 'default',
|
||||||
|
transition: 'transform 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (hasImage && !disableFullscreen) {
|
||||||
|
e.currentTarget.style.transform = 'scale(1.02)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'scale(1)';
|
||||||
|
}}
|
||||||
|
onClick={handleAvatarClick}
|
||||||
|
>
|
||||||
<MantineAvatar
|
<MantineAvatar
|
||||||
alt={name}
|
alt={name}
|
||||||
key={name}
|
key={name}
|
||||||
@@ -31,12 +69,79 @@ const Avatar = ({
|
|||||||
w={size}
|
w={size}
|
||||||
styles={{
|
styles={{
|
||||||
image: {
|
image: {
|
||||||
objectFit: "contain",
|
objectFit: contain ? 'contain' : 'cover',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={isFullscreenOpen}
|
||||||
|
onClose={() => setIsFullscreenOpen(false)}
|
||||||
|
size="auto"
|
||||||
|
centered
|
||||||
|
withCloseButton={false}
|
||||||
|
overlayProps={{
|
||||||
|
backgroundOpacity: 0.9,
|
||||||
|
blur: 2,
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
content: {
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="dark"
|
||||||
|
size="lg"
|
||||||
|
radius="xl"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -10,
|
||||||
|
right: -10,
|
||||||
|
zIndex: 1000,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
}}
|
||||||
|
onClick={() => setIsFullscreenOpen(false)}
|
||||||
|
>
|
||||||
|
<XIcon size={18} color="white" />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<Image
|
||||||
|
src={props.src}
|
||||||
|
alt={name}
|
||||||
|
fit="contain"
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
maxWidth: '90vw',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group
|
||||||
|
justify="center"
|
||||||
|
mt="md"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -50,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text c="white" size="sm" fw={500}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ interface ListLinkProps {
|
|||||||
label: string;
|
label: string;
|
||||||
to: string;
|
to: string;
|
||||||
Icon?: Icon;
|
Icon?: Icon;
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ListLink = ({ label, to, Icon }: ListLinkProps) => {
|
const ListLink = ({ label, to, Icon, disabled=false }: ListLinkProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NavLink
|
<NavLink
|
||||||
|
disabled={disabled}
|
||||||
w="100%"
|
w="100%"
|
||||||
p="md"
|
p="md"
|
||||||
component={"button"}
|
component={"button"}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function RichTextEditor({
|
|||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [StarterKit],
|
extensions: [StarterKit],
|
||||||
content: value,
|
content: value,
|
||||||
|
immediatelyRender: false,
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
onChange(editor.getHTML());
|
onChange(editor.getHTML());
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Box, Container, Flex, Loader, useComputedColorScheme } from "@mantine/core";
|
import { Box, Container, Flex, Loader, useComputedColorScheme } from "@mantine/core";
|
||||||
import { PropsWithChildren, Suspense, useEffect } from "react";
|
import { PropsWithChildren, Suspense, useEffect, useRef } from "react";
|
||||||
import { Drawer as VaulDrawer } from "vaul";
|
import { Drawer as VaulDrawer } from "vaul";
|
||||||
import styles from "./styles.module.css";
|
import styles from "./styles.module.css";
|
||||||
import FullScreenLoader from "../full-screen-loader";
|
|
||||||
|
|
||||||
interface DrawerProps extends PropsWithChildren {
|
interface DrawerProps extends PropsWithChildren {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -17,6 +16,7 @@ const Drawer: React.FC<DrawerProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
}) => {
|
}) => {
|
||||||
const colorScheme = useComputedColorScheme("light");
|
const colorScheme = useComputedColorScheme("light");
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const appElement = document.querySelector(".app") as HTMLElement;
|
const appElement = document.querySelector(".app") as HTMLElement;
|
||||||
@@ -59,11 +59,56 @@ const Drawer: React.FC<DrawerProps> = ({
|
|||||||
};
|
};
|
||||||
}, [opened, colorScheme]);
|
}, [opened, colorScheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened || !contentRef.current) return;
|
||||||
|
|
||||||
|
const updateDrawerHeight = () => {
|
||||||
|
if (contentRef.current) {
|
||||||
|
const drawerContent = contentRef.current;
|
||||||
|
const visualViewport = window.visualViewport;
|
||||||
|
|
||||||
|
if (visualViewport) {
|
||||||
|
const availableHeight = visualViewport.height;
|
||||||
|
const maxDrawerHeight = Math.min(availableHeight * 0.75, window.innerHeight * 0.75);
|
||||||
|
|
||||||
|
drawerContent.style.maxHeight = `${maxDrawerHeight}px`;
|
||||||
|
} else {
|
||||||
|
drawerContent.style.maxHeight = '75vh';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (contentRef.current) {
|
||||||
|
const drawerContent = contentRef.current.closest('[data-vaul-drawer-wrapper]');
|
||||||
|
if (drawerContent) {
|
||||||
|
(drawerContent as HTMLElement).style.height = 'auto';
|
||||||
|
(drawerContent as HTMLElement).offsetHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateDrawerHeight();
|
||||||
|
|
||||||
|
if (window.visualViewport) {
|
||||||
|
window.visualViewport.addEventListener('resize', updateDrawerHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeObserver.observe(contentRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
if (window.visualViewport) {
|
||||||
|
window.visualViewport.removeEventListener('resize', updateDrawerHeight);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [opened, children]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VaulDrawer.Root open={opened} onOpenChange={onChange}>
|
<VaulDrawer.Root repositionInputs={false} open={opened} onOpenChange={onChange}>
|
||||||
<VaulDrawer.Portal>
|
<VaulDrawer.Portal>
|
||||||
<VaulDrawer.Overlay className={styles.drawerOverlay} />
|
<VaulDrawer.Overlay className={styles.drawerOverlay} />
|
||||||
<VaulDrawer.Content className={styles.drawerContent}>
|
<VaulDrawer.Content className={styles.drawerContent} aria-describedby="drawer" ref={contentRef}>
|
||||||
<Container flex={1} p="md">
|
<Container flex={1} p="md">
|
||||||
<Box
|
<Box
|
||||||
mb="sm"
|
mb="sm"
|
||||||
@@ -74,7 +119,7 @@ const Drawer: React.FC<DrawerProps> = ({
|
|||||||
mr="auto"
|
mr="auto"
|
||||||
style={{ borderRadius: "9999px" }}
|
style={{ borderRadius: "9999px" }}
|
||||||
/>
|
/>
|
||||||
<Container mah="fit-content" mx="auto" maw="28rem" px={0}>
|
<Container mx="auto" maw="28rem" px={0}>
|
||||||
<VaulDrawer.Title>{title}</VaulDrawer.Title>
|
<VaulDrawer.Title>{title}</VaulDrawer.Title>
|
||||||
<Suspense fallback={
|
<Suspense fallback={
|
||||||
<Flex justify='center' align='center' w='100%' h={400}>
|
<Flex justify='center' align='center' w='100%' h={400}>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { PropsWithChildren, useCallback } from "react";
|
|||||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||||
import Drawer from "./drawer";
|
import Drawer from "./drawer";
|
||||||
import Modal from "./modal";
|
import Modal from "./modal";
|
||||||
import { Box, ScrollArea } from "@mantine/core";
|
import { ScrollArea } from "@mantine/core";
|
||||||
|
|
||||||
interface SheetProps extends PropsWithChildren {
|
interface SheetProps extends PropsWithChildren {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -23,14 +23,14 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
>
|
>
|
||||||
<ScrollArea
|
<ScrollArea.Autosize
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1, maxHeight: '75dvh' }}
|
||||||
scrollbarSize={8}
|
scrollbarSize={8}
|
||||||
scrollbars="y"
|
scrollbars="y"
|
||||||
type="scroll"
|
type="scroll"
|
||||||
>
|
>
|
||||||
<Box mah="70vh">{children}</Box>
|
{children}
|
||||||
</ScrollArea>
|
</ScrollArea.Autosize>
|
||||||
</SheetComponent>
|
</SheetComponent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,10 +11,12 @@
|
|||||||
border-top-left-radius: 20px;
|
border-top-left-radius: 20px;
|
||||||
border-top-right-radius: 20px;
|
border-top-right-radius: 20px;
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
height: fit-content;
|
height: auto !important;
|
||||||
|
min-height: fit-content;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
transition: height 0.2s ease-out, max-height 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
ArrowDownIcon,
|
ArrowDownIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { BaseStats } from "@/shared/types/stats";
|
import { BaseStats } from "@/types/stats";
|
||||||
|
|
||||||
interface StatsOverviewProps {
|
interface StatsOverviewProps {
|
||||||
statsData: BaseStats | null;
|
statsData: BaseStats | null;
|
||||||
@@ -50,18 +50,18 @@ const StatItem = ({
|
|||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
{value !== null ? (
|
||||||
<Text size="sm" fw={700} c="dimmed">
|
<Text size="sm" fw={700} c="dimmed">
|
||||||
{value !== null ? `${value}${suffix}` : "—"}
|
{`${value}${suffix}`}
|
||||||
</Text>
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Skeleton width={20} height={20} />
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) => {
|
const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) => {
|
||||||
if (isLoading || (!statsData && isLoading)) {
|
|
||||||
return <StatsSkeleton />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!statsData && !isLoading) {
|
if (!statsData && !isLoading) {
|
||||||
return (
|
return (
|
||||||
<Box p="sm" h="auto" mih={200}>
|
<Box p="sm" h="auto" mih={200}>
|
||||||
@@ -126,7 +126,7 @@ const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) =>
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StatsSkeleton = () => {
|
export const StatsSkeleton = () => {
|
||||||
const skeletonStats = [
|
const skeletonStats = [
|
||||||
{ label: "Matches Played", Icon: BoxingGloveIcon },
|
{ label: "Matches Played", Icon: BoxingGloveIcon },
|
||||||
{ label: "Wins", Icon: CrownIcon },
|
{ label: "Wins", Icon: CrownIcon },
|
||||||
@@ -101,20 +101,23 @@ function SwipeableTabs({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeoutId = setTimeout(updateHeight, 0);
|
const timeoutId = setTimeout(updateHeight, 0);
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
});
|
}, [updateHeight]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeSlideRef = slideRefs.current[activeTab];
|
const activeSlideRef = slideRefs.current[activeTab];
|
||||||
if (!activeSlideRef) return;
|
if (!activeSlideRef) return;
|
||||||
|
|
||||||
|
let timeoutId: any;
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
updateHeight();
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(updateHeight, 16);
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(activeSlideRef);
|
resizeObserver.observe(activeSlideRef);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
|
clearTimeout(timeoutId);
|
||||||
};
|
};
|
||||||
}, [activeTab, updateHeight]);
|
}, [activeTab, updateHeight]);
|
||||||
|
|
||||||
|
|||||||
175
src/components/typeahead.tsx
Normal file
175
src/components/typeahead.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useState, useRef, useEffect, ReactNode } from "react";
|
||||||
|
import { TextInput, Loader, Paper, Stack, Box, Text } from "@mantine/core";
|
||||||
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
|
|
||||||
|
export interface TypeaheadOption<T = any> {
|
||||||
|
id: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TypeaheadProps<T> {
|
||||||
|
onSelect: (option: TypeaheadOption<T>) => void;
|
||||||
|
searchFn: (query: string) => Promise<TypeaheadOption<T>[]>;
|
||||||
|
renderOption: (option: TypeaheadOption<T>, isSelected?: boolean) => ReactNode;
|
||||||
|
format?: (option: TypeaheadOption<T>) => string;
|
||||||
|
placeholder?: string;
|
||||||
|
debounceMs?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
initialValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Typeahead = <T,>({
|
||||||
|
onSelect,
|
||||||
|
searchFn,
|
||||||
|
renderOption,
|
||||||
|
format,
|
||||||
|
placeholder = "Search...",
|
||||||
|
debounceMs = 300,
|
||||||
|
disabled = false,
|
||||||
|
initialValue = ""
|
||||||
|
}: TypeaheadProps<T>) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState(initialValue);
|
||||||
|
const [searchResults, setSearchResults] = useState<TypeaheadOption<T>[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebouncedCallback(async (query: string) => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
setSearchResults([]);
|
||||||
|
setIsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const results = await searchFn(query);
|
||||||
|
setSearchResults(results);
|
||||||
|
setIsOpen(results.length > 0);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
setSearchResults([]);
|
||||||
|
setIsOpen(false);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, debounceMs);
|
||||||
|
|
||||||
|
const handleSearchChange = (value: string) => {
|
||||||
|
setSearchQuery(value);
|
||||||
|
debouncedSearch(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOptionSelect = (option: TypeaheadOption<T>) => {
|
||||||
|
onSelect(option);
|
||||||
|
const displayValue = format ? format(option) : String(option.data);
|
||||||
|
setSearchQuery(displayValue);
|
||||||
|
setIsOpen(false);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!isOpen || searchResults.length === 0) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => (prev < searchResults.length - 1 ? prev + 1 : prev));
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev));
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedIndex >= 0 && searchResults[selectedIndex]) {
|
||||||
|
handleOptionSelect(searchResults[selectedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
setIsOpen(false);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box ref={containerRef} pos="relative" w="100%">
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => handleSearchChange(event.currentTarget.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => {
|
||||||
|
if (searchResults.length > 0) setIsOpen(true);
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rightSection={isLoading ? <Loader size="xs" /> : null}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<Paper
|
||||||
|
shadow="md"
|
||||||
|
p={0}
|
||||||
|
bd="1px solid var(--mantine-color-dimmed)"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
maxHeight: '160px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
touchAction: 'pan-y',
|
||||||
|
borderTop: 0,
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderTopRightRadius: 0
|
||||||
|
}}
|
||||||
|
onTouchMove={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{searchResults.length > 0 ? (
|
||||||
|
<Stack gap={0}>
|
||||||
|
{searchResults.map((option, index) => (
|
||||||
|
<Box
|
||||||
|
key={option.id}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: selectedIndex === index ? 'var(--mantine-color-gray-1)' : 'transparent',
|
||||||
|
}}
|
||||||
|
onClick={() => handleOptionSelect(option)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
>
|
||||||
|
{renderOption(option, selectedIndex === index)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Box p="md">
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{searchQuery.trim() ? 'No results found' : 'Start typing to search...'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Typeahead;
|
||||||
@@ -14,12 +14,14 @@ interface AuthData {
|
|||||||
user: Player | undefined;
|
user: Player | undefined;
|
||||||
metadata: { accentColor: MantineColor; colorScheme: MantineColorScheme };
|
metadata: { accentColor: MantineColor; colorScheme: MantineColorScheme };
|
||||||
roles: string[];
|
roles: string[];
|
||||||
|
phone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultAuthData: AuthData = {
|
export const defaultAuthData: AuthData = {
|
||||||
user: undefined,
|
user: undefined,
|
||||||
metadata: { accentColor: "blue", colorScheme: "auto" },
|
metadata: { accentColor: "blue", colorScheme: "dark" },
|
||||||
roles: [],
|
roles: [],
|
||||||
|
phone: ""
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AuthContextType extends AuthData {
|
export interface AuthContextType extends AuthData {
|
||||||
@@ -56,12 +58,13 @@ export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
user: data?.user || defaultAuthData.user,
|
user: data?.user,
|
||||||
metadata: data?.metadata || defaultAuthData.metadata,
|
metadata: data?.metadata || { accentColor: "blue" as MantineColor, colorScheme: "dark" as MantineColorScheme },
|
||||||
roles: data?.roles || defaultAuthData.roles,
|
roles: data?.roles || [],
|
||||||
|
phone: data?.phone || "",
|
||||||
set,
|
set,
|
||||||
}),
|
}),
|
||||||
[data, defaultAuthData]
|
[data, set]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AuthContext value={value}>{children}</AuthContext>;
|
return <AuthContext value={value}>{children}</AuthContext>;
|
||||||
|
|||||||
@@ -186,6 +186,29 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, [authState.isAuthenticated]);
|
}, [authState.isAuthenticated]);
|
||||||
|
|
||||||
|
const playTrack = useCallback(async (trackId: string, deviceId?: string, positionMs?: number) => {
|
||||||
|
if (!authState.isAuthenticated) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await makeSpotifyRequest('playback', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ action: 'playTrack', trackId, deviceId, positionMs }),
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(refreshPlaybackState, 500);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && !error.message.includes('JSON')) {
|
||||||
|
setError(error.message);
|
||||||
|
}
|
||||||
|
console.warn('Track playback action completed with warning:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [authState.isAuthenticated]);
|
||||||
|
|
||||||
const pause = useCallback(async () => {
|
const pause = useCallback(async () => {
|
||||||
if (!authState.isAuthenticated) return;
|
if (!authState.isAuthenticated) return;
|
||||||
|
|
||||||
@@ -422,6 +445,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
play,
|
play,
|
||||||
|
playTrack,
|
||||||
pause,
|
pause,
|
||||||
skipNext,
|
skipNext,
|
||||||
skipPrevious,
|
skipPrevious,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import ListButton from "@/components/list-button";
|
|||||||
|
|
||||||
const AdminPage = () => {
|
const AdminPage = () => {
|
||||||
return (
|
return (
|
||||||
<List>
|
<List p="0">
|
||||||
<ListLink
|
<ListLink
|
||||||
label="Manage Tournaments"
|
label="Manage Tournaments"
|
||||||
Icon={TrophyIcon}
|
Icon={TrophyIcon}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import ListLink from "@/components/list-link";
|
|||||||
const ManageTournaments = () => {
|
const ManageTournaments = () => {
|
||||||
const { data: tournaments } = useTournaments();
|
const { data: tournaments } = useTournaments();
|
||||||
return (
|
return (
|
||||||
<List>
|
<List p="0">
|
||||||
{tournaments.map((t) => (
|
{tournaments.map((t) => (
|
||||||
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} />
|
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
4
src/features/badges/server.ts
Normal file
4
src/features/badges/server.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
|
import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
|
||||||
8
src/features/badges/util.ts
Normal file
8
src/features/badges/util.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
|
||||||
|
pb.collection("team_stats_per_tournament").getFullList({
|
||||||
|
filter: `tournament_id = "${id}"`,
|
||||||
|
sort: "-wins,-total_cups_made"
|
||||||
|
})
|
||||||
|
|
||||||
|
*/
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Flex } from "@mantine/core";
|
import { Flex, Box } from "@mantine/core";
|
||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
import { MatchCard } from "./match-card";
|
import { MatchCard } from "./match-card";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
interface BracketProps {
|
interface BracketProps {
|
||||||
rounds: Match[][];
|
rounds: Match[][];
|
||||||
@@ -13,8 +14,105 @@ export const Bracket: React.FC<BracketProps> = ({
|
|||||||
orders,
|
orders,
|
||||||
showControls,
|
showControls,
|
||||||
}) => {
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateConnectorLines = () => {
|
||||||
|
if (!containerRef.current || !svgRef.current) return;
|
||||||
|
|
||||||
|
const svg = svgRef.current;
|
||||||
|
const container = containerRef.current;
|
||||||
|
const flexContainer = container.querySelector('.bracket-flex-container') as HTMLElement;
|
||||||
|
if (!flexContainer) return;
|
||||||
|
|
||||||
|
svg.innerHTML = '';
|
||||||
|
|
||||||
|
const flexRect = flexContainer.getBoundingClientRect();
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
svg.style.width = `${flexContainer.scrollWidth}px`;
|
||||||
|
svg.style.height = `${flexContainer.scrollHeight}px`;
|
||||||
|
|
||||||
|
rounds.forEach((round, roundIndex) => {
|
||||||
|
if (roundIndex === rounds.length - 1) return;
|
||||||
|
|
||||||
|
const nextRound = rounds[roundIndex + 1];
|
||||||
|
|
||||||
|
round.forEach((match, matchIndex) => {
|
||||||
|
if (match.bye) return;
|
||||||
|
|
||||||
|
const matchElement = container.querySelector(`[data-match-lid="${match.lid}"]`) as HTMLElement;
|
||||||
|
if (!matchElement) return;
|
||||||
|
|
||||||
|
const nextMatches = nextRound.filter(nextMatch =>
|
||||||
|
!nextMatch.bye && (
|
||||||
|
orders[nextMatch.home_from_lid] === match.order ||
|
||||||
|
orders[nextMatch.away_from_lid] === match.order
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
nextMatches.forEach(nextMatch => {
|
||||||
|
const nextMatchElement = container.querySelector(`[data-match-lid="${nextMatch.lid}"]`) as HTMLElement;
|
||||||
|
if (!nextMatchElement) return;
|
||||||
|
|
||||||
|
const matchRect = matchElement.getBoundingClientRect();
|
||||||
|
const nextMatchRect = nextMatchElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
const startX = matchRect.right - flexRect.left;
|
||||||
|
const startY = matchRect.top + matchRect.height / 2 - flexRect.top;
|
||||||
|
const endX = nextMatchRect.left - flexRect.left;
|
||||||
|
const endY = nextMatchRect.top + nextMatchRect.height / 2 - flexRect.top;
|
||||||
|
|
||||||
|
const midX = startX + (endX - startX) * 0.5;
|
||||||
|
|
||||||
|
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||||
|
const pathData = `M ${startX} ${startY} L ${midX} ${startY} L ${midX} ${endY} L ${endX} ${endY}`;
|
||||||
|
|
||||||
|
path.setAttribute('d', pathData);
|
||||||
|
path.setAttribute('stroke', 'var(--mantine-color-default-border)');
|
||||||
|
path.setAttribute('stroke-width', '2');
|
||||||
|
path.setAttribute('fill', 'none');
|
||||||
|
path.setAttribute('stroke-linecap', 'round');
|
||||||
|
path.setAttribute('stroke-linejoin', 'round');
|
||||||
|
|
||||||
|
svg.appendChild(path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateConnectorLines();
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
requestAnimationFrame(updateConnectorLines);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollContainer = containerRef.current?.closest('.mantine-ScrollArea-viewport');
|
||||||
|
scrollContainer?.addEventListener('scroll', handleUpdate);
|
||||||
|
window.addEventListener('resize', handleUpdate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollContainer?.removeEventListener('scroll', handleUpdate);
|
||||||
|
window.removeEventListener('resize', handleUpdate);
|
||||||
|
};
|
||||||
|
}, [rounds, orders]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="row" gap={24} justify="left">
|
<Box pos="relative" ref={containerRef}>
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Flex direction="row" gap={24} justify="left" pos="relative" style={{ zIndex: 1 }} className="bracket-flex-container">
|
||||||
{rounds.map((round, roundIndex) => (
|
{rounds.map((round, roundIndex) => (
|
||||||
<Flex
|
<Flex
|
||||||
key={roundIndex}
|
key={roundIndex}
|
||||||
@@ -41,5 +139,6 @@ export const Bracket: React.FC<BracketProps> = ({
|
|||||||
</Flex>
|
</Flex>
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { ActionIcon, Card, Flex, Text, Stack, Indicator } from "@mantine/core";
|
import { ActionIcon, Card, Flex, Text, Indicator } from "@mantine/core";
|
||||||
import { PlayIcon, PencilIcon, SpeakerHighIcon } from "@phosphor-icons/react";
|
import { PlayIcon, PencilIcon, SpeakerHighIcon } from "@phosphor-icons/react";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { MatchSlot } from "./match-slot";
|
import { MatchSlot } from "./match-slot";
|
||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
|
import { Team } from "@/features/teams/types";
|
||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
import { MatchForm } from "./match-form";
|
import { MatchForm } from "./match-form";
|
||||||
import Sheet from "@/components/sheet/sheet";
|
import Sheet from "@/components/sheet/sheet";
|
||||||
@@ -10,6 +11,7 @@ import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
|||||||
import { endMatch, startMatch } from "@/features/matches/server";
|
import { endMatch, startMatch } from "@/features/matches/server";
|
||||||
import { tournamentKeys } from "@/features/tournaments/queries";
|
import { tournamentKeys } from "@/features/tournaments/queries";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useSpotifyPlayback } from "@/lib/spotify/hooks";
|
||||||
|
|
||||||
interface MatchCardProps {
|
interface MatchCardProps {
|
||||||
match: Match;
|
match: Match;
|
||||||
@@ -24,6 +26,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const editSheet = useSheet();
|
const editSheet = useSheet();
|
||||||
|
const { playTrack, pause } = useSpotifyPlayback();
|
||||||
const homeSlot = useMemo(
|
const homeSlot = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
from: orders[match.home_from_lid],
|
from: orders[match.home_from_lid],
|
||||||
@@ -65,6 +68,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
[showControls, match.status]
|
[showControls, match.status]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasWalkoutData = showControls && match.home && match.away && 'song_id' in match.home && 'song_id' in match.away;
|
||||||
|
|
||||||
const start = useServerMutation({
|
const start = useServerMutation({
|
||||||
mutationFn: startMatch,
|
mutationFn: startMatch,
|
||||||
successMessage: "Match started!",
|
successMessage: "Match started!",
|
||||||
@@ -84,19 +89,13 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleStart = useCallback(async () => {
|
|
||||||
await start.mutate({
|
|
||||||
data: match.id,
|
|
||||||
});
|
|
||||||
}, [match]);
|
|
||||||
|
|
||||||
const handleFormSubmit = useCallback(
|
const handleFormSubmit = useCallback(
|
||||||
async (data: {
|
async (data: {
|
||||||
home_cups: number;
|
home_cups: number;
|
||||||
away_cups: number;
|
away_cups: number;
|
||||||
ot_count: number;
|
ot_count: number;
|
||||||
}) => {
|
}) => {
|
||||||
await end.mutate({
|
end.mutate({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
matchId: match.id,
|
matchId: match.id,
|
||||||
@@ -107,12 +106,14 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
[match.id, editSheet]
|
[match.id, editSheet]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSpeakerClick = useCallback(() => {
|
const speak = useCallback((text: string): Promise<void> => {
|
||||||
if ("speechSynthesis" in window && match.home?.name && match.away?.name) {
|
return new Promise((resolve) => {
|
||||||
const utterance = new SpeechSynthesisUtterance(
|
if (!("speechSynthesis" in window)) {
|
||||||
`${match.home.name} vs. ${match.away.name}`
|
resolve();
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const utterance = new SpeechSynthesisUtterance(text);
|
||||||
const voices = window.speechSynthesis.getVoices();
|
const voices = window.speechSynthesis.getVoices();
|
||||||
|
|
||||||
const preferredVoice =
|
const preferredVoice =
|
||||||
@@ -130,13 +131,83 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
utterance.volume = 0.8;
|
utterance.volume = 0.8;
|
||||||
utterance.pitch = 1.0;
|
utterance.pitch = 1.0;
|
||||||
|
|
||||||
|
utterance.onend = () => resolve();
|
||||||
|
utterance.onerror = () => resolve();
|
||||||
|
|
||||||
window.speechSynthesis.speak(utterance);
|
window.speechSynthesis.speak(utterance);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const playTeamWalkout = useCallback((team: Team): Promise<void> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const songDuration = (team.song_end - team.song_start) * 1000;
|
||||||
|
|
||||||
|
playTrack(team.song_id, undefined, team.song_start * 1000);
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
await pause();
|
||||||
|
resolve();
|
||||||
|
}, songDuration);
|
||||||
|
});
|
||||||
|
}, [playTrack, pause]);
|
||||||
|
|
||||||
|
const handleSpeakerClick = useCallback(async () => {
|
||||||
|
if (!hasWalkoutData || !match.home?.name || !match.away?.name) {
|
||||||
|
await speak(`${match.home?.name || "Home"} vs. ${match.away?.name || "Away"}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [match.home?.name, match.away?.name]);
|
|
||||||
|
try {
|
||||||
|
const homeTeam = match.home as Team;
|
||||||
|
const awayTeam = match.away as Team;
|
||||||
|
|
||||||
|
await playTeamWalkout(homeTeam);
|
||||||
|
await speak(homeTeam.name);
|
||||||
|
await speak("versus");
|
||||||
|
await playTeamWalkout(awayTeam);
|
||||||
|
await speak(awayTeam.name);
|
||||||
|
await speak("have fun, good luck!");
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Walkout sequence error:', error);
|
||||||
|
await speak(`${match.home.name} vs. ${match.away.name}`);
|
||||||
|
}
|
||||||
|
}, [hasWalkoutData, match.home, match.away, speak, playTeamWalkout]);
|
||||||
|
|
||||||
|
const handleStart = useCallback(async () => {
|
||||||
|
start.mutate({
|
||||||
|
data: match.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Play walkout sequence after starting the match
|
||||||
|
if (hasWalkoutData && match.home?.name && match.away?.name) {
|
||||||
|
try {
|
||||||
|
const homeTeam = match.home as Team;
|
||||||
|
const awayTeam = match.away as Team;
|
||||||
|
|
||||||
|
await playTeamWalkout(homeTeam);
|
||||||
|
await speak(homeTeam.name);
|
||||||
|
await speak("versus");
|
||||||
|
await playTeamWalkout(awayTeam);
|
||||||
|
await speak(awayTeam.name);
|
||||||
|
await speak("have fun, good luck!");
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Auto-walkout sequence error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [match, start, hasWalkoutData, playTeamWalkout, speak]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="row" align="center" justify="end" gap={8}>
|
<Flex direction="row" align="center" justify="end" gap={8}>
|
||||||
<Text c="dimmed" fw="bolder">
|
<Text
|
||||||
|
c="dimmed"
|
||||||
|
fw="bolder"
|
||||||
|
px={6}
|
||||||
|
py={2}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--mantine-color-body)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
{match.order}
|
{match.order}
|
||||||
</Text>
|
</Text>
|
||||||
<Flex align="stretch">
|
<Flex align="stretch">
|
||||||
@@ -151,7 +222,12 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
w={showToolbar || showEditButton ? 200 : 220}
|
w={showToolbar || showEditButton ? 200 : 220}
|
||||||
withBorder
|
withBorder
|
||||||
pos="relative"
|
pos="relative"
|
||||||
style={{ overflow: "visible" }}
|
style={{
|
||||||
|
overflow: "visible",
|
||||||
|
backgroundColor: 'var(--mantine-color-body)',
|
||||||
|
borderColor: 'var(--mantine-color-default-border)',
|
||||||
|
boxShadow: 'var(--mantine-shadow-sm)',
|
||||||
|
}}
|
||||||
data-match-lid={match.lid}
|
data-match-lid={match.lid}
|
||||||
>
|
>
|
||||||
<Card.Section withBorder p={0}>
|
<Card.Section withBorder p={0}>
|
||||||
@@ -175,7 +251,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showControls && (
|
{showControls && match.status !== "tbd" && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
bottom={-2}
|
bottom={-2}
|
||||||
@@ -210,6 +286,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{showEditButton && (
|
{showEditButton && (
|
||||||
<Flex direction="column" justify="center" align="center">
|
<Flex direction="column" justify="center" align="center">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
|
|||||||
{match.home?.name} Cups
|
{match.home?.name} Cups
|
||||||
</Text>
|
</Text>
|
||||||
{
|
{
|
||||||
match.home?.players.map(p => (<Text size='xs' c='dimmed'>
|
match.home?.players?.map(p => (<Text key={p.id} size='xs' c='dimmed'>
|
||||||
{p.first_name} {p.last_name}
|
{p.first_name} {p.last_name}
|
||||||
</Text>))
|
</Text>))
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
|
|||||||
{match.away?.name} Cups
|
{match.away?.name} Cups
|
||||||
</Text>
|
</Text>
|
||||||
{
|
{
|
||||||
match.away?.players.map(p => (<Text size='xs' c='dimmed'>
|
match.away?.players?.map(p => (<Text key={p.id} size='xs' c='dimmed'>
|
||||||
{p.first_name} {p.last_name}
|
{p.first_name} {p.last_name}
|
||||||
</Text>))
|
</Text>))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,16 +21,23 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
|||||||
cups,
|
cups,
|
||||||
isWinner
|
isWinner
|
||||||
}) => (
|
}) => (
|
||||||
<Flex align="stretch">
|
<Flex
|
||||||
|
align="stretch"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isWinner ? 'var(--mantine-color-green-light)' : 'transparent',
|
||||||
|
borderRadius: 'var(--mantine-radius-sm)',
|
||||||
|
transition: 'background-color 200ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{(seed && seed > 0) ? <SeedBadge seed={seed} /> : undefined}
|
{(seed && seed > 0) ? <SeedBadge seed={seed} /> : undefined}
|
||||||
<Flex p="4px 8px" w='100%' align="center">
|
<Flex p="6px 10px" w='100%' align="center">
|
||||||
<Flex align="center" gap={4} flex={1}>
|
<Flex align="center" gap={4} flex={1}>
|
||||||
{team ? (
|
{team ? (
|
||||||
<>
|
<>
|
||||||
<Text
|
<Text
|
||||||
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
|
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
|
||||||
truncate
|
truncate
|
||||||
style={{ minWidth: 0, flex: 1 }}
|
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
|
||||||
>
|
>
|
||||||
{team.name}
|
{team.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
|||||||
const logger = new Logger("Bracket Generation");
|
const logger = new Logger("Bracket Generation");
|
||||||
|
|
||||||
export const previewBracket = createServerFn()
|
export const previewBracket = createServerFn()
|
||||||
.validator(z.number())
|
.inputValidator(z.number())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: teams }) =>
|
.handler(async ({ data: teams }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Title, AppShell, Flex } from "@mantine/core";
|
import { Title, AppShell, Flex, Box, Paper } from "@mantine/core";
|
||||||
import { HeaderConfig } from "../types/header-config";
|
import { HeaderConfig } from "../types/header-config";
|
||||||
import useRouterConfig from "../hooks/use-router-config";
|
|
||||||
import BackButton from "./back-button";
|
import BackButton from "./back-button";
|
||||||
|
|
||||||
interface HeaderProps extends HeaderConfig {}
|
interface HeaderProps extends HeaderConfig {}
|
||||||
|
|
||||||
const Header = ({ collapsed, title }: HeaderProps) => {
|
const Header = ({ collapsed, title, withBackButton }: HeaderProps) => {
|
||||||
const { header } = useRouterConfig();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
|
<AppShell.Header
|
||||||
{ header.withBackButton && <BackButton /> }
|
id='app-header'
|
||||||
|
display={collapsed ? 'none' : 'block'}
|
||||||
|
>
|
||||||
|
{ withBackButton && <BackButton /> }
|
||||||
<Flex justify='center' align='center' h='100%' px='md'>
|
<Flex justify='center' align='center' h='100%' px='md'>
|
||||||
<Title order={2}>{title}</Title>
|
<Title order={2}>{title}</Title>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
pos='relative'
|
pos='relative'
|
||||||
h='100dvh'
|
h='100dvh'
|
||||||
mah='100dvh'
|
mah='100dvh'
|
||||||
style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
|
// style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
|
||||||
>
|
>
|
||||||
<Header {...header} />
|
<Header {...header} />
|
||||||
<AppShell.Main
|
<AppShell.Main
|
||||||
pos='relative'
|
pos='relative'
|
||||||
h='100%'
|
h='100%'
|
||||||
mah='100%'
|
mah='100%'
|
||||||
pb={{ base: 70, md: 0 }}
|
pb={{ base: 65, md: 0 }}
|
||||||
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
|
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
|
||||||
maw='100dvw'
|
maw='100dvw'
|
||||||
style={{ transition: 'none', overflow: 'hidden' }}
|
style={{ transition: 'none', overflow: 'hidden' }}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AppShell, ScrollArea, Stack, Group, Paper } from "@mantine/core";
|
import { AppShell, ScrollArea, Stack, Group, Paper, useMantineColorScheme } from "@mantine/core";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { NavLink } from "./nav-link";
|
import { NavLink } from "./nav-link";
|
||||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||||
@@ -9,11 +9,17 @@ import { memo } from "react";
|
|||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
const { user, roles } = useAuth()
|
const { user, roles } = useAuth()
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
|
||||||
const links = useLinks(user?.id, roles);
|
const links = useLinks(user?.id, roles);
|
||||||
|
|
||||||
|
const isDark = colorScheme === 'dark';
|
||||||
|
const borderColor = isDark ? 'var(--mantine-color-dimmed)' : 'black';
|
||||||
|
const boxShadowColor = isDark ? 'var(--mantine-color-dimmed)' : 'black';
|
||||||
|
// boxShadow: `5px 5px ${boxShadowColor}`, borderColor
|
||||||
|
|
||||||
if (isMobile) return (
|
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 }}>
|
<Paper component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1.5rem)' pos='fixed' m='0.75rem' bottom='0' style={{ zIndex: 10 }}>
|
||||||
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
<NavLink key={link.href} {...link} />
|
<NavLink key={link.href} {...link} />
|
||||||
@@ -30,9 +36,6 @@ const Navbar = () => {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</AppShell.Section>
|
</AppShell.Section>
|
||||||
<AppShell.Section>
|
|
||||||
<Link to="/logout">Logout</Link>
|
|
||||||
</AppShell.Section>
|
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import useAppShellHeight from "@/hooks/use-appshell-height";
|
|||||||
import { ArrowClockwiseIcon, SpinnerIcon } from "@phosphor-icons/react";
|
import { ArrowClockwiseIcon, SpinnerIcon } from "@phosphor-icons/react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import useRouterConfig from "../hooks/use-router-config";
|
import useRouterConfig from "../hooks/use-router-config";
|
||||||
|
import { useLocation } from "@tanstack/react-router";
|
||||||
|
|
||||||
const THRESHOLD = 80;
|
const THRESHOLD = 80;
|
||||||
|
|
||||||
@@ -21,6 +22,8 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
|
|||||||
const [scrolling, setScrolling] = useState(false);
|
const [scrolling, setScrolling] = useState(false);
|
||||||
const { refresh } = useRouterConfig();
|
const { refresh } = useRouterConfig();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const location = useLocation();
|
||||||
|
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const scrollY = useMemo(() => scrollPosition.y < 0 && scrolling ? Math.abs(scrollPosition.y) : 0, [scrollPosition.y, scrolling]);
|
const scrollY = useMemo(() => scrollPosition.y < 0 && scrolling ? Math.abs(scrollPosition.y) : 0, [scrollPosition.y, scrolling]);
|
||||||
|
|
||||||
@@ -79,6 +82,21 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
|
|||||||
return () => void ac.abort();
|
return () => void ac.abort();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (scrollAreaRef.current) {
|
||||||
|
const viewport = scrollAreaRef.current.querySelector('.mantine-ScrollArea-viewport') as HTMLElement;
|
||||||
|
if (viewport) {
|
||||||
|
viewport.scrollTop = 0;
|
||||||
|
viewport.scrollLeft = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onScrollPositionChange({ x: 0, y: 0 });
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [location.pathname, onScrollPositionChange]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -103,6 +121,7 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
|
|||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
|
ref={scrollAreaRef}
|
||||||
id='scroll-wrapper'
|
id='scroll-wrapper'
|
||||||
onScrollPositionChange={onScrollPositionChange}
|
onScrollPositionChange={onScrollPositionChange}
|
||||||
type='never' mah='100%' h='100%'
|
type='never' mah='100%' h='100%'
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Alert } from "@mantine/core";
|
|
||||||
import { Info } from "@phosphor-icons/react";
|
|
||||||
import { Transition } from "@mantine/core";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
const Error = ({ error }: { error?: string }) => {
|
|
||||||
const show = useMemo(() => (error ? error.length > 0 : false), [error]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Transition
|
|
||||||
mounted={show}
|
|
||||||
transition="slide-up"
|
|
||||||
duration={400}
|
|
||||||
timingFunction="ease"
|
|
||||||
>
|
|
||||||
{(styles) => (
|
|
||||||
<Alert w='95%' color="red" icon={<Info />} style={styles}>{error}</Alert>
|
|
||||||
)}
|
|
||||||
</Transition>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Error;
|
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Text, Group, Stack, Paper, Indicator, Box } from "@mantine/core";
|
import { Text, Group, Stack, Paper, Indicator, Box, Tooltip } from "@mantine/core";
|
||||||
import { CrownIcon } from "@phosphor-icons/react";
|
import { CrownIcon } from "@phosphor-icons/react";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { Match } from "../types";
|
import { Match } from "../types";
|
||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
import EmojiBar from "@/features/reactions/components/emoji-bar";
|
import EmojiBar from "@/features/reactions/components/emoji-bar";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
interface MatchCardProps {
|
interface MatchCardProps {
|
||||||
match: Match;
|
match: Match;
|
||||||
@@ -88,15 +89,28 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
<Tooltip
|
||||||
|
label={match.home?.name!}
|
||||||
|
disabled={!match.home?.name}
|
||||||
|
events={{ hover: true, focus: true, touch: true }}
|
||||||
|
>
|
||||||
<Text
|
<Text
|
||||||
size="sm"
|
size="sm"
|
||||||
fw={600}
|
fw={600}
|
||||||
lineClamp={1}
|
lineClamp={1}
|
||||||
style={{ minWidth: 0, flex: 1 }}
|
style={{ minWidth: 0, flex: 1, cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
{match.home?.name!}
|
{match.home?.name!}
|
||||||
</Text>
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
|
<Stack gap={1}>
|
||||||
|
{match.home?.players.map((p) => (
|
||||||
|
<Text key={`match-card-p-${p.id}`} size="xs" fw={600} c="dimmed" ta="right">
|
||||||
|
{p.first_name} {p.last_name}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
<Text
|
<Text
|
||||||
size="xl"
|
size="xl"
|
||||||
fw={700}
|
fw={700}
|
||||||
@@ -105,13 +119,6 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
>
|
>
|
||||||
{match.home_cups}
|
{match.home_cups}
|
||||||
</Text>
|
</Text>
|
||||||
<Stack gap={1}>
|
|
||||||
{match.home?.players.map((p) => (
|
|
||||||
<Text size="xs" fw={600} c="dimmed" ta="right">
|
|
||||||
{p.first_name} {p.last_name}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
@@ -144,15 +151,28 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
<Tooltip
|
||||||
|
label={match.away?.name}
|
||||||
|
disabled={!match.away?.name}
|
||||||
|
events={{ hover: true, focus: true, touch: true }}
|
||||||
|
>
|
||||||
<Text
|
<Text
|
||||||
size="sm"
|
size="sm"
|
||||||
fw={600}
|
fw={600}
|
||||||
lineClamp={1}
|
lineClamp={1}
|
||||||
style={{ minWidth: 0, flex: 1 }}
|
style={{ minWidth: 0, flex: 1, cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
{match.away?.name}
|
{match.away?.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
|
<Stack gap={1}>
|
||||||
|
{match.away?.players.map((p) => (
|
||||||
|
<Text key={`match-card-p-${p.id}`} size="xs" fw={600} c="dimmed" ta="right">
|
||||||
|
{p.first_name} {p.last_name}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
<Text
|
<Text
|
||||||
size="xl"
|
size="xl"
|
||||||
fw={700}
|
fw={700}
|
||||||
@@ -161,13 +181,6 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
>
|
>
|
||||||
{match.away_cups}
|
{match.away_cups}
|
||||||
</Text>
|
</Text>
|
||||||
<Stack gap={1}>
|
|
||||||
{match.away?.players.map((p) => (
|
|
||||||
<Text size="xs" fw={600} c="dimmed" ta="right">
|
|
||||||
{p.first_name} {p.last_name}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -187,7 +200,9 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
border: "1px solid var(--mantine-color-default-border)",
|
border: "1px solid var(--mantine-color-default-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Suspense>
|
||||||
<EmojiBar matchId={match.id} />
|
<EmojiBar matchId={match.id} />
|
||||||
|
</Suspense>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
</Indicator>
|
</Indicator>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Stack } from "@mantine/core";
|
import { Stack } from "@mantine/core";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { Match } from "../types";
|
import { Match } from "../types";
|
||||||
import MatchCard from "./match-card";
|
import MatchCard from "./match-card";
|
||||||
|
|
||||||
@@ -18,19 +17,13 @@ const MatchList = ({ matches }: MatchListProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack p="md" gap="sm">
|
<Stack p="md" gap="sm">
|
||||||
<AnimatePresence>
|
|
||||||
{filteredMatches.map((match, index) => (
|
{filteredMatches.map((match, index) => (
|
||||||
<motion.div
|
<div
|
||||||
key={`match-${match.id}-${index}`}
|
key={`match-${match.id}-${index}`}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
transition={{ duration: 0.2, delay: index * 0.01 }}
|
|
||||||
>
|
>
|
||||||
<MatchCard match={match} />
|
<MatchCard match={match} />
|
||||||
</motion.div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const orderedTeamsSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const generateTournamentBracket = createServerFn()
|
export const generateTournamentBracket = createServerFn()
|
||||||
.validator(orderedTeamsSchema)
|
.inputValidator(orderedTeamsSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
@@ -137,7 +137,7 @@ export const generateTournamentBracket = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const startMatch = createServerFn()
|
export const startMatch = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
@@ -170,7 +170,7 @@ const endMatchSchema = z.object({
|
|||||||
ot_count: z.number(),
|
ot_count: z.number(),
|
||||||
});
|
});
|
||||||
export const endMatch = createServerFn()
|
export const endMatch = createServerFn()
|
||||||
.validator(endMatchSchema)
|
.inputValidator(endMatchSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
|
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
@@ -252,7 +252,7 @@ const toggleReactionSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const toggleMatchReaction = createServerFn()
|
export const toggleMatchReaction = createServerFn()
|
||||||
.validator(toggleReactionSchema)
|
.inputValidator(toggleReactionSchema)
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: { matchId, emoji }, context }) =>
|
.handler(async ({ data: { matchId, emoji }, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
@@ -312,7 +312,7 @@ export interface Reaction {
|
|||||||
players: PlayerInfo[];
|
players: PlayerInfo[];
|
||||||
}
|
}
|
||||||
export const getMatchReactions = createServerFn()
|
export const getMatchReactions = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: matchId, context }) =>
|
.handler(async ({ data: matchId, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TeamInfo } from "../teams/types";
|
import { TeamInfo, Team } from "../teams/types";
|
||||||
import { TournamentInfo } from "../tournaments/types";
|
import { TournamentInfo } from "../tournaments/types";
|
||||||
|
|
||||||
export type MatchStatus = "tbd" | "ready" | "started" | "ended";
|
export type MatchStatus = "tbd" | "ready" | "started" | "ended";
|
||||||
@@ -23,8 +23,8 @@ export interface Match {
|
|||||||
is_losers_bracket: boolean;
|
is_losers_bracket: boolean;
|
||||||
status: MatchStatus;
|
status: MatchStatus;
|
||||||
tournament: TournamentInfo;
|
tournament: TournamentInfo;
|
||||||
home?: TeamInfo;
|
home?: TeamInfo | Team;
|
||||||
away?: TeamInfo;
|
away?: TeamInfo | Team;
|
||||||
created: string;
|
created: string;
|
||||||
updated: string;
|
updated: string;
|
||||||
home_seed?: number;
|
home_seed?: number;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const PlayerList = ({ players, loading = false }: PlayerListProps) => {
|
|||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
return <List>
|
return <List p="0">
|
||||||
{players?.map((player) => (
|
{players?.map((player) => (
|
||||||
<ListItem key={player.id}
|
<ListItem key={player.id}
|
||||||
py='xs'
|
py='xs'
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Group,
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
Skeleton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
|
||||||
|
const PlayerListItemSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<Box p="md">
|
||||||
|
<Group justify="space-between" align="center" w="100%">
|
||||||
|
<Group gap="sm" align="center">
|
||||||
|
<Skeleton height={45} circle />
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Group gap='xs'>
|
||||||
|
<Skeleton height={16} width={120} />
|
||||||
|
<Skeleton height={12} width={60} />
|
||||||
|
<Skeleton height={12} width={80} />
|
||||||
|
</Group>
|
||||||
|
<Group gap="md" ta="center">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Skeleton height={10} width={25} />
|
||||||
|
<Skeleton height={10} width={30} />
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Skeleton height={10} width={10} />
|
||||||
|
<Skeleton height={10} width={15} />
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Skeleton height={10} width={10} />
|
||||||
|
<Skeleton height={10} width={15} />
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Skeleton height={10} width={20} />
|
||||||
|
<Skeleton height={10} width={25} />
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Skeleton height={10} width={25} />
|
||||||
|
<Skeleton height={10} width={20} />
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Skeleton height={10} width={15} />
|
||||||
|
<Skeleton height={10} width={25} />
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Skeleton height={10} width={15} />
|
||||||
|
<Skeleton height={10} width={25} />
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PlayerStatsTableSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<Container size="100%" px={0}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Box px="md" pb="xs">
|
||||||
|
<Skeleton height={40} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Group px="md" justify="space-between" align="center">
|
||||||
|
<Skeleton height={12} width={100} />
|
||||||
|
<Group gap="xs">
|
||||||
|
<Skeleton height={12} width={200} />
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Stack>
|
||||||
|
{Array(10).fill(null).map((_, index) => (
|
||||||
|
<Box key={index}>
|
||||||
|
<PlayerListItemSkeleton />
|
||||||
|
{index < 9 && <Divider />}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlayerStatsTableSkeleton;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useCallback, memo } from "react";
|
||||||
import {
|
import {
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
Popover,
|
Popover,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Skeleton,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
@@ -24,10 +23,7 @@ import {
|
|||||||
import { PlayerStats } from "../types";
|
import { PlayerStats } from "../types";
|
||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useAllPlayerStats } from "../queries";
|
||||||
interface PlayerStatsTableProps {
|
|
||||||
playerStats: PlayerStats[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type SortKey = keyof PlayerStats | "mmr";
|
type SortKey = keyof PlayerStats | "mmr";
|
||||||
type SortDirection = "asc" | "desc";
|
type SortDirection = "asc" | "desc";
|
||||||
@@ -39,33 +35,11 @@ interface SortConfig {
|
|||||||
|
|
||||||
interface PlayerListItemProps {
|
interface PlayerListItemProps {
|
||||||
stat: PlayerStats;
|
stat: PlayerStats;
|
||||||
index: number;
|
|
||||||
onPlayerClick: (playerId: string) => void;
|
onPlayerClick: (playerId: string) => void;
|
||||||
|
mmr: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlayerListItem = ({ stat, index, onPlayerClick }: PlayerListItemProps) => {
|
const PlayerListItem = memo(({ stat, onPlayerClick, mmr }: PlayerListItemProps) => {
|
||||||
const calculateMMR = (stat: PlayerStats): number => {
|
|
||||||
if (stat.matches === 0) return 0;
|
|
||||||
|
|
||||||
const winScore = stat.win_percentage;
|
|
||||||
const matchConfidence = Math.min(stat.matches / 15, 1);
|
|
||||||
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100);
|
|
||||||
const marginScore = stat.margin_of_victory
|
|
||||||
? Math.min(stat.margin_of_victory * 20, 50)
|
|
||||||
: 0;
|
|
||||||
const volumeBonus = Math.min(stat.matches * 0.5, 10);
|
|
||||||
|
|
||||||
const baseMMR =
|
|
||||||
winScore * 0.5 +
|
|
||||||
avgCupsScore * 0.25 +
|
|
||||||
marginScore * 0.15 +
|
|
||||||
volumeBonus * 0.1;
|
|
||||||
|
|
||||||
const finalMMR = baseMMR * matchConfidence;
|
|
||||||
return Math.round(finalMMR * 10) / 10;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mmr = calculateMMR(stat);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -165,9 +139,12 @@ const PlayerListItem = ({ stat, index, onPlayerClick }: PlayerListItemProps) =>
|
|||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
PlayerListItem.displayName = 'PlayerListItem';
|
||||||
|
|
||||||
|
const PlayerStatsTable = () => {
|
||||||
|
const { data: playerStats } = useAllPlayerStats();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
||||||
@@ -196,8 +173,15 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
return Math.round(finalMMR * 10) / 10;
|
return Math.round(finalMMR * 10) / 10;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const statsWithMMR = useMemo(() => {
|
||||||
|
return playerStats.map((stat) => ({
|
||||||
|
...stat,
|
||||||
|
mmr: calculateMMR(stat),
|
||||||
|
}));
|
||||||
|
}, [playerStats]);
|
||||||
|
|
||||||
const filteredAndSortedStats = useMemo(() => {
|
const filteredAndSortedStats = useMemo(() => {
|
||||||
let filtered = playerStats.filter((stat) =>
|
let filtered = statsWithMMR.filter((stat) =>
|
||||||
stat.player_name.toLowerCase().includes(search.toLowerCase())
|
stat.player_name.toLowerCase().includes(search.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -206,8 +190,8 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
let bValue: number | string;
|
let bValue: number | string;
|
||||||
|
|
||||||
if (sortConfig.key === "mmr") {
|
if (sortConfig.key === "mmr") {
|
||||||
aValue = calculateMMR(a);
|
aValue = a.mmr;
|
||||||
bValue = calculateMMR(b);
|
bValue = b.mmr;
|
||||||
} else {
|
} else {
|
||||||
aValue = a[sortConfig.key];
|
aValue = a[sortConfig.key];
|
||||||
bValue = b[sortConfig.key];
|
bValue = b[sortConfig.key];
|
||||||
@@ -227,11 +211,11 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
}, [playerStats, search, sortConfig]);
|
}, [statsWithMMR, search, sortConfig]);
|
||||||
|
|
||||||
const handlePlayerClick = (playerId: string) => {
|
const handlePlayerClick = useCallback((playerId: string) => {
|
||||||
navigate({ to: `/profile/${playerId}` });
|
navigate({ to: `/profile/${playerId}` });
|
||||||
};
|
}, [navigate]);
|
||||||
|
|
||||||
const handleSort = (key: SortKey) => {
|
const handleSort = (key: SortKey) => {
|
||||||
setSortConfig((prev) => ({
|
setSortConfig((prev) => ({
|
||||||
@@ -351,8 +335,8 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
<Box key={stat.id}>
|
<Box key={stat.id}>
|
||||||
<PlayerListItem
|
<PlayerListItem
|
||||||
stat={stat}
|
stat={stat}
|
||||||
index={index}
|
|
||||||
onPlayerClick={handlePlayerClick}
|
onPlayerClick={handlePlayerClick}
|
||||||
|
mmr={stat.mmr}
|
||||||
/>
|
/>
|
||||||
{index < filteredAndSortedStats.length - 1 && <Divider />}
|
{index < filteredAndSortedStats.length - 1 && <Divider />}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Flex, Skeleton } from "@mantine/core";
|
|||||||
|
|
||||||
const HeaderSkeleton = () => {
|
const HeaderSkeleton = () => {
|
||||||
return (
|
return (
|
||||||
<Flex h="10vh" px='xl' w='100%' align='self-end' gap='md'>
|
<Flex h="15dvh" px='xl' w='100%' align='self-end' gap='md'>
|
||||||
<Skeleton opacity={0} height={100} width={100} radius="50%" />
|
<Skeleton opacity={0} height={100} width={100} radius="50%" />
|
||||||
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
||||||
<Skeleton height={24} width={200} />
|
<Skeleton height={24} width={200} />
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const Header = ({ player }: HeaderProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex h="10vh" px='xl' w='100%' align='self-end' gap='md'>
|
<Flex h="15dvh" px='xl' w='100%' align='self-end' gap='md'>
|
||||||
<Avatar name={name} size={100} />
|
<Avatar name={name} size={100} />
|
||||||
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
||||||
<Title ta='center' style={{ fontSize, lineHeight: 1.2 }}>{name}</Title>
|
<Title ta='center' style={{ fontSize, lineHeight: 1.2 }}>{name}</Title>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Header from "./header";
|
|||||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
|
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
|
||||||
import TeamList from "@/features/teams/components/team-list";
|
import TeamList from "@/features/teams/components/team-list";
|
||||||
import StatsOverview from "@/shared/components/stats-overview";
|
import StatsOverview from "@/components/stats-overview";
|
||||||
import MatchList from "@/features/matches/components/match-list";
|
import MatchList from "@/features/matches/components/match-list";
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Header from "./header";
|
|||||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
|
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
|
||||||
import TeamList from "@/features/teams/components/team-list";
|
import TeamList from "@/features/teams/components/team-list";
|
||||||
import StatsOverview from "@/shared/components/stats-overview";
|
import StatsOverview, { StatsSkeleton } from "@/components/stats-overview";
|
||||||
import MatchList from "@/features/matches/components/match-list";
|
import MatchList from "@/features/matches/components/match-list";
|
||||||
import HeaderSkeleton from "./header-skeleton";
|
import HeaderSkeleton from "./header-skeleton";
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ const ProfileSkeleton = () => {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
content: <SkeletonLoader />,
|
content: <StatsSkeleton />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Matches",
|
label: "Matches",
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import { Match } from "@/features/matches/types";
|
|||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { logger } from ".";
|
import { logger } from ".";
|
||||||
import { getWebRequest } from "@tanstack/react-start/server";
|
import { getRequest } from "@tanstack/react-start/server";
|
||||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
|
|
||||||
export const fetchMe = createServerFn()
|
export const fetchMe = createServerFn()
|
||||||
.handler(async () =>
|
.handler(async () =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const request = getWebRequest();
|
const request = getRequest();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const context = await getSessionContext(request);
|
const context = await getSessionContext(request);
|
||||||
@@ -21,24 +21,31 @@ export const fetchMe = createServerFn()
|
|||||||
return {
|
return {
|
||||||
user: result || undefined,
|
user: result || undefined,
|
||||||
roles: context.roles,
|
roles: context.roles,
|
||||||
metadata: context.metadata
|
metadata: context.metadata,
|
||||||
|
phone: context.phone
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.info('fetchMe: Session error', error.message);
|
// logger.info("FetchMe: Session error", error)
|
||||||
return { user: undefined, roles: [], metadata: {} };
|
if (error?.response?.status === 401) {
|
||||||
|
const errorData = error?.response?.data;
|
||||||
|
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { user: undefined, roles: [], metadata: {}, phone: undefined };
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getPlayer = createServerFn()
|
export const getPlayer = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<Player>(async () => await pbAdmin.getPlayer(data))
|
toServerResult<Player>(async () => await pbAdmin.getPlayer(data))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const updatePlayer = createServerFn()
|
export const updatePlayer = createServerFn()
|
||||||
.validator(playerUpdateSchema)
|
.inputValidator(playerUpdateSchema)
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
@@ -65,7 +72,7 @@ export const updatePlayer = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const createPlayer = createServerFn()
|
export const createPlayer = createServerFn()
|
||||||
.validator(playerInputSchema)
|
.inputValidator(playerInputSchema)
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
@@ -90,7 +97,7 @@ export const createPlayer = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const associatePlayer = createServerFn()
|
export const associatePlayer = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
@@ -122,7 +129,7 @@ export const getUnassociatedPlayers = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getPlayerStats = createServerFn()
|
export const getPlayerStats = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<PlayerStats>(async () => await pbAdmin.getPlayerStats(data))
|
toServerResult<PlayerStats>(async () => await pbAdmin.getPlayerStats(data))
|
||||||
@@ -135,14 +142,14 @@ export const getAllPlayerStats = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getPlayerMatches = createServerFn()
|
export const getPlayerMatches = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<Match[]>(async () => await pbAdmin.getPlayerMatches(data))
|
toServerResult<Match[]>(async () => await pbAdmin.getPlayerMatches(data))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getUnenrolledPlayers = createServerFn()
|
export const getUnenrolledPlayers = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: tournamentId }) =>
|
.handler(async ({ data: tournamentId }) =>
|
||||||
toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId))
|
toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId))
|
||||||
|
|||||||
@@ -58,15 +58,12 @@ const EmojiBar = ({
|
|||||||
return reaction.players.map(p => p.id).includes(user?.id || "");
|
return reaction.players.map(p => p.id).includes(user?.id || "");
|
||||||
}, [user?.id]);
|
}, [user?.id]);
|
||||||
|
|
||||||
// Get emojis the current user has reacted to
|
|
||||||
const userReactions = reactions?.filter(r => hasReacted(r)).map(r => r.emoji) || [];
|
const userReactions = reactions?.filter(r => hasReacted(r)).map(r => r.emoji) || [];
|
||||||
|
|
||||||
if (!reactions) return;
|
if (!reactions) return;
|
||||||
|
|
||||||
// Sort reactions by count (descending)
|
|
||||||
const sortedReactions = [...reactions].sort((a, b) => b.count - a.count);
|
const sortedReactions = [...reactions].sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
// Group reactions: show first 3, group the rest
|
|
||||||
const visibleReactions = sortedReactions.slice(0, 3);
|
const visibleReactions = sortedReactions.slice(0, 3);
|
||||||
const groupedReactions = sortedReactions.slice(3);
|
const groupedReactions = sortedReactions.slice(3);
|
||||||
|
|
||||||
@@ -81,8 +78,7 @@ const EmojiBar = ({
|
|||||||
{visibleReactions.map((reaction) => (
|
{visibleReactions.map((reaction) => (
|
||||||
<Button
|
<Button
|
||||||
key={reaction.emoji}
|
key={reaction.emoji}
|
||||||
variant={hasReacted(reaction) ? "filled" : "light"}
|
variant={"light"}
|
||||||
color="gray"
|
|
||||||
bd={hasReacted(reaction) ? "1px solid var(--mantine-primary-color-filled)" : undefined}
|
bd={hasReacted(reaction) ? "1px solid var(--mantine-primary-color-filled)" : undefined}
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
@@ -112,8 +108,7 @@ const EmojiBar = ({
|
|||||||
|
|
||||||
{hasGrouped && (
|
{hasGrouped && (
|
||||||
<Button
|
<Button
|
||||||
variant={userHasReactedToGrouped ? "filled" : "light"}
|
variant={"light"}
|
||||||
color="gray"
|
|
||||||
bd={userHasReactedToGrouped ? "1px solid var(--mantine-primary-color-filled)" : undefined}
|
bd={userHasReactedToGrouped ? "1px solid var(--mantine-primary-color-filled)" : undefined}
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useServerQuery, useServerMutation } from "@/lib/tanstack-query/hooks";
|
import { useServerMutation, useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
import { getMatchReactions, toggleMatchReaction } from "@/features/matches/server";
|
import { getMatchReactions, toggleMatchReaction } from "@/features/matches/server";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export const reactionQueries = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useMatchReactions = (matchId: string) =>
|
export const useMatchReactions = (matchId: string) =>
|
||||||
useServerQuery(reactionQueries.match(matchId));
|
useServerSuspenseQuery(reactionQueries.match(matchId));
|
||||||
|
|
||||||
export const useToggleMatchReaction = (matchId: string) => {
|
export const useToggleMatchReaction = (matchId: string) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|||||||
165
src/features/teams/components/manage-teams.tsx
Normal file
165
src/features/teams/components/manage-teams.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Stack,
|
||||||
|
Container,
|
||||||
|
Box,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import { Tournament } from "@/features/tournaments/types";
|
||||||
|
import TeamList from "./team-list";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import TeamForm from "./team-form";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
import { useTeam } from "../queries";
|
||||||
|
|
||||||
|
interface TeamEditSheetProps {
|
||||||
|
teamId: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamEditSheet = ({ teamId, isOpen, onClose }: TeamEditSheetProps) => {
|
||||||
|
const { data: team } = useTeam(teamId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet
|
||||||
|
title={team ? `Edit ${team.name}` : "Edit Team"}
|
||||||
|
opened={isOpen}
|
||||||
|
onChange={onClose}
|
||||||
|
>
|
||||||
|
{team && (
|
||||||
|
<TeamForm
|
||||||
|
teamId={team.id}
|
||||||
|
initialValues={{
|
||||||
|
...team,
|
||||||
|
players: team.players ? team.players.map((p) => p.id) : [],
|
||||||
|
logo: typeof team.logo === "string" ? undefined : team.logo,
|
||||||
|
}}
|
||||||
|
close={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ManageTeamsProps {
|
||||||
|
tournament: Tournament;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ManageTeams = ({ tournament }: ManageTeamsProps) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isOpen: editTeamOpened,
|
||||||
|
open: openEditTeam,
|
||||||
|
close: closeEditTeam,
|
||||||
|
} = useSheet();
|
||||||
|
|
||||||
|
const teams = tournament.teams || [];
|
||||||
|
|
||||||
|
const filteredTeams = useMemo(() => {
|
||||||
|
if (!search.trim()) return teams;
|
||||||
|
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
|
||||||
|
return teams.filter((team) => {
|
||||||
|
if (team.name.toLowerCase().includes(searchLower)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (team.players) {
|
||||||
|
return team.players.some((player) => {
|
||||||
|
const firstName = player.first_name?.toLowerCase() || "";
|
||||||
|
const lastName = player.last_name?.toLowerCase() || "";
|
||||||
|
const fullName = `${firstName} ${lastName}`.toLowerCase();
|
||||||
|
|
||||||
|
return fullName.includes(searchLower) ||
|
||||||
|
firstName.includes(searchLower) ||
|
||||||
|
lastName.includes(searchLower);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}, [teams, search]);
|
||||||
|
|
||||||
|
const handleTeamClick = (teamId: string) => {
|
||||||
|
setSelectedTeamId(teamId);
|
||||||
|
openEditTeam();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseEditTeam = () => {
|
||||||
|
setSelectedTeamId(null);
|
||||||
|
closeEditTeam();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!teams.length) {
|
||||||
|
return (
|
||||||
|
<Container px={0} size="md">
|
||||||
|
<Stack align="center" gap="md" py="xl">
|
||||||
|
<ThemeIcon size="xl" variant="light" radius="md">
|
||||||
|
<UsersIcon size={32} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Title order={3} c="dimmed">
|
||||||
|
No Teams Enrolled
|
||||||
|
</Title>
|
||||||
|
<Text c="dimmed" ta="center">
|
||||||
|
This tournament has no enrolled teams yet.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Container size="100%" px={0}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search teams by name or player..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
leftSection={<MagnifyingGlassIcon size={16} />}
|
||||||
|
size="md"
|
||||||
|
px="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box px="md">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{filteredTeams.length} of {teams.length} teams
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TeamList
|
||||||
|
teams={filteredTeams}
|
||||||
|
onTeamClick={handleTeamClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{filteredTeams.length === 0 && search && (
|
||||||
|
<Text ta="center" c="dimmed" py="xl">
|
||||||
|
No teams found matching "{search}"
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{selectedTeamId && (
|
||||||
|
<TeamEditSheet
|
||||||
|
teamId={selectedTeamId}
|
||||||
|
isOpen={editTeamOpened}
|
||||||
|
onClose={handleCloseEditTeam}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ManageTeams;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Stack, Text, Group, RangeSlider, Divider } from "@mantine/core";
|
import { Stack, Text, Group, TextInput, Button } from "@mantine/core";
|
||||||
|
|
||||||
interface DurationPickerProps {
|
interface DurationPickerProps {
|
||||||
songDurationMs: number;
|
songDurationMs: number;
|
||||||
@@ -9,6 +9,41 @@ interface DurationPickerProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IncrementButtonsProps {
|
||||||
|
onAdjust: (seconds: number) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
isPositive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IncrementButtons = ({ onAdjust, disabled, isPositive = true }: IncrementButtonsProps) => {
|
||||||
|
const increments = [1, 5, 30, 60];
|
||||||
|
const labels = ["1s", "5s", "30s", "1m"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap={3} wrap="nowrap" flex={1}>
|
||||||
|
{increments.map((increment, index) => (
|
||||||
|
<Button
|
||||||
|
key={increment}
|
||||||
|
variant={isPositive ? "light" : "outline"}
|
||||||
|
color={isPositive ? "blue" : "gray"}
|
||||||
|
size="xs"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onAdjust(isPositive ? increment : -increment)}
|
||||||
|
flex={1}
|
||||||
|
h={24}
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 500,
|
||||||
|
minWidth: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPositive ? '+' : '-'}{labels[index]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const DurationPicker = ({
|
const DurationPicker = ({
|
||||||
songDurationMs,
|
songDurationMs,
|
||||||
initialStart = 0,
|
initialStart = 0,
|
||||||
@@ -17,11 +52,6 @@ const DurationPicker = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
}: DurationPickerProps) => {
|
}: DurationPickerProps) => {
|
||||||
const songDurationSeconds = Math.floor(songDurationMs / 1000);
|
const songDurationSeconds = Math.floor(songDurationMs / 1000);
|
||||||
const [range, setRange] = useState<[number, number]>([
|
|
||||||
initialStart,
|
|
||||||
initialEnd,
|
|
||||||
]);
|
|
||||||
const [isValid, setIsValid] = useState(true);
|
|
||||||
|
|
||||||
const formatTime = useCallback((seconds: number) => {
|
const formatTime = useCallback((seconds: number) => {
|
||||||
const minutes = Math.floor(seconds / 60);
|
const minutes = Math.floor(seconds / 60);
|
||||||
@@ -29,7 +59,26 @@ const DurationPicker = ({
|
|||||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const validateRange = useCallback(
|
const [startTime, setStartTime] = useState(initialStart);
|
||||||
|
const [endTime, setEndTime] = useState(initialEnd);
|
||||||
|
const [isValid, setIsValid] = useState(true);
|
||||||
|
const [startInputValue, setStartInputValue] = useState(formatTime(initialStart));
|
||||||
|
const [endInputValue, setEndInputValue] = useState(formatTime(initialEnd));
|
||||||
|
|
||||||
|
const parseTimeInput = useCallback((input: string): number | null => {
|
||||||
|
if (input.includes(':')) {
|
||||||
|
const parts = input.split(':');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const minutes = parseInt(parts[0]) || 0;
|
||||||
|
const seconds = parseInt(parts[1]) || 0;
|
||||||
|
return minutes * 60 + seconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parsed = parseInt(input);
|
||||||
|
return isNaN(parsed) ? null : parsed;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const validateTimes = useCallback(
|
||||||
(start: number, end: number) => {
|
(start: number, end: number) => {
|
||||||
const duration = end - start;
|
const duration = end - start;
|
||||||
const withinBounds = start >= 0 && end <= songDurationSeconds;
|
const withinBounds = start >= 0 && end <= songDurationSeconds;
|
||||||
@@ -53,146 +102,150 @@ const DurationPicker = ({
|
|||||||
return null;
|
return null;
|
||||||
}, [songDurationSeconds]);
|
}, [songDurationSeconds]);
|
||||||
|
|
||||||
const handleRangeChange = useCallback(
|
const updateTimes = useCallback((newStart: number, newEnd: number) => {
|
||||||
(newRange: [number, number]) => {
|
const clampedStart = Math.max(0, Math.min(newStart, songDurationSeconds - 10));
|
||||||
setRange(newRange);
|
const clampedEnd = Math.min(songDurationSeconds, Math.max(newEnd, clampedStart + 10));
|
||||||
const [start, end] = newRange;
|
|
||||||
const valid = validateRange(start, end);
|
setStartTime(clampedStart);
|
||||||
|
setEndTime(clampedEnd);
|
||||||
|
setStartInputValue(formatTime(clampedStart));
|
||||||
|
setEndInputValue(formatTime(clampedEnd));
|
||||||
|
|
||||||
|
const valid = validateTimes(clampedStart, clampedEnd);
|
||||||
setIsValid(valid);
|
setIsValid(valid);
|
||||||
|
|
||||||
if (valid) {
|
if (valid) {
|
||||||
onChange(start, end);
|
onChange(clampedStart, clampedEnd);
|
||||||
}
|
}
|
||||||
},
|
}, [songDurationSeconds, validateTimes, onChange, formatTime]);
|
||||||
[onChange, validateRange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRangeChangeEnd = useCallback(
|
const handleStartInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
(newRange: [number, number]) => {
|
setStartInputValue(event.target.value);
|
||||||
let [start, end] = newRange;
|
}, []);
|
||||||
let duration = end - start;
|
|
||||||
|
|
||||||
if (duration < 10) {
|
const handleEndInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (start < songDurationSeconds / 2) {
|
setEndInputValue(event.target.value);
|
||||||
end = Math.min(start + 10, songDurationSeconds);
|
}, []);
|
||||||
|
|
||||||
|
const handleStartBlur = useCallback(() => {
|
||||||
|
const parsed = parseTimeInput(startInputValue);
|
||||||
|
if (parsed !== null) {
|
||||||
|
updateTimes(parsed, endTime);
|
||||||
} else {
|
} else {
|
||||||
start = Math.max(end - 10, 0);
|
setStartInputValue(formatTime(startTime));
|
||||||
}
|
|
||||||
duration = end - start;
|
|
||||||
}
|
}
|
||||||
|
}, [startInputValue, endTime, updateTimes, parseTimeInput, formatTime, startTime]);
|
||||||
|
|
||||||
if (duration > 15) {
|
const handleEndBlur = useCallback(() => {
|
||||||
const startDiff = Math.abs(start - range[0]);
|
const parsed = parseTimeInput(endInputValue);
|
||||||
const endDiff = Math.abs(end - range[1]);
|
if (parsed !== null) {
|
||||||
|
updateTimes(startTime, parsed);
|
||||||
if (startDiff > endDiff) {
|
|
||||||
end = start + 15;
|
|
||||||
if (end > songDurationSeconds) {
|
|
||||||
end = songDurationSeconds;
|
|
||||||
start = end - 15;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
start = end - 15;
|
setEndInputValue(formatTime(endTime));
|
||||||
if (start < 0) {
|
|
||||||
start = 0;
|
|
||||||
end = start + 15;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}, [endInputValue, startTime, updateTimes, parseTimeInput, formatTime, endTime]);
|
||||||
|
|
||||||
start = Math.max(0, start);
|
const adjustStartTime = useCallback((seconds: number) => {
|
||||||
end = Math.min(songDurationSeconds, end);
|
updateTimes(startTime + seconds, endTime);
|
||||||
|
}, [startTime, endTime, updateTimes]);
|
||||||
|
|
||||||
const finalRange: [number, number] = [start, end];
|
const adjustEndTime = useCallback((seconds: number) => {
|
||||||
setRange(finalRange);
|
updateTimes(startTime, endTime + seconds);
|
||||||
setIsValid(validateRange(start, end));
|
}, [startTime, endTime, updateTimes]);
|
||||||
onChange(start, end);
|
|
||||||
},
|
|
||||||
[range, songDurationSeconds, onChange, validateRange]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!validateRange(initialStart, initialEnd)) {
|
if (!validateTimes(initialStart, initialEnd)) {
|
||||||
const defaultStart = Math.min(30, Math.max(0, songDurationSeconds - 15));
|
const defaultStart = Math.min(30, Math.max(0, songDurationSeconds - 15));
|
||||||
const defaultEnd = Math.min(defaultStart + 15, songDurationSeconds);
|
const defaultEnd = Math.min(defaultStart + 15, songDurationSeconds);
|
||||||
const defaultRange: [number, number] = [defaultStart, defaultEnd];
|
updateTimes(defaultStart, defaultEnd);
|
||||||
setRange(defaultRange);
|
|
||||||
onChange(defaultStart, defaultEnd);
|
|
||||||
}
|
}
|
||||||
}, [initialStart, initialEnd, songDurationSeconds, validateRange, onChange]);
|
}, [initialStart, initialEnd, songDurationSeconds, validateTimes, updateTimes]);
|
||||||
|
|
||||||
const segmentDuration = range[1] - range[0];
|
const segmentDuration = endTime - startTime;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md" opacity={disabled ? 0.5 : 1}>
|
<Stack gap="sm" opacity={disabled ? 0.5 : 1}>
|
||||||
<div>
|
<Text size="sm" fw={500} c={disabled ? "dimmed" : undefined} ta="center">
|
||||||
<Text size="sm" fw={500} mb="xs" c={disabled ? "dimmed" : undefined}>
|
Walkout Segment ({segmentDuration}s)
|
||||||
Start and End
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed" mb="md">
|
|
||||||
{disabled ? "Select a song to choose segment timing" : "Choose a 10-15 second segment for your walkout song"}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RangeSlider
|
|
||||||
min={0}
|
|
||||||
max={songDurationSeconds}
|
|
||||||
step={1}
|
|
||||||
value={range}
|
|
||||||
onChange={disabled ? undefined : handleRangeChange}
|
|
||||||
onChangeEnd={disabled ? undefined : handleRangeChangeEnd}
|
|
||||||
marks={[
|
|
||||||
{ value: 0, label: "0:00" },
|
|
||||||
{
|
|
||||||
value: songDurationSeconds,
|
|
||||||
label: formatTime(songDurationSeconds),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
size="lg"
|
|
||||||
m='xs'
|
|
||||||
color={disabled ? "gray" : (isValid ? "blue" : "red")}
|
|
||||||
thumbSize={20}
|
|
||||||
label={disabled ? undefined : (value) => formatTime(value)}
|
|
||||||
disabled={disabled}
|
|
||||||
styles={{
|
|
||||||
track: { height: 8 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Stack gap={4}>
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Stack gap={2} align="center">
|
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
Start
|
Start
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={500}>
|
<TextInput
|
||||||
{formatTime(range[0])}
|
value={startInputValue}
|
||||||
</Text>
|
onChange={handleStartInputChange}
|
||||||
|
onBlur={handleStartBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
size="xs"
|
||||||
|
w={70}
|
||||||
|
placeholder="0:00"
|
||||||
|
ta="center"
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '12px'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group gap={4}>
|
||||||
|
<IncrementButtons
|
||||||
|
onAdjust={adjustStartTime}
|
||||||
|
disabled={disabled || startTime <= 0}
|
||||||
|
isPositive={false}
|
||||||
|
/>
|
||||||
|
<IncrementButtons
|
||||||
|
onAdjust={adjustStartTime}
|
||||||
|
disabled={disabled || startTime >= songDurationSeconds - 10}
|
||||||
|
isPositive={true}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack gap={2} align="center">
|
<Stack gap={4}>
|
||||||
<Text size="xs" c="dimmed">
|
<Group justify="space-between" align="center">
|
||||||
Duration
|
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
|
||||||
</Text>
|
|
||||||
<Text size="sm" fw={500} c={isValid ? undefined : "red"}>
|
|
||||||
{segmentDuration}s
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack gap={2} align="center">
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
End
|
End
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={500}>
|
<TextInput
|
||||||
{formatTime(range[1])}
|
value={endInputValue}
|
||||||
</Text>
|
onChange={handleEndInputChange}
|
||||||
</Stack>
|
onBlur={handleEndBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
size="xs"
|
||||||
|
w={70}
|
||||||
|
placeholder="0:15"
|
||||||
|
ta="center"
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '12px'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
<Group gap={4}>
|
||||||
|
<IncrementButtons
|
||||||
|
onAdjust={adjustEndTime}
|
||||||
|
disabled={disabled || endTime <= startTime + 10}
|
||||||
|
isPositive={false}
|
||||||
|
/>
|
||||||
|
<IncrementButtons
|
||||||
|
onAdjust={adjustEndTime}
|
||||||
|
disabled={disabled || endTime >= songDurationSeconds}
|
||||||
|
isPositive={true}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
{!isValid && (
|
{!isValid && (
|
||||||
<Text size="xs" c="red" ta="center">
|
<Text size="xs" c="red" ta="center">
|
||||||
{getValidationMessage(range[0], range[1])}
|
{getValidationMessage(startTime, endTime)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Badge, FileInput, Group, Stack, Text, TextInput } from "@mantine/core";
|
import { FileInput, Stack, TextInput } from "@mantine/core";
|
||||||
import { useForm, UseFormInput } from "@mantine/form";
|
import { useForm, UseFormInput } from "@mantine/form";
|
||||||
import { LinkIcon } from "@phosphor-icons/react";
|
import { LinkIcon } from "@phosphor-icons/react";
|
||||||
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
|
import SlidePanel from "@/components/sheet/slide-panel";
|
||||||
import { isNotEmpty } from "@mantine/form";
|
import { isNotEmpty } from "@mantine/form";
|
||||||
import useCreateTeam from "../../hooks/use-create-team";
|
import useCreateTeam from "../../hooks/use-create-team";
|
||||||
import useUpdateTeam from "../../hooks/use-update-team";
|
import useUpdateTeam from "../../hooks/use-update-team";
|
||||||
@@ -13,8 +13,8 @@ import { useCallback } from "react";
|
|||||||
import { TeamInput } from "../../types";
|
import { TeamInput } from "../../types";
|
||||||
import { teamKeys } from "../../queries";
|
import { teamKeys } from "../../queries";
|
||||||
import SongPicker from "./song-picker";
|
import SongPicker from "./song-picker";
|
||||||
import TeamColorPicker from "./color-picker";
|
|
||||||
import PlayersPicker from "./players-picker";
|
import PlayersPicker from "./players-picker";
|
||||||
|
import imageCompression from "browser-image-compression";
|
||||||
|
|
||||||
interface TeamFormProps {
|
interface TeamFormProps {
|
||||||
close: () => void;
|
close: () => void;
|
||||||
@@ -113,9 +113,32 @@ const TeamForm = ({
|
|||||||
|
|
||||||
if (logo && team) {
|
if (logo && team) {
|
||||||
try {
|
try {
|
||||||
|
let processedLogo = logo;
|
||||||
|
|
||||||
|
if (logo.size > 500 * 1024) {
|
||||||
|
const compressionOptions = {
|
||||||
|
maxSizeMB: 0.5,
|
||||||
|
maxWidthOrHeight: 800,
|
||||||
|
useWebWorker: true,
|
||||||
|
fileType: logo.type,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
processedLogo = await imageCompression(logo, compressionOptions);
|
||||||
|
logger.info("image compressed", {
|
||||||
|
originalSize: logo.size,
|
||||||
|
compressedSize: processedLogo.size,
|
||||||
|
reduction: ((logo.size - processedLogo.size) / logo.size * 100).toFixed(1) + "%"
|
||||||
|
});
|
||||||
|
} catch (compressionError) {
|
||||||
|
logger.warn("compression failed, falling back", compressionError);
|
||||||
|
processedLogo = logo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("teamId", team.id);
|
formData.append("teamId", team.id);
|
||||||
formData.append("logo", logo);
|
formData.append("logo", processedLogo);
|
||||||
|
|
||||||
const response = await fetch("/api/teams/upload-logo", {
|
const response = await fetch("/api/teams/upload-logo", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import SongSearch from "./song-search";
|
|||||||
import DurationPicker from "./duration-picker";
|
import DurationPicker from "./duration-picker";
|
||||||
import SongSummary from "./song-summary";
|
import SongSummary from "./song-summary";
|
||||||
import { MusicNote } from "@phosphor-icons/react/dist/ssr";
|
import { MusicNote } from "@phosphor-icons/react/dist/ssr";
|
||||||
|
import { MusicNoteIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
interface Song {
|
interface Song {
|
||||||
song_id: string;
|
song_id: string;
|
||||||
@@ -17,6 +18,7 @@ interface Song {
|
|||||||
song_start?: number;
|
song_start?: number;
|
||||||
song_end?: number;
|
song_end?: number;
|
||||||
song_image_url: string;
|
song_image_url: string;
|
||||||
|
duration_ms?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SongPickerProps {
|
interface SongPickerProps {
|
||||||
@@ -61,7 +63,7 @@ const SongPicker = ({ form, error }: SongPickerProps) => {
|
|||||||
}}
|
}}
|
||||||
error={error}
|
error={error}
|
||||||
Component={SongPickerComponent}
|
Component={SongPickerComponent}
|
||||||
componentProps={{ formValues: form.getValues() }}
|
componentProps={{}}
|
||||||
title={"Select Song"}
|
title={"Select Song"}
|
||||||
label={"Walkout Song"}
|
label={"Walkout Song"}
|
||||||
placeholder={"Select your walkout song"}
|
placeholder={"Select your walkout song"}
|
||||||
@@ -72,10 +74,9 @@ const SongPicker = ({ form, error }: SongPickerProps) => {
|
|||||||
interface SongPickerComponentProps {
|
interface SongPickerComponentProps {
|
||||||
value: Song | undefined;
|
value: Song | undefined;
|
||||||
onChange: (song: Song) => void;
|
onChange: (song: Song) => void;
|
||||||
formValues: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerComponentProps) => {
|
const SongPickerComponent = ({ value: song, onChange }: SongPickerComponentProps) => {
|
||||||
const handleSongSelect = (track: SpotifyTrack) => {
|
const handleSongSelect = (track: SpotifyTrack) => {
|
||||||
const defaultStart = 0;
|
const defaultStart = 0;
|
||||||
const defaultEnd = Math.min(15, Math.floor(track.duration_ms / 1000));
|
const defaultEnd = Math.min(15, Math.floor(track.duration_ms / 1000));
|
||||||
@@ -88,6 +89,7 @@ const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerCo
|
|||||||
song_image_url: track.album.images[0]?.url || '',
|
song_image_url: track.album.images[0]?.url || '',
|
||||||
song_start: defaultStart,
|
song_start: defaultStart,
|
||||||
song_end: defaultEnd,
|
song_end: defaultEnd,
|
||||||
|
duration_ms: track.duration_ms,
|
||||||
};
|
};
|
||||||
|
|
||||||
onChange(newSong);
|
onChange(newSong);
|
||||||
@@ -117,7 +119,7 @@ const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerCo
|
|||||||
radius="md"
|
radius="md"
|
||||||
bg="transparent"
|
bg="transparent"
|
||||||
>
|
>
|
||||||
{!song?.song_image_url && <MusicNote size={24} color="var(--mantine-color-dimmed)" />}
|
{!song?.song_image_url && <MusicNoteIcon size={24} color="var(--mantine-color-dimmed)" />}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm" fw={500} c={song?.song_name ? undefined : "dimmed"}>
|
<Text size="sm" fw={500} c={song?.song_name ? undefined : "dimmed"}>
|
||||||
@@ -134,7 +136,7 @@ const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerCo
|
|||||||
|
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<DurationPicker
|
<DurationPicker
|
||||||
songDurationMs={180000}
|
songDurationMs={song?.duration_ms || 180000}
|
||||||
initialStart={song?.song_start || 0}
|
initialStart={song?.song_start || 0}
|
||||||
initialEnd={song?.song_end || 15}
|
initialEnd={song?.song_end || 15}
|
||||||
onChange={handleDurationChange}
|
onChange={handleDurationChange}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { Text, Group, Avatar, Box } from "@mantine/core";
|
||||||
import { Text, Combobox, InputBase, useCombobox, Group, Avatar, Loader } from "@mantine/core";
|
|
||||||
import { SpotifyTrack } from "@/lib/spotify/types";
|
import { SpotifyTrack } from "@/lib/spotify/types";
|
||||||
import { useDebouncedCallback } from "@mantine/hooks";
|
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
|
||||||
|
|
||||||
interface SongSearchProps {
|
interface SongSearchProps {
|
||||||
onChange: (track: SpotifyTrack) => void;
|
onChange: (track: SpotifyTrack) => void;
|
||||||
@@ -9,14 +8,7 @@ interface SongSearchProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
|
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const searchSpotifyTracks = async (query: string): Promise<TypeaheadOption<SpotifyTrack>[]> => {
|
||||||
const [searchResults, setSearchResults] = useState<SpotifyTrack[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const combobox = useCombobox();
|
|
||||||
|
|
||||||
// Standalone search function that doesn't require Spotify context
|
|
||||||
const searchSpotifyTracks = async (query: string): Promise<SpotifyTrack[]> => {
|
|
||||||
if (!query.trim()) return [];
|
if (!query.trim()) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -27,45 +19,36 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.tracks || [];
|
const tracks = data.tracks || [];
|
||||||
|
|
||||||
|
return tracks.map((track: SpotifyTrack) => ({
|
||||||
|
id: track.id,
|
||||||
|
data: track
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to search tracks:', error);
|
console.error('Failed to search tracks:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const debouncedSearch = useDebouncedCallback(async (query: string) => {
|
const handleSongSelect = (option: TypeaheadOption<SpotifyTrack>) => {
|
||||||
if (!query.trim()) {
|
onChange(option.data);
|
||||||
setSearchResults([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const results = await searchSpotifyTracks(query);
|
|
||||||
setSearchResults(results);
|
|
||||||
combobox.openDropdown();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Search failed:', error);
|
|
||||||
setSearchResults([]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
|
||||||
setSearchQuery(value);
|
|
||||||
debouncedSearch(value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSongSelect = (track: SpotifyTrack) => {
|
const formatTrack = (option: TypeaheadOption<SpotifyTrack>) => {
|
||||||
onChange(track);
|
const track = option.data;
|
||||||
setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`);
|
return `${track.name} - ${track.artists.map(a => a.name).join(', ')}`;
|
||||||
combobox.closeDropdown();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = searchResults.map((track) => (
|
const renderOption = (option: TypeaheadOption<SpotifyTrack>) => {
|
||||||
<Combobox.Option value={track.id} key={track.id}>
|
const track = option.data;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
borderBottom: '1px solid var(--mantine-color-dimmed)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
{track.album.images[2] && (
|
{track.album.images[2] && (
|
||||||
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
|
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
|
||||||
@@ -79,43 +62,19 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
</Combobox.Option>
|
</Box>
|
||||||
));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Combobox
|
|
||||||
store={combobox}
|
|
||||||
onOptionSubmit={(value) => {
|
|
||||||
const track = searchResults.find(t => t.id === value);
|
|
||||||
if (track) handleSongSelect(track);
|
|
||||||
}}
|
|
||||||
width='100%'
|
|
||||||
zIndex={9999}
|
|
||||||
withinPortal={false}
|
|
||||||
>
|
|
||||||
<Combobox.Target>
|
|
||||||
<InputBase
|
|
||||||
rightSection={isLoading ? <Loader size="xs" /> : <Combobox.Chevron />}
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(event) => handleSearchChange(event.currentTarget.value)}
|
|
||||||
onClick={() => combobox.openDropdown()}
|
|
||||||
onFocus={() => combobox.openDropdown()}
|
|
||||||
onBlur={() => combobox.closeDropdown()}
|
|
||||||
placeholder={placeholder}
|
|
||||||
/>
|
|
||||||
</Combobox.Target>
|
|
||||||
|
|
||||||
<Combobox.Dropdown>
|
|
||||||
<Combobox.Options>
|
|
||||||
{options.length > 0 ? options :
|
|
||||||
<Combobox.Empty>
|
|
||||||
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
|
|
||||||
</Combobox.Empty>
|
|
||||||
}
|
|
||||||
</Combobox.Options>
|
|
||||||
</Combobox.Dropdown>
|
|
||||||
</Combobox>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Typeahead
|
||||||
|
onSelect={handleSongSelect}
|
||||||
|
searchFn={searchSpotifyTracks}
|
||||||
|
renderOption={renderOption}
|
||||||
|
format={formatTrack}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
export default SongSearch;
|
export default SongSearch;
|
||||||
@@ -39,19 +39,26 @@ const TeamListItem = React.memo(({ team }: TeamListItemProps) => {
|
|||||||
interface TeamListProps {
|
interface TeamListProps {
|
||||||
teams: TeamInfo[];
|
teams: TeamInfo[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
onTeamClick?: (teamId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TeamList = ({ teams, loading = false }: TeamListProps) => {
|
const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(teamId: string) => navigate({ to: `/teams/${teamId}` }),
|
(teamId: string) => {
|
||||||
[navigate]
|
if (onTeamClick) {
|
||||||
|
onTeamClick(teamId);
|
||||||
|
} else {
|
||||||
|
navigate({ to: `/teams/${teamId}` });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[navigate, onTeamClick]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading)
|
if (loading)
|
||||||
return (
|
return (
|
||||||
<List>
|
<List p="0">
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={`skeleton-${i}`}
|
key={`skeleton-${i}`}
|
||||||
@@ -65,10 +72,11 @@ const TeamList = ({ teams, loading = false }: TeamListProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List>
|
<List p="0">
|
||||||
{teams?.map((team) => (
|
{teams?.map((team) => (
|
||||||
<div key={team.id}>
|
<div key={team.id}>
|
||||||
<ListItem
|
<ListItem
|
||||||
|
key={`team-list-${team.id}`}
|
||||||
p="xs"
|
p="xs"
|
||||||
icon={
|
icon={
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Flex, Skeleton } from "@mantine/core";
|
||||||
|
|
||||||
|
const HeaderSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<Flex h="20dvh" px='xl' w='100%' align='flex-end' gap='md'>
|
||||||
|
<Skeleton opacity={0} height={200} width={150} />
|
||||||
|
<Flex align='center' justify='center' gap={4} w='100%'>
|
||||||
|
<Skeleton height={36} width={200} />
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeaderSkeleton;
|
||||||
@@ -11,7 +11,7 @@ interface HeaderProps {
|
|||||||
const Header = ({ name, logo, id }: HeaderProps) => {
|
const Header = ({ name, logo, id }: HeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex px="xl" w="100%" align="self-end" gap="md">
|
<Flex h="20dvh" px="xl" w="100%" align="self-end" gap="md">
|
||||||
<Avatar
|
<Avatar
|
||||||
radius="sm"
|
radius="sm"
|
||||||
name={name}
|
name={name}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Box, Divider, Text, Stack } from "@mantine/core";
|
|||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
import TournamentList from "@/features/tournaments/components/tournament-list";
|
import TournamentList from "@/features/tournaments/components/tournament-list";
|
||||||
import StatsOverview from "@/shared/components/stats-overview";
|
import StatsOverview from "@/components/stats-overview";
|
||||||
import { useTeam, useTeamMatches, useTeamStats } from "../../queries";
|
import { useTeam, useTeamMatches, useTeamStats } from "../../queries";
|
||||||
import MatchList from "@/features/matches/components/match-list";
|
import MatchList from "@/features/matches/components/match-list";
|
||||||
import PlayerList from "@/features/players/components/player-list";
|
import PlayerList from "@/features/players/components/player-list";
|
||||||
|
|||||||
37
src/features/teams/components/team-profile/skeleton.tsx
Normal file
37
src/features/teams/components/team-profile/skeleton.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Box, Flex, Loader } from "@mantine/core";
|
||||||
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
|
import HeaderSkeleton from "./header-skeleton";
|
||||||
|
|
||||||
|
const SkeletonLoader = () => (
|
||||||
|
<Flex h="30vh" w="100%" align="center" justify="center">
|
||||||
|
<Loader />
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ProfileSkeleton = () => {
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
label: "Overview",
|
||||||
|
content: <SkeletonLoader />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Matches",
|
||||||
|
content: <SkeletonLoader />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Tournaments",
|
||||||
|
content: <SkeletonLoader />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeaderSkeleton />
|
||||||
|
<Box mt="lg">
|
||||||
|
<SwipeableTabs tabs={tabs} />
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileSkeleton;
|
||||||
@@ -15,21 +15,21 @@ export const listTeamInfos = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getTeam = createServerFn()
|
export const getTeam = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: teamId }) =>
|
.handler(async ({ data: teamId }) =>
|
||||||
toServerResult(() => pbAdmin.getTeam(teamId))
|
toServerResult(() => pbAdmin.getTeam(teamId))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getTeamInfo = createServerFn()
|
export const getTeamInfo = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: teamId }) =>
|
.handler(async ({ data: teamId }) =>
|
||||||
toServerResult(() => pbAdmin.getTeamInfo(teamId))
|
toServerResult(() => pbAdmin.getTeamInfo(teamId))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const createTeam = createServerFn()
|
export const createTeam = createServerFn()
|
||||||
.validator(teamInputSchema)
|
.inputValidator(teamInputSchema)
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data, context }) =>
|
.handler(async ({ data, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
@@ -46,7 +46,7 @@ export const createTeam = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const updateTeam = createServerFn()
|
export const updateTeam = createServerFn()
|
||||||
.validator(z.object({
|
.inputValidator(z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
updates: teamUpdateSchema
|
updates: teamUpdateSchema
|
||||||
}))
|
}))
|
||||||
@@ -72,14 +72,14 @@ export const updateTeam = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getTeamStats = createServerFn()
|
export const getTeamStats = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: teamId }) =>
|
.handler(async ({ data: teamId }) =>
|
||||||
toServerResult(() => pbAdmin.getTeamStats(teamId))
|
toServerResult(() => pbAdmin.getTeamStats(teamId))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getTeamMatches = createServerFn()
|
export const getTeamMatches = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<Match[]>(async () => await pbAdmin.getTeamMatches(data))
|
toServerResult<Match[]>(async () => await pbAdmin.getTeamMatches(data))
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
Autocomplete,
|
|
||||||
Stack,
|
Stack,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Text,
|
Text,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
|
||||||
import { TrashIcon } from "@phosphor-icons/react";
|
import { TrashIcon } from "@phosphor-icons/react";
|
||||||
import { useState, useCallback, useMemo, memo } from "react";
|
import { useState, useCallback, useMemo, memo } from "react";
|
||||||
import { useTournament, useUnenrolledTeams } from "../queries";
|
import { useTournament, useUnenrolledTeams } from "../queries";
|
||||||
@@ -36,6 +36,7 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => {
|
|||||||
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
|
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
|
||||||
<Avatar
|
<Avatar
|
||||||
size={32}
|
size={32}
|
||||||
|
radius="sm"
|
||||||
name={team.name}
|
name={team.name}
|
||||||
src={
|
src={
|
||||||
team.logo
|
team.logo
|
||||||
@@ -67,8 +68,6 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
|
|
||||||
const { data: tournament, isLoading: tournamentLoading } =
|
const { data: tournament, isLoading: tournamentLoading } =
|
||||||
useTournament(tournamentId);
|
useTournament(tournamentId);
|
||||||
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
|
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
|
||||||
@@ -77,27 +76,24 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
|||||||
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
|
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
|
||||||
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
|
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
|
||||||
|
|
||||||
const autocompleteData = useMemo(
|
const searchTeams = async (query: string): Promise<TypeaheadOption<Team>[]> => {
|
||||||
() =>
|
if (!query.trim()) return [];
|
||||||
unenrolledTeams.map((team: Team) => ({
|
|
||||||
value: team.id,
|
const filtered = unenrolledTeams.filter((team: Team) =>
|
||||||
label: team.name,
|
team.name.toLowerCase().includes(query.toLowerCase())
|
||||||
})),
|
|
||||||
[unenrolledTeams]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return filtered.map((team: Team) => ({
|
||||||
|
id: team.id,
|
||||||
|
data: team
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const handleEnrollTeam = useCallback(
|
const handleEnrollTeam = useCallback(
|
||||||
(teamId: string) => {
|
(option: TypeaheadOption<Team>) => {
|
||||||
enrollTeam(
|
enrollTeam({ tournamentId, teamId: option.data.id });
|
||||||
{ tournamentId, teamId },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
setSearch("");
|
|
||||||
},
|
},
|
||||||
}
|
[enrollTeam, tournamentId]
|
||||||
);
|
|
||||||
},
|
|
||||||
[enrollTeam, tournamentId, setSearch]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUnenrollTeam = useCallback(
|
const handleUnenrollTeam = useCallback(
|
||||||
@@ -107,6 +103,31 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
|||||||
[unenrollTeam, tournamentId]
|
[unenrollTeam, tournamentId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderTeamOption = (option: TypeaheadOption<Team>) => {
|
||||||
|
const team = option.data;
|
||||||
|
return (
|
||||||
|
<Group py="xs" px="sm" gap="sm" align="center">
|
||||||
|
<Avatar
|
||||||
|
size={32}
|
||||||
|
radius="sm"
|
||||||
|
name={team.name}
|
||||||
|
src={
|
||||||
|
team.logo
|
||||||
|
? `/api/files/teams/${team.id}/${team.logo}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text fw={500} truncate>
|
||||||
|
{team.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTeam = (option: TypeaheadOption<Team>) => {
|
||||||
|
return option.data.name;
|
||||||
|
};
|
||||||
|
|
||||||
const isLoading = tournamentLoading || unenrolledLoading;
|
const isLoading = tournamentLoading || unenrolledLoading;
|
||||||
const enrolledTeams = tournament?.teams || [];
|
const enrolledTeams = tournament?.teams || [];
|
||||||
const hasEnrolledTeams = enrolledTeams.length > 0;
|
const hasEnrolledTeams = enrolledTeams.length > 0;
|
||||||
@@ -117,16 +138,13 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
|||||||
<Text fw={600} size="sm">
|
<Text fw={600} size="sm">
|
||||||
Add Team
|
Add Team
|
||||||
</Text>
|
</Text>
|
||||||
<Autocomplete
|
<Typeahead
|
||||||
placeholder="Search for teams to enroll..."
|
placeholder="Search for teams to enroll..."
|
||||||
data={autocompleteData}
|
onSelect={handleEnrollTeam}
|
||||||
value={search}
|
searchFn={searchTeams}
|
||||||
onChange={setSearch}
|
renderOption={renderTeamOption}
|
||||||
onOptionSubmit={handleEnrollTeam}
|
format={formatTeam}
|
||||||
disabled={isEnrolling || unenrolledLoading}
|
disabled={isEnrolling || unenrolledLoading}
|
||||||
rightSection={isEnrolling ? <Loader size="xs" /> : null}
|
|
||||||
maxDropdownHeight={200}
|
|
||||||
limit={10}
|
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
PencilLineIcon,
|
PencilLineIcon,
|
||||||
TreeStructureIcon,
|
TreeStructureIcon,
|
||||||
UsersThreeIcon,
|
UsersThreeIcon,
|
||||||
|
UsersIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
import EditEnrolledTeams from "./edit-enrolled-teams";
|
import EditEnrolledTeams from "./edit-enrolled-teams";
|
||||||
@@ -44,7 +45,7 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<List>
|
<List p="0">
|
||||||
<ListButton
|
<ListButton
|
||||||
label="Edit Tournament"
|
label="Edit Tournament"
|
||||||
Icon={HardDrivesIcon}
|
Icon={HardDrivesIcon}
|
||||||
@@ -56,10 +57,15 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
|||||||
onClick={openEditRules}
|
onClick={openEditRules}
|
||||||
/>
|
/>
|
||||||
<ListButton
|
<ListButton
|
||||||
label="Edit Enrolled Teams"
|
label="Edit Enrollments"
|
||||||
Icon={UsersThreeIcon}
|
Icon={UsersThreeIcon}
|
||||||
onClick={openEditTeams}
|
onClick={openEditTeams}
|
||||||
/>
|
/>
|
||||||
|
<ListLink
|
||||||
|
label="Manage Team Songs/Logos"
|
||||||
|
Icon={UsersIcon}
|
||||||
|
to={`/admin/tournaments/${tournamentId}/teams`}
|
||||||
|
/>
|
||||||
<ListLink
|
<ListLink
|
||||||
label="Run Tournament"
|
label="Run Tournament"
|
||||||
Icon={TreeStructureIcon}
|
Icon={TreeStructureIcon}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Flex, Skeleton } from "@mantine/core";
|
||||||
|
|
||||||
|
const HeaderSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<Flex h="20dvh" px='xl' w='100%' align='flex-end' gap='md'>
|
||||||
|
<Skeleton opacity={0} height={150} width={150} />
|
||||||
|
<Flex align='center' justify='center' gap={4} w='100%'>
|
||||||
|
<Skeleton height={36} width={200} />
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeaderSkeleton;
|
||||||
@@ -10,8 +10,8 @@ const Header = ({ tournament }: HeaderProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex px='xl' w='100%' align='self-end' gap='md'>
|
<Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'>
|
||||||
<Avatar name={tournament.name} radius={0} withBorder={false} size={125} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />
|
<Avatar contain name={tournament.name} radius={0} withBorder={false} size={150} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />
|
||||||
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
||||||
<Title ta='center' order={2}>{tournament.name}</Title>
|
<Title ta='center' order={2}>{tournament.name}</Title>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Box, Text } from "@mantine/core";
|
import { useMemo } from "react";
|
||||||
|
import { Box } from "@mantine/core";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
import TeamList from "@/features/teams/components/team-list";
|
import TeamList from "@/features/teams/components/team-list";
|
||||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
import { useTournament } from "../../queries";
|
import { useTournament } from "../../queries";
|
||||||
import MatchList from "@/features/matches/components/match-list";
|
import MatchList from "@/features/matches/components/match-list";
|
||||||
|
import { TournamentStats } from "../tournament-stats";
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,10 +15,10 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
const { data: tournament } = useTournament(id);
|
const { data: tournament } = useTournament(id);
|
||||||
if (!tournament) return null;
|
if (!tournament) return null;
|
||||||
|
|
||||||
const tabs = [
|
const tabs = useMemo(() => [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
content: <Text p="md">Stats/Badges will go here, bracket link</Text>
|
content: <TournamentStats tournament={tournament} />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Matches",
|
label: "Matches",
|
||||||
@@ -28,7 +30,7 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
<TeamList teams={tournament.teams || []} />
|
<TeamList teams={tournament.teams || []} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
];
|
], [tournament]);
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<Header tournament={tournament} />
|
<Header tournament={tournament} />
|
||||||
|
|||||||
37
src/features/tournaments/components/profile/skeleton.tsx
Normal file
37
src/features/tournaments/components/profile/skeleton.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Box, Flex, Loader } from "@mantine/core";
|
||||||
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
|
import HeaderSkeleton from "./header-skeleton";
|
||||||
|
|
||||||
|
const SkeletonLoader = () => (
|
||||||
|
<Flex h="30vh" w="100%" align="center" justify="center">
|
||||||
|
<Loader />
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ProfileSkeleton = () => {
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
label: "Overview",
|
||||||
|
content: <SkeletonLoader />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Matches",
|
||||||
|
content: <SkeletonLoader />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Teams",
|
||||||
|
content: <SkeletonLoader />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeaderSkeleton />
|
||||||
|
<Box mt="lg">
|
||||||
|
<SwipeableTabs tabs={tabs} />
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileSkeleton;
|
||||||
@@ -18,6 +18,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
<Stack px="sm" align="center" gap={0}>
|
<Stack px="sm" align="center" gap={0}>
|
||||||
<Avatar
|
<Avatar
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
|
contain
|
||||||
src={
|
src={
|
||||||
tournament.logo
|
tournament.logo
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
>
|
>
|
||||||
{startedMatches.map((match, index) => (
|
{startedMatches.map((match, index) => (
|
||||||
<Carousel.Slide key={match.id}>
|
<Carousel.Slide key={match.id}>
|
||||||
<Box pl={index === 0 ? "xl" : undefined } pr={index === startedMatches.length - 1 ? "xl" : undefined}>
|
<Box pl={index === 0 ? "md" : undefined } pr={index === startedMatches.length - 1 ? "md" : undefined}>
|
||||||
<MatchCard match={match} />
|
<MatchCard match={match} />
|
||||||
</Box>
|
</Box>
|
||||||
</Carousel.Slide>
|
</Carousel.Slide>
|
||||||
@@ -69,8 +69,8 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
to={`/tournaments/${tournament.id}/bracket`}
|
to={`/tournaments/${tournament.id}/bracket`}
|
||||||
Icon={TreeStructureIcon}
|
Icon={TreeStructureIcon}
|
||||||
/>
|
/>
|
||||||
<RulesListButton tournamentId={tournament.id} />
|
|
||||||
<TeamListButton teams={tournament.teams || []} />
|
<TeamListButton teams={tournament.teams || []} />
|
||||||
|
<RulesListButton tournamentId={tournament.id} />
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Box, Card, Center, Divider, Group, Skeleton, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
const StartedTournamentSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
{/* Header skeleton */}
|
||||||
|
<Stack px="md">
|
||||||
|
<Group justify="space-between" align="flex-start">
|
||||||
|
<Box style={{ flex: 1 }}>
|
||||||
|
<Skeleton height={32} width="60%" mb="xs" />
|
||||||
|
<Skeleton height={16} width="40%" />
|
||||||
|
</Box>
|
||||||
|
<Skeleton height={60} width={60} radius="md" />
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Match carousel skeleton */}
|
||||||
|
<Box>
|
||||||
|
<Group gap="xs" px="xl">
|
||||||
|
{Array.from({ length: 2 }).map((_, index) => (
|
||||||
|
<Card
|
||||||
|
key={index}
|
||||||
|
withBorder
|
||||||
|
radius="lg"
|
||||||
|
p="lg"
|
||||||
|
style={{ minWidth: "95%", flex: "0 0 auto" }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{/* Match header */}
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Skeleton height={14} width="30%" />
|
||||||
|
<Skeleton height={20} width={60} radius="xl" />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Teams */}
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group>
|
||||||
|
<Skeleton height={32} width={32} radius="sm" />
|
||||||
|
<Skeleton height={16} width="40%" />
|
||||||
|
<Box ml="auto">
|
||||||
|
<Skeleton height={24} width={30} />
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Center>
|
||||||
|
<Skeleton height={14} width={20} />
|
||||||
|
</Center>
|
||||||
|
<Group>
|
||||||
|
<Skeleton height={32} width={32} radius="sm" />
|
||||||
|
<Skeleton height={16} width="40%" />
|
||||||
|
<Box ml="auto">
|
||||||
|
<Skeleton height={24} width={30} />
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Actions section skeleton */}
|
||||||
|
<Box>
|
||||||
|
<Divider />
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Skeleton height={48} width="100%" />
|
||||||
|
<Skeleton height={48} width="100%" />
|
||||||
|
<Skeleton height={48} width="100%" />
|
||||||
|
<Skeleton height={48} width="100%" />
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StartedTournamentSkeleton;
|
||||||
37
src/features/tournaments/components/tournament-card-list.tsx
Normal file
37
src/features/tournaments/components/tournament-card-list.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { useTournaments } from "../queries";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
import { Button, Stack } from "@mantine/core";
|
||||||
|
import { PlusIcon } from "@phosphor-icons/react";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import TournamentForm from "./tournament-form";
|
||||||
|
import { TournamentCard } from "./tournament-card";
|
||||||
|
|
||||||
|
const TournamentCardList = () => {
|
||||||
|
const { data: tournaments } = useTournaments();
|
||||||
|
const { roles } = useAuth();
|
||||||
|
const sheet = useSheet();
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
{roles?.includes("Admin") ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
leftSection={<PlusIcon />}
|
||||||
|
variant="subtle"
|
||||||
|
onClick={sheet.open}
|
||||||
|
>
|
||||||
|
Create Tournament
|
||||||
|
</Button>
|
||||||
|
<Sheet {...sheet.props} title="Create Tournament">
|
||||||
|
<TournamentForm close={sheet.close} />
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{tournaments?.map((tournament: any) => (
|
||||||
|
<TournamentCard key={tournament.id} tournament={tournament} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TournamentCardList;
|
||||||
@@ -1,48 +1,27 @@
|
|||||||
import {
|
import {
|
||||||
Badge,
|
|
||||||
Card,
|
Card,
|
||||||
Text,
|
Text,
|
||||||
Stack,
|
Stack,
|
||||||
Group,
|
Group,
|
||||||
Box,
|
|
||||||
ThemeIcon,
|
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
|
Badge,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Tournament } from "@/features/tournaments/types";
|
import { TournamentInfo } from "@/features/tournaments/types";
|
||||||
import { useMemo } from "react";
|
|
||||||
import {
|
import {
|
||||||
TrophyIcon,
|
TrophyIcon,
|
||||||
CalendarIcon,
|
CrownIcon,
|
||||||
MapPinIcon,
|
MedalIcon,
|
||||||
UsersIcon,
|
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
|
|
||||||
interface TournamentCardProps {
|
interface TournamentCardProps {
|
||||||
tournament: Tournament;
|
tournament: TournamentInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const displayDate = useMemo(() => {
|
|
||||||
if (!tournament.start_time) return null;
|
|
||||||
const date = new Date(tournament.start_time);
|
|
||||||
if (isNaN(date.getTime())) return null;
|
|
||||||
return date.toLocaleDateString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}, [tournament.start_time]);
|
|
||||||
|
|
||||||
const enrollmentDeadline = tournament.enroll_time
|
|
||||||
? new Date(tournament.enroll_time)
|
|
||||||
: new Date(tournament.start_time);
|
|
||||||
const isEnrollmentOpen = enrollmentDeadline > new Date();
|
|
||||||
const enrolledTeamsCount = tournament.teams?.length || 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
w="100%"
|
w="100%"
|
||||||
@@ -78,9 +57,10 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
|||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Group gap="md" align="center">
|
<Group gap="md" align="center">
|
||||||
<Avatar
|
<Avatar
|
||||||
size={120}
|
size={90}
|
||||||
radius="sm"
|
radius="sm"
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
|
contain
|
||||||
src={
|
src={
|
||||||
tournament.logo
|
tournament.logo
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
@@ -93,31 +73,62 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
|||||||
<Text fw={600} size="lg" lineClamp={2}>
|
<Text fw={600} size="lg" lineClamp={2}>
|
||||||
{tournament.name}
|
{tournament.name}
|
||||||
</Text>
|
</Text>
|
||||||
{displayDate && (
|
{(tournament.first_place || tournament.second_place || tournament.third_place) && (
|
||||||
<Group gap="xs">
|
<Stack gap={6} >
|
||||||
<ThemeIcon
|
{tournament.first_place && (
|
||||||
size="sm"
|
<Badge
|
||||||
variant="light"
|
size="md"
|
||||||
radius="sm"
|
radius="md"
|
||||||
color="gray"
|
variant="filled"
|
||||||
|
color="yellow"
|
||||||
|
leftSection={
|
||||||
|
<CrownIcon size={16} />
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<CalendarIcon size={12} />
|
{tournament.first_place.name}
|
||||||
</ThemeIcon>
|
</Badge>
|
||||||
<Text size="sm" c="dimmed">
|
)}
|
||||||
{displayDate}
|
{tournament.second_place && (
|
||||||
</Text>
|
<Badge
|
||||||
</Group>
|
size="md"
|
||||||
|
radius="md"
|
||||||
|
color="gray"
|
||||||
|
variant="filled"
|
||||||
|
leftSection={
|
||||||
|
<MedalIcon size={16} />
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tournament.second_place.name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{tournament.third_place && (
|
||||||
|
<Badge
|
||||||
|
size="md"
|
||||||
|
radius="md"
|
||||||
|
color="orange"
|
||||||
|
variant="filled"
|
||||||
|
leftSection={
|
||||||
|
<MedalIcon size={16} />
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tournament.third_place.name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group gap="xs">
|
|
||||||
<ThemeIcon size="sm" variant="light" radius="sm" color="gray">
|
|
||||||
<UsersIcon size={12} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{enrolledTeamsCount} team
|
|
||||||
{enrolledTeamsCount !== 1 ? "s" : ""}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<List>
|
<List p="0">
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={`skeleton-${i}`}
|
key={`skeleton-${i}`}
|
||||||
@@ -97,7 +97,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List>
|
<List p="0">
|
||||||
{tournaments.map((tournament) => (
|
{tournaments.map((tournament) => (
|
||||||
<>
|
<>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -108,6 +108,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
|
|||||||
radius="sm"
|
radius="sm"
|
||||||
size={40}
|
size={40}
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
|
contain
|
||||||
src={
|
src={
|
||||||
tournament.logo
|
tournament.logo
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
|
|||||||
283
src/features/tournaments/components/tournament-stats.tsx
Normal file
283
src/features/tournaments/components/tournament-stats.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { useMemo, memo } from "react";
|
||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
UnstyledButton,
|
||||||
|
Container,
|
||||||
|
Box,
|
||||||
|
Center,
|
||||||
|
ThemeIcon,
|
||||||
|
Divider,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Tournament } from "@/features/tournaments/types";
|
||||||
|
import { CrownIcon, MedalIcon, TreeStructureIcon } from "@phosphor-icons/react";
|
||||||
|
import Avatar from "@/components/avatar";
|
||||||
|
import ListLink from "@/components/list-link";
|
||||||
|
|
||||||
|
interface TournamentStatsProps {
|
||||||
|
tournament: Tournament;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
||||||
|
|
||||||
|
const matches = tournament.matches || [];
|
||||||
|
const nonByeMatches = useMemo(() =>
|
||||||
|
matches.filter((match) => !(match.status === 'tbd' && match.bye === true)),
|
||||||
|
[matches]
|
||||||
|
);
|
||||||
|
const isComplete = useMemo(() =>
|
||||||
|
nonByeMatches.length > 0 && nonByeMatches.every((match) => match.status === 'ended'),
|
||||||
|
[nonByeMatches]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedTeamStats = useMemo(() => {
|
||||||
|
return [...(tournament.team_stats || [])].sort((a, b) => {
|
||||||
|
if (b.wins !== a.wins) {
|
||||||
|
return b.wins - a.wins;
|
||||||
|
}
|
||||||
|
return b.total_cups_made - a.total_cups_made;
|
||||||
|
});
|
||||||
|
}, [tournament.team_stats]);
|
||||||
|
|
||||||
|
const renderPodium = () => {
|
||||||
|
if (!isComplete || !tournament.first_place) {
|
||||||
|
return (
|
||||||
|
<Box p="md">
|
||||||
|
<Center>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Podium will appear here when the tournament is over
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" px="md">
|
||||||
|
{tournament.first_place && (
|
||||||
|
<Group
|
||||||
|
gap="md"
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--mantine-color-yellow-light)',
|
||||||
|
borderRadius: 'var(--mantine-radius-md)',
|
||||||
|
border: '3px solid var(--mantine-color-yellow-outline)',
|
||||||
|
boxShadow: 'var(--mantine-shadow-md)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeIcon size="xl" color="yellow" variant="light" radius="xl">
|
||||||
|
<CrownIcon size={24} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={4} style={{ flex: 1 }}>
|
||||||
|
<Text size="md" fw={600}>
|
||||||
|
{tournament.first_place.name}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
{tournament.first_place.players?.map((player) => (
|
||||||
|
<Text key={player.id} size="sm" c="dimmed">
|
||||||
|
{player.first_name} {player.last_name}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tournament.second_place && (
|
||||||
|
<Group
|
||||||
|
gap="md"
|
||||||
|
p="xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--mantine-color-default)',
|
||||||
|
borderRadius: 'var(--mantine-radius-md)',
|
||||||
|
border: '2px solid var(--mantine-color-default-border)',
|
||||||
|
boxShadow: 'var(--mantine-shadow-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeIcon size="lg" color="gray" variant="light" radius="xl">
|
||||||
|
<MedalIcon size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={4} style={{ flex: 1 }}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{tournament.second_place.name}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
{tournament.second_place.players?.map((player) => (
|
||||||
|
<Text key={player.id} size="xs" c="dimmed">
|
||||||
|
{player.first_name} {player.last_name}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tournament.third_place && (
|
||||||
|
<Group
|
||||||
|
gap="md"
|
||||||
|
p="xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--mantine-color-orange-light)',
|
||||||
|
borderRadius: 'var(--mantine-radius-md)',
|
||||||
|
border: '2px solid var(--mantine-color-orange-outline)',
|
||||||
|
boxShadow: 'var(--mantine-shadow-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeIcon size="lg" color="orange" variant="light" radius="xl">
|
||||||
|
<MedalIcon size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={4} style={{ flex: 1 }}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{tournament.third_place.name}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
{tournament.third_place.players?.map((player) => (
|
||||||
|
<Text key={player.id} size="xs" c="dimmed">
|
||||||
|
{player.first_name} {player.last_name}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const teamStatsWithCalculations = useMemo(() => {
|
||||||
|
return sortedTeamStats.map((stat, index) => ({
|
||||||
|
...stat,
|
||||||
|
index,
|
||||||
|
winPercentage: stat.matches > 0 ? (stat.wins / stat.matches) * 100 : 0,
|
||||||
|
avgCupsPerMatch: stat.matches > 0 ? stat.total_cups_made / stat.matches : 0,
|
||||||
|
}));
|
||||||
|
}, [sortedTeamStats]);
|
||||||
|
|
||||||
|
const renderTeamStatsTable = () => {
|
||||||
|
if (!teamStatsWithCalculations.length) {
|
||||||
|
return (
|
||||||
|
<Box p="md">
|
||||||
|
<Center>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
No stats available yet
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text px="md" size="lg" fw={600}>Results</Text>
|
||||||
|
{teamStatsWithCalculations.map((stat) => {
|
||||||
|
return (
|
||||||
|
<Box key={stat.id}>
|
||||||
|
<UnstyledButton
|
||||||
|
w="100%"
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
borderRadius: 0,
|
||||||
|
transition: "background-color 0.15s ease",
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center" w="100%">
|
||||||
|
<Group gap="sm" align="center">
|
||||||
|
<Avatar name={stat.team_name} size={40} radius="sm" />
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Group gap='xs'>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
#{stat.index + 1}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{stat.team_name}
|
||||||
|
</Text>
|
||||||
|
{stat.index === 0 && isComplete && (
|
||||||
|
<ThemeIcon size="xs" color="yellow" variant="light" radius="xl">
|
||||||
|
<CrownIcon size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Group gap="md" ta="center">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
W
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{stat.wins}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
L
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{stat.losses}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
W%
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{stat.winPercentage.toFixed(1)}%
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
AVG
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{stat.avgCupsPerMatch.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
CF
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{stat.total_cups_made}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
CA
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{stat.total_cups_against}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
{stat.index < teamStatsWithCalculations.length - 1 && <Divider />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="100%" px={0}>
|
||||||
|
<Stack gap="md">
|
||||||
|
{renderPodium()}
|
||||||
|
<ListLink
|
||||||
|
label={`View Bracket`}
|
||||||
|
to={`/tournaments/${tournament.id}/bracket`}
|
||||||
|
Icon={TreeStructureIcon}
|
||||||
|
/>
|
||||||
|
{renderTeamStatsTable()}
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -2,11 +2,23 @@ import Button from "@/components/button";
|
|||||||
import Sheet from "@/components/sheet/sheet";
|
import Sheet from "@/components/sheet/sheet";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
import { Text } from "@mantine/core";
|
import { Stack, Text } from "@mantine/core";
|
||||||
|
import useEnrollFreeAgent from "../../hooks/use-enroll-free-agent";
|
||||||
|
|
||||||
const EnrollFreeAgent = () => {
|
const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
|
||||||
const { open, isOpen, toggle } = useSheet();
|
const { open, isOpen, toggle } = useSheet();
|
||||||
const { user } = useAuth();
|
const { user, phone } = useAuth();
|
||||||
|
|
||||||
|
const { mutate: enrollFreeAgent, isPending: isEnrolling } = useEnrollFreeAgent();
|
||||||
|
const handleEnroll = () => {
|
||||||
|
console.log('enrolling...')
|
||||||
|
enrollFreeAgent({ playerId: user!.id, tournamentId, phone }, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant="subtle" size="sm" onClick={open}>
|
<Button variant="subtle" size="sm" onClick={open}>
|
||||||
@@ -14,13 +26,19 @@ const EnrollFreeAgent = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Sheet title="Free Agent Enrollment" opened={isOpen} onChange={toggle}>
|
<Sheet title="Free Agent Enrollment" opened={isOpen} onChange={toggle}>
|
||||||
<Text size="md" mb="md">
|
<Stack gap="xs">
|
||||||
|
<Text size="md">
|
||||||
Enrolling as a free agent will enter you in a pool of players wanting to play but don't have a teammate yet.
|
Enrolling as a free agent will enter you in a pool of players wanting to play but don't have a teammate yet.
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" mb="md" c='dimmed'>
|
<Text size="sm" c='dimmed'>
|
||||||
You will be automatically paired with a partner before the tournament starts, and you will be able to see your new team and set a walkout song in the app.
|
You will be able to see a list of other enrolled free agents, as well as their contact information for organizing your team and walkout song. By enrolling, your phone number will be visible to other free agents.
|
||||||
</Text>
|
</Text>
|
||||||
<Button onClick={console.log}>Confirm</Button>
|
<Text size="xs" c="dimmed">
|
||||||
|
Note: this does not guarantee you a spot in the tournament. One person from your team must enroll in the app and choose a walkout song in order to secure a spot.
|
||||||
|
</Text>
|
||||||
|
<Button onClick={handleEnroll}>Confirm</Button>
|
||||||
|
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
|
||||||
|
</Stack>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user