Compare commits
10 Commits
f99d6efaf9
...
main_old
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ed77dd471 | ||
|
|
94ea44c66e | ||
|
|
7441d1ac58 | ||
|
|
7ff26229d9 | ||
|
|
b93ce38d48 | ||
|
|
ae934e77f4 | ||
|
|
cae5fa1c71 | ||
|
|
fc3f626313 | ||
|
|
1027b49258 | ||
|
|
5e20b94a1f |
@@ -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,7 @@
|
|||||||
"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": "node .output/server/index.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
@@ -21,15 +21,15 @@
|
|||||||
"@svgmoji/noto": "^3.2.0",
|
"@svgmoji/noto": "^3.2.0",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
"@tanstack/react-query-devtools": "^5.66.0",
|
"@tanstack/react-query-devtools": "^5.66.0",
|
||||||
"@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.130.15",
|
||||||
"@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/ioredis": "^4.28.10",
|
"@types/ioredis": "^4.28.10",
|
||||||
|
"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",
|
||||||
|
|||||||
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)
|
||||||
|
})
|
||||||
@@ -27,8 +27,9 @@ import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/prof
|
|||||||
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 AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
||||||
|
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
|
||||||
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
|
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
|
||||||
import { ServerRoute as ApiTeamsUploadLogoServerRouteImport } from './routes/api/teams/upload-logo'
|
import { ServerRoute as ApiTeamsUploadLogoServerRouteImport } from './routes/api/teams/upload-logo'
|
||||||
import { ServerRoute as ApiSpotifyTokenServerRouteImport } from './routes/api/spotify/token'
|
import { ServerRoute as ApiSpotifyTokenServerRouteImport } from './routes/api/spotify/token'
|
||||||
@@ -125,10 +126,10 @@ 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 AuthedAdminTournamentsRunIdRoute =
|
const AuthedAdminTournamentsRunIdRoute =
|
||||||
@@ -137,6 +138,12 @@ const AuthedAdminTournamentsRunIdRoute =
|
|||||||
path: '/tournaments/run/$id',
|
path: '/tournaments/run/$id',
|
||||||
getParentRoute: () => AuthedAdminRoute,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthedAdminTournamentsIdTeamsRoute =
|
||||||
|
AuthedAdminTournamentsIdTeamsRouteImport.update({
|
||||||
|
id: '/tournaments/$id/teams',
|
||||||
|
path: '/tournaments/$id/teams',
|
||||||
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
|
} as any)
|
||||||
const ApiTournamentsUploadLogoServerRoute =
|
const ApiTournamentsUploadLogoServerRoute =
|
||||||
ApiTournamentsUploadLogoServerRouteImport.update({
|
ApiTournamentsUploadLogoServerRouteImport.update({
|
||||||
id: '/api/tournaments/upload-logo',
|
id: '/api/tournaments/upload-logo',
|
||||||
@@ -212,10 +219,11 @@ export interface FileRoutesByFullPath {
|
|||||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
'/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
|
||||||
|
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
@@ -230,10 +238,11 @@ export interface FileRoutesByTo {
|
|||||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
'/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
|
||||||
|
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -251,10 +260,11 @@ export interface FileRoutesById {
|
|||||||
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
'/_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
|
||||||
|
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
@@ -272,10 +282,11 @@ export interface FileRouteTypes {
|
|||||||
| '/tournaments/$tournamentId'
|
| '/tournaments/$tournamentId'
|
||||||
| '/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'
|
||||||
|
| '/admin/tournaments/$id'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/login'
|
| '/login'
|
||||||
@@ -290,10 +301,11 @@ export interface FileRouteTypes {
|
|||||||
| '/tournaments/$tournamentId'
|
| '/tournaments/$tournamentId'
|
||||||
| '/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'
|
||||||
|
| '/admin/tournaments/$id'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/_authed'
|
| '/_authed'
|
||||||
@@ -310,10 +322,11 @@ export interface FileRouteTypes {
|
|||||||
| '/_authed/tournaments/$tournamentId'
|
| '/_authed/tournaments/$tournamentId'
|
||||||
| '/_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'
|
||||||
|
| '/_authed/admin/tournaments/$id/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -532,11 +545,11 @@ 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
|
||||||
}
|
}
|
||||||
'/_authed/admin/tournaments/run/$id': {
|
'/_authed/admin/tournaments/run/$id': {
|
||||||
@@ -546,6 +559,13 @@ 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'
|
||||||
|
path: '/tournaments/$id/teams'
|
||||||
|
fullPath: '/admin/tournaments/$id/teams'
|
||||||
|
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
|
||||||
|
parentRoute: typeof AuthedAdminRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
declare module '@tanstack/react-start/server' {
|
declare module '@tanstack/react-start/server' {
|
||||||
@@ -633,17 +653,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(
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ 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";
|
||||||
|
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
|
||||||
export function createRouter() {
|
export function createRouter() {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
@@ -131,7 +134,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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const ServerRoute = createServerFileRoute("/api/events/$").middleware([su
|
|||||||
|
|
||||||
serverEvents.on("test", handleEvent);
|
serverEvents.on("test", handleEvent);
|
||||||
serverEvents.on("match", handleEvent);
|
serverEvents.on("match", handleEvent);
|
||||||
|
serverEvents.on("reaction", handleEvent);
|
||||||
|
|
||||||
const pingInterval = setInterval(() => {
|
const pingInterval = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createServerFileRoute } from '@tanstack/react-start/server'
|
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||||
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
|
||||||
@@ -28,7 +27,7 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -36,6 +35,18 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
|
|||||||
case 'play':
|
case 'play':
|
||||||
await spotifyClient.play(deviceId)
|
await spotifyClient.play(deviceId)
|
||||||
break
|
break
|
||||||
|
case 'playTrack':
|
||||||
|
if (!trackId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'trackId is required for playTrack action' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await spotifyClient.playTrack(trackId, deviceId, positionMs)
|
||||||
|
break
|
||||||
case 'pause':
|
case 'pause':
|
||||||
await spotifyClient.pause()
|
await spotifyClient.pause()
|
||||||
break
|
break
|
||||||
@@ -89,7 +100,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
|
|||||||
} 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(
|
||||||
@@ -111,7 +121,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
@@ -129,7 +138,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -144,7 +152,7 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -167,7 +175,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} 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(),
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/token').methods({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh access token
|
|
||||||
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
|
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -46,7 +45,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/token').methods({
|
|||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ export const ServerRoute = createServerFileRoute('/api/teams/upload-logo')
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!team.players.map(p => p.id).includes(context.userId) && !isAdmin)
|
const user = await pbAdmin.getPlayerByAuthId(context.userAuthId)
|
||||||
|
if (!team.players.map(p => p.id).includes(user!.id) && !isAdmin)
|
||||||
return new Response('Unauthorized', { status: 403 });
|
return new Response('Unauthorized', { status: 403 });
|
||||||
|
|
||||||
logger.info('Uploading team logo', {
|
logger.info('Uploading team logo', {
|
||||||
|
|||||||
@@ -7,23 +7,22 @@ import {
|
|||||||
redirect,
|
redirect,
|
||||||
} from '@tanstack/react-router'
|
} from '@tanstack/react-router'
|
||||||
import type { ErrorComponentProps } from '@tanstack/react-router'
|
import type { ErrorComponentProps } from '@tanstack/react-router'
|
||||||
import {
|
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,112 +49,90 @@ 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)',
|
</Text>
|
||||||
}}
|
<Group gap="sm" mt="md">
|
||||||
>
|
<Button
|
||||||
<Stack align="center" gap="lg">
|
variant="light"
|
||||||
<ThemeIcon color="red" size={80} radius="xl">
|
onClick={() => window.history.back()}
|
||||||
<XCircleIcon size={48} />
|
>
|
||||||
</ThemeIcon>
|
Go Back
|
||||||
<Title order={2} ta="center">Access Denied</Title>
|
</Button>
|
||||||
<Text size="lg" c="dimmed" ta="center">
|
<MantineButton
|
||||||
You don't have permission to access this.
|
component={Link}
|
||||||
</Text>
|
to="/"
|
||||||
<Group>
|
variant="filled"
|
||||||
<Button
|
>
|
||||||
variant="light"
|
Home
|
||||||
onClick={() => window.history.back()}
|
</MantineButton>
|
||||||
>
|
</Group>
|
||||||
Go Back
|
</Stack>
|
||||||
</Button>
|
</Center>
|
||||||
<MantineButton
|
</Container>
|
||||||
component={Link}
|
|
||||||
to="/"
|
|
||||||
variant="filled"
|
|
||||||
>
|
|
||||||
Home
|
|
||||||
</MantineButton>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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="lg" c="dimmed" ta="center">
|
|
||||||
There was an unexpected error. Please try again later.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Alert
|
<Text size="xl" fw={600}>Something went wrong</Text>
|
||||||
variant="light"
|
|
||||||
color="red"
|
|
||||||
title="Error Details"
|
|
||||||
w="100%"
|
|
||||||
>
|
|
||||||
<Text mb="sm">{errorMessage}</Text>
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
size="compact-sm"
|
|
||||||
onClick={toggleDetails}
|
|
||||||
>
|
|
||||||
{detailsOpened ? 'Hide' : 'Show'} stack trace
|
|
||||||
</Button>
|
|
||||||
<Collapse in={detailsOpened}>
|
|
||||||
<Code block mt="md" p="md">
|
|
||||||
{errorStack}
|
|
||||||
</Code>
|
|
||||||
</Collapse>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Group>
|
<Text c="dimmed" ta="center">
|
||||||
<Button
|
An error occurred while loading this page.
|
||||||
variant="light"
|
</Text>
|
||||||
onClick={() => router.invalidate()}
|
|
||||||
>
|
<Box w="100%" mt="md">
|
||||||
Try Again
|
<Text size="sm" c="dimmed" mb="xs">Error: {errorMessage}</Text>
|
||||||
</Button>
|
|
||||||
{isRoot ? (
|
|
||||||
<MantineButton
|
|
||||||
component={Link}
|
|
||||||
to="/"
|
|
||||||
variant="filled"
|
|
||||||
>
|
|
||||||
Home
|
|
||||||
</MantineButton>
|
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
variant="filled"
|
variant="subtle"
|
||||||
onClick={() => window.history.back()}
|
size="compact-sm"
|
||||||
|
onClick={toggleDetails}
|
||||||
|
fullWidth
|
||||||
>
|
>
|
||||||
Go Back
|
{detailsOpened ? 'Hide' : 'Show'} details
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
<Collapse in={detailsOpened}>
|
||||||
</Group>
|
<Code block mt="sm" p="sm" style={{ fontSize: '11px' }}>
|
||||||
</Stack>
|
{errorStack}
|
||||||
</Box>
|
</Code>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Group gap="sm" mt="lg">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
onClick={() => router.invalidate()}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
{isRoot ? (
|
||||||
|
<MantineButton
|
||||||
|
component={Link}
|
||||||
|
to="/"
|
||||||
|
variant="filled"
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</MantineButton>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="filled"
|
||||||
|
onClick={() => window.history.back()}
|
||||||
|
>
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,31 @@ const Drawer: React.FC<DrawerProps> = ({
|
|||||||
};
|
};
|
||||||
}, [opened, colorScheme]);
|
}, [opened, colorScheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened || !contentRef.current) return;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(contentRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [opened, children]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VaulDrawer.Root open={opened} onOpenChange={onChange}>
|
<VaulDrawer.Root 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 +94,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;
|
||||||
@@ -29,7 +29,7 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
|
|||||||
scrollbars="y"
|
scrollbars="y"
|
||||||
type="scroll"
|
type="scroll"
|
||||||
>
|
>
|
||||||
<Box mah="70vh">{children}</Box>
|
{children}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</SheetComponent>
|
</SheetComponent>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,10 +11,13 @@
|
|||||||
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;
|
||||||
|
max-height: 100dvh;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
<Text size="sm" fw={700} c="dimmed">
|
{value !== null ? (
|
||||||
{value !== null ? `${value}${suffix}` : "—"}
|
<Text size="sm" fw={700} c="dimmed">
|
||||||
</Text>
|
{`${value}${suffix}`}
|
||||||
|
</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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -59,6 +61,7 @@ export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
user: data?.user || defaultAuthData.user,
|
user: data?.user || defaultAuthData.user,
|
||||||
metadata: data?.metadata || defaultAuthData.metadata,
|
metadata: data?.metadata || defaultAuthData.metadata,
|
||||||
roles: data?.roles || defaultAuthData.roles,
|
roles: data?.roles || defaultAuthData.roles,
|
||||||
|
phone: data?.phone || "",
|
||||||
set,
|
set,
|
||||||
}),
|
}),
|
||||||
[data, defaultAuthData]
|
[data, defaultAuthData]
|
||||||
|
|||||||
@@ -165,16 +165,16 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
|
|
||||||
const play = useCallback(async (deviceId?: string) => {
|
const play = useCallback(async (deviceId?: string) => {
|
||||||
if (!authState.isAuthenticated) return;
|
if (!authState.isAuthenticated) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await makeSpotifyRequest('playback', {
|
await makeSpotifyRequest('playback', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ action: 'play', deviceId }),
|
body: JSON.stringify({ action: 'play', deviceId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(refreshPlaybackState, 500);
|
setTimeout(refreshPlaybackState, 500);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && !error.message.includes('JSON')) {
|
if (error instanceof Error && !error.message.includes('JSON')) {
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -415,13 +438,13 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
activeDevice,
|
activeDevice,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
// Capture/Resume state
|
|
||||||
capturedState,
|
capturedState,
|
||||||
isCaptureLoading,
|
isCaptureLoading,
|
||||||
isResumeLoading,
|
isResumeLoading,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
play,
|
play,
|
||||||
|
playTrack,
|
||||||
pause,
|
pause,
|
||||||
skipNext,
|
skipNext,
|
||||||
skipPrevious,
|
skipPrevious,
|
||||||
@@ -429,11 +452,9 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
getDevices,
|
getDevices,
|
||||||
setActiveDevice,
|
setActiveDevice,
|
||||||
refreshPlaybackState,
|
refreshPlaybackState,
|
||||||
// Capture/Resume methods
|
|
||||||
capturePlaybackState,
|
capturePlaybackState,
|
||||||
resumePlaybackState,
|
resumePlaybackState,
|
||||||
clearCapturedState,
|
clearCapturedState,
|
||||||
// Search
|
|
||||||
searchTracks,
|
searchTracks,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +131,71 @@ 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}>
|
||||||
@@ -175,7 +238,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 +273,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
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
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 id='app-header' display={collapsed ? 'none' : 'block'}>
|
||||||
{ header.withBackButton && <BackButton /> }
|
{ 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,18 @@ 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: 0,
|
||||||
|
minHeight: '100dvh',
|
||||||
|
maxHeight: '100dvh'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<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' }}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const Navbar = () => {
|
|||||||
const links = useLinks(user?.id, roles);
|
const links = useLinks(user?.id, roles);
|
||||||
|
|
||||||
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 radius='lg' h='4rem' w='calc(100% - 1rem)' shadow='sm' pos='fixed' m='0.5rem' 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} />
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -29,8 +30,6 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(match);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Indicator
|
<Indicator
|
||||||
disabled={!isStarted}
|
disabled={!isStarted}
|
||||||
@@ -90,15 +89,28 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Text
|
<Tooltip
|
||||||
size="sm"
|
label={match.home?.name!}
|
||||||
fw={600}
|
disabled={!match.home?.name}
|
||||||
lineClamp={1}
|
events={{ hover: true, focus: true, touch: true }}
|
||||||
style={{ minWidth: 0, flex: 1 }}
|
|
||||||
>
|
>
|
||||||
{match.home?.name!}
|
<Text
|
||||||
</Text>
|
size="sm"
|
||||||
|
fw={600}
|
||||||
|
lineClamp={1}
|
||||||
|
style={{ minWidth: 0, flex: 1, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{match.home?.name!}
|
||||||
|
</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}
|
||||||
@@ -107,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">
|
||||||
@@ -146,15 +151,28 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Text
|
<Tooltip
|
||||||
size="sm"
|
label={match.away?.name}
|
||||||
fw={600}
|
disabled={!match.away?.name}
|
||||||
lineClamp={1}
|
events={{ hover: true, focus: true, touch: true }}
|
||||||
style={{ minWidth: 0, flex: 1 }}
|
|
||||||
>
|
>
|
||||||
{match.away?.name}
|
<Text
|
||||||
</Text>
|
size="sm"
|
||||||
|
fw={600}
|
||||||
|
lineClamp={1}
|
||||||
|
style={{ minWidth: 0, flex: 1, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{match.away?.name}
|
||||||
|
</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}
|
||||||
@@ -163,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>
|
||||||
@@ -189,7 +200,9 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
border: "1px solid var(--mantine-color-default-border)",
|
border: "1px solid var(--mantine-color-default-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EmojiBar matchId={match.id} />
|
<Suspense>
|
||||||
|
<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) => (
|
<div
|
||||||
<motion.div
|
key={`match-${match.id}-${index}`}
|
||||||
key={`match-${match.id}-${index}`}
|
>
|
||||||
initial={{ opacity: 0, y: 10 }}
|
<MatchCard match={match} />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</div>
|
||||||
exit={{ opacity: 0, y: -10 }}
|
))}
|
||||||
transition={{ duration: 0.2, delay: index * 0.01 }}
|
|
||||||
>
|
|
||||||
<MatchCard match={match} />
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { useServerSuspenseQuery, useServerQuery } from "@/lib/tanstack-query/hooks";
|
|
||||||
import { getMatchReactions } from "./server";
|
|
||||||
|
|
||||||
export const matchKeys = {
|
|
||||||
list: ['matches', 'list'] as const,
|
|
||||||
details: (id: string) => ['matches', 'details', id] as const,
|
|
||||||
reactions: (id: string) => ['matches', 'reactions', id] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const matchQueries = {
|
|
||||||
reactions: (matchId: string) => ({
|
|
||||||
queryKey: matchKeys.reactions(matchId),
|
|
||||||
queryFn: () => getMatchReactions({ data: matchId }),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useMatchReactions = (matchId: string) =>
|
|
||||||
useServerQuery(matchQueries.reactions(matchId));
|
|
||||||
@@ -153,7 +153,6 @@ export const startMatch = createServerFn()
|
|||||||
status: "started",
|
status: "started",
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('emitting start match...')
|
|
||||||
serverEvents.emit("match", {
|
serverEvents.emit("match", {
|
||||||
type: "match",
|
type: "match",
|
||||||
matchId: match.id,
|
matchId: match.id,
|
||||||
@@ -300,7 +299,6 @@ export const toggleMatchReaction = createServerFn()
|
|||||||
serverEvents.emit("reaction", {
|
serverEvents.emit("reaction", {
|
||||||
type: "reaction",
|
type: "reaction",
|
||||||
matchId,
|
matchId,
|
||||||
tournamentId: match.tournament.id,
|
|
||||||
reactions,
|
reactions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -15,8 +15,6 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
const { data: matches } = usePlayerMatches(id);
|
const { data: matches } = usePlayerMatches(id);
|
||||||
const { data: stats, isLoading: statsLoading } = usePlayerStats(id);
|
const { data: stats, isLoading: statsLoading } = usePlayerStats(id);
|
||||||
|
|
||||||
console.log(player.teams)
|
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export const useMe = () => {
|
|||||||
const errorData = error?.response?.data;
|
const errorData = error?.response?.data;
|
||||||
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
|
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
|
||||||
const currentUrl = window.location.pathname + window.location.search;
|
const currentUrl = window.location.pathname + window.location.search;
|
||||||
|
console.log('redirecting 3')
|
||||||
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
|
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,18 @@ 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 };
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -146,4 +153,4 @@ export const getUnenrolledPlayers = createServerFn()
|
|||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: tournamentId }) =>
|
.handler(async ({ data: tournamentId }) =>
|
||||||
toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId))
|
toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Button,
|
Button,
|
||||||
Text,
|
Text,
|
||||||
Tabs,
|
Stack,
|
||||||
|
ScrollArea,
|
||||||
|
Paper,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { useState, useRef, useCallback } from "react";
|
import { useState, useRef, useCallback } from "react";
|
||||||
@@ -27,12 +29,12 @@ const EmojiBar = ({
|
|||||||
const toggleReaction = useToggleMatchReaction(matchId);
|
const toggleReaction = useToggleMatchReaction(matchId);
|
||||||
|
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [activeTab, setActiveTab] = useState<string | null>(null);
|
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
|
||||||
const longPressTimeout = useRef<NodeJS.Timeout | null>(null);
|
const longPressTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const handleLongPressStart = (emoji: string) => {
|
const handleLongPressStart = (emoji: string) => {
|
||||||
longPressTimeout.current = setTimeout(() => {
|
longPressTimeout.current = setTimeout(() => {
|
||||||
setActiveTab(emoji);
|
setSelectedEmoji(emoji);
|
||||||
open();
|
open();
|
||||||
}, 500);
|
}, 500);
|
||||||
};
|
};
|
||||||
@@ -54,21 +56,29 @@ const EmojiBar = ({
|
|||||||
|
|
||||||
const hasReacted = useCallback((reaction: Reaction) => {
|
const hasReacted = useCallback((reaction: Reaction) => {
|
||||||
return reaction.players.map(p => p.id).includes(user?.id || "");
|
return reaction.players.map(p => p.id).includes(user?.id || "");
|
||||||
}, []);
|
}, [user?.id]);
|
||||||
|
|
||||||
|
const userReactions = reactions?.filter(r => hasReacted(r)).map(r => r.emoji) || [];
|
||||||
|
|
||||||
if (!reactions) return;
|
if (!reactions) return;
|
||||||
|
|
||||||
console.log(reactions)
|
const sortedReactions = [...reactions].sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
const visibleReactions = sortedReactions.slice(0, 3);
|
||||||
|
const groupedReactions = sortedReactions.slice(3);
|
||||||
|
|
||||||
|
const hasGrouped = groupedReactions.length > 0;
|
||||||
|
const groupedCount = groupedReactions.reduce((sum, r) => sum + r.count, 0);
|
||||||
|
const userHasReactedToGrouped = groupedReactions.some(r => hasReacted(r));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Group gap="xs" wrap="wrap" justify="space-between">
|
<Group gap="xs" wrap="wrap" justify="space-between">
|
||||||
<Group gap="xs" wrap="wrap">
|
<Group gap="xs" wrap="wrap">
|
||||||
{reactions.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"
|
||||||
@@ -95,31 +105,99 @@ const EmojiBar = ({
|
|||||||
</Group>
|
</Group>
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{hasGrouped && (
|
||||||
|
<Button
|
||||||
|
variant={"light"}
|
||||||
|
bd={userHasReactedToGrouped ? "1px solid var(--mantine-primary-color-filled)" : undefined}
|
||||||
|
size="compact-xs"
|
||||||
|
radius="xl"
|
||||||
|
onMouseDown={() => handleLongPressStart(groupedReactions[0]?.emoji || "")}
|
||||||
|
onMouseUp={handleLongPressEnd}
|
||||||
|
onMouseLeave={handleLongPressEnd}
|
||||||
|
onTouchStart={() => handleLongPressStart(groupedReactions[0]?.emoji || "")}
|
||||||
|
onTouchEnd={handleLongPressEnd}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedEmoji(groupedReactions[0]?.emoji || "");
|
||||||
|
open();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
userSelect: "none",
|
||||||
|
WebkitUserSelect: "none",
|
||||||
|
MozUserSelect: "none",
|
||||||
|
msUserSelect: "none",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={2} align="center">
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "1px",
|
||||||
|
alignItems: "center",
|
||||||
|
fontSize: "10px",
|
||||||
|
lineHeight: 1
|
||||||
|
}}>
|
||||||
|
{groupedReactions.slice(0, 2).map((reaction) => (
|
||||||
|
<span key={reaction.emoji}>{reaction.emoji}</span>
|
||||||
|
))}
|
||||||
|
{groupedReactions.length > 2 && (
|
||||||
|
<Text size="8px" fw={600} c="dimmed">
|
||||||
|
+{groupedReactions.length - 2}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Text size="xs" fw={600}>
|
||||||
|
{groupedCount}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<EmojiPicker onSelect={onReactionPress || ((emoji) => toggleReaction.mutate({ data: { matchId, emoji } }))} />
|
<EmojiPicker
|
||||||
|
onSelect={onReactionPress || ((emoji) => toggleReaction.mutate({ data: { matchId, emoji } }))}
|
||||||
|
userReactions={userReactions}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Sheet title="Reactions" opened={opened} onChange={() => close()}>
|
<Sheet title="Reactions" opened={opened} onChange={() => close()}>
|
||||||
<Tabs value={activeTab || reactions[0]?.emoji} onChange={setActiveTab}>
|
<Stack gap="md">
|
||||||
<Tabs.List grow>
|
<ScrollArea w="100%" offsetScrollbars>
|
||||||
{reactions.map((reaction) => (
|
<Group gap="xs" wrap="nowrap" px="xs">
|
||||||
<Tabs.Tab key={reaction.emoji} value={reaction.emoji}>
|
{sortedReactions.map((reaction) => (
|
||||||
<Group gap="xs" align="center">
|
<Button
|
||||||
<Text size="lg">{reaction.emoji}</Text>
|
key={reaction.emoji}
|
||||||
<Text size="xs" c="dimmed">
|
variant={selectedEmoji === reaction.emoji ? "filled" : "light"}
|
||||||
{reaction.count}
|
color="gray"
|
||||||
</Text>
|
size="compact-sm"
|
||||||
</Group>
|
radius="xl"
|
||||||
</Tabs.Tab>
|
onClick={() => setSelectedEmoji(reaction.emoji)}
|
||||||
))}
|
style={{ flexShrink: 0 }}
|
||||||
</Tabs.List>
|
>
|
||||||
|
<Group gap={4} align="center">
|
||||||
|
<Text size="sm">{reaction.emoji}</Text>
|
||||||
|
<Text size="xs" fw={600}>
|
||||||
|
{reaction.count}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
{reactions.map((reaction) => (
|
{selectedEmoji && (
|
||||||
<Tabs.Panel key={reaction.emoji} value={reaction.emoji} pt="md">
|
<Paper p="md" withBorder radius="md">
|
||||||
<PlayerList players={reaction.players} />
|
<Group gap="sm" mb="md">
|
||||||
</Tabs.Panel>
|
<Text size="2xl">{selectedEmoji}</Text>
|
||||||
))}
|
<div>
|
||||||
</Tabs>
|
<Text size="lg" fw={600}>
|
||||||
|
{sortedReactions.find(r => r.emoji === selectedEmoji)?.count || 0}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<PlayerList players={sortedReactions.find(r => r.emoji === selectedEmoji)?.players || []} />
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,20 +5,28 @@ import { useState } from "react";
|
|||||||
interface EmojiPickerProps {
|
interface EmojiPickerProps {
|
||||||
onSelect: (emoji: string) => void;
|
onSelect: (emoji: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
userReactions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMOJIS = [
|
const EMOJIS = [
|
||||||
{ emoji: "😊", label: "smile" },
|
{ emoji: "🫡", label: "salute" },
|
||||||
{ emoji: "😢", label: "cry" },
|
{ emoji: "😭", label: "crying" },
|
||||||
|
{ emoji: "🫦", label: "lip" },
|
||||||
|
{ emoji: "🏗️", label: "crane" },
|
||||||
{ emoji: "👀", label: "eyes" },
|
{ emoji: "👀", label: "eyes" },
|
||||||
{ emoji: "🔥", label: "fire" },
|
{ emoji: "😱", label: "scream" },
|
||||||
{ emoji: "❤️", label: "heart" },
|
{ emoji: "🥹", label: "owo" },
|
||||||
{ emoji: "👑", label: "crown" },
|
{ emoji: "🤣", label: "rofl" },
|
||||||
|
{ emoji: "🤪", label: "crazy" },
|
||||||
|
{ emoji: "🤓", label: "nerd" },
|
||||||
|
{ emoji: "🥵", label: "hot" },
|
||||||
|
{ emoji: "🥶", label: "cold" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const EmojiPicker = ({
|
const EmojiPicker = ({
|
||||||
onSelect,
|
onSelect,
|
||||||
disabled = false
|
disabled = false,
|
||||||
|
userReactions = []
|
||||||
}: EmojiPickerProps) => {
|
}: EmojiPickerProps) => {
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
|
|
||||||
@@ -50,36 +58,43 @@ const EmojiPicker = ({
|
|||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
|
|
||||||
<Popover.Dropdown p="xs">
|
<Popover.Dropdown p="xs">
|
||||||
<SimpleGrid cols={6} spacing={0}>
|
<SimpleGrid cols={6} spacing={4}>
|
||||||
{EMOJIS.map(({ emoji, label }) => (
|
{EMOJIS.map(({ emoji, label }) => {
|
||||||
<UnstyledButton
|
const hasReacted = userReactions.includes(emoji);
|
||||||
key={emoji}
|
return (
|
||||||
onClick={() => handleEmojiSelect(emoji)}
|
<UnstyledButton
|
||||||
style={{
|
key={emoji}
|
||||||
borderRadius: "var(--mantine-radius-sm)",
|
onClick={() => handleEmojiSelect(emoji)}
|
||||||
display: "flex",
|
style={{
|
||||||
alignItems: "center",
|
borderRadius: "var(--mantine-radius-sm)",
|
||||||
justifyContent: "center",
|
display: "flex",
|
||||||
minHeight: 36,
|
alignItems: "center",
|
||||||
minWidth: 36,
|
justifyContent: "center",
|
||||||
}}
|
minHeight: 36,
|
||||||
styles={{
|
minWidth: 36,
|
||||||
root: {
|
backgroundColor: hasReacted ? 'var(--mantine-primary-color-light)' : undefined,
|
||||||
'&:hover': {
|
border: hasReacted ? '1px solid var(--mantine-primary-color-filled)' : undefined,
|
||||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
}}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: hasReacted
|
||||||
|
? 'var(--mantine-primary-color-light-hover)'
|
||||||
|
: 'var(--mantine-color-gray-1)',
|
||||||
|
},
|
||||||
|
'&:active': {
|
||||||
|
transform: 'scale(0.95)',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'&:active': {
|
}}
|
||||||
transform: 'scale(0.95)',
|
aria-label={label}
|
||||||
},
|
>
|
||||||
},
|
<Text size="lg" style={{ lineHeight: 1 }}>
|
||||||
}}
|
{emoji}
|
||||||
aria-label={label}
|
</Text>
|
||||||
>
|
</UnstyledButton>
|
||||||
<Text size="lg" style={{ lineHeight: 1 }}>
|
);
|
||||||
{emoji}
|
})}
|
||||||
</Text>
|
|
||||||
</UnstyledButton>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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";
|
||||||
import { matchKeys } from "@/features/matches/queries";
|
|
||||||
|
|
||||||
export const reactionKeys = {
|
export const reactionKeys = {
|
||||||
match: (matchId: string) => ['reactions', 'match', matchId] as const,
|
match: (matchId: string) => ['reactions', 'match', matchId] as const,
|
||||||
@@ -15,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();
|
||||||
@@ -26,9 +25,6 @@ export const useToggleMatchReaction = (matchId: string) => {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: reactionKeys.match(matchId)
|
queryKey: reactionKeys.match(matchId)
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: matchKeys.reactions(matchId)
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
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;
|
||||||
@@ -17,8 +17,6 @@ interface TeamCardProps {
|
|||||||
const TeamCard = ({ teamId }: TeamCardProps) => {
|
const TeamCard = ({ teamId }: TeamCardProps) => {
|
||||||
const { data: team, error } = useTeam(teamId);
|
const { data: team, error } = useTeam(teamId);
|
||||||
|
|
||||||
console.log(team)
|
|
||||||
|
|
||||||
if (error || !team) {
|
if (error || !team) {
|
||||||
return (
|
return (
|
||||||
<Paper p="sm" withBorder radius="md">
|
<Paper p="sm" withBorder radius="md">
|
||||||
|
|||||||
@@ -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);
|
|
||||||
setIsValid(valid);
|
|
||||||
|
|
||||||
if (valid) {
|
setStartTime(clampedStart);
|
||||||
onChange(start, end);
|
setEndTime(clampedEnd);
|
||||||
}
|
setStartInputValue(formatTime(clampedStart));
|
||||||
},
|
setEndInputValue(formatTime(clampedEnd));
|
||||||
[onChange, validateRange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRangeChangeEnd = useCallback(
|
const valid = validateTimes(clampedStart, clampedEnd);
|
||||||
(newRange: [number, number]) => {
|
setIsValid(valid);
|
||||||
let [start, end] = newRange;
|
|
||||||
let duration = end - start;
|
|
||||||
|
|
||||||
if (duration < 10) {
|
if (valid) {
|
||||||
if (start < songDurationSeconds / 2) {
|
onChange(clampedStart, clampedEnd);
|
||||||
end = Math.min(start + 10, songDurationSeconds);
|
}
|
||||||
} else {
|
}, [songDurationSeconds, validateTimes, onChange, formatTime]);
|
||||||
start = Math.max(end - 10, 0);
|
|
||||||
}
|
|
||||||
duration = end - start;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (duration > 15) {
|
const handleStartInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const startDiff = Math.abs(start - range[0]);
|
setStartInputValue(event.target.value);
|
||||||
const endDiff = Math.abs(end - range[1]);
|
}, []);
|
||||||
|
|
||||||
if (startDiff > endDiff) {
|
const handleEndInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
end = start + 15;
|
setEndInputValue(event.target.value);
|
||||||
if (end > songDurationSeconds) {
|
}, []);
|
||||||
end = songDurationSeconds;
|
|
||||||
start = end - 15;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
start = end - 15;
|
|
||||||
if (start < 0) {
|
|
||||||
start = 0;
|
|
||||||
end = start + 15;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
start = Math.max(0, start);
|
const handleStartBlur = useCallback(() => {
|
||||||
end = Math.min(songDurationSeconds, end);
|
const parsed = parseTimeInput(startInputValue);
|
||||||
|
if (parsed !== null) {
|
||||||
|
updateTimes(parsed, endTime);
|
||||||
|
} else {
|
||||||
|
setStartInputValue(formatTime(startTime));
|
||||||
|
}
|
||||||
|
}, [startInputValue, endTime, updateTimes, parseTimeInput, formatTime, startTime]);
|
||||||
|
|
||||||
const finalRange: [number, number] = [start, end];
|
const handleEndBlur = useCallback(() => {
|
||||||
setRange(finalRange);
|
const parsed = parseTimeInput(endInputValue);
|
||||||
setIsValid(validateRange(start, end));
|
if (parsed !== null) {
|
||||||
onChange(start, end);
|
updateTimes(startTime, parsed);
|
||||||
},
|
} else {
|
||||||
[range, songDurationSeconds, onChange, validateRange]
|
setEndInputValue(formatTime(endTime));
|
||||||
);
|
}
|
||||||
|
}, [endInputValue, startTime, updateTimes, parseTimeInput, formatTime, endTime]);
|
||||||
|
|
||||||
|
const adjustStartTime = useCallback((seconds: number) => {
|
||||||
|
updateTimes(startTime + seconds, endTime);
|
||||||
|
}, [startTime, endTime, updateTimes]);
|
||||||
|
|
||||||
|
const adjustEndTime = useCallback((seconds: number) => {
|
||||||
|
updateTimes(startTime, endTime + seconds);
|
||||||
|
}, [startTime, endTime, updateTimes]);
|
||||||
|
|
||||||
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
|
<Stack gap="sm">
|
||||||
min={0}
|
<Stack gap={4}>
|
||||||
max={songDurationSeconds}
|
<Group justify="space-between" align="center">
|
||||||
step={1}
|
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
|
||||||
value={range}
|
Start
|
||||||
onChange={disabled ? undefined : handleRangeChange}
|
</Text>
|
||||||
onChangeEnd={disabled ? undefined : handleRangeChangeEnd}
|
<TextInput
|
||||||
marks={[
|
value={startInputValue}
|
||||||
{ value: 0, label: "0:00" },
|
onChange={handleStartInputChange}
|
||||||
{
|
onBlur={handleStartBlur}
|
||||||
value: songDurationSeconds,
|
disabled={disabled}
|
||||||
label: formatTime(songDurationSeconds),
|
size="xs"
|
||||||
},
|
w={70}
|
||||||
]}
|
placeholder="0:00"
|
||||||
size="lg"
|
ta="center"
|
||||||
m='xs'
|
styles={{
|
||||||
color={disabled ? "gray" : (isValid ? "blue" : "red")}
|
input: {
|
||||||
thumbSize={20}
|
fontWeight: 600,
|
||||||
label={disabled ? undefined : (value) => formatTime(value)}
|
fontSize: '12px'
|
||||||
disabled={disabled}
|
}
|
||||||
styles={{
|
}}
|
||||||
track: { height: 8 },
|
/>
|
||||||
}}
|
</Group>
|
||||||
/>
|
<Group gap={4}>
|
||||||
|
<IncrementButtons
|
||||||
<Divider />
|
onAdjust={adjustStartTime}
|
||||||
|
disabled={disabled || startTime <= 0}
|
||||||
<Group justify="space-between" align="center">
|
isPositive={false}
|
||||||
<Stack gap={2} align="center">
|
/>
|
||||||
<Text size="xs" c="dimmed">
|
<IncrementButtons
|
||||||
Start
|
onAdjust={adjustStartTime}
|
||||||
</Text>
|
disabled={disabled || startTime >= songDurationSeconds - 10}
|
||||||
<Text size="sm" fw={500}>
|
isPositive={true}
|
||||||
{formatTime(range[0])}
|
/>
|
||||||
</Text>
|
</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>
|
End
|
||||||
<Text size="sm" fw={500} c={isValid ? undefined : "red"}>
|
</Text>
|
||||||
{segmentDuration}s
|
<TextInput
|
||||||
</Text>
|
value={endInputValue}
|
||||||
|
onChange={handleEndInputChange}
|
||||||
|
onBlur={handleEndBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
size="xs"
|
||||||
|
w={70}
|
||||||
|
placeholder="0:15"
|
||||||
|
ta="center"
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '12px'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
</Stack>
|
||||||
<Stack gap={2} align="center">
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
End
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{formatTime(range[1])}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{!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>
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ const TeamForm = ({
|
|||||||
|
|
||||||
mutation(teamData, {
|
mutation(teamData, {
|
||||||
onSuccess: async (team: any) => {
|
onSuccess: async (team: any) => {
|
||||||
console.log(team)
|
|
||||||
queryClient.invalidateQueries({ queryKey: teamKeys.list });
|
queryClient.invalidateQueries({ queryKey: teamKeys.list });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: teamKeys.details(team.id),
|
queryKey: teamKeys.details(team.id),
|
||||||
@@ -130,7 +129,6 @@ const TeamForm = ({
|
|||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
console.log('here for some reason', result)
|
|
||||||
|
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
tournamentKeys.details(result.team!.id),
|
tournamentKeys.details(result.team!.id),
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { Text, Combobox, InputBase, useCombobox, Group, Avatar, Loader } from "@mantine/core";
|
import { Text, TextInput, Group, Avatar, Loader, Paper, Stack, Box } from "@mantine/core";
|
||||||
import { SpotifyTrack } from "@/lib/spotify/types";
|
import { SpotifyTrack } from "@/lib/spotify/types";
|
||||||
import { useDebouncedCallback } from "@mantine/hooks";
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
|
|
||||||
@@ -12,10 +12,11 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
|
|||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [searchResults, setSearchResults] = useState<SpotifyTrack[]>([]);
|
const [searchResults, setSearchResults] = useState<SpotifyTrack[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
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 combobox = useCombobox();
|
|
||||||
|
|
||||||
// Standalone search function that doesn't require Spotify context
|
|
||||||
const searchSpotifyTracks = async (query: string): Promise<SpotifyTrack[]> => {
|
const searchSpotifyTracks = async (query: string): Promise<SpotifyTrack[]> => {
|
||||||
if (!query.trim()) return [];
|
if (!query.trim()) return [];
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
|
|||||||
const debouncedSearch = useDebouncedCallback(async (query: string) => {
|
const debouncedSearch = useDebouncedCallback(async (query: string) => {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
|
setIsOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,10 +46,12 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
|
|||||||
try {
|
try {
|
||||||
const results = await searchSpotifyTracks(query);
|
const results = await searchSpotifyTracks(query);
|
||||||
setSearchResults(results);
|
setSearchResults(results);
|
||||||
combobox.openDropdown();
|
setIsOpen(results.length > 0);
|
||||||
|
setSelectedIndex(-1);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search failed:', error);
|
console.error('Search failed:', error);
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
|
setIsOpen(false);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -61,60 +65,117 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
|
|||||||
const handleSongSelect = (track: SpotifyTrack) => {
|
const handleSongSelect = (track: SpotifyTrack) => {
|
||||||
onChange(track);
|
onChange(track);
|
||||||
setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`);
|
setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`);
|
||||||
combobox.closeDropdown();
|
setIsOpen(false);
|
||||||
|
setSelectedIndex(-1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = searchResults.map((track) => (
|
useEffect(() => {
|
||||||
<Combobox.Option value={track.id} key={track.id}>
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
<Group gap="sm">
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
{track.album.images[2] && (
|
setIsOpen(false);
|
||||||
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
|
}
|
||||||
)}
|
};
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={500}>
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
{track.name}
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
</Text>
|
}, []);
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{track.artists.map(a => a.name).join(', ')} • {track.album.name}
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
</Text>
|
if (!isOpen || searchResults.length === 0) return;
|
||||||
</div>
|
|
||||||
</Group>
|
switch (e.key) {
|
||||||
</Combobox.Option>
|
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]) {
|
||||||
|
handleSongSelect(searchResults[selectedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
setIsOpen(false);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Box ref={containerRef} pos="relative" w="100%">
|
||||||
store={combobox}
|
<TextInput
|
||||||
onOptionSubmit={(value) => {
|
ref={inputRef}
|
||||||
const track = searchResults.find(t => t.id === value);
|
value={searchQuery}
|
||||||
if (track) handleSongSelect(track);
|
onChange={(event) => handleSearchChange(event.currentTarget.value)}
|
||||||
}}
|
onKeyDown={handleKeyDown}
|
||||||
width='100%'
|
onFocus={() => {
|
||||||
zIndex={9999}
|
if (searchResults.length > 0) setIsOpen(true);
|
||||||
withinPortal={false}
|
}}
|
||||||
>
|
placeholder={placeholder}
|
||||||
<Combobox.Target>
|
rightSection={isLoading ? <Loader size="xs" /> : null}
|
||||||
<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>
|
{isOpen && (
|
||||||
<Combobox.Options>
|
<Paper
|
||||||
{options.length > 0 ? options :
|
shadow="md"
|
||||||
<Combobox.Empty>
|
p={0}
|
||||||
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
|
style={{
|
||||||
</Combobox.Empty>
|
position: 'absolute',
|
||||||
}
|
top: '100%',
|
||||||
</Combobox.Options>
|
left: 0,
|
||||||
</Combobox.Dropdown>
|
right: 0,
|
||||||
</Combobox>
|
zIndex: 9999,
|
||||||
|
maxHeight: '160px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
touchAction: 'pan-y'
|
||||||
|
}}
|
||||||
|
onTouchMove={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{searchResults.length > 0 ? (
|
||||||
|
<Stack gap={0}>
|
||||||
|
{searchResults.map((track, index) => (
|
||||||
|
<Box
|
||||||
|
key={track.id}
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: selectedIndex === index ? 'var(--mantine-color-gray-1)' : 'transparent',
|
||||||
|
borderBottom: index < searchResults.length - 1 ? '1px solid var(--mantine-color-gray-3)' : 'none'
|
||||||
|
}}
|
||||||
|
onClick={() => handleSongSelect(track)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
>
|
||||||
|
<Group gap="sm">
|
||||||
|
{track.album.images[2] && (
|
||||||
|
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{track.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{track.artists.map(a => a.name).join(', ')} • {track.album.name}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Box p="md">
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -39,14 +39,21 @@ 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)
|
||||||
@@ -69,6 +76,7 @@ const TeamList = ({ teams, loading = false }: TeamListProps) => {
|
|||||||
{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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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,7 +10,7 @@ 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 name={tournament.name} radius={0} withBorder={false} size={125} 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>
|
||||||
|
|||||||
@@ -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,22 +15,22 @@ 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",
|
||||||
content: <MatchList matches={tournament.matches?.sort((a, b) => b.order - a.order) || []} />
|
content: <MatchList matches={tournament.matches?.sort((a, b) => b.order - a.order) || []} />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Teams",
|
label: "Teams",
|
||||||
content: <>
|
content: <>
|
||||||
<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;
|
||||||
@@ -15,7 +15,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack align="center" gap={0}>
|
<Stack px="sm" align="center" gap={0}>
|
||||||
<Avatar
|
<Avatar
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
src={
|
src={
|
||||||
|
|||||||
@@ -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,7 +57,7 @@ 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}
|
||||||
src={
|
src={
|
||||||
@@ -93,31 +72,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"
|
||||||
<CalendarIcon size={12} />
|
leftSection={
|
||||||
</ThemeIcon>
|
<CrownIcon size={16} />
|
||||||
<Text size="sm" c="dimmed">
|
}
|
||||||
{displayDate}
|
style={{
|
||||||
</Text>
|
textTransform: 'none',
|
||||||
</Group>
|
fontWeight: 600,
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tournament.first_place.name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{tournament.second_place && (
|
||||||
|
<Badge
|
||||||
|
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>
|
||||||
|
|||||||
285
src/features/tournaments/components/tournament-stats.tsx
Normal file
285
src/features/tournaments/components/tournament-stats.tsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TournamentStats.displayName = 'TournamentStats';
|
||||||
@@ -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">
|
||||||
Enrolling as a free agent will enter you in a pool of players wanting to play but don't have a teammate yet.
|
<Text size="md">
|
||||||
</Text>
|
Enrolling as a free agent will enter you in a pool of players wanting to play but don't have a teammate yet.
|
||||||
<Text size="sm" mb="md" c='dimmed'>
|
</Text>
|
||||||
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.
|
<Text size="sm" c='dimmed'>
|
||||||
</Text>
|
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.
|
||||||
<Button onClick={console.log}>Confirm</Button>
|
</Text>
|
||||||
|
<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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { Group, Stack, Text, Card, Badge, Box, ActionIcon } from "@mantine/core";
|
||||||
|
import { UserIcon, PhoneIcon } from "@phosphor-icons/react";
|
||||||
|
import { useFreeAgents } from "../../queries";
|
||||||
|
import UnenrollFreeAgent from "./unenroll-free-agent";
|
||||||
|
import toast from "@/lib/sonner";
|
||||||
|
|
||||||
|
const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
|
||||||
|
tournamentId
|
||||||
|
}) => {
|
||||||
|
const { data: freeAgents } = useFreeAgents(tournamentId);
|
||||||
|
|
||||||
|
const copyToClipboard = async (phone: string) => {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(phone);
|
||||||
|
toast.success("Phone number copied!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = phone;
|
||||||
|
textArea.style.display = "hidden";
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
const successful = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
|
||||||
|
if (successful) {
|
||||||
|
toast.success("Phone number copied!");
|
||||||
|
} else {
|
||||||
|
throw new Error("Copy command failed");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy:", err);
|
||||||
|
toast.error("Failed to copy");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Group gap="xs" align="center">
|
||||||
|
<UserIcon size={16} />
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
Enrolled as Free Agent
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
You're on the free agent list. Other free agents looking for teams:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{freeAgents.length > 1 ? (
|
||||||
|
<Card withBorder radius="md" p="sm">
|
||||||
|
<Stack gap="xs">
|
||||||
|
{freeAgents
|
||||||
|
.filter(agent => agent.player)
|
||||||
|
.map((agent) => (
|
||||||
|
<Group key={agent.id} justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="sm" fw={500} truncate>
|
||||||
|
{agent.player?.first_name} {agent.player?.last_name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{agent.phone && (
|
||||||
|
<Group gap={4} align="center" style={{ flexShrink: 0 }}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => copyToClipboard(agent.phone!)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<PhoneIcon size={12} />
|
||||||
|
</ActionIcon>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
c="dimmed"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => copyToClipboard(agent.phone!)}
|
||||||
|
>
|
||||||
|
{agent.phone}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{freeAgents.length > 1 && (
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
color="blue"
|
||||||
|
style={{ alignSelf: 'flex-start', marginTop: '4px' }}
|
||||||
|
>
|
||||||
|
{freeAgents.length} free agents total
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card withBorder radius="md" p="sm">
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
You're the only free agent so far
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
<UnenrollFreeAgent tournamentId={tournamentId} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EnrolledFreeAgent;
|
||||||
@@ -14,10 +14,6 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
() => new Date(tournament.start_time),
|
() => new Date(tournament.start_time),
|
||||||
[tournament.start_time]
|
[tournament.start_time]
|
||||||
);
|
);
|
||||||
const teamCount = useMemo(
|
|
||||||
() => tournament.teams?.length || 0,
|
|
||||||
[tournament.teams]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack align="center" gap={0}>
|
<Stack align="center" gap={0}>
|
||||||
@@ -35,7 +31,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
>
|
>
|
||||||
<TrophyIcon size={32} />
|
<TrophyIcon size={32} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Flex gap="xs" direction="row" wrap="wrap" justify="space-around">
|
<Flex gap="xs" direction="column" justify="space-around">
|
||||||
{tournament.location && (
|
{tournament.location && (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import TeamCard from "@/features/teams/components/team-card";
|
|||||||
import UpdateTeam from "./update-team";
|
import UpdateTeam from "./update-team";
|
||||||
import UnenrollTeam from "./unenroll-team";
|
import UnenrollTeam from "./unenroll-team";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { tournamentKeys } from "../../queries";
|
import { tournamentKeys, useFreeAgents } from "../../queries";
|
||||||
import RulesListButton from "./rules-list-button";
|
import RulesListButton from "./rules-list-button";
|
||||||
|
import EnrolledFreeAgent from "./enrolled-free-agent";
|
||||||
|
|
||||||
const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
||||||
tournament,
|
tournament,
|
||||||
@@ -40,57 +41,79 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: tournamentKeys.current })
|
queryClient.invalidateQueries({ queryKey: tournamentKeys.current });
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const { data: free_agents } = useFreeAgents(tournament.id);
|
||||||
|
const isFreeAgent = useMemo(() => !isUserEnrolled && free_agents.find(a => a.player?.id === user?.id), [free_agents, isUserEnrolled]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Header tournament={tournament} />
|
<Header tournament={tournament} />
|
||||||
{tournament.desc && <Text size="sm">{tournament.desc}</Text>}
|
|
||||||
|
|
||||||
<Card withBorder radius="lg" p="lg">
|
<Stack px="xs">
|
||||||
<Stack gap="xs">
|
{tournament.desc && <Text px="md" size="sm">{tournament.desc}</Text>}
|
||||||
<Group mb="sm" gap="xs" align="center">
|
|
||||||
<UsersIcon size={16} />
|
<Card withBorder radius="lg" p="lg">
|
||||||
<Text size="sm" fw={500}>
|
<Stack gap="xs">
|
||||||
Enrollment
|
<Group mb="sm" gap="xs" align="center">
|
||||||
</Text>
|
<UsersIcon size={16} />
|
||||||
{isEnrollmentOpen && (
|
<Text size="sm" fw={500}>
|
||||||
<Box ml="auto">
|
Enrollment
|
||||||
<Countdown
|
</Text>
|
||||||
date={enrollmentDeadline}
|
{isEnrollmentOpen && (
|
||||||
label="Time left"
|
<Box ml="auto">
|
||||||
color="yellow"
|
<Countdown
|
||||||
/>
|
date={enrollmentDeadline}
|
||||||
</Box>
|
label="Time left"
|
||||||
|
color="yellow"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!isUserEnrolled && !isEnrollmentOpen && (
|
||||||
|
<Text fw={600} c="dimmed" size="sm">
|
||||||
|
Enrollment has been closed for this tournament.
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Group>
|
|
||||||
|
|
||||||
{!isUserEnrolled &&!isEnrollmentOpen && (
|
{!isUserEnrolled && isEnrollmentOpen && !isFreeAgent && (
|
||||||
<Text fw={600} c="dimmed" size="sm">
|
<>
|
||||||
Enrollment has been closed for this tournament.
|
<EnrollTeam
|
||||||
</Text>
|
tournamentId={tournament.id}
|
||||||
)}
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
<Divider my={0} label="or" />
|
||||||
|
<EnrollFreeAgent tournamentId={tournament.id} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isUserEnrolled &&isEnrollmentOpen && (
|
{isUserEnrolled && (
|
||||||
<>
|
<>
|
||||||
<EnrollTeam tournamentId={tournament.id} onSubmit={handleSubmit} />
|
<Suspense fallback={<TeamCardSkeleton />}>
|
||||||
<Divider my={0} label="or" />
|
<TeamCard teamId={userTeam.id} />
|
||||||
<EnrollFreeAgent />
|
</Suspense>
|
||||||
</>
|
<UpdateTeam tournamentId={tournament.id} teamId={userTeam.id} />
|
||||||
)}
|
{isEnrollmentOpen && (
|
||||||
|
<UnenrollTeam
|
||||||
|
tournamentId={tournament.id}
|
||||||
|
teamId={userTeam.id}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{
|
{
|
||||||
isUserEnrolled && <>
|
isFreeAgent && isEnrollmentOpen && (
|
||||||
<Suspense fallback={<TeamCardSkeleton />}>
|
<EnrolledFreeAgent tournamentId={tournament.id} />
|
||||||
<TeamCard teamId={userTeam.id} />
|
)
|
||||||
</Suspense>
|
}
|
||||||
<UpdateTeam tournamentId={tournament.id} teamId={userTeam.id} />
|
|
||||||
{ isEnrollmentOpen && <UnenrollTeam tournamentId={tournament.id} teamId={userTeam.id} onSubmit={handleSubmit} />}
|
</Stack>
|
||||||
</>
|
</Card>
|
||||||
}
|
</Stack>
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -102,12 +125,13 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ListLink
|
<ListLink
|
||||||
label={`View Bracket`}
|
label={`View Bracket`}
|
||||||
to={`/tournaments/${tournament.id}/bracket`}
|
to={`/tournaments/${tournament.id}/bracket`}
|
||||||
Icon={TreeStructureIcon}
|
Icon={TreeStructureIcon}
|
||||||
/>
|
disabled
|
||||||
<RulesListButton tournamentId={tournament.id} />
|
/>
|
||||||
<TeamListButton teams={tournament.teams || []} />
|
<TeamListButton teams={tournament.teams || []} />
|
||||||
|
<RulesListButton tournamentId={tournament.id} />
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const RulesListButton: React.FC<RulesListButtonProps> = ({ tournamentId }) => {
|
|||||||
extensions: [StarterKit],
|
extensions: [StarterKit],
|
||||||
content: tournament?.rules || '',
|
content: tournament?.rules || '',
|
||||||
editable: false,
|
editable: false,
|
||||||
|
immediatelyRender: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Box, Card, Divider, Flex, Group, Skeleton, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
const UpcomingTournamentSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Flex px="md" justify="center" w="100%">
|
||||||
|
<Skeleton height={200} width={240} radius="md" />
|
||||||
|
</Flex>
|
||||||
|
<Stack align="center" gap={2}>
|
||||||
|
<Skeleton height={16} w="30%" mb="md" />
|
||||||
|
<Skeleton height={16} w="30%" />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack px="md">
|
||||||
|
<Card withBorder radius="lg" p="lg">
|
||||||
|
<Skeleton height={14} width="80%" mb={16} />
|
||||||
|
<Group mb="sm" gap="xs" align="center">
|
||||||
|
<Skeleton height={32} width={16} />
|
||||||
|
<Skeleton height={32} width="20%" />
|
||||||
|
<Box ml="auto">
|
||||||
|
<Skeleton height={32} width={80} radius="sm" />
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Group mb="sm" gap="xs" align="center">
|
||||||
|
<Skeleton height={32} width={16} />
|
||||||
|
<Skeleton height={32} width="20%" />
|
||||||
|
<Box ml="auto">
|
||||||
|
<Skeleton height={32} width={80} radius="sm" />
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UpcomingTournamentSkeleton;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Button from "@/components/button";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
import { Stack, Text } from "@mantine/core";
|
||||||
|
import useUnenrollFreeAgent from "../../hooks/use-unenroll-free-agent";
|
||||||
|
|
||||||
|
const UnenrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
|
||||||
|
const { open, isOpen, toggle } = useSheet();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const { mutate: unenrollFreeAgent, isPending: isEnrolling } = useUnenrollFreeAgent();
|
||||||
|
const handleUnenroll = () => {
|
||||||
|
unenrollFreeAgent({ playerId: user!.id, tournamentId }, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="subtle" size="sm" onClick={open}>
|
||||||
|
Unenroll
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Sheet title="Are you sure?" opened={isOpen} onChange={toggle}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="md">
|
||||||
|
This will remove you from the free agent list.
|
||||||
|
</Text>
|
||||||
|
<Button onClick={handleUnenroll}>Confirm</Button>
|
||||||
|
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
|
||||||
|
</Stack>
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UnenrollFreeAgent;
|
||||||
20
src/features/tournaments/hooks/use-enroll-free-agent.ts
Normal file
20
src/features/tournaments/hooks/use-enroll-free-agent.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||||
|
import { enrollFreeAgent } from "@/features/tournaments/server";
|
||||||
|
import { tournamentKeys } from "../queries";
|
||||||
|
|
||||||
|
const useEnrollFreeAgent = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useServerMutation({
|
||||||
|
mutationFn: (data: { tournamentId: string, playerId: string, phone: string }) => {
|
||||||
|
return enrollFreeAgent({ data });
|
||||||
|
},
|
||||||
|
onSuccess: (data, { tournamentId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
|
||||||
|
},
|
||||||
|
successMessage: 'You\'ve been added as a free agent!',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useEnrollFreeAgent;
|
||||||
20
src/features/tournaments/hooks/use-unenroll-free-agent.ts
Normal file
20
src/features/tournaments/hooks/use-unenroll-free-agent.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||||
|
import { unenrollFreeAgent } from "@/features/tournaments/server";
|
||||||
|
import { tournamentKeys } from "../queries";
|
||||||
|
|
||||||
|
const useUnenrollFreeAgent = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useServerMutation({
|
||||||
|
mutationFn: (data: { tournamentId: string, playerId: string }) => {
|
||||||
|
return unenrollFreeAgent({ data });
|
||||||
|
},
|
||||||
|
onSuccess: (data, { tournamentId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
|
||||||
|
},
|
||||||
|
successMessage: 'You\'ve been removed as a free agent.',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useUnenrollFreeAgent;
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { getCurrentTournament, getTournament, getUnenrolledTeams, listTournaments } from "./server";
|
import { getCurrentTournament, getFreeAgents, getTournament, getUnenrolledTeams, listTournaments } from "./server";
|
||||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
|
|
||||||
export const tournamentKeys = {
|
export const tournamentKeys = {
|
||||||
list: ['tournaments', 'list'] as const,
|
list: ['tournaments', 'list'] as const,
|
||||||
details: (id: string) => ['tournaments', 'details', id] as const,
|
details: (id: string) => ['tournaments', 'details', id] as const,
|
||||||
current: ['tournaments', 'current'] as const,
|
current: ['tournaments', 'current'] as const,
|
||||||
unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const
|
unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const,
|
||||||
|
free_agents: (id: string) => ['tournaments', 'free_agents', id] as const
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tournamentQueries = {
|
export const tournamentQueries = {
|
||||||
@@ -24,6 +25,10 @@ export const tournamentQueries = {
|
|||||||
unenrolled: (id: string) => ({
|
unenrolled: (id: string) => ({
|
||||||
queryKey: tournamentKeys.unenrolled(id),
|
queryKey: tournamentKeys.unenrolled(id),
|
||||||
queryFn: () => getUnenrolledTeams({ data: id })
|
queryFn: () => getUnenrolledTeams({ data: id })
|
||||||
|
}),
|
||||||
|
free_agents: (id: string) => ({
|
||||||
|
queryKey: tournamentKeys.free_agents(id),
|
||||||
|
queryFn: () => getFreeAgents({ data: id })
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,3 +43,6 @@ export const useCurrentTournament = () =>
|
|||||||
|
|
||||||
export const useUnenrolledTeams = (tournamentId: string) =>
|
export const useUnenrolledTeams = (tournamentId: string) =>
|
||||||
useServerSuspenseQuery(tournamentQueries.unenrolled(tournamentId));
|
useServerSuspenseQuery(tournamentQueries.unenrolled(tournamentId));
|
||||||
|
|
||||||
|
export const useFreeAgents = (tournamentId: string) =>
|
||||||
|
useServerSuspenseQuery(tournamentQueries.free_agents(tournamentId));
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ export const updateTournament = createServerFn()
|
|||||||
export const getTournament = createServerFn()
|
export const getTournament = createServerFn()
|
||||||
.validator(z.string())
|
.validator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: tournamentId }) =>
|
.handler(async ({ data: tournamentId, context }) => {
|
||||||
toServerResult(() => pbAdmin.getTournament(tournamentId))
|
const isAdmin = context.roles.includes("Admin");
|
||||||
);
|
return toServerResult(() => pbAdmin.getTournament(tournamentId, isAdmin));
|
||||||
|
});
|
||||||
|
|
||||||
export const getCurrentTournament = createServerFn()
|
export const getCurrentTournament = createServerFn()
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
@@ -83,4 +84,39 @@ export const getUnenrolledTeams = createServerFn()
|
|||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data: tournamentId }) =>
|
.handler(async ({ data: tournamentId }) =>
|
||||||
toServerResult(() => pbAdmin.getUnenrolledTeams(tournamentId))
|
toServerResult(() => pbAdmin.getUnenrolledTeams(tournamentId))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getFreeAgents = createServerFn()
|
||||||
|
.validator(z.string())
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
|
.handler(async ({ data: tournamentId }) =>
|
||||||
|
toServerResult(() => pbAdmin.getFreeAgents(tournamentId))
|
||||||
|
);
|
||||||
|
|
||||||
|
export const enrollFreeAgent = createServerFn()
|
||||||
|
.validator(z.object({ phone: z.string(), tournamentId: z.string() }))
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ context, data }) =>
|
||||||
|
toServerResult(async () => {
|
||||||
|
const userAuthId = context.userAuthId;
|
||||||
|
const player = await pbAdmin.getPlayerByAuthId(userAuthId);
|
||||||
|
if (!player) throw new Error("Player not found");
|
||||||
|
|
||||||
|
await pbAdmin.enrollFreeAgent(player.id, data.phone, data.tournamentId);
|
||||||
|
logger.info('Player enrolled as free agent', { playerId: player.id, phone: data.phone });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const unenrollFreeAgent = createServerFn()
|
||||||
|
.validator(z.object({ tournamentId: z.string() }))
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ context, data }) =>
|
||||||
|
toServerResult(async () => {
|
||||||
|
const userAuthId = context.userAuthId;
|
||||||
|
const player = await pbAdmin.getPlayerByAuthId(userAuthId);
|
||||||
|
if (!player) throw new Error("Player not found");
|
||||||
|
|
||||||
|
await pbAdmin.unenrollFreeAgent(player.id, data.tournamentId);
|
||||||
|
logger.info('Player unenrolled as free agent', { playerId: player.id });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,6 +2,22 @@ import { TeamInfo } from "@/features/teams/types";
|
|||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export interface TournamentTeamStats {
|
||||||
|
id: string;
|
||||||
|
team_id: string;
|
||||||
|
tournament_id: string;
|
||||||
|
team_name: string;
|
||||||
|
matches: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
total_cups_made: number;
|
||||||
|
total_cups_against: number;
|
||||||
|
margin_of_victory: number;
|
||||||
|
margin_of_loss: number;
|
||||||
|
win_percentage: number;
|
||||||
|
avg_cups_per_match: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TournamentInfo {
|
export interface TournamentInfo {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -9,6 +25,9 @@ export interface TournamentInfo {
|
|||||||
start_time?: string;
|
start_time?: string;
|
||||||
end_time?: string;
|
end_time?: string;
|
||||||
logo?: string;
|
logo?: string;
|
||||||
|
first_place?: TeamInfo;
|
||||||
|
second_place?: TeamInfo;
|
||||||
|
third_place?: TeamInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Tournament {
|
export interface Tournament {
|
||||||
@@ -25,6 +44,10 @@ export interface Tournament {
|
|||||||
updated: string;
|
updated: string;
|
||||||
teams?: TeamInfo[];
|
teams?: TeamInfo[];
|
||||||
matches?: Match[];
|
matches?: Match[];
|
||||||
|
first_place?: TeamInfo;
|
||||||
|
second_place?: TeamInfo;
|
||||||
|
third_place?: TeamInfo;
|
||||||
|
team_stats?: TournamentTeamStats[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tournamentInputSchema = z.object({
|
export const tournamentInputSchema = z.object({
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ import { useEffect, useRef } from "react";
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Logger } from "@/lib/logger";
|
import { Logger } from "@/lib/logger";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { tournamentKeys, tournamentQueries } from "@/features/tournaments/queries";
|
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||||
|
import { reactionKeys, reactionQueries } from "@/features/reactions/queries";
|
||||||
let newIdeasAvailable = false;
|
|
||||||
let newIdeasCallbacks: (() => void)[] = [];
|
|
||||||
|
|
||||||
const logger = new Logger('ServerEvents');
|
const logger = new Logger('ServerEvents');
|
||||||
|
|
||||||
@@ -18,42 +16,19 @@ type EventHandler = (event: SSEEvent, queryClient: ReturnType<typeof useQueryCli
|
|||||||
|
|
||||||
const eventHandlers: Record<string, EventHandler> = {
|
const eventHandlers: Record<string, EventHandler> = {
|
||||||
"connected": () => {
|
"connected": () => {
|
||||||
logger.info("ServerEvents | New Connection");
|
logger.info("New Connection");
|
||||||
},
|
},
|
||||||
|
|
||||||
"ping": () => {},
|
"ping": () => {},
|
||||||
|
|
||||||
"test": (event, queryClient) => {
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
"match": (event, queryClient) => {
|
"match": (event, queryClient) => {
|
||||||
console.log(event);
|
|
||||||
|
|
||||||
queryClient.invalidateQueries(tournamentQueries.details(event.tournamentId))
|
queryClient.invalidateQueries(tournamentQueries.details(event.tournamentId))
|
||||||
queryClient.invalidateQueries(tournamentQueries.current())
|
queryClient.invalidateQueries(tournamentQueries.current())
|
||||||
|
},
|
||||||
|
"reaction": (event, queryClient) => {
|
||||||
|
queryClient.invalidateQueries(reactionQueries.match(event.matchId));
|
||||||
|
queryClient.setQueryData(reactionKeys.match(event.matchId), () => event.reactions);
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getNewIdeasAvailable(): boolean {
|
|
||||||
return newIdeasAvailable;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearNewIdeasAvailable(): void {
|
|
||||||
newIdeasAvailable = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subscribeToNewIdeas(callback: () => void): () => void {
|
|
||||||
newIdeasCallbacks.push(callback);
|
|
||||||
return () => {
|
|
||||||
const index = newIdeasCallbacks.indexOf(callback);
|
|
||||||
if (index > -1) {
|
|
||||||
newIdeasCallbacks.splice(index, 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useServerEvents() {
|
export function useServerEvents() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|||||||
@@ -13,4 +13,9 @@ export type MatchEvent = {
|
|||||||
tournamentId: string;
|
tournamentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerEvent = TestEvent | MatchEvent;
|
export type ReactionEvent = {
|
||||||
|
type: "reaction";
|
||||||
|
matchId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerEvent = TestEvent | MatchEvent | ReactionEvent;
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const MantineProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
setIsHydrated(true);
|
setIsHydrated(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const colorScheme = isHydrated ? metadata.colorScheme || "auto" : "auto";
|
const colorScheme = isHydrated ? metadata.colorScheme || "dark" : "dark";
|
||||||
const primaryColor = isHydrated ? metadata.accentColor || "blue" : "blue";
|
const primaryColor = isHydrated ? metadata.accentColor || "blue" : "blue";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ class PocketBaseAdminClient {
|
|||||||
public authPromise: Promise<void>;
|
public authPromise: Promise<void>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
console.log('Environment variables loaded:', {
|
||||||
|
POCKETBASE_URL: process.env.POCKETBASE_URL,
|
||||||
|
POCKETBASE_ADMIN_EMAIL: process.env.POCKETBASE_ADMIN_EMAIL,
|
||||||
|
POCKETBASE_ADMIN_PASSWORD: process.env.POCKETBASE_ADMIN_PASSWORD,
|
||||||
|
});
|
||||||
this.pb = new PocketBase(process.env.POCKETBASE_URL);
|
this.pb = new PocketBase(process.env.POCKETBASE_URL);
|
||||||
|
|
||||||
this.pb.beforeSend = (url, options) => {
|
this.pb.beforeSend = (url, options) => {
|
||||||
|
|||||||
@@ -80,8 +80,6 @@ export function createPlayersService(pb: PocketBase) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getPlayerMatches(playerId: string): Promise<Match[]> {
|
async getPlayerMatches(playerId: string): Promise<Match[]> {
|
||||||
console.log('----------------')
|
|
||||||
console.log(playerId)
|
|
||||||
const player = await pb.collection("players").getOne(playerId.trim(), {
|
const player = await pb.collection("players").getOne(playerId.trim(), {
|
||||||
expand: "teams",
|
expand: "teams",
|
||||||
});
|
});
|
||||||
@@ -102,18 +100,16 @@ export function createPlayersService(pb: PocketBase) {
|
|||||||
expand: "tournament,home,away",
|
expand: "tournament,home,away",
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.map(transformMatch);
|
return result.map((match) => transformMatch(match));
|
||||||
},
|
},
|
||||||
|
|
||||||
async getUnenrolledPlayers(tournamentId: string): Promise<Player[]> {
|
async getUnenrolledPlayers(tournamentId: string): Promise<Player[]> {
|
||||||
try {
|
try {
|
||||||
// Get the tournament with its enrolled teams
|
|
||||||
const tournament = await pb.collection("tournaments").getOne(tournamentId, {
|
const tournament = await pb.collection("tournaments").getOne(tournamentId, {
|
||||||
fields: "teams",
|
fields: "teams",
|
||||||
expand: "teams,teams.players"
|
expand: "teams,teams.players"
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract player IDs from all enrolled teams
|
|
||||||
const enrolledPlayerIds: string[] = [];
|
const enrolledPlayerIds: string[] = [];
|
||||||
if (tournament.expand?.teams) {
|
if (tournament.expand?.teams) {
|
||||||
const teams = Array.isArray(tournament.expand.teams) ? tournament.expand.teams : [tournament.expand.teams];
|
const teams = Array.isArray(tournament.expand.teams) ? tournament.expand.teams : [tournament.expand.teams];
|
||||||
@@ -127,7 +123,6 @@ export function createPlayersService(pb: PocketBase) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no players are enrolled, return all players
|
|
||||||
if (enrolledPlayerIds.length === 0) {
|
if (enrolledPlayerIds.length === 0) {
|
||||||
const allPlayers = await pb.collection("players").getFullList<Player>({
|
const allPlayers = await pb.collection("players").getFullList<Player>({
|
||||||
fields: "id,first_name,last_name,email",
|
fields: "id,first_name,last_name,email",
|
||||||
@@ -135,7 +130,6 @@ export function createPlayersService(pb: PocketBase) {
|
|||||||
return allPlayers.map(transformPlayer);
|
return allPlayers.map(transformPlayer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build filter to exclude enrolled players
|
|
||||||
const filter = enrolledPlayerIds
|
const filter = enrolledPlayerIds
|
||||||
.map((playerId: string) => `id != "${playerId}"`)
|
.map((playerId: string) => `id != "${playerId}"`)
|
||||||
.join(" && ");
|
.join(" && ");
|
||||||
@@ -148,7 +142,6 @@ export function createPlayersService(pb: PocketBase) {
|
|||||||
return availablePlayers.map(transformPlayer);
|
return availablePlayers.map(transformPlayer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error getting unenrolled players:", error);
|
console.error("Error getting unenrolled players:", error);
|
||||||
// Fallback to all players if there's an error
|
|
||||||
const allPlayers = await pb.collection("players").getFullList<Player>({
|
const allPlayers = await pb.collection("players").getFullList<Player>({
|
||||||
fields: "id,first_name,last_name,email",
|
fields: "id,first_name,last_name,email",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function createTeamsService(pb: PocketBase) {
|
|||||||
expand: "tournament,home,away",
|
expand: "tournament,home,away",
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.map(transformMatch);
|
return result.map((match) => transformMatch(match));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,16 +7,26 @@ import type {
|
|||||||
} from "@/features/tournaments/types";
|
} from "@/features/tournaments/types";
|
||||||
import type { Team } from "@/features/teams/types";
|
import type { Team } from "@/features/teams/types";
|
||||||
import PocketBase from "pocketbase";
|
import PocketBase from "pocketbase";
|
||||||
import { transformTournament, transformTournamentInfo } from "@/lib/pocketbase/util/transform-types";
|
import { transformFreeAgent, transformTournament, transformTournamentInfo } from "@/lib/pocketbase/util/transform-types";
|
||||||
import { transformTeam } from "@/lib/pocketbase/util/transform-types";
|
import { transformTeam } from "@/lib/pocketbase/util/transform-types";
|
||||||
|
import { PlayerInfo } from "@/features/players/types";
|
||||||
|
|
||||||
export function createTournamentsService(pb: PocketBase) {
|
export function createTournamentsService(pb: PocketBase) {
|
||||||
return {
|
return {
|
||||||
async getTournament(id: string): Promise<Tournament> {
|
async getTournament(id: string, isAdmin: boolean = false): Promise<Tournament> {
|
||||||
const result = await pb.collection("tournaments").getOne(id, {
|
const [tournamentResult, teamStatsResult] = await Promise.all([
|
||||||
expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players",
|
pb.collection("tournaments").getOne(id, {
|
||||||
});
|
expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players",
|
||||||
return transformTournament(result);
|
}),
|
||||||
|
pb.collection("team_stats_per_tournament").getFullList({
|
||||||
|
filter: `tournament_id = "${id}"`,
|
||||||
|
sort: "-wins,-total_cups_made"
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
tournamentResult.team_stats = teamStatsResult;
|
||||||
|
|
||||||
|
return transformTournament(tournamentResult, isAdmin);
|
||||||
},
|
},
|
||||||
async getMostRecentTournament(): Promise<Tournament> {
|
async getMostRecentTournament(): Promise<Tournament> {
|
||||||
const result = await pb
|
const result = await pb
|
||||||
@@ -27,17 +37,35 @@ export function createTournamentsService(pb: PocketBase) {
|
|||||||
sort: "-created",
|
sort: "-created",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const teamStatsResult = await pb.collection("team_stats_per_tournament").getFullList({
|
||||||
|
filter: `tournament_id = "${result.id}"`,
|
||||||
|
sort: "-wins,-total_cups_made"
|
||||||
|
});
|
||||||
|
|
||||||
|
result.team_stats = teamStatsResult;
|
||||||
|
|
||||||
return transformTournament(result);
|
return transformTournament(result);
|
||||||
},
|
},
|
||||||
async listTournaments(): Promise<TournamentInfo[]> {
|
async listTournaments(): Promise<TournamentInfo[]> {
|
||||||
const result = await pb
|
const result = await pb
|
||||||
.collection("tournaments")
|
.collection("tournaments")
|
||||||
.getFullList({
|
.getFullList({
|
||||||
fields: "id,name,location,start_time,end_time,logo",
|
expand: "teams,teams.players,matches",
|
||||||
sort: "-created",
|
sort: "-created",
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.map(transformTournamentInfo);
|
const tournamentsWithStats = await Promise.all(result.map(async (tournament) => {
|
||||||
|
const teamStats = await pb.collection("team_stats_per_tournament").getFullList({
|
||||||
|
filter: `tournament_id = "${tournament.id}"`,
|
||||||
|
sort: "-wins,-total_cups_made"
|
||||||
|
});
|
||||||
|
|
||||||
|
tournament.team_stats = teamStats;
|
||||||
|
return tournament;
|
||||||
|
}));
|
||||||
|
|
||||||
|
return tournamentsWithStats.map(transformTournamentInfo);
|
||||||
},
|
},
|
||||||
async createTournament(data: TournamentInput): Promise<Tournament> {
|
async createTournament(data: TournamentInput): Promise<Tournament> {
|
||||||
const result = await pb
|
const result = await pb
|
||||||
@@ -133,5 +161,33 @@ export function createTournamentsService(pb: PocketBase) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async enrollFreeAgent(playerId: string, phone: string, tournamentId: string): Promise<void> {
|
||||||
|
await pb.collection("free_agents").create({
|
||||||
|
tournament: tournamentId,
|
||||||
|
player: playerId,
|
||||||
|
phone: phone
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async unenrollFreeAgent(playerId: string, tournamentId: string): Promise<void> {
|
||||||
|
const result = await pb.collection("free_agents").getFirstListItem(
|
||||||
|
`player = "${playerId}" && tournament = "${tournamentId}"`
|
||||||
|
);
|
||||||
|
await pb.collection("free_agents").delete(result.id);
|
||||||
|
},
|
||||||
|
async getFreeAgents(tournamentId: string): Promise<{ id: string, phone: string, player: PlayerInfo | undefined }[]> {
|
||||||
|
try {
|
||||||
|
const free_agents = await pb
|
||||||
|
.collection("free_agents")
|
||||||
|
.getFullList({ filter: `tournament = "${tournamentId}"`,
|
||||||
|
expand: 'player'
|
||||||
|
});
|
||||||
|
|
||||||
|
return free_agents.map(transformFreeAgent);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("PocketBase | Error getting unenrolled teams", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function transformTeamInfo(record: any): TeamInfo {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const transformMatch = (record: any): Match => {
|
export const transformMatch = (record: any, isAdmin: boolean = false): Match => {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
order: record.order,
|
order: record.order,
|
||||||
@@ -47,8 +47,8 @@ export const transformMatch = (record: any): Match => {
|
|||||||
is_losers_bracket: record.is_losers_bracket,
|
is_losers_bracket: record.is_losers_bracket,
|
||||||
status: record.status || "tbd",
|
status: record.status || "tbd",
|
||||||
tournament: record.expand?.tournament ? transformTournamentInfo(record.expand?.tournament) : record.tournament,
|
tournament: record.expand?.tournament ? transformTournamentInfo(record.expand?.tournament) : record.tournament,
|
||||||
home: record.expand?.home ? transformTeamInfo(record.expand.home) : record.home,
|
home: record.expand?.home ? (isAdmin ? transformTeam(record.expand.home) : transformTeamInfo(record.expand.home)) : record.home,
|
||||||
away: record.expand?.away ? transformTeamInfo(record.expand.away) : record.away,
|
away: record.expand?.away ? (isAdmin ? transformTeam(record.expand.away) : transformTeamInfo(record.expand.away)) : record.away,
|
||||||
created: record.created,
|
created: record.created,
|
||||||
updated: record.updated,
|
updated: record.updated,
|
||||||
home_seed: record.home_seed,
|
home_seed: record.home_seed,
|
||||||
@@ -57,12 +57,58 @@ export const transformMatch = (record: any): Match => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const transformTournamentInfo = (record: any): TournamentInfo => {
|
export const transformTournamentInfo = (record: any): TournamentInfo => {
|
||||||
|
// Check if tournament is complete by looking at matches
|
||||||
|
const matches = record.expand?.matches || [];
|
||||||
|
// Filter out bye matches (tbd status with bye=true) when checking completion
|
||||||
|
const nonByeMatches = matches.filter((match: any) => !(match.status === 'tbd' && match.bye === true));
|
||||||
|
const isComplete = nonByeMatches.length > 0 && nonByeMatches.every((match: any) => match.status === 'ended');
|
||||||
|
|
||||||
|
let first_place: TeamInfo | undefined = undefined;
|
||||||
|
let second_place: TeamInfo | undefined = undefined;
|
||||||
|
let third_place: TeamInfo | undefined = undefined;
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
const teams = record.expand?.teams || [];
|
||||||
|
const teamMap = new Map<string, TeamInfo>(teams.map((team: any) => [team.id, transformTeamInfo(team)]));
|
||||||
|
|
||||||
|
const winnersMatches = matches.filter((match: any) => !match.is_losers_bracket);
|
||||||
|
const finalsMatch = winnersMatches.reduce((highest: any, current: any) =>
|
||||||
|
(!highest || current.lid > highest.lid) ? current : highest, null);
|
||||||
|
|
||||||
|
const losersMatches = matches.filter((match: any) => match.is_losers_bracket);
|
||||||
|
const losersFinale = losersMatches.reduce((highest: any, current: any) =>
|
||||||
|
(!highest || current.lid > highest.lid) ? current : highest, null);
|
||||||
|
|
||||||
|
if (finalsMatch && finalsMatch.status === 'ended') {
|
||||||
|
const finalsWinner = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.home : finalsMatch.away;
|
||||||
|
const finalsLoser = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.away : finalsMatch.home;
|
||||||
|
|
||||||
|
const finalsWinnerId = typeof finalsWinner === 'string' ? finalsWinner : finalsWinner?.id;
|
||||||
|
const finalsLoserId = typeof finalsLoser === 'string' ? finalsLoser : finalsLoser?.id;
|
||||||
|
|
||||||
|
first_place = finalsWinnerId ? teamMap.get(finalsWinnerId) || undefined : undefined;
|
||||||
|
second_place = finalsLoserId ? teamMap.get(finalsLoserId) || undefined : undefined;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (losersFinale && losersFinale.status === 'ended') {
|
||||||
|
const losersFinaleLoser = (losersFinale.home_cups > losersFinale.away_cups) ? losersFinale.away : losersFinale.home;
|
||||||
|
|
||||||
|
const losersFinaleloserId = typeof losersFinaleLoser === 'string' ? losersFinaleLoser : losersFinaleLoser?.id;
|
||||||
|
third_place = losersFinaleloserId ? teamMap.get(losersFinaleloserId) || undefined : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
name: record.name,
|
name: record.name,
|
||||||
location: record.location,
|
location: record.location,
|
||||||
start_time: record.start_time,
|
start_time: record.start_time,
|
||||||
|
end_time: record.end_time,
|
||||||
logo: record.logo,
|
logo: record.logo,
|
||||||
|
first_place,
|
||||||
|
second_place,
|
||||||
|
third_place,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +131,22 @@ export function transformPlayer(record: any): Player {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function transformFreeAgent(record: any) {
|
||||||
|
const player = record.expand?.player ? transformPlayerInfo(record.expand.player) : undefined;
|
||||||
|
const tournaments =
|
||||||
|
record.expand?.tournaments
|
||||||
|
?.sort((a: any, b: any) =>
|
||||||
|
new Date(a.created!) < new Date(b.created!) ? -1 : 0
|
||||||
|
)
|
||||||
|
?.map(transformTournamentInfo) ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: record.id as string,
|
||||||
|
phone: record.phone as string,
|
||||||
|
player
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function transformTeam(record: any): Team {
|
export function transformTeam(record: any): Team {
|
||||||
const players =
|
const players =
|
||||||
record.expand?.players
|
record.expand?.players
|
||||||
@@ -119,21 +181,74 @@ export function transformTeam(record: any): Team {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function transformTournament(record: any): Tournament {
|
export function transformTournament(record: any, isAdmin: boolean = false): Tournament {
|
||||||
const teams =
|
const teams =
|
||||||
record.expand?.teams
|
record.expand?.teams
|
||||||
?.sort((a: any, b: any) =>
|
?.sort((a: any, b: any) =>
|
||||||
new Date(a.created) < new Date(b.created) ? -1 : 0
|
new Date(a.created) < new Date(b.created) ? -1 : 0
|
||||||
)
|
)
|
||||||
?.map(transformTeamInfo) ?? [];
|
?.map(isAdmin ? transformTeam : transformTeamInfo) ?? [];
|
||||||
|
|
||||||
const matches =
|
const matches =
|
||||||
record.expand?.matches
|
record.expand?.matches
|
||||||
?.sort((a: any, b: any) =>
|
?.sort((a: any, b: any) =>
|
||||||
a.lid - b.lid ? -1 : 0
|
a.lid - b.lid ? -1 : 0
|
||||||
)
|
)
|
||||||
?.map(transformMatch) ?? [];
|
?.map((match: any) => transformMatch(match, isAdmin)) ?? [];
|
||||||
|
|
||||||
|
const team_stats = record.team_stats?.map((stat: any) => ({
|
||||||
|
id: stat.id,
|
||||||
|
team_id: stat.team_id,
|
||||||
|
tournament_id: stat.tournament_id,
|
||||||
|
team_name: stat.team_name,
|
||||||
|
matches: stat.matches,
|
||||||
|
wins: stat.wins,
|
||||||
|
losses: stat.losses,
|
||||||
|
total_cups_made: stat.total_cups_made,
|
||||||
|
total_cups_against: stat.total_cups_against,
|
||||||
|
margin_of_victory: stat.margin_of_victory,
|
||||||
|
margin_of_loss: stat.margin_of_loss,
|
||||||
|
win_percentage: (stat.wins / stat.matches) * 100,
|
||||||
|
avg_cups_per_match: stat.total_cups_made / stat.matches,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
const nonByeMatches = matches.filter((match: any) => !(match.status === 'tbd' && match.bye === true));
|
||||||
|
const isComplete = nonByeMatches.length > 0 && nonByeMatches.every((match: any) => match.status === 'ended');
|
||||||
|
|
||||||
|
let first_place: TeamInfo | undefined = undefined;
|
||||||
|
let second_place: TeamInfo | undefined = undefined;
|
||||||
|
let third_place: TeamInfo | undefined = undefined;
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
const teamMap = new Map<string, TeamInfo>(teams.map((team: any) => [team.id, team]));
|
||||||
|
|
||||||
|
const winnersMatches = matches.filter((match: any) => !match.is_losers_bracket);
|
||||||
|
const finalsMatch = winnersMatches.reduce((highest: any, current: any) =>
|
||||||
|
(!highest || current.lid > highest.lid) ? current : highest, null);
|
||||||
|
|
||||||
|
const losersMatches = matches.filter((match: any) => match.is_losers_bracket);
|
||||||
|
const losersFinale = losersMatches.reduce((highest: any, current: any) =>
|
||||||
|
(!highest || current.lid > highest.lid) ? current : highest, null);
|
||||||
|
|
||||||
|
if (finalsMatch && finalsMatch.status === 'ended') {
|
||||||
|
const finalsWinner = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.home : finalsMatch.away;
|
||||||
|
const finalsLoser = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.away : finalsMatch.home;
|
||||||
|
|
||||||
|
const finalsWinnerId = typeof finalsWinner === 'string' ? finalsWinner : finalsWinner?.id;
|
||||||
|
const finalsLoserId = typeof finalsLoser === 'string' ? finalsLoser : finalsLoser?.id;
|
||||||
|
|
||||||
|
first_place = finalsWinnerId ? teamMap.get(finalsWinnerId) || undefined : undefined;
|
||||||
|
second_place = finalsLoserId ? teamMap.get(finalsLoserId) || undefined : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (losersFinale && losersFinale.status === 'ended') {
|
||||||
|
const losersFinaleLoser = (losersFinale.home_cups > losersFinale.away_cups) ? losersFinale.away : losersFinale.home;
|
||||||
|
|
||||||
|
const losersFinaleloserId = typeof losersFinaleLoser === 'string' ? losersFinaleLoser : losersFinaleLoser?.id;
|
||||||
|
third_place = losersFinaleloserId ? teamMap.get(losersFinaleloserId) || undefined : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
name: record.name,
|
name: record.name,
|
||||||
@@ -147,7 +262,11 @@ export function transformTournament(record: any): Tournament {
|
|||||||
created: record.created,
|
created: record.created,
|
||||||
updated: record.updated,
|
updated: record.updated,
|
||||||
teams,
|
teams,
|
||||||
matches
|
matches,
|
||||||
|
first_place,
|
||||||
|
second_place,
|
||||||
|
third_place,
|
||||||
|
team_stats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,17 @@ export class SpotifyWebApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async playTrack(trackId: string, deviceId?: string, positionMs?: number): Promise<void> {
|
||||||
|
const endpoint = deviceId ? `/me/player/play?device_id=${deviceId}` : '/me/player/play';
|
||||||
|
await this.request(endpoint, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
uris: [`spotify:track:${trackId}`],
|
||||||
|
position_ms: positionMs || 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async pause(): Promise<void> {
|
async pause(): Promise<void> {
|
||||||
await this.request('/me/player/pause', {
|
await this.request('/me/player/pause', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user