work on team enrollment
This commit is contained in:
167
pb_migrations/1757910262_deleted_player_stats_per_tournament.js
Normal file
167
pb_migrations/1757910262_deleted_player_stats_per_tournament.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_135889471");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2582050271",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "player_id",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json4231605813",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "player_name",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1568971955",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation694999214",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "team_id",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_XGbN",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "team_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_340646327",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation869376999",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "tournament_id",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_uud6",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "tournament_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number103159226",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "matches",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json2732118329",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "wins",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json724428801",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "losses",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json3154249934",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "total_cups_made",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json3227208027",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "total_cups_against",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_135889471",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "player_stats_per_tournament",
|
||||||
|
"system": false,
|
||||||
|
"type": "view",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewQuery": "SELECT\n (p.id || '_' || t.id || '_' || tour.id) as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n t.id as team_id,\n t.name as team_name,\n tour.id as tournament_id,\n tour.name as tournament_name,\n COUNT(m.id) as matches,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended'\n GROUP BY p.id, t.id, tour.id",
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
})
|
||||||
96
pb_migrations/1757950535_updated_team_stats.js
Normal file
96
pb_migrations/1757950535_updated_team_stats.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_135889472")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"name": "team_stats_per_tournament"
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("_clone_2Mic")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("_clone_C8ev")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(2, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_QmWG",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "team_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(4, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_1o7N",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "tournament_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_135889472")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"name": "team_stats"
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(2, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_2Mic",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "team_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(4, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_C8ev",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "tournament_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("_clone_QmWG")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("_clone_1o7N")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
157
pb_migrations/1757950649_created_team_stats.js
Normal file
157
pb_migrations/1757950649_created_team_stats.js
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1568971955",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation694999214",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "team_id",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_nYJn",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "team_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number103159226",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "matches",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json2732118329",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "wins",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json724428801",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "losses",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json3041953980",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "margin_of_victory",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json1531431708",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "margin_of_loss",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json1062535948",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "total_cups_won_by",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json4249694556",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "total_cups_lost_by",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json3154249934",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "total_cups_made",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json3227208027",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "total_cups_against",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_1582517110",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "team_stats",
|
||||||
|
"system": false,
|
||||||
|
"type": "view",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewQuery": "\n SELECT\n t.id as id,\n t.id as team_id,\n t.name as team_name,\n COUNT(m.id) as matches,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END) as margin_of_victory,\n AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END) as margin_of_loss,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_won_by,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_lost_by,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against\n FROM teams t\n JOIN matches m ON (m.home = t.id OR m.away = t.id)\n WHERE m.status = 'ended'\n GROUP BY t.id",
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1582517110");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
@@ -29,6 +29,7 @@ import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authe
|
|||||||
import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id'
|
import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id'
|
||||||
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
||||||
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
|
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/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'
|
||||||
import { ServerRoute as ApiSpotifyResumeServerRouteImport } from './routes/api/spotify/resume'
|
import { ServerRoute as ApiSpotifyResumeServerRouteImport } from './routes/api/spotify/resume'
|
||||||
import { ServerRoute as ApiSpotifyPlaybackServerRouteImport } from './routes/api/spotify/playback'
|
import { ServerRoute as ApiSpotifyPlaybackServerRouteImport } from './routes/api/spotify/playback'
|
||||||
@@ -134,6 +135,12 @@ const ApiTournamentsUploadLogoServerRoute =
|
|||||||
path: '/api/tournaments/upload-logo',
|
path: '/api/tournaments/upload-logo',
|
||||||
getParentRoute: () => rootServerRouteImport,
|
getParentRoute: () => rootServerRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiTeamsUploadLogoServerRoute =
|
||||||
|
ApiTeamsUploadLogoServerRouteImport.update({
|
||||||
|
id: '/api/teams/upload-logo',
|
||||||
|
path: '/api/teams/upload-logo',
|
||||||
|
getParentRoute: () => rootServerRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiSpotifyTokenServerRoute = ApiSpotifyTokenServerRouteImport.update({
|
const ApiSpotifyTokenServerRoute = ApiSpotifyTokenServerRouteImport.update({
|
||||||
id: '/api/spotify/token',
|
id: '/api/spotify/token',
|
||||||
path: '/api/spotify/token',
|
path: '/api/spotify/token',
|
||||||
@@ -304,6 +311,7 @@ export interface FileServerRoutesByFullPath {
|
|||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||||
|
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||||
}
|
}
|
||||||
@@ -315,6 +323,7 @@ export interface FileServerRoutesByTo {
|
|||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||||
|
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||||
}
|
}
|
||||||
@@ -327,6 +336,7 @@ export interface FileServerRoutesById {
|
|||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||||
|
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||||
}
|
}
|
||||||
@@ -340,6 +350,7 @@ export interface FileServerRouteTypes {
|
|||||||
| '/api/spotify/playback'
|
| '/api/spotify/playback'
|
||||||
| '/api/spotify/resume'
|
| '/api/spotify/resume'
|
||||||
| '/api/spotify/token'
|
| '/api/spotify/token'
|
||||||
|
| '/api/teams/upload-logo'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
fileServerRoutesByTo: FileServerRoutesByTo
|
fileServerRoutesByTo: FileServerRoutesByTo
|
||||||
@@ -351,6 +362,7 @@ export interface FileServerRouteTypes {
|
|||||||
| '/api/spotify/playback'
|
| '/api/spotify/playback'
|
||||||
| '/api/spotify/resume'
|
| '/api/spotify/resume'
|
||||||
| '/api/spotify/token'
|
| '/api/spotify/token'
|
||||||
|
| '/api/teams/upload-logo'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
id:
|
id:
|
||||||
@@ -362,6 +374,7 @@ export interface FileServerRouteTypes {
|
|||||||
| '/api/spotify/playback'
|
| '/api/spotify/playback'
|
||||||
| '/api/spotify/resume'
|
| '/api/spotify/resume'
|
||||||
| '/api/spotify/token'
|
| '/api/spotify/token'
|
||||||
|
| '/api/teams/upload-logo'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
fileServerRoutesById: FileServerRoutesById
|
fileServerRoutesById: FileServerRoutesById
|
||||||
@@ -374,6 +387,7 @@ export interface RootServerRouteChildren {
|
|||||||
ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute
|
ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute
|
||||||
ApiSpotifyResumeServerRoute: typeof ApiSpotifyResumeServerRoute
|
ApiSpotifyResumeServerRoute: typeof ApiSpotifyResumeServerRoute
|
||||||
ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute
|
ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute
|
||||||
|
ApiTeamsUploadLogoServerRoute: typeof ApiTeamsUploadLogoServerRoute
|
||||||
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
|
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
|
||||||
ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute
|
ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||||
}
|
}
|
||||||
@@ -510,6 +524,13 @@ declare module '@tanstack/react-start/server' {
|
|||||||
preLoaderRoute: typeof ApiTournamentsUploadLogoServerRouteImport
|
preLoaderRoute: typeof ApiTournamentsUploadLogoServerRouteImport
|
||||||
parentRoute: typeof rootServerRouteImport
|
parentRoute: typeof rootServerRouteImport
|
||||||
}
|
}
|
||||||
|
'/api/teams/upload-logo': {
|
||||||
|
id: '/api/teams/upload-logo'
|
||||||
|
path: '/api/teams/upload-logo'
|
||||||
|
fullPath: '/api/teams/upload-logo'
|
||||||
|
preLoaderRoute: typeof ApiTeamsUploadLogoServerRouteImport
|
||||||
|
parentRoute: typeof rootServerRouteImport
|
||||||
|
}
|
||||||
'/api/spotify/token': {
|
'/api/spotify/token': {
|
||||||
id: '/api/spotify/token'
|
id: '/api/spotify/token'
|
||||||
path: '/api/spotify/token'
|
path: '/api/spotify/token'
|
||||||
@@ -631,6 +652,7 @@ const rootServerRouteChildren: RootServerRouteChildren = {
|
|||||||
ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute,
|
ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute,
|
||||||
ApiSpotifyResumeServerRoute: ApiSpotifyResumeServerRoute,
|
ApiSpotifyResumeServerRoute: ApiSpotifyResumeServerRoute,
|
||||||
ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute,
|
ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute,
|
||||||
|
ApiTeamsUploadLogoServerRoute: ApiTeamsUploadLogoServerRoute,
|
||||||
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
|
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
|
||||||
ApiFilesCollectionRecordIdFileServerRoute:
|
ApiFilesCollectionRecordIdFileServerRoute:
|
||||||
ApiFilesCollectionRecordIdFileServerRoute,
|
ApiFilesCollectionRecordIdFileServerRoute,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { playerQueries } from "@/features/players/queries";
|
|||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
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 { scan } from "react-scan";
|
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<{
|
export const Route = createRootRouteWithContext<{
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
@@ -106,12 +105,6 @@ function RootComponent() {
|
|||||||
|
|
||||||
// todo: analytics -> process.env data-website-id
|
// todo: analytics -> process.env data-website-id
|
||||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
React.useEffect(() => {
|
|
||||||
scan({
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
{...mantineHtmlProps}
|
{...mantineHtmlProps}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { redirect, createFileRoute, Outlet } from "@tanstack/react-router";
|
import { redirect, createFileRoute, Outlet } from "@tanstack/react-router";
|
||||||
import Layout from "@/features/core/components/layout";
|
import Layout from "@/features/core/components/layout";
|
||||||
import { useServerEvents } from "@/hooks/use-server-events";
|
import { useServerEvents } from "@/hooks/use-server-events";
|
||||||
import { Loader } from "@mantine/core";
|
import { Flex, Loader } from "@mantine/core";
|
||||||
import FullScreenLoader from "@/components/full-screen-loader";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed")({
|
export const Route = createFileRoute("/_authed")({
|
||||||
beforeLoad: ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
@@ -27,7 +26,9 @@ export const Route = createFileRoute("/_authed")({
|
|||||||
},
|
},
|
||||||
pendingComponent: () => (
|
pendingComponent: () => (
|
||||||
<Layout>
|
<Layout>
|
||||||
<FullScreenLoader />
|
<Flex w='100%' align="center">
|
||||||
|
<Loader />
|
||||||
|
</Flex>
|
||||||
</Layout>
|
</Layout>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Profile from "@/features/players/components/profile";
|
import Profile from "@/features/players/components/profile";
|
||||||
import { playerQueries } from "@/features/players/queries";
|
import { playerKeys, playerQueries } from "@/features/players/queries";
|
||||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -31,7 +31,7 @@ export const Route = createFileRoute("/_authed/profile/$playerId")({
|
|||||||
context?.auth.user.id === params.playerId ? "/settings" : undefined,
|
context?.auth.user.id === params.playerId ? "/settings" : undefined,
|
||||||
},
|
},
|
||||||
withPadding: false,
|
withPadding: false,
|
||||||
refresh: [playerQueries.details(params.playerId).queryKey],
|
refresh: [playerKeys.details(params.playerId), playerKeys.matches(params.playerId), playerKeys.stats(params.playerId)],
|
||||||
}),
|
}),
|
||||||
component: () => {
|
component: () => {
|
||||||
const { playerId } = Route.useParams();
|
const { playerId } = Route.useParams();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import TeamProfile from "@/features/teams/components/team-profile";
|
import TeamProfile from "@/features/teams/components/team-profile";
|
||||||
import { teamQueries } from "@/features/teams/queries";
|
import { teamKeys, teamQueries } from "@/features/teams/queries";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
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 { redirect, createFileRoute } from "@tanstack/react-router";
|
||||||
@@ -20,7 +20,8 @@ export const Route = createFileRoute("/_authed/teams/$teamId")({
|
|||||||
collapsed: true,
|
collapsed: true,
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
},
|
},
|
||||||
refresh: [teamQueries.details(params.teamId).queryKey],
|
refresh: [teamKeys.details(params.teamId), teamKeys.matches(params.teamId), teamKeys.stats(params.teamId)],
|
||||||
|
withPadding: false
|
||||||
}),
|
}),
|
||||||
component: () => {
|
component: () => {
|
||||||
const { teamId } = Route.useParams();
|
const { teamId } = Route.useParams();
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
|
|||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
settingsLink: context.auth.roles.includes("Admin") ? `/admin/tournaments/${params.tournamentId}` : undefined
|
settingsLink: context.auth.roles.includes("Admin") ? `/admin/tournaments/${params.tournamentId}` : undefined
|
||||||
},
|
},
|
||||||
refresh: tournamentQueries.details(params.tournamentId).queryKey,
|
refresh: [tournamentQueries.details(params.tournamentId).queryKey],
|
||||||
withPadding: false
|
withPadding: false
|
||||||
}),
|
}),
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|||||||
116
src/app/routes/api/teams/upload-logo.ts
Normal file
116
src/app/routes/api/teams/upload-logo.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { createServerFileRoute } from '@tanstack/react-start/server';
|
||||||
|
import { superTokensRequestMiddleware } from '@/utils/supertokens';
|
||||||
|
import { pbAdmin } from '@/lib/pocketbase/client';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const uploadSchema = z.object({
|
||||||
|
teamId: z.string().min(1, 'Team ID is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ServerRoute = createServerFileRoute('/api/teams/upload-logo')
|
||||||
|
.middleware([superTokensRequestMiddleware])
|
||||||
|
.methods({
|
||||||
|
POST: async ({ request, context }) => {
|
||||||
|
try {
|
||||||
|
const userId = context.userAuthId;
|
||||||
|
const isAdmin = context.roles.includes("Admin");
|
||||||
|
|
||||||
|
if (!userId) return new Response('Unauthenticated', { status: 401 });
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const teamId = formData.get('teamId') as string;
|
||||||
|
const logoFile = formData.get('logo') as File;
|
||||||
|
|
||||||
|
const validationResult = uploadSchema.safeParse({ teamId });
|
||||||
|
if (!validationResult.success) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Invalid input',
|
||||||
|
details: validationResult.error.issues
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!logoFile || logoFile.size === 0) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Logo file is required'
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
|
||||||
|
if (!allowedTypes.includes(logoFile.type)) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Invalid file type. Only JPEG, PNG and GIF are allowed.'
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSize = 10 * 1024 * 1024;
|
||||||
|
if (logoFile.size > maxSize) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'File too large. Maximum size is 10MB.'
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = await pbAdmin.getTeam(teamId);
|
||||||
|
if (!team) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Team not found'
|
||||||
|
}), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!team.players.map(p => p.id).includes(context.userId) && !isAdmin)
|
||||||
|
return new Response('Unauthorized', { status: 403 });
|
||||||
|
|
||||||
|
logger.info('Uploading team logo', {
|
||||||
|
teamId,
|
||||||
|
fileName: logoFile.name,
|
||||||
|
fileSize: logoFile.size,
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
|
||||||
|
const pbFormData = new FormData();
|
||||||
|
pbFormData.append('logo', logoFile);
|
||||||
|
|
||||||
|
const updatedTeam= await pbAdmin.updateTeam(teamId, pbFormData as any);
|
||||||
|
|
||||||
|
logger.info('Team logo uploaded successfully', {
|
||||||
|
teamId,
|
||||||
|
logo: updatedTeam.logo
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
team: updatedTeam,
|
||||||
|
message: 'Logo uploaded successfully'
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error uploading team logo:', error);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Failed to upload logo',
|
||||||
|
message: error.message || 'Unknown error occurred'
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -22,10 +22,10 @@ const Page = ({ children, noPadding, fullWidth, ...props }: PageProps) => {
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{header.collapsed && header.withBackButton && (
|
{header.collapsed && header.withBackButton && (
|
||||||
<BackButton offsetY={0} />
|
<BackButton />
|
||||||
)}
|
)}
|
||||||
{header.collapsed && header.settingsLink && (
|
{header.collapsed && header.settingsLink && (
|
||||||
<SettingsButton to={header.settingsLink} offsetY={0} />
|
<SettingsButton to={header.settingsLink} />
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Box, Text, UnstyledButton, Flex, Stack } from "@mantine/core";
|
import { Box, Text, UnstyledButton, Flex, Stack } from "@mantine/core";
|
||||||
import { CaretRightIcon } from "@phosphor-icons/react";
|
import { CaretRightIcon } from "@phosphor-icons/react";
|
||||||
import { ComponentType, useContext } from "react";
|
import React, { ComponentType, useContext } from "react";
|
||||||
import { SlidePanelContext } from "./slide-panel-context";
|
import { SlidePanelContext } from "./slide-panel-context";
|
||||||
|
|
||||||
interface SlidePanelFieldProps {
|
interface SlidePanelFieldProps {
|
||||||
@@ -11,7 +11,7 @@ interface SlidePanelFieldProps {
|
|||||||
title: string;
|
title: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
formatValue?: (value: any) => string;
|
formatValue?: (value: any) => string | React.ReactNode;
|
||||||
componentProps?: Record<string, any>;
|
componentProps?: Record<string, any>;
|
||||||
withAsterisk?: boolean;
|
withAsterisk?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|||||||
@@ -167,14 +167,11 @@ const SlidePanel = ({
|
|||||||
bg="var(--mantine-color-dimmed)"
|
bg="var(--mantine-color-dimmed)"
|
||||||
my="xs"
|
my="xs"
|
||||||
/>
|
/>
|
||||||
|
<panelConfig.Component
|
||||||
<Box>
|
value={tempValue}
|
||||||
<panelConfig.Component
|
onChange={setTempValue}
|
||||||
value={tempValue}
|
{...(panelConfig.componentProps || {})}
|
||||||
onChange={setTempValue}
|
/>
|
||||||
{...(panelConfig.componentProps || {})}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function SwipeableTabs({
|
|||||||
}
|
}
|
||||||
}, [search?.tab]);
|
}, [search?.tab]);
|
||||||
|
|
||||||
useEffect(() => {
|
const updateHeight = useCallback(() => {
|
||||||
const activeSlideRef = slideRefs.current[activeTab];
|
const activeSlideRef = slideRefs.current[activeTab];
|
||||||
if (activeSlideRef) {
|
if (activeSlideRef) {
|
||||||
const height = activeSlideRef.scrollHeight;
|
const height = activeSlideRef.scrollHeight;
|
||||||
@@ -94,6 +94,32 @@ function SwipeableTabs({
|
|||||||
}
|
}
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateHeight();
|
||||||
|
}, [activeTab, updateHeight]);
|
||||||
|
|
||||||
|
// Update height when content changes (after render)
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = setTimeout(updateHeight, 0);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use ResizeObserver to watch for content size changes
|
||||||
|
useEffect(() => {
|
||||||
|
const activeSlideRef = slideRefs.current[activeTab];
|
||||||
|
if (!activeSlideRef) return;
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
updateHeight();
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(activeSlideRef);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [activeTab, updateHeight]);
|
||||||
|
|
||||||
const setControlRef = useCallback(
|
const setControlRef = useCallback(
|
||||||
(index: number) => (node: HTMLSpanElement | null) => {
|
(index: number) => (node: HTMLSpanElement | null) => {
|
||||||
controlsRefs.current[index] = node;
|
controlsRefs.current[index] = node;
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { createContext, PropsWithChildren, useCallback, useContext, useMemo } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
PropsWithChildren,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
} from "react";
|
||||||
import { MantineColor, MantineColorScheme } from "@mantine/core";
|
import { MantineColor, MantineColorScheme } from "@mantine/core";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Player } from "@/features/players/types";
|
import { Player } from "@/features/players/types";
|
||||||
import { playerKeys, playerQueries, useMe } from "@/features/players/queries";
|
import { playerKeys, playerQueries, useMe } from "@/features/players/queries";
|
||||||
|
|
||||||
|
|
||||||
interface AuthData {
|
interface AuthData {
|
||||||
user: Player | undefined;
|
user: Player | undefined;
|
||||||
metadata: { accentColor: MantineColor; colorScheme: MantineColorScheme };
|
metadata: { accentColor: MantineColor; colorScheme: MantineColorScheme };
|
||||||
@@ -13,9 +18,9 @@ interface AuthData {
|
|||||||
|
|
||||||
export const defaultAuthData: AuthData = {
|
export const defaultAuthData: AuthData = {
|
||||||
user: undefined,
|
user: undefined,
|
||||||
metadata: { accentColor: 'blue', colorScheme: 'auto' },
|
metadata: { accentColor: "blue", colorScheme: "auto" },
|
||||||
roles: [],
|
roles: [],
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface AuthContextType extends AuthData {
|
export interface AuthContextType extends AuthData {
|
||||||
set: ({ user, metadata, roles }: Partial<AuthContextType>) => void;
|
set: ({ user, metadata, roles }: Partial<AuthContextType>) => void;
|
||||||
@@ -27,39 +32,45 @@ const AuthContext = createContext<AuthContextType>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data } = useMe();
|
const { data } = useMe();
|
||||||
|
|
||||||
const set = useCallback((updates: Partial<AuthData>) => {
|
|
||||||
queryClient.setQueryData(playerKeys.auth, (oldData: AuthData | undefined) => {
|
|
||||||
const currentData = oldData || defaultAuthData;
|
|
||||||
return {
|
|
||||||
...currentData,
|
|
||||||
...updates,
|
|
||||||
metadata: updates.metadata
|
|
||||||
? { ...currentData.metadata, ...updates.metadata }
|
|
||||||
: currentData.metadata
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [queryClient]);
|
|
||||||
|
|
||||||
return (
|
const set = useCallback(
|
||||||
<AuthContext
|
(updates: Partial<AuthData>) => {
|
||||||
value={{
|
queryClient.setQueryData(
|
||||||
user: data?.user || defaultAuthData.user,
|
playerKeys.auth,
|
||||||
metadata: data?.metadata || defaultAuthData.metadata,
|
(oldData: AuthData | undefined) => {
|
||||||
roles: data?.roles || defaultAuthData.roles,
|
const currentData = oldData || defaultAuthData;
|
||||||
set
|
return {
|
||||||
}}>
|
...currentData,
|
||||||
{children}
|
...updates,
|
||||||
</AuthContext>
|
metadata: updates.metadata
|
||||||
)
|
? { ...currentData.metadata, ...updates.metadata }
|
||||||
|
: currentData.metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[queryClient]
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
user: data?.user || defaultAuthData.user,
|
||||||
|
metadata: data?.metadata || defaultAuthData.metadata,
|
||||||
|
roles: data?.roles || defaultAuthData.roles,
|
||||||
|
set,
|
||||||
|
}),
|
||||||
|
[data, defaultAuthData]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext value={value}>{children}</AuthContext>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
const context = useContext(AuthContext);
|
const context = useContext(AuthContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
throw new Error("useAuth must be used within an AuthProvider");
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ import { Box } from "@mantine/core"
|
|||||||
import { ArrowLeftIcon } from "@phosphor-icons/react"
|
import { ArrowLeftIcon } from "@phosphor-icons/react"
|
||||||
import { useRouter } from "@tanstack/react-router"
|
import { useRouter } from "@tanstack/react-router"
|
||||||
|
|
||||||
interface BackButtonProps {
|
const BackButton = () => {
|
||||||
offsetY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BackButton = ({ offsetY }: BackButtonProps) => {
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import { Title, AppShell, Flex } from "@mantine/core";
|
import { Title, AppShell, Flex } from "@mantine/core";
|
||||||
import { HeaderConfig } from "../types/header-config";
|
import { HeaderConfig } from "../types/header-config";
|
||||||
import BackButton from "./back-button";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import SettingsButton from "./settings-button";
|
|
||||||
interface HeaderProps extends HeaderConfig {
|
|
||||||
scrollPosition: { x: number, y: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
const Header = ({ withBackButton, settingsLink, collapsed, title, scrollPosition }: HeaderProps) => {
|
interface HeaderProps extends HeaderConfig {}
|
||||||
const offsetY = useMemo(() => {
|
|
||||||
return collapsed ? scrollPosition.y : 0;
|
const Header = ({ collapsed, title }: HeaderProps) => {
|
||||||
}, [collapsed, scrollPosition.y]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
|
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
mah='100dvh'
|
mah='100dvh'
|
||||||
style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
|
style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
|
||||||
>
|
>
|
||||||
<Header scrollPosition={scrollPosition} {...header} />
|
<Header {...header} />
|
||||||
<AppShell.Main
|
<AppShell.Main
|
||||||
pos='relative'
|
pos='relative'
|
||||||
h='100%'
|
h='100%'
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { NavLink } from "./nav-link";
|
|||||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { useLinks } from "../hooks/use-links";
|
import { useLinks } from "../hooks/use-links";
|
||||||
|
import { memo } from "react";
|
||||||
|
|
||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
const { user, roles } = useAuth()
|
const { user, roles } = useAuth()
|
||||||
@@ -35,4 +36,4 @@ const Navbar = () => {
|
|||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Navbar;
|
export default memo(Navbar);
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ interface PullableProps extends PropsWithChildren {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Pullable is a component that allows the user to pull down to refresh the page
|
* Pullable is a component that allows the user to pull down to refresh the page
|
||||||
* TODO: Need to make the router config nicer
|
|
||||||
*/
|
*/
|
||||||
const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollPositionChange }) => {
|
const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollPositionChange }) => {
|
||||||
const height = useAppShellHeight();
|
const height = useAppShellHeight();
|
||||||
@@ -110,10 +109,6 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
|
|||||||
pt={(scrolling || scrollY > 40) || !isRefreshing ? 0 : 40 - scrollY}
|
pt={(scrolling || scrollY > 40) || !isRefreshing ? 0 : 40 - scrollY}
|
||||||
>
|
>
|
||||||
<Box pt='1rem'pb='0.285rem' mih={height} style={{ boxSizing: 'content-box' }}>
|
<Box pt='1rem'pb='0.285rem' mih={height} style={{ boxSizing: 'content-box' }}>
|
||||||
{ /* TODO: Remove this debug button */}
|
|
||||||
<ActionIcon display={!!refresh.length ? 'unset' : 'none' } style={{ zIndex: 1000 }} pos='absolute' top={8} left='calc(50% - 24px)' onClick={onTrigger} variant='filled' color='var(--mantine-color-dimmed)'>
|
|
||||||
<ArrowClockwiseIcon />
|
|
||||||
</ActionIcon>
|
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Box } from "@mantine/core"
|
import { Box } from "@mantine/core"
|
||||||
import { GearIcon } from "@phosphor-icons/react"
|
import { GearIcon } from "@phosphor-icons/react"
|
||||||
import { useNavigate } from "@tanstack/react-router"
|
import { useNavigate } from "@tanstack/react-router"
|
||||||
|
import { memo } from "react";
|
||||||
|
|
||||||
interface SettingButtonProps {
|
interface SettingButtonProps {
|
||||||
offsetY: number;
|
|
||||||
to: string;
|
to: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SettingsButton = ({ offsetY, to }: SettingButtonProps) => {
|
const SettingsButton = ({ to }: SettingButtonProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -23,4 +23,4 @@ const SettingsButton = ({ offsetY, to }: SettingButtonProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SettingsButton;
|
export default memo(SettingsButton, (prev, next) => prev.to !== next.to);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Indicator,
|
Indicator,
|
||||||
Box,
|
Box,
|
||||||
Badge,
|
Badge,
|
||||||
|
Skeleton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { TrophyIcon, CrownIcon } from "@phosphor-icons/react";
|
import { TrophyIcon, CrownIcon } from "@phosphor-icons/react";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
@@ -54,7 +55,7 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
</Text>
|
</Text>
|
||||||
<Text c="dimmed">-</Text>
|
<Text c="dimmed">-</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Round {match.round}
|
Round {match.round + 1}
|
||||||
{match.is_losers_bracket && " (Losers)"}
|
{match.is_losers_bracket && " (Losers)"}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -81,7 +82,7 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Text
|
<Text
|
||||||
size="sm"
|
size="md"
|
||||||
fw={600}
|
fw={600}
|
||||||
lineClamp={1}
|
lineClamp={1}
|
||||||
style={{ minWidth: 0, flex: 1 }}
|
style={{ minWidth: 0, flex: 1 }}
|
||||||
@@ -89,42 +90,17 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
{match.home?.name!}
|
{match.home?.name!}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
<Text
|
||||||
|
size="xl"
|
||||||
|
fw={700}
|
||||||
|
c={"dimmed"}
|
||||||
|
>
|
||||||
|
{match.home_cups}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Group gap="sm" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Text
|
<Group gap="sm" style={{ flex: 1 }}>
|
||||||
size="md"
|
|
||||||
fw={700}
|
|
||||||
c={"dimmed"}
|
|
||||||
>
|
|
||||||
{match.home_cups}
|
|
||||||
</Text>
|
|
||||||
<Text size="md" c="dimmed" fw={500}>
|
|
||||||
-
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
size="md"
|
|
||||||
fw={700}
|
|
||||||
c={"dimmed"}
|
|
||||||
>
|
|
||||||
{match.away_cups}
|
|
||||||
</Text>
|
|
||||||
{match.ot_count > 0 && (
|
|
||||||
<Badge size="xs" color="orange" variant="light">
|
|
||||||
{match.ot_count}OT
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group gap="sm" style={{ flex: 1 }} justify="flex-end">
|
|
||||||
<Text
|
|
||||||
size="sm"
|
|
||||||
fw={600}
|
|
||||||
lineClamp={1}
|
|
||||||
ta="right"
|
|
||||||
style={{ minWidth: 0, flex: 1 }}
|
|
||||||
>
|
|
||||||
{match.away?.name}
|
|
||||||
</Text>
|
|
||||||
<Box
|
<Box
|
||||||
style={{ position: "relative", cursor: "pointer" }}
|
style={{ position: "relative", cursor: "pointer" }}
|
||||||
onClick={handleAwayTeamClick}
|
onClick={handleAwayTeamClick}
|
||||||
@@ -135,8 +111,8 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: -10,
|
top: -10,
|
||||||
right: -4,
|
left: -4,
|
||||||
transform: "rotate(25deg)",
|
transform: "rotate(-25deg)",
|
||||||
color: "gold",
|
color: "gold",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -144,7 +120,22 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
<Text
|
||||||
|
size="md"
|
||||||
|
fw={600}
|
||||||
|
lineClamp={1}
|
||||||
|
style={{ minWidth: 0, flex: 1 }}
|
||||||
|
>
|
||||||
|
{match.away?.name}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
<Text
|
||||||
|
size="xl"
|
||||||
|
fw={700}
|
||||||
|
c={"dimmed"}
|
||||||
|
>
|
||||||
|
{match.away_cups}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Stack, Text, ThemeIcon, Box } from "@mantine/core";
|
import { Stack } from "@mantine/core";
|
||||||
import { TrophyIcon } from "@phosphor-icons/react";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Match } from "../types";
|
import { Match } from "../types";
|
||||||
import MatchCard from "./match-card";
|
import MatchCard from "./match-card";
|
||||||
@@ -14,20 +13,11 @@ const MatchList = ({ matches }: MatchListProps) => {
|
|||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
if (!filteredMatches.length) {
|
if (!filteredMatches.length) {
|
||||||
return (
|
return undefined;
|
||||||
<Box ta="center" py="xl">
|
|
||||||
<ThemeIcon size="xl" variant="light" radius="md" mb="md">
|
|
||||||
<TrophyIcon size={32} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text c="dimmed" size="lg">
|
|
||||||
No matches found
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<Stack p="md" gap="sm">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{filteredMatches.map((match, index) => (
|
{filteredMatches.map((match, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ interface HeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Header = ({ player }: HeaderProps) => {
|
const Header = ({ player }: HeaderProps) => {
|
||||||
|
|
||||||
const sheet = useSheet();
|
const sheet = useSheet();
|
||||||
const { user: authUser } = useAuth();
|
const { user: authUser } = useAuth();
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { Box } from "@mantine/core";
|
import { Box } from "@mantine/core";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
import { Player, PlayerStats } from "@/features/players/types";
|
|
||||||
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 "@/shared/components/stats-overview";
|
||||||
import MatchList from "@/features/matches/components/match-list";
|
import MatchList from "@/features/matches/components/match-list";
|
||||||
import { BaseStats } from "@/shared/types/stats";
|
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,26 +15,14 @@ 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);
|
||||||
|
|
||||||
// Aggregate player stats from multiple tournaments into a single BaseStats object
|
|
||||||
const aggregatedStats: BaseStats | null = stats && stats.length > 0 ? {
|
|
||||||
id: `player_${id}_aggregate`,
|
|
||||||
matches: stats.reduce((acc, stat) => acc + stat.matches, 0),
|
|
||||||
wins: stats.reduce((acc, stat) => acc + stat.wins, 0),
|
|
||||||
losses: stats.reduce((acc, stat) => acc + stat.losses, 0),
|
|
||||||
total_cups_made: stats.reduce((acc, stat) => acc + stat.total_cups_made, 0),
|
|
||||||
total_cups_against: stats.reduce((acc, stat) => acc + stat.total_cups_against, 0),
|
|
||||||
margin_of_victory: stats.filter(s => s.margin_of_victory > 0).reduce((acc, stat, _, arr) => acc + stat.margin_of_victory / arr.length, 0),
|
|
||||||
margin_of_loss: stats.filter(s => s.margin_of_loss > 0).reduce((acc, stat, _, arr) => acc + stat.margin_of_loss / arr.length, 0),
|
|
||||||
} : null;
|
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
content: <StatsOverview statsData={aggregatedStats} isLoading={statsLoading} />,
|
content: <StatsOverview statsData={stats} isLoading={statsLoading} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Matches",
|
label: "Matches",
|
||||||
content: <Box p="md"><MatchList matches={matches || []} /></Box>,
|
content: <MatchList matches={matches || []} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Teams",
|
label: "Teams",
|
||||||
@@ -47,7 +33,7 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header player={player} />
|
<Header player={player} />
|
||||||
<Box m='md' mt="lg">
|
<Box m='xs' mt="lg">
|
||||||
<SwipeableTabs tabs={tabs} />
|
<SwipeableTabs tabs={tabs} />
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches } from "./server";
|
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches, getUnenrolledPlayers } from "./server";
|
||||||
|
|
||||||
export const playerKeys = {
|
export const playerKeys = {
|
||||||
auth: ['auth'],
|
auth: ['auth'],
|
||||||
list: ['players', 'list'],
|
list: ['players', 'list'],
|
||||||
details: (id: string) => ['players', 'details', id],
|
details: (id: string) => ['players', 'details', id],
|
||||||
unassociated: ['players','unassociated'],
|
unassociated: ['players','unassociated'],
|
||||||
|
unenrolled: (tournamentId: string) => ['players', 'unenrolled', tournamentId],
|
||||||
stats: (id: string) => ['players', 'stats', id],
|
stats: (id: string) => ['players', 'stats', id],
|
||||||
allStats: ['players', 'stats', 'all'],
|
allStats: ['players', 'stats', 'all'],
|
||||||
matches: (id: string) => ['players', 'matches', id],
|
matches: (id: string) => ['players', 'matches', id],
|
||||||
@@ -28,6 +29,10 @@ export const playerQueries = {
|
|||||||
queryKey: playerKeys.unassociated,
|
queryKey: playerKeys.unassociated,
|
||||||
queryFn: async () => await getUnassociatedPlayers()
|
queryFn: async () => await getUnassociatedPlayers()
|
||||||
}),
|
}),
|
||||||
|
unenrolled: (tournamentId: string) => ({
|
||||||
|
queryKey: playerKeys.unenrolled(tournamentId),
|
||||||
|
queryFn: async () => await getUnenrolledPlayers({ data: tournamentId })
|
||||||
|
}),
|
||||||
stats: (id: string) => ({
|
stats: (id: string) => ({
|
||||||
queryKey: playerKeys.stats(id),
|
queryKey: playerKeys.stats(id),
|
||||||
queryFn: async () => await getPlayerStats({ data: id })
|
queryFn: async () => await getPlayerStats({ data: id })
|
||||||
@@ -81,4 +86,7 @@ export const useAllPlayerStats = () =>
|
|||||||
useServerSuspenseQuery(playerQueries.allStats());
|
useServerSuspenseQuery(playerQueries.allStats());
|
||||||
|
|
||||||
export const usePlayerMatches = (id: string) =>
|
export const usePlayerMatches = (id: string) =>
|
||||||
useServerSuspenseQuery(playerQueries.matches(id));
|
useServerSuspenseQuery(playerQueries.matches(id));
|
||||||
|
|
||||||
|
export const useUnenrolledPlayers = (tournamentId: string) =>
|
||||||
|
useServerSuspenseQuery(playerQueries.unenrolled(tournamentId));
|
||||||
@@ -125,7 +125,7 @@ export const getPlayerStats = createServerFn()
|
|||||||
.validator(z.string())
|
.validator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<PlayerStats[]>(async () => await pbAdmin.getPlayerStats(data))
|
toServerResult<PlayerStats>(async () => await pbAdmin.getPlayerStats(data))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getAllPlayerStats = createServerFn()
|
export const getAllPlayerStats = createServerFn()
|
||||||
@@ -139,4 +139,11 @@ export const getPlayerMatches = createServerFn()
|
|||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<Match[]>(async () => await pbAdmin.getPlayerMatches(data))
|
toServerResult<Match[]>(async () => await pbAdmin.getPlayerMatches(data))
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getUnenrolledPlayers = createServerFn()
|
||||||
|
.validator(z.string())
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data: tournamentId }) =>
|
||||||
|
toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId))
|
||||||
);
|
);
|
||||||
80
src/features/teams/components/team-form/color-picker.tsx
Normal file
80
src/features/teams/components/team-form/color-picker.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { ColorPicker, TextInput, Stack, Group, ColorSwatch, Text } from '@mantine/core';
|
||||||
|
import React, { useState, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
const presetColors = [
|
||||||
|
'#FF0000', '#00FF00', '#0000FF', '#FFFF00',
|
||||||
|
'#FF00FF', '#00FFFF', '#FFA500', '#800080',
|
||||||
|
'#008000', '#000080', '#800000', '#808000'
|
||||||
|
];
|
||||||
|
|
||||||
|
interface TeamColorPickerProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamColorPicker: React.FC<TeamColorPickerProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label = "Select Color"
|
||||||
|
}) => {
|
||||||
|
const [customHex, setCustomHex] = useState(value || '');
|
||||||
|
|
||||||
|
const isValidHex = useMemo(() => {
|
||||||
|
const hexRegex = /^#[0-9A-F]{6}$/i;
|
||||||
|
return hexRegex.test(customHex);
|
||||||
|
}, [customHex]);
|
||||||
|
|
||||||
|
const handleColorChange = useCallback((color: string) => {
|
||||||
|
setCustomHex(color);
|
||||||
|
onChange(color);
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
const handleHexInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const hex = event.currentTarget.value;
|
||||||
|
setCustomHex(hex);
|
||||||
|
|
||||||
|
if (/^#[0-9A-F]{6}$/i.test(hex)) {
|
||||||
|
onChange(hex);
|
||||||
|
}
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md" p="md" w="100%">
|
||||||
|
<Text fw={500}>{label}</Text>
|
||||||
|
|
||||||
|
<ColorPicker
|
||||||
|
value={value || '#000000'}
|
||||||
|
onChange={handleColorChange}
|
||||||
|
format="hex"
|
||||||
|
swatches={presetColors}
|
||||||
|
withPicker={true}
|
||||||
|
fullWidth
|
||||||
|
size="md"
|
||||||
|
swatchesPerRow={12}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group gap="xs" align="flex-end">
|
||||||
|
<TextInput
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
label="Custom Hex Code"
|
||||||
|
placeholder="#FF0000"
|
||||||
|
value={customHex}
|
||||||
|
onChange={handleHexInputChange}
|
||||||
|
error={customHex && !isValidHex ? 'Invalid hex color format' : undefined}
|
||||||
|
w="100%"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isValidHex && (
|
||||||
|
<ColorSwatch
|
||||||
|
color={customHex}
|
||||||
|
size={36}
|
||||||
|
style={{ marginBottom: customHex && !isValidHex ? 24 : 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TeamColorPicker;
|
||||||
@@ -1,81 +1,88 @@
|
|||||||
import { FileInput, Stack, TextInput, Textarea } from "@mantine/core";
|
import { Badge, FileInput, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||||
import { useForm, UseFormInput } from "@mantine/form";
|
import { useForm, UseFormInput } from "@mantine/form";
|
||||||
import { LinkIcon } from "@phosphor-icons/react";
|
import { LinkIcon } from "@phosphor-icons/react";
|
||||||
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
|
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
|
||||||
import { TournamentInput } from "@/features/tournaments/types";
|
|
||||||
import { isNotEmpty } from "@mantine/form";
|
import { isNotEmpty } from "@mantine/form";
|
||||||
import useCreateTournament from "../hooks/use-create-team";
|
import useCreateTeam from "../../hooks/use-create-team";
|
||||||
import useUpdateTournament from "../hooks/use-update-tournament";
|
import useUpdateTeam from "../../hooks/use-update-team";
|
||||||
import toast from "@/lib/sonner";
|
import toast from "@/lib/sonner";
|
||||||
import { logger } from "..";
|
import { logger } from "../..";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { tournamentKeys } from "@/features/tournaments/queries";
|
import { tournamentKeys } from "@/features/tournaments/queries";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { DateTimePicker } from "@/components/date-time-picker";
|
import { TeamInput } from "../../types";
|
||||||
|
import { teamKeys } from "../../queries";
|
||||||
|
import SongPicker from "./song-picker";
|
||||||
|
import TeamColorPicker from "./color-picker";
|
||||||
|
import PlayersPicker from "./players-picker";
|
||||||
|
|
||||||
interface TournamentFormProps {
|
interface TeamFormProps {
|
||||||
close: () => void;
|
close: () => void;
|
||||||
initialValues?: Partial<TournamentInput>;
|
initialValues?: Partial<TeamInput>;
|
||||||
|
teamId?: string;
|
||||||
tournamentId?: string;
|
tournamentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TournamentForm = ({
|
const TeamForm = ({
|
||||||
close,
|
close,
|
||||||
initialValues,
|
initialValues,
|
||||||
|
teamId,
|
||||||
tournamentId,
|
tournamentId,
|
||||||
}: TournamentFormProps) => {
|
}: TeamFormProps) => {
|
||||||
const isEditMode = !!tournamentId;
|
const isEditMode = !!teamId;
|
||||||
|
|
||||||
const config: UseFormInput<TournamentInput> = {
|
const config: UseFormInput<TeamInput> = {
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: initialValues?.name || "",
|
name: initialValues?.name || "",
|
||||||
location: initialValues?.location || "",
|
primary_color: initialValues?.primary_color,
|
||||||
desc: initialValues?.desc || "",
|
accent_color: initialValues?.accent_color,
|
||||||
start_time: initialValues?.start_time || "",
|
song_id: initialValues?.song_id,
|
||||||
enroll_time: initialValues?.enroll_time || "",
|
song_name: initialValues?.song_name,
|
||||||
end_time: initialValues?.end_time || "",
|
song_artist: initialValues?.song_artist,
|
||||||
|
song_album: initialValues?.song_album,
|
||||||
|
song_year: initialValues?.song_year,
|
||||||
|
song_start: initialValues?.song_start,
|
||||||
|
song_end: initialValues?.song_end,
|
||||||
|
song_image_url: initialValues?.song_image_url,
|
||||||
logo: undefined,
|
logo: undefined,
|
||||||
|
players: initialValues?.players || []
|
||||||
},
|
},
|
||||||
onSubmitPreventDefault: "always",
|
onSubmitPreventDefault: "always",
|
||||||
validate: {
|
validate: {
|
||||||
name: isNotEmpty("Name is required"),
|
name: isNotEmpty("Name is required"),
|
||||||
location: isNotEmpty("Location is required"),
|
players: (value: string[]) => value.length > 1 && value[1] !== '' ? undefined : "Players are required"
|
||||||
start_time: isNotEmpty("Start time is required"),
|
|
||||||
enroll_time: isNotEmpty("Enrollment time is required"),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const form = useForm(config);
|
const form = useForm(config);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { mutate: createTournament, isPending: createPending } =
|
const { mutate: createTournament, isPending: createPending } = useCreateTeam();
|
||||||
useCreateTournament();
|
const { mutate: updateTournament, isPending: updatePending } = useUpdateTeam(teamId!);
|
||||||
const { mutate: updateTournament, isPending: updatePending } =
|
|
||||||
useUpdateTournament(tournamentId || "");
|
|
||||||
|
|
||||||
const isPending = createPending || updatePending;
|
const isPending = createPending || updatePending;
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (values: TournamentInput) => {
|
async (values: TeamInput) => {
|
||||||
const { logo, ...tournamentData } = values;
|
const { logo, ...teamData } = values;
|
||||||
|
|
||||||
const mutation = isEditMode ? updateTournament : createTournament;
|
const mutation = isEditMode ? updateTournament : createTournament;
|
||||||
const successMessage = isEditMode
|
const successMessage = isEditMode
|
||||||
? "Tournament updated successfully!"
|
? "Team updated successfully!"
|
||||||
: "Tournament created successfully!";
|
: "Team created successfully!";
|
||||||
const errorMessage = isEditMode
|
const errorMessage = isEditMode
|
||||||
? "Failed to update tournament"
|
? "Failed to update team"
|
||||||
: "Failed to create tournament";
|
: "Failed to create team";
|
||||||
|
|
||||||
mutation(tournamentData, {
|
mutation(teamData, {
|
||||||
onSuccess: async (tournament) => {
|
onSuccess: async (team) => {
|
||||||
if (logo && tournament) {
|
if (logo && team) {
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("tournamentId", tournament.id);
|
formData.append("teamId", team.id);
|
||||||
formData.append("logo", logo);
|
formData.append("logo", logo);
|
||||||
|
|
||||||
const response = await fetch("/api/tournaments/upload-logo", {
|
const response = await fetch("/api/teams/upload-logo", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -87,22 +94,22 @@ const TournamentForm = ({
|
|||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: tournamentKeys.list });
|
queryClient.invalidateQueries({ queryKey: teamKeys.list });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: tournamentKeys.details(result.tournament!.id),
|
queryKey: teamKeys.details(result.team!.id),
|
||||||
});
|
});
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
tournamentKeys.details(result.tournament!.id),
|
tournamentKeys.details(result.team!.id),
|
||||||
result.tournament
|
result.team
|
||||||
);
|
);
|
||||||
|
|
||||||
toast.success(successMessage);
|
toast.success(successMessage);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const logoErrorMessage = isEditMode
|
const logoErrorMessage = isEditMode
|
||||||
? `Tournament updated but logo upload failed: ${error.message}`
|
? `Team updated but logo upload failed: ${error.message}`
|
||||||
: `Tournament created but logo upload failed: ${error.message}`;
|
: `Team created but logo upload failed: ${error.message}`;
|
||||||
toast.error(logoErrorMessage);
|
toast.error(logoErrorMessage);
|
||||||
logger.error("Tournament logo upload error", error);
|
logger.error("Team logo upload error", error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.success(successMessage);
|
toast.success(successMessage);
|
||||||
@@ -112,7 +119,7 @@ const TournamentForm = ({
|
|||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast.error(`${errorMessage}: ${error.message}`);
|
toast.error(`${errorMessage}: ${error.message}`);
|
||||||
logger.error(
|
logger.error(
|
||||||
`Tournament ${isEditMode ? "update" : "create"} error`,
|
`Team ${isEditMode ? "update" : "create"} error`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -123,9 +130,9 @@ const TournamentForm = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SlidePanel
|
<SlidePanel
|
||||||
onSubmit={form.onSubmit(handleSubmit)}
|
onSubmit={form.onSubmit((values) => console.log(values))}
|
||||||
onCancel={close}
|
onCancel={close}
|
||||||
submitText={isEditMode ? "Update Tournament" : "Create Tournament"}
|
submitText={isEditMode ? "Update Team" : "Create Team"}
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
>
|
>
|
||||||
@@ -136,19 +143,6 @@ const TournamentForm = ({
|
|||||||
key={form.key("name")}
|
key={form.key("name")}
|
||||||
{...form.getInputProps("name")}
|
{...form.getInputProps("name")}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
|
||||||
label="Location"
|
|
||||||
withAsterisk
|
|
||||||
key={form.key("location")}
|
|
||||||
{...form.getInputProps("location")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Textarea
|
|
||||||
label="Description"
|
|
||||||
key={form.key("desc")}
|
|
||||||
{...form.getInputProps("desc")}
|
|
||||||
minRows={3}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FileInput
|
<FileInput
|
||||||
key={form.key("logo")}
|
key={form.key("logo")}
|
||||||
@@ -158,6 +152,50 @@ const TournamentForm = ({
|
|||||||
{...form.getInputProps("logo")}
|
{...form.getInputProps("logo")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{
|
||||||
|
tournamentId && (
|
||||||
|
<PlayersPicker
|
||||||
|
tournamentId={tournamentId}
|
||||||
|
key={form.key("players")}
|
||||||
|
{...form.getInputProps("players")}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<SlidePanelField
|
||||||
|
key={form.key("primary_color")}
|
||||||
|
{...form.getInputProps("primary_color")}
|
||||||
|
Component={TeamColorPicker}
|
||||||
|
title="Select Primary Color"
|
||||||
|
placeholder="Select Primary Color"
|
||||||
|
label="Primary Color"
|
||||||
|
formatValue={(value) => (
|
||||||
|
<Group>
|
||||||
|
<Badge variant="filled" radius="sm" color={value} />
|
||||||
|
{value}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SlidePanelField
|
||||||
|
key={form.key("accent_color")}
|
||||||
|
{...form.getInputProps("accent_color")}
|
||||||
|
Component={TeamColorPicker}
|
||||||
|
title="Select Accent Color"
|
||||||
|
placeholder="Select Accent Color"
|
||||||
|
label="Accent Color"
|
||||||
|
formatValue={(value) => (
|
||||||
|
<Group>
|
||||||
|
<Badge variant="filled" radius="sm" color={value} />
|
||||||
|
{value}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SongPicker form={form} />
|
||||||
|
|
||||||
|
|
||||||
|
{/*
|
||||||
<SlidePanelField
|
<SlidePanelField
|
||||||
key={form.key("start_time")}
|
key={form.key("start_time")}
|
||||||
{...form.getInputProps("start_time")}
|
{...form.getInputProps("start_time")}
|
||||||
@@ -221,9 +259,10 @@ const TournamentForm = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
*/}
|
||||||
</Stack>
|
</Stack>
|
||||||
</SlidePanel>
|
</SlidePanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TournamentForm;
|
export default TeamForm;
|
||||||
62
src/features/teams/components/team-form/players-picker.tsx
Normal file
62
src/features/teams/components/team-form/players-picker.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { SlidePanelField } from "@/components/sheet/slide-panel";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { useUnenrolledPlayers } from "@/features/players/queries";
|
||||||
|
import { Autocomplete, Stack, Text, TextInput } from "@mantine/core";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
interface PlayersPickerProps {
|
||||||
|
tournamentId: string;
|
||||||
|
value?: string[];
|
||||||
|
onChange: (value: string[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlayersPicker = ({ tournamentId, value = [], onChange, disabled, error }: PlayersPickerProps) => {
|
||||||
|
const { roles, user } = useAuth();
|
||||||
|
const isAdmin = roles.includes("Admin");
|
||||||
|
|
||||||
|
const { data } = useUnenrolledPlayers(tournamentId);
|
||||||
|
|
||||||
|
const options = useMemo(() => data.filter(p => p.id != user?.id).map(p => (p.first_name + " " + p.last_name)), [data])
|
||||||
|
|
||||||
|
const findPlayerId = useCallback((name?: string) => {
|
||||||
|
const player = data.find(player => player?.first_name + " " + player?.last_name === name)
|
||||||
|
return player?.id || ""
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const findPlayerName = useCallback((id?: string) => {
|
||||||
|
const player = data.find(player => player.id === id)
|
||||||
|
return player?.first_name + " " + player?.last_name
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const [playerOne, setPlayerOne] = useState(value.length ? findPlayerName(value[0]) : findPlayerName(user!.id));
|
||||||
|
const [playerTwo, setPlayerTwo] = useState(value.length > 1 ? findPlayerName(value[1]) : undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChange([findPlayerId(playerOne), findPlayerId(playerTwo)])
|
||||||
|
}, [playerOne, playerTwo])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Autocomplete
|
||||||
|
label="Player 1"
|
||||||
|
data={options}
|
||||||
|
disabled={!isAdmin || disabled}
|
||||||
|
value={playerOne}
|
||||||
|
onChange={setPlayerOne}
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
label="Player 2"
|
||||||
|
data={options}
|
||||||
|
disabled={disabled && !isAdmin}
|
||||||
|
value={playerTwo}
|
||||||
|
onChange={setPlayerTwo}
|
||||||
|
withAsterisk
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlayersPicker;
|
||||||
56
src/features/teams/components/team-form/song-picker.tsx
Normal file
56
src/features/teams/components/team-form/song-picker.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { SlidePanelField } from "@/components/sheet/slide-panel";
|
||||||
|
import { Stack, Text } from "@mantine/core";
|
||||||
|
import { UseFormReturnType } from "@mantine/form";
|
||||||
|
import { TeamInput } from "../../types";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface Song {
|
||||||
|
song_id: string;
|
||||||
|
song_name: string;
|
||||||
|
song_artist: string;
|
||||||
|
song_album: string;
|
||||||
|
song_year?: number;
|
||||||
|
song_start?: number;
|
||||||
|
song_end?: number;
|
||||||
|
song_image_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SongPickerProps {
|
||||||
|
form: UseFormReturnType<TeamInput>
|
||||||
|
}
|
||||||
|
|
||||||
|
const SongPicker = ({ form }: SongPickerProps) => {
|
||||||
|
const [song, setSong] = useState<Song>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const values = form.getValues();
|
||||||
|
|
||||||
|
setSong({
|
||||||
|
song_id: values.song_id || "",
|
||||||
|
song_name: values.song_name || "",
|
||||||
|
song_artist: values.song_artist || "",
|
||||||
|
song_album: values.song_album || "",
|
||||||
|
song_year: values.song_year,
|
||||||
|
song_start: values.song_start,
|
||||||
|
song_end: values.song_end,
|
||||||
|
song_image_url: values.song_image_url || "",
|
||||||
|
})
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlidePanelField
|
||||||
|
key={"song-picker"}
|
||||||
|
value={""}
|
||||||
|
onChange={console.log}
|
||||||
|
Component={() => (
|
||||||
|
<Stack>
|
||||||
|
<Text>Song picker</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
title={"Select Song"}
|
||||||
|
label={"Song"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SongPicker;
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { ColorPicker, TextInput, Stack, Group, ColorSwatch, Text } from '@mantine/core';
|
|
||||||
import React, { useState, useCallback, useMemo } from 'react';
|
|
||||||
|
|
||||||
interface TeamColorPickerProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TeamColorPicker: React.FC<TeamColorPickerProps> = ({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
label = "Select Color"
|
|
||||||
}) => {
|
|
||||||
const [customHex, setCustomHex] = useState(value || '');
|
|
||||||
|
|
||||||
const isValidHex = useMemo(() => {
|
|
||||||
const hexRegex = /^#[0-9A-F]{6}$/i;
|
|
||||||
return hexRegex.test(customHex);
|
|
||||||
}, [customHex]);
|
|
||||||
|
|
||||||
const handleColorChange = useCallback((color: string) => {
|
|
||||||
setCustomHex(color);
|
|
||||||
onChange(color);
|
|
||||||
}, [onChange]);
|
|
||||||
|
|
||||||
const handleHexInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const hex = event.currentTarget.value;
|
|
||||||
setCustomHex(hex);
|
|
||||||
|
|
||||||
if (/^#[0-9A-F]{6}$/i.test(hex)) {
|
|
||||||
onChange(hex);
|
|
||||||
}
|
|
||||||
}, [onChange]);
|
|
||||||
|
|
||||||
const presetColors = [
|
|
||||||
'#FF0000', '#00FF00', '#0000FF', '#FFFF00',
|
|
||||||
'#FF00FF', '#00FFFF', '#FFA500', '#800080',
|
|
||||||
'#008000', '#000080', '#800000', '#808000'
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md" p="md">
|
|
||||||
<Text fw={500}>{label}</Text>
|
|
||||||
|
|
||||||
<ColorPicker
|
|
||||||
value={value || '#000000'}
|
|
||||||
onChange={handleColorChange}
|
|
||||||
format="hex"
|
|
||||||
swatches={presetColors}
|
|
||||||
withPicker={true}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group gap="xs" align="flex-end">
|
|
||||||
<TextInput
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
label="Custom Hex Code"
|
|
||||||
placeholder="#FF0000"
|
|
||||||
value={customHex}
|
|
||||||
onChange={handleHexInputChange}
|
|
||||||
error={customHex && !isValidHex ? 'Invalid hex color format' : undefined}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isValidHex && (
|
|
||||||
<ColorSwatch
|
|
||||||
color={customHex}
|
|
||||||
size={36}
|
|
||||||
style={{ marginBottom: customHex && !isValidHex ? 24 : 0 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TeamColorPicker;
|
|
||||||
@@ -3,7 +3,8 @@ 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 "@/shared/components/stats-overview";
|
||||||
import { useTeam, useTeamStats } from "../../queries";
|
import { useTeam, useTeamMatches, useTeamStats } from "../../queries";
|
||||||
|
import MatchList from "@/features/matches/components/match-list";
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,6 +12,7 @@ interface ProfileProps {
|
|||||||
|
|
||||||
const TeamProfile = ({ id }: ProfileProps) => {
|
const TeamProfile = ({ id }: ProfileProps) => {
|
||||||
const { data: team } = useTeam(id);
|
const { data: team } = useTeam(id);
|
||||||
|
const { data: matches } = useTeamMatches(id);
|
||||||
const { data: stats, isLoading: statsLoading, error: statsError } = useTeamStats(id);
|
const { data: stats, isLoading: statsLoading, error: statsError } = useTeamStats(id);
|
||||||
if (!team) return <Text p="md">Team not found</Text>;
|
if (!team) return <Text p="md">Team not found</Text>;
|
||||||
|
|
||||||
@@ -21,7 +23,7 @@ const TeamProfile = ({ id }: ProfileProps) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Matches",
|
label: "Matches",
|
||||||
content: <Text p="md">Matches feed will go here</Text>,
|
content: <MatchList matches={matches || []} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Tournaments",
|
label: "Tournaments",
|
||||||
@@ -35,7 +37,7 @@ const TeamProfile = ({ id }: ProfileProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header name={team.name} logo={team.logo} />
|
<Header name={team.name} logo={team.logo} />
|
||||||
<Box m="sm" mt="lg">
|
<Box m="xs" mt="lg">
|
||||||
<SwipeableTabs tabs={tabs} />
|
<SwipeableTabs tabs={tabs} />
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useServerSuspenseQuery, useServerQuery } from "@/lib/tanstack-query/hooks";
|
import { useServerSuspenseQuery, useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||||
import { getTeam, getTeamStats } from "./server";
|
import { getTeam, getTeamMatches, getTeamStats } from "./server";
|
||||||
|
|
||||||
export const teamKeys = {
|
export const teamKeys = {
|
||||||
|
list: ['teams', 'list'] as const,
|
||||||
details: (id: string) => ['teams', 'details', id] as const,
|
details: (id: string) => ['teams', 'details', id] as const,
|
||||||
stats: (id: string) => ['teams', 'stats', id] as const,
|
stats: (id: string) => ['teams', 'stats', id] as const,
|
||||||
|
matches: (id: string) => ['teams', 'matches', id] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const teamQueries = {
|
export const teamQueries = {
|
||||||
@@ -15,14 +17,17 @@ export const teamQueries = {
|
|||||||
queryKey: teamKeys.stats(id),
|
queryKey: teamKeys.stats(id),
|
||||||
queryFn: () => getTeamStats({ data: id }),
|
queryFn: () => getTeamStats({ data: id }),
|
||||||
}),
|
}),
|
||||||
|
matches: (id: string) => ({
|
||||||
|
queryKey: teamKeys.matches(id),
|
||||||
|
queryFn: () => getTeamMatches({ data: id })
|
||||||
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useTeam = (id: string) =>
|
export const useTeam = (id: string) =>
|
||||||
useServerSuspenseQuery(teamQueries.details(id));
|
useServerSuspenseQuery(teamQueries.details(id));
|
||||||
|
|
||||||
export const useTeamStats = (id: string) =>
|
export const useTeamStats = (id: string) =>
|
||||||
useServerQuery({
|
useServerQuery(teamQueries.stats(id));
|
||||||
...teamQueries.stats(id),
|
|
||||||
retry: 1,
|
export const useTeamMatches = (id: string) =>
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
useServerQuery(teamQueries.matches(id));
|
||||||
});
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
|||||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
import { teamInputSchema, teamUpdateSchema } from "./types";
|
import { teamInputSchema, teamUpdateSchema } from "./types";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { Match } from "../matches/types";
|
||||||
|
|
||||||
|
|
||||||
export const listTeamInfos = createServerFn()
|
export const listTeamInfos = createServerFn()
|
||||||
@@ -55,13 +56,11 @@ export const updateTeam = createServerFn()
|
|||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const isAdmin = context.roles.includes("Admin");
|
||||||
|
|
||||||
// Get the team to verify ownership
|
|
||||||
const team = await pbAdmin.getTeam(id);
|
const team = await pbAdmin.getTeam(id);
|
||||||
if (!team) {
|
if (!team) {
|
||||||
throw new Error("Team not found");
|
throw new Error("Team not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has permission to update this team
|
|
||||||
const isPlayerOnTeam = team.players.some(player => player.id === userId);
|
const isPlayerOnTeam = team.players.some(player => player.id === userId);
|
||||||
if (!isAdmin && !isPlayerOnTeam) {
|
if (!isAdmin && !isPlayerOnTeam) {
|
||||||
throw new Error("You can only update teams that you are a member of");
|
throw new Error("You can only update teams that you are a member of");
|
||||||
@@ -78,3 +77,10 @@ export const getTeamStats = createServerFn()
|
|||||||
.handler(async ({ data: teamId }) =>
|
.handler(async ({ data: teamId }) =>
|
||||||
toServerResult(() => pbAdmin.getTeamStats(teamId))
|
toServerResult(() => pbAdmin.getTeamStats(teamId))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getTeamMatches = createServerFn()
|
||||||
|
.validator(z.string())
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data }) =>
|
||||||
|
toServerResult<Match[]>(async () => await pbAdmin.getTeamMatches(data))
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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";
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,15 +20,12 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Matches",
|
label: "Matches",
|
||||||
content: <Text p="md">Matches feed will go here</Text>
|
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 || []} />
|
||||||
<TeamList teams={tournament.teams || []} />
|
|
||||||
<TeamList teams={tournament.teams || []} />
|
|
||||||
<TeamList teams={tournament.teams || []} />
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const EnrollFreeAgent = () => {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={open}>
|
<Button variant="subtle" size="sm" onClick={open}>
|
||||||
Enroll As Free Agent
|
Enroll As Free Agent
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -4,14 +4,19 @@ import { useAuth } from "@/contexts/auth-context";
|
|||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
import { useMemo, useState, useCallback } from "react";
|
import { useMemo, useState, useCallback } from "react";
|
||||||
import TeamSelectionView from "./team-selection-view";
|
import TeamSelectionView from "./team-selection-view";
|
||||||
|
import TeamForm from "@/features/teams/components/team-form";
|
||||||
|
|
||||||
const EnrollTeam = () => {
|
interface EnrollTeamProps {
|
||||||
|
tournamentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EnrollTeam = ({ tournamentId }: EnrollTeamProps) => {
|
||||||
const { open, isOpen, toggle } = useSheet();
|
const { open, isOpen, toggle } = useSheet();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const hasTeams = useMemo(() => !!user?.teams?.length, [user?.teams]);
|
const hasTeams = useMemo(() => !!user?.teams?.length, [user?.teams]);
|
||||||
const [selectedTeamId, setSelectedTeamId] = useState<string>();
|
const [selectedTeamId, setSelectedTeamId] = useState<string>();
|
||||||
const [showTeamForm, setShowTeamForm] = useState<boolean>(!hasTeams);
|
const [showTeamForm, setShowTeamForm] = useState<boolean>(!!hasTeams);
|
||||||
|
|
||||||
const teamOptions = useMemo(() =>
|
const teamOptions = useMemo(() =>
|
||||||
user?.teams?.map(team => ({
|
user?.teams?.map(team => ({
|
||||||
@@ -21,28 +26,21 @@ const EnrollTeam = () => {
|
|||||||
[user?.teams]
|
[user?.teams]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBack = useCallback(() => {
|
|
||||||
setShowTeamForm(false);
|
|
||||||
setSelectedTeamId(undefined);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSelect = useCallback((teamId: string | undefined) => {
|
const handleSelect = useCallback((teamId: string | undefined) => {
|
||||||
setSelectedTeamId(teamId);
|
setSelectedTeamId(teamId);
|
||||||
setShowTeamForm(true);
|
setShowTeamForm(true);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={open}>
|
<Button size="sm" onClick={open}>
|
||||||
Enroll Your Team
|
Enroll Your Team
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Sheet title={showTeamForm ? "Team Details" : "Enroll Team"} opened={isOpen} onChange={toggle}>
|
<Sheet title={showTeamForm ? "Team Details" : "Enroll Team"} opened={!isOpen} onChange={toggle}>
|
||||||
{showTeamForm ? (
|
{showTeamForm ? (
|
||||||
<>
|
<TeamForm close={console.log} tournamentId={tournamentId} />
|
||||||
<p>Team Form {selectedTeamId === undefined ? "new team" : selectedTeamId}</p>
|
|
||||||
<Button variant="subtle" color="red" onClick={handleBack}>Back</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<TeamSelectionView
|
<TeamSelectionView
|
||||||
@@ -1,72 +1,81 @@
|
|||||||
import { Group, Stack, ThemeIcon, Text } from "@mantine/core";
|
import { Group, Stack, ThemeIcon, Text } from "@mantine/core";
|
||||||
import { Tournament } from "../../types";
|
import { Tournament } from "../../types";
|
||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
import { CalendarIcon, MapPinIcon, TrophyIcon, UsersIcon } from "@phosphor-icons/react";
|
import {
|
||||||
|
CalendarIcon,
|
||||||
|
MapPinIcon,
|
||||||
|
TrophyIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
const Header = ({ tournament }: { tournament: Tournament}) => {
|
const Header = ({ tournament }: { tournament: Tournament }) => {
|
||||||
const tournamentStart = useMemo(() => new Date(tournament.start_time), [tournament.start_time]);
|
const tournamentStart = useMemo(
|
||||||
const teamCount = useMemo(() => tournament.teams?.length || 0, [tournament.teams]);
|
() => new Date(tournament.start_time),
|
||||||
|
[tournament.start_time]
|
||||||
|
);
|
||||||
|
const teamCount = useMemo(
|
||||||
|
() => tournament.teams?.length || 0,
|
||||||
|
[tournament.teams]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group justify="space-around" align="flex-start" w='100%'>
|
<Stack align="center" gap="lg">
|
||||||
<Group align="center" gap="lg">
|
<Avatar
|
||||||
<Avatar
|
name={tournament.name}
|
||||||
name={tournament.name}
|
src={
|
||||||
src={
|
tournament.logo
|
||||||
tournament.logo
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
: undefined
|
||||||
: undefined
|
}
|
||||||
}
|
radius="md"
|
||||||
radius="md"
|
size={200}
|
||||||
size={200}
|
px="xs"
|
||||||
px='md'
|
withBorder={false}
|
||||||
withBorder={false}
|
>
|
||||||
>
|
<TrophyIcon size={32} />
|
||||||
<TrophyIcon size={32} />
|
</Avatar>
|
||||||
</Avatar>
|
<Stack gap="xs">
|
||||||
<Stack gap="xs">
|
{tournament.location && (
|
||||||
{tournament.location && (
|
<Group gap="xs">
|
||||||
<Group gap="xs">
|
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
<MapPinIcon size={14} />
|
||||||
<MapPinIcon size={14} />
|
</ThemeIcon>
|
||||||
</ThemeIcon>
|
<Text size="sm" c="dimmed">
|
||||||
<Text size="sm" c="dimmed">
|
{tournament.location}
|
||||||
{tournament.location}
|
</Text>
|
||||||
</Text>
|
</Group>
|
||||||
</Group>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||||
<CalendarIcon size={14} />
|
<CalendarIcon size={14} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{tournamentStart.toLocaleDateString(undefined, {
|
{tournamentStart.toLocaleDateString(undefined, {
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
})}{" "}
|
})}{" "}
|
||||||
at{" "}
|
at{" "}
|
||||||
{tournamentStart.toLocaleTimeString([], {
|
{tournamentStart.toLocaleTimeString([], {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group gap="xs">
|
|
||||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
|
||||||
<UsersIcon size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{teamCount} teams enrolled
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Header;
|
<Group gap="xs">
|
||||||
|
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||||
|
<UsersIcon size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{teamCount} teams enrolled
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
|
|||||||
@@ -1,30 +1,23 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { Tournament } from "../../types";
|
import { Tournament } from "../../types";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Card,
|
Card,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Stack,
|
Stack,
|
||||||
Title,
|
|
||||||
Text,
|
Text,
|
||||||
ThemeIcon,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import Avatar from "@/components/avatar";
|
|
||||||
import Countdown from "@/components/countdown";
|
import Countdown from "@/components/countdown";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
import ListButton from "@/components/list-button";
|
import ListButton from "@/components/list-button";
|
||||||
import {
|
import {
|
||||||
TrophyIcon,
|
|
||||||
CalendarIcon,
|
|
||||||
MapPinIcon,
|
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import EnrollTeam from "../enroll-team";
|
import EnrollTeam from "./enroll-team";
|
||||||
import EnrollFreeAgent from "../enroll-free-agent";
|
import EnrollFreeAgent from "./enroll-free-agent";
|
||||||
import TeamListButton from "./team-list-button";
|
import TeamListButton from "./team-list-button";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
|
|
||||||
@@ -56,8 +49,8 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
{tournament.desc && <Text size="sm">{tournament.desc}</Text>}
|
{tournament.desc && <Text size="sm">{tournament.desc}</Text>}
|
||||||
|
|
||||||
<Card withBorder radius="md" p="lg">
|
<Card withBorder radius="md" p="lg">
|
||||||
<Stack gap="md">
|
<Stack gap="xs">
|
||||||
<Group gap="xs" align="center">
|
<Group mb='sm' gap="xs" align="center">
|
||||||
<UsersIcon size={16} />
|
<UsersIcon size={16} />
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
Enrollment
|
Enrollment
|
||||||
@@ -73,10 +66,14 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{!isEnrollmentOpen && !isUserEnrolled && (
|
||||||
|
<Text fw={600} c="dimmed" size="sm">Enrollment has been closed for this tournament.</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isUserEnrolled && isEnrollmentOpen && (
|
{!isUserEnrolled && isEnrollmentOpen && (
|
||||||
<>
|
<>
|
||||||
<EnrollTeam />
|
<EnrollTeam tournamentId={tournament.id} />
|
||||||
<Divider label="or" />
|
<Divider my={0} label="or" />
|
||||||
<EnrollFreeAgent />
|
<EnrollFreeAgent />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -65,10 +65,10 @@ export function createPlayersService(pb: PocketBase) {
|
|||||||
return result.map(transformPlayer);
|
return result.map(transformPlayer);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getPlayerStats(playerId: string): Promise<PlayerStats[]> {
|
async getPlayerStats(playerId: string): Promise<PlayerStats> {
|
||||||
const result = await pb.collection("player_stats").getFullList<PlayerStats>({
|
const result = await pb.collection("player_stats").getFirstListItem<PlayerStats>(
|
||||||
filter: `player_id = "${playerId}"`,
|
`player_id = "${playerId}"`
|
||||||
});
|
);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -102,5 +102,56 @@ export function createPlayersService(pb: PocketBase) {
|
|||||||
|
|
||||||
return result.map(transformMatch);
|
return result.map(transformMatch);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getUnenrolledPlayers(tournamentId: string): Promise<Player[]> {
|
||||||
|
try {
|
||||||
|
// Get the tournament with its enrolled teams
|
||||||
|
const tournament = await pb.collection("tournaments").getOne(tournamentId, {
|
||||||
|
fields: "teams",
|
||||||
|
expand: "teams,teams.players"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract player IDs from all enrolled teams
|
||||||
|
const enrolledPlayerIds: string[] = [];
|
||||||
|
if (tournament.expand?.teams) {
|
||||||
|
const teams = Array.isArray(tournament.expand.teams) ? tournament.expand.teams : [tournament.expand.teams];
|
||||||
|
teams.forEach((team: any) => {
|
||||||
|
if (team.expand?.players) {
|
||||||
|
const players = Array.isArray(team.expand.players) ? team.expand.players : [team.expand.players];
|
||||||
|
players.forEach((player: any) => {
|
||||||
|
enrolledPlayerIds.push(player.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no players are enrolled, return all players
|
||||||
|
if (enrolledPlayerIds.length === 0) {
|
||||||
|
const allPlayers = await pb.collection("players").getFullList<Player>({
|
||||||
|
fields: "id,first_name,last_name,email",
|
||||||
|
});
|
||||||
|
return allPlayers.map(transformPlayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build filter to exclude enrolled players
|
||||||
|
const filter = enrolledPlayerIds
|
||||||
|
.map((playerId: string) => `id != "${playerId}"`)
|
||||||
|
.join(" && ");
|
||||||
|
|
||||||
|
const availablePlayers = await pb.collection("players").getFullList<Player>({
|
||||||
|
filter,
|
||||||
|
fields: "id,first_name,last_name,email",
|
||||||
|
});
|
||||||
|
|
||||||
|
return availablePlayers.map(transformPlayer);
|
||||||
|
} catch (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>({
|
||||||
|
fields: "id,first_name,last_name,email",
|
||||||
|
});
|
||||||
|
return allPlayers.map(transformPlayer);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import PocketBase from "pocketbase";
|
import PocketBase from "pocketbase";
|
||||||
import { transformTeam, transformTeamInfo } from "@/lib/pocketbase/util/transform-types";
|
import { transformMatch, transformTeam, transformTeamInfo } from "@/lib/pocketbase/util/transform-types";
|
||||||
import { Team, TeamInfo, TeamInput, TeamUpdateInput, TeamStats } from "@/features/teams/types";
|
import { Team, TeamInfo, TeamInput, TeamUpdateInput, TeamStats } from "@/features/teams/types";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
|
||||||
export function createTeamsService(pb: PocketBase) {
|
export function createTeamsService(pb: PocketBase) {
|
||||||
return {
|
return {
|
||||||
@@ -86,11 +87,23 @@ export function createTeamsService(pb: PocketBase) {
|
|||||||
logger.info("PocketBase | Getting team stats", id);
|
logger.info("PocketBase | Getting team stats", id);
|
||||||
try {
|
try {
|
||||||
const result = await pb.collection("team_stats").getFirstListItem(`team_id="${id}"`);
|
const result = await pb.collection("team_stats").getFirstListItem(`team_id="${id}"`);
|
||||||
return result as TeamStats;
|
return result as unknown as TeamStats;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.info("PocketBase | No team stats found", id);
|
logger.info("PocketBase | No team stats found", id);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getTeamMatches(teamId: string): Promise<Match[]> {
|
||||||
|
const teamFilter = `home = "${teamId}" || away = "${teamId}"`
|
||||||
|
|
||||||
|
const result = await pb.collection("matches").getFullList({
|
||||||
|
filter: `(${teamFilter}) && (status = "ended" || status = "started")`,
|
||||||
|
sort: "-start_time",
|
||||||
|
expand: "tournament,home,away",
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.map(transformMatch);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const appInfo = {
|
export const appInfo = {
|
||||||
appName: 'Tanstack Start SuperTokens',
|
appName: 'FLXN',
|
||||||
apiDomain: import.meta.env.VITE_API_DOMAIN || 'http://localhost:3000',
|
apiDomain: import.meta.env.VITE_API_DOMAIN || 'http://localhost:3000',
|
||||||
websiteDomain: import.meta.env.VITE_WEBSITE_DOMAIN || 'http://localhost:3000',
|
websiteDomain: import.meta.env.VITE_WEBSITE_DOMAIN || 'http://localhost:3000',
|
||||||
apiBasePath: '/api/auth',
|
apiBasePath: '/api/auth',
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Card,
|
Card,
|
||||||
Avatar,
|
|
||||||
Progress,
|
|
||||||
Badge,
|
|
||||||
Divider,
|
|
||||||
Skeleton,
|
Skeleton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +17,6 @@ import {
|
|||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
BoxingGloveIcon,
|
BoxingGloveIcon,
|
||||||
Icon,
|
Icon,
|
||||||
TrendUpIcon,
|
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
ArrowDownIcon,
|
ArrowDownIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
@@ -99,55 +94,10 @@ const StatCard = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) => {
|
const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) => {
|
||||||
// Show skeleton loading state
|
|
||||||
if (isLoading || (!statsData && isLoading)) {
|
if (isLoading || (!statsData && isLoading)) {
|
||||||
return (
|
return <StatsSkeleton />
|
||||||
<Box p="sm" h="auto" mih={400}>
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Skeleton height={20} width="40%" />
|
|
||||||
<Grid gutter="xs">
|
|
||||||
<Grid.Col span={4}>
|
|
||||||
<StatCard label="Matches" value={null} Icon={BoxingGloveIcon} variant="compact" isLoading />
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={4}>
|
|
||||||
<StatCard label="Wins" value={null} Icon={CrownIcon} variant="compact" isLoading />
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={4}>
|
|
||||||
<StatCard label="Losses" value={null} Icon={XIcon} variant="compact" isLoading />
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Skeleton height={20} width="30%" />
|
|
||||||
<Grid gutter="xs">
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<StatCard label="Cups Made" value={null} Icon={FireIcon} isLoading />
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<StatCard label="Cups Against" value={null} Icon={ShieldIcon} isLoading />
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<StatCard label="Avg Per Game" value={null} Icon={ChartLineUpIcon} isLoading />
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<StatCard label="Avg Against" value={null} Icon={ShieldCheckIcon} isLoading />
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<StatCard label="Win Margin" value={null} Icon={ArrowUpIcon} isLoading />
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<StatCard label="Loss Margin" value={null} Icon={ArrowDownIcon} isLoading />
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show no data state only when we know for sure there's no data
|
|
||||||
if (!statsData && !isLoading) {
|
if (!statsData && !isLoading) {
|
||||||
return (
|
return (
|
||||||
<Box p="sm" h="auto" mih={200}>
|
<Box p="sm" h="auto" mih={200}>
|
||||||
@@ -278,4 +228,49 @@ const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) =>
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const StatsSkeleton = () => (
|
||||||
|
<Box p="sm" h="auto" mih={400}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Skeleton height={20} width="40%" />
|
||||||
|
<Grid gutter="xs">
|
||||||
|
<Grid.Col span={4}>
|
||||||
|
<StatCard label="Matches" value={null} Icon={BoxingGloveIcon} variant="compact" isLoading />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={4}>
|
||||||
|
<StatCard label="Wins" value={null} Icon={CrownIcon} variant="compact" isLoading />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={4}>
|
||||||
|
<StatCard label="Losses" value={null} Icon={XIcon} variant="compact" isLoading />
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Skeleton height={20} width="30%" />
|
||||||
|
<Grid gutter="xs">
|
||||||
|
<Grid.Col span={6}>
|
||||||
|
<StatCard label="Cups Made" value={null} Icon={FireIcon} isLoading />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={6}>
|
||||||
|
<StatCard label="Cups Against" value={null} Icon={ShieldIcon} isLoading />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={6}>
|
||||||
|
<StatCard label="Avg Per Game" value={null} Icon={ChartLineUpIcon} isLoading />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={6}>
|
||||||
|
<StatCard label="Avg Against" value={null} Icon={ShieldCheckIcon} isLoading />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={6}>
|
||||||
|
<StatCard label="Win Margin" value={null} Icon={ArrowUpIcon} isLoading />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={6}>
|
||||||
|
<StatCard label="Loss Margin" value={null} Icon={ArrowDownIcon} isLoading />
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
|
||||||
export default StatsOverview;
|
export default StatsOverview;
|
||||||
|
|||||||
Reference in New Issue
Block a user