Compare commits
20 Commits
2396464a19
...
8efc0a7a4b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8efc0a7a4b | ||
|
|
d11e50d4ef | ||
|
|
4bc25fb0bc | ||
|
|
3be2284da9 | ||
|
|
a35c688a64 | ||
|
|
617a94262b | ||
|
|
3fe92be980 | ||
|
|
7d3c0a3fa4 | ||
|
|
a926dcde07 | ||
|
|
e20582897f | ||
|
|
cf09014d50 | ||
|
|
0169468114 | ||
|
|
9d92a8a510 | ||
|
|
51e3d5141c | ||
|
|
97c8c9e72b | ||
|
|
3ffa6b03c7 | ||
|
|
8dfff139e1 | ||
|
|
22be6682dd | ||
|
|
c74da09bde | ||
|
|
c5d69f1a19 |
16
.nitro/types/nitro-config.d.ts
vendored
Normal file
16
.nitro/types/nitro-config.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// Generated by nitro
|
||||||
|
|
||||||
|
// App Config
|
||||||
|
import type { Defu } from 'defu'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
type UserAppConfig = Defu<{}, []>
|
||||||
|
|
||||||
|
declare module "nitropack/types" {
|
||||||
|
interface AppConfig extends UserAppConfig {}
|
||||||
|
interface NitroRuntimeConfig {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export {}
|
||||||
1
.nitro/types/nitro-imports.d.ts
vendored
Normal file
1
.nitro/types/nitro-imports.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export {}
|
||||||
8
.nitro/types/nitro-routes.d.ts
vendored
Normal file
8
.nitro/types/nitro-routes.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// Generated by nitro
|
||||||
|
import type { Serialize, Simplify } from "nitropack/types";
|
||||||
|
declare module "nitropack/types" {
|
||||||
|
type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T
|
||||||
|
interface InternalApi {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export {}
|
||||||
3
.nitro/types/nitro.d.ts
vendored
Normal file
3
.nitro/types/nitro.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/// <reference path="./nitro-routes.d.ts" />
|
||||||
|
/// <reference path="./nitro-config.d.ts" />
|
||||||
|
/// <reference path="./nitro-imports.d.ts" />
|
||||||
323
brackets/11.json
Normal file
323
brackets/11.json
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
{
|
||||||
|
"winners": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"lid": 0,
|
||||||
|
"round": 0,
|
||||||
|
"order": null,
|
||||||
|
"bye": true,
|
||||||
|
"home_from_lid": null,
|
||||||
|
"home_from_loser": null,
|
||||||
|
"home_seed": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lid": 1,
|
||||||
|
"round": 0,
|
||||||
|
"order": 1,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": null,
|
||||||
|
"home_from_loser": null,
|
||||||
|
"home_seed": 8,
|
||||||
|
"away_seed": 9,
|
||||||
|
"away_from_lid": null,
|
||||||
|
"away_from_loser": null,
|
||||||
|
"reset": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lid": 2,
|
||||||
|
"round": 0,
|
||||||
|
"order": null,
|
||||||
|
"bye": true,
|
||||||
|
"home_from_lid": null,
|
||||||
|
"home_from_loser": null,
|
||||||
|
"home_seed": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lid": 3,
|
||||||
|
"round": 0,
|
||||||
|
"order": null,
|
||||||
|
"bye": true,
|
||||||
|
"home_from_lid": null,
|
||||||
|
"home_from_loser": null,
|
||||||
|
"home_seed": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lid": 4,
|
||||||
|
"round": 0,
|
||||||
|
"order": null,
|
||||||
|
"bye": true,
|
||||||
|
"home_from_lid": null,
|
||||||
|
"home_from_loser": null,
|
||||||
|
"home_seed": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lid": 5,
|
||||||
|
"round": 0,
|
||||||
|
"order": 2,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": null,
|
||||||
|
"home_from_loser": null,
|
||||||
|
"home_seed": 7,
|
||||||
|
"away_seed": 10,
|
||||||
|
"away_from_lid": null,
|
||||||
|
"away_from_loser": null,
|
||||||
|
"reset": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lid": 6,
|
||||||
|
"round": 0,
|
||||||
|
"order": null,
|
||||||
|
"bye": true,
|
||||||
|
"home_from_lid": null,
|
||||||
|
"home_from_loser": null,
|
||||||
|
"home_seed": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lid": 7,
|
||||||
|
"round": 0,
|
||||||
|
"order": 3,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": null,
|
||||||
|
"home_from_loser": null,
|
||||||
|
"home_seed": 6,
|
||||||
|
"away_seed": 11,
|
||||||
|
"away_from_lid": null,
|
||||||
|
"away_from_loser": null,
|
||||||
|
"reset": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"lid": 8,
|
||||||
|
"round": 1,
|
||||||
|
"order": 5,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": null,
|
||||||
|
"home_from_loser": null,
|
||||||
|
"home_seed": 1,
|
||||||
|
"away_from_lid": 1,
|
||||||
|
"away_from_loser": false,
|
||||||
|
"reset": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lid": 9,
|
||||||
|
"round": 1,
|
||||||
|
"order": 4,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": null,
|
||||||
|
"home_from_loser": null,
|
||||||
|
"home_seed": 4,
|
||||||
|
"away_seed": 5,
|
||||||
|
"away_from_lid": null,
|
||||||
|
"away_from_loser": null,
|
||||||
|
"reset": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lid": 10,
|
||||||
|
"round": 1,
|
||||||
|
"order": 6,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": null,
|
||||||
|
"home_from_loser": null,
|
||||||
|
"home_seed": 2,
|
||||||
|
"away_from_lid": 5,
|
||||||
|
"away_from_loser": false,
|
||||||
|
"reset": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lid": 11,
|
||||||
|
"round": 1,
|
||||||
|
"order": 7,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": null,
|
||||||
|
"home_from_loser": null,
|
||||||
|
"home_seed": 3,
|
||||||
|
"away_from_lid": 7,
|
||||||
|
"away_from_loser": null,
|
||||||
|
"reset": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"lid": 18,
|
||||||
|
"round": 2,
|
||||||
|
"order": 13,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": 8,
|
||||||
|
"home_from_loser": false,
|
||||||
|
"away_from_lid": 9,
|
||||||
|
"away_from_loser": false,
|
||||||
|
"reset": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lid": 19,
|
||||||
|
"round": 2,
|
||||||
|
"order": 14,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": 10,
|
||||||
|
"home_from_loser": false,
|
||||||
|
"away_from_lid": 11,
|
||||||
|
"away_from_loser": false,
|
||||||
|
"reset": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"lid": 23,
|
||||||
|
"round": 3,
|
||||||
|
"order": 16,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": 18,
|
||||||
|
"home_from_loser": false,
|
||||||
|
"away_from_lid": 19,
|
||||||
|
"away_from_loser": false,
|
||||||
|
"reset": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"lid": 25,
|
||||||
|
"round": 4,
|
||||||
|
"order": 18,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": 23,
|
||||||
|
"home_from_loser": false,
|
||||||
|
"away_from_lid": 24,
|
||||||
|
"away_from_loser": false,
|
||||||
|
"reset": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"lid": 26,
|
||||||
|
"round": 5,
|
||||||
|
"order": 19,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": 25,
|
||||||
|
"home_from_loser": false,
|
||||||
|
"away_from_lid": 25,
|
||||||
|
"away_from_loser": true,
|
||||||
|
"reset": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"losers": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"lid": 12,
|
||||||
|
"round": 0,
|
||||||
|
"order": null,
|
||||||
|
"bye": true,
|
||||||
|
"home_from_lid": 10,
|
||||||
|
"home_from_loser": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lid": 13,
|
||||||
|
"round": 0,
|
||||||
|
"order": 10,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": 11,
|
||||||
|
"home_from_loser": true,
|
||||||
|
"away_from_lid": 1,
|
||||||
|
"away_from_loser": true,
|
||||||
|
"reset": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lid": 14,
|
||||||
|
"round": 0,
|
||||||
|
"order": 8,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": 9,
|
||||||
|
"home_from_loser": true,
|
||||||
|
"away_from_lid": 5,
|
||||||
|
"away_from_loser": true,
|
||||||
|
"reset": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lid": 15,
|
||||||
|
"round": 0,
|
||||||
|
"order": 9,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": 8,
|
||||||
|
"home_from_loser": true,
|
||||||
|
"away_from_lid": 7,
|
||||||
|
"away_from_loser": true,
|
||||||
|
"reset": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"lid": 16,
|
||||||
|
"round": 1,
|
||||||
|
"order": 9,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": 10,
|
||||||
|
"home_from_loser": true,
|
||||||
|
"away_from_lid": 13,
|
||||||
|
"away_from_loser": false,
|
||||||
|
"reset": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lid": 17,
|
||||||
|
"round": 1,
|
||||||
|
"order": 10,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": 14,
|
||||||
|
"home_from_loser": false,
|
||||||
|
"away_from_lid": 15,
|
||||||
|
"away_from_loser": false,
|
||||||
|
"reset": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"lid": 20,
|
||||||
|
"round": 2,
|
||||||
|
"order": 13,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": 18,
|
||||||
|
"home_from_loser": true,
|
||||||
|
"away_from_lid": 16,
|
||||||
|
"away_from_loser": false,
|
||||||
|
"reset": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lid": 21,
|
||||||
|
"round": 2,
|
||||||
|
"order": 14,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": 19,
|
||||||
|
"home_from_loser": true,
|
||||||
|
"away_from_lid": 17,
|
||||||
|
"away_from_loser": false,
|
||||||
|
"reset": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"lid": 22,
|
||||||
|
"round": 3,
|
||||||
|
"order": 15,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": 20,
|
||||||
|
"home_from_loser": false,
|
||||||
|
"away_from_lid": 21,
|
||||||
|
"away_from_loser": false,
|
||||||
|
"reset": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"lid": 24,
|
||||||
|
"round": 4,
|
||||||
|
"order": 17,
|
||||||
|
"bye": false,
|
||||||
|
"home_from_lid": 23,
|
||||||
|
"home_from_loser": true,
|
||||||
|
"away_from_lid": 22,
|
||||||
|
"away_from_loser": false,
|
||||||
|
"reset": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
27
pb_migrations/1757263183_updated_matches.js
Normal file
27
pb_migrations/1757263183_updated_matches.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(20, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number3320769076",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "round",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("number3320769076")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
28
pb_migrations/1757386414_updated_players.js
Normal file
28
pb_migrations/1757386414_updated_players.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_3072146508")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": "",
|
||||||
|
"deleteRule": "",
|
||||||
|
"listRule": "",
|
||||||
|
"updateRule": "",
|
||||||
|
"viewRule": ""
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_3072146508")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"listRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
28
pb_migrations/1757386423_updated_matches.js
Normal file
28
pb_migrations/1757386423_updated_matches.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": "",
|
||||||
|
"deleteRule": "",
|
||||||
|
"listRule": "",
|
||||||
|
"updateRule": "",
|
||||||
|
"viewRule": ""
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"listRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
28
pb_migrations/1757386431_updated_teams.js
Normal file
28
pb_migrations/1757386431_updated_teams.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1568971955")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": "",
|
||||||
|
"deleteRule": "",
|
||||||
|
"listRule": "",
|
||||||
|
"updateRule": "",
|
||||||
|
"viewRule": ""
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1568971955")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"listRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
28
pb_migrations/1757386438_updated_tournaments.js
Normal file
28
pb_migrations/1757386438_updated_tournaments.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": "",
|
||||||
|
"deleteRule": "",
|
||||||
|
"listRule": "",
|
||||||
|
"updateRule": "",
|
||||||
|
"viewRule": ""
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"listRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
31
pb_migrations/1757615830_updated_matches.js
Normal file
31
pb_migrations/1757615830_updated_matches.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(21, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "select2063623452",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "status",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"tbd",
|
||||||
|
"ready",
|
||||||
|
"started",
|
||||||
|
"ended"
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("select2063623452")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
235
pb_migrations/1757710506_created_player_stats.js
Normal file
235
pb_migrations/1757710506_created_player_stats.js
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/// <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_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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_976G",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "first_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_njLe",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "last_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"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_ZNMy",
|
||||||
|
"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_nxTv",
|
||||||
|
"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": "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_135889471",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "player_stats",
|
||||||
|
"system": false,
|
||||||
|
"type": "view",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewQuery": "\n 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 p.first_name,\n p.last_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 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 players p\n JOIN teams t ON json_extract(t.players, '$[*]') LIKE '%' || p.id || '%'\n JOIN matches m ON (m.home = t.id OR m.away = t.id)\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended'\n GROUP BY p.id, t.id, tour.id",
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_135889471");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
194
pb_migrations/1757710693_updated_player_stats.js
Normal file
194
pb_migrations/1757710693_updated_player_stats.js
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_135889471")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"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"
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("_clone_976G")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("_clone_njLe")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("_clone_ZNMy")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("_clone_nxTv")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("json3041953980")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("json1531431708")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("json1062535948")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("json4249694556")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(4, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_YqC8",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "team_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(6, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_jZTo",
|
||||||
|
"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_135889471")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"viewQuery": "\n 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 p.first_name,\n p.last_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 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 players p\n JOIN teams t ON json_extract(t.players, '$[*]') LIKE '%' || p.id || '%'\n JOIN matches m ON (m.home = t.id OR m.away = t.id)\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended'\n GROUP BY p.id, t.id, tour.id"
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(3, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_976G",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "first_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_njLe",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "last_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(6, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_ZNMy",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "team_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(8, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_nxTv",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "tournament_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(12, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json3041953980",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "margin_of_victory",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(13, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json1531431708",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "margin_of_loss",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(14, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json1062535948",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "total_cups_won_by",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(15, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json4249694556",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "total_cups_lost_by",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("_clone_YqC8")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("_clone_jZTo")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
96
pb_migrations/1757710817_updated_player_stats.js
Normal file
96
pb_migrations/1757710817_updated_player_stats.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_135889471")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"name": "player_stats_per_tournament"
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("_clone_YqC8")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("_clone_jZTo")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(4, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_XGbN",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "team_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(6, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_uud6",
|
||||||
|
"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_135889471")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"name": "player_stats"
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(4, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_YqC8",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "team_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(6, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_jZTo",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "tournament_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("_clone_XGbN")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("_clone_uud6")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
180
pb_migrations/1757710882_created_player_stats.js
Normal file
180
pb_migrations/1757710882_created_player_stats.js
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/// <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_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_fQu1",
|
||||||
|
"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": "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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json2379943496",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "win_percentage",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json3165107022",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "avg_cups_per_match",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_1358894712",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "player_stats",
|
||||||
|
"system": false,
|
||||||
|
"type": "view",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewQuery": "SELECT\n (p.id || '_' || t.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 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 -- Additional calculated stats\n ROUND((CAST(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 REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n ROUND(CAST(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 REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin calculations\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 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",
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1358894712");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
57
pb_migrations/1757711182_updated_player_stats.js
Normal file
57
pb_migrations/1757711182_updated_player_stats.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1358894712")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_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 -- Win percentage\n ROUND((CAST(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 REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(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 REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(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), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(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), 2) as margin_of_loss\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"
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("relation694999214")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("_clone_fQu1")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1358894712")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"viewQuery": "SELECT\n (p.id || '_' || t.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 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 -- Additional calculated stats\n ROUND((CAST(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 REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n ROUND(CAST(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 REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin calculations\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 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"
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(3, new Field({
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1568971955",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation694999214",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "team_id",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(4, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_fQu1",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "team_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
@@ -16,6 +16,7 @@ import { Route as LogoutRouteImport } from './routes/logout'
|
|||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as AuthedRouteImport } from './routes/_authed'
|
import { Route as AuthedRouteImport } from './routes/_authed'
|
||||||
import { Route as AuthedIndexRouteImport } from './routes/_authed/index'
|
import { Route as AuthedIndexRouteImport } from './routes/_authed/index'
|
||||||
|
import { Route as AuthedStatsRouteImport } from './routes/_authed/stats'
|
||||||
import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings'
|
import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings'
|
||||||
import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
|
import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
|
||||||
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
|
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
|
||||||
@@ -28,6 +29,11 @@ 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 ApiSpotifyTokenServerRouteImport } from './routes/api/spotify/token'
|
||||||
|
import { ServerRoute as ApiSpotifyResumeServerRouteImport } from './routes/api/spotify/resume'
|
||||||
|
import { ServerRoute as ApiSpotifyPlaybackServerRouteImport } from './routes/api/spotify/playback'
|
||||||
|
import { ServerRoute as ApiSpotifyCaptureServerRouteImport } from './routes/api/spotify/capture'
|
||||||
|
import { ServerRoute as ApiSpotifyCallbackServerRouteImport } from './routes/api/spotify/callback'
|
||||||
import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$'
|
import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$'
|
||||||
import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$'
|
import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$'
|
||||||
import { ServerRoute as ApiFilesCollectionRecordIdFileServerRouteImport } from './routes/api/files/$collection/$recordId/$file'
|
import { ServerRoute as ApiFilesCollectionRecordIdFileServerRouteImport } from './routes/api/files/$collection/$recordId/$file'
|
||||||
@@ -58,6 +64,11 @@ const AuthedIndexRoute = AuthedIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => AuthedRoute,
|
getParentRoute: () => AuthedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthedStatsRoute = AuthedStatsRouteImport.update({
|
||||||
|
id: '/stats',
|
||||||
|
path: '/stats',
|
||||||
|
getParentRoute: () => AuthedRoute,
|
||||||
|
} as any)
|
||||||
const AuthedSettingsRoute = AuthedSettingsRouteImport.update({
|
const AuthedSettingsRoute = AuthedSettingsRouteImport.update({
|
||||||
id: '/settings',
|
id: '/settings',
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
@@ -123,6 +134,33 @@ const ApiTournamentsUploadLogoServerRoute =
|
|||||||
path: '/api/tournaments/upload-logo',
|
path: '/api/tournaments/upload-logo',
|
||||||
getParentRoute: () => rootServerRouteImport,
|
getParentRoute: () => rootServerRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiSpotifyTokenServerRoute = ApiSpotifyTokenServerRouteImport.update({
|
||||||
|
id: '/api/spotify/token',
|
||||||
|
path: '/api/spotify/token',
|
||||||
|
getParentRoute: () => rootServerRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiSpotifyResumeServerRoute = ApiSpotifyResumeServerRouteImport.update({
|
||||||
|
id: '/api/spotify/resume',
|
||||||
|
path: '/api/spotify/resume',
|
||||||
|
getParentRoute: () => rootServerRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiSpotifyPlaybackServerRoute =
|
||||||
|
ApiSpotifyPlaybackServerRouteImport.update({
|
||||||
|
id: '/api/spotify/playback',
|
||||||
|
path: '/api/spotify/playback',
|
||||||
|
getParentRoute: () => rootServerRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiSpotifyCaptureServerRoute = ApiSpotifyCaptureServerRouteImport.update({
|
||||||
|
id: '/api/spotify/capture',
|
||||||
|
path: '/api/spotify/capture',
|
||||||
|
getParentRoute: () => rootServerRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiSpotifyCallbackServerRoute =
|
||||||
|
ApiSpotifyCallbackServerRouteImport.update({
|
||||||
|
id: '/api/spotify/callback',
|
||||||
|
path: '/api/spotify/callback',
|
||||||
|
getParentRoute: () => rootServerRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiEventsSplatServerRoute = ApiEventsSplatServerRouteImport.update({
|
const ApiEventsSplatServerRoute = ApiEventsSplatServerRouteImport.update({
|
||||||
id: '/api/events/$',
|
id: '/api/events/$',
|
||||||
path: '/api/events/$',
|
path: '/api/events/$',
|
||||||
@@ -146,6 +184,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/refresh-session': typeof RefreshSessionRoute
|
'/refresh-session': typeof RefreshSessionRoute
|
||||||
'/admin': typeof AuthedAdminRouteWithChildren
|
'/admin': typeof AuthedAdminRouteWithChildren
|
||||||
'/settings': typeof AuthedSettingsRoute
|
'/settings': typeof AuthedSettingsRoute
|
||||||
|
'/stats': typeof AuthedStatsRoute
|
||||||
'/': typeof AuthedIndexRoute
|
'/': typeof AuthedIndexRoute
|
||||||
'/admin/preview': typeof AuthedAdminPreviewRoute
|
'/admin/preview': typeof AuthedAdminPreviewRoute
|
||||||
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
@@ -162,6 +201,7 @@ export interface FileRoutesByTo {
|
|||||||
'/logout': typeof LogoutRoute
|
'/logout': typeof LogoutRoute
|
||||||
'/refresh-session': typeof RefreshSessionRoute
|
'/refresh-session': typeof RefreshSessionRoute
|
||||||
'/settings': typeof AuthedSettingsRoute
|
'/settings': typeof AuthedSettingsRoute
|
||||||
|
'/stats': typeof AuthedStatsRoute
|
||||||
'/': typeof AuthedIndexRoute
|
'/': typeof AuthedIndexRoute
|
||||||
'/admin/preview': typeof AuthedAdminPreviewRoute
|
'/admin/preview': typeof AuthedAdminPreviewRoute
|
||||||
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
@@ -181,6 +221,7 @@ export interface FileRoutesById {
|
|||||||
'/refresh-session': typeof RefreshSessionRoute
|
'/refresh-session': typeof RefreshSessionRoute
|
||||||
'/_authed/admin': typeof AuthedAdminRouteWithChildren
|
'/_authed/admin': typeof AuthedAdminRouteWithChildren
|
||||||
'/_authed/settings': typeof AuthedSettingsRoute
|
'/_authed/settings': typeof AuthedSettingsRoute
|
||||||
|
'/_authed/stats': typeof AuthedStatsRoute
|
||||||
'/_authed/': typeof AuthedIndexRoute
|
'/_authed/': typeof AuthedIndexRoute
|
||||||
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute
|
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute
|
||||||
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
@@ -200,6 +241,7 @@ export interface FileRouteTypes {
|
|||||||
| '/refresh-session'
|
| '/refresh-session'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
|
| '/stats'
|
||||||
| '/'
|
| '/'
|
||||||
| '/admin/preview'
|
| '/admin/preview'
|
||||||
| '/profile/$playerId'
|
| '/profile/$playerId'
|
||||||
@@ -216,6 +258,7 @@ export interface FileRouteTypes {
|
|||||||
| '/logout'
|
| '/logout'
|
||||||
| '/refresh-session'
|
| '/refresh-session'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
|
| '/stats'
|
||||||
| '/'
|
| '/'
|
||||||
| '/admin/preview'
|
| '/admin/preview'
|
||||||
| '/profile/$playerId'
|
| '/profile/$playerId'
|
||||||
@@ -234,6 +277,7 @@ export interface FileRouteTypes {
|
|||||||
| '/refresh-session'
|
| '/refresh-session'
|
||||||
| '/_authed/admin'
|
| '/_authed/admin'
|
||||||
| '/_authed/settings'
|
| '/_authed/settings'
|
||||||
|
| '/_authed/stats'
|
||||||
| '/_authed/'
|
| '/_authed/'
|
||||||
| '/_authed/admin/preview'
|
| '/_authed/admin/preview'
|
||||||
| '/_authed/profile/$playerId'
|
| '/_authed/profile/$playerId'
|
||||||
@@ -255,12 +299,22 @@ export interface RootRouteChildren {
|
|||||||
export interface FileServerRoutesByFullPath {
|
export interface FileServerRoutesByFullPath {
|
||||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
||||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
'/api/events/$': typeof ApiEventsSplatServerRoute
|
||||||
|
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
|
||||||
|
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
||||||
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||||
|
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||||
|
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||||
'/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
|
||||||
}
|
}
|
||||||
export interface FileServerRoutesByTo {
|
export interface FileServerRoutesByTo {
|
||||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
||||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
'/api/events/$': typeof ApiEventsSplatServerRoute
|
||||||
|
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
|
||||||
|
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
||||||
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||||
|
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||||
|
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||||
'/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
|
||||||
}
|
}
|
||||||
@@ -268,6 +322,11 @@ export interface FileServerRoutesById {
|
|||||||
__root__: typeof rootServerRouteImport
|
__root__: typeof rootServerRouteImport
|
||||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
||||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
'/api/events/$': typeof ApiEventsSplatServerRoute
|
||||||
|
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
|
||||||
|
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
||||||
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||||
|
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||||
|
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||||
'/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
|
||||||
}
|
}
|
||||||
@@ -276,18 +335,33 @@ export interface FileServerRouteTypes {
|
|||||||
fullPaths:
|
fullPaths:
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/events/$'
|
| '/api/events/$'
|
||||||
|
| '/api/spotify/callback'
|
||||||
|
| '/api/spotify/capture'
|
||||||
|
| '/api/spotify/playback'
|
||||||
|
| '/api/spotify/resume'
|
||||||
|
| '/api/spotify/token'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
fileServerRoutesByTo: FileServerRoutesByTo
|
fileServerRoutesByTo: FileServerRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/events/$'
|
| '/api/events/$'
|
||||||
|
| '/api/spotify/callback'
|
||||||
|
| '/api/spotify/capture'
|
||||||
|
| '/api/spotify/playback'
|
||||||
|
| '/api/spotify/resume'
|
||||||
|
| '/api/spotify/token'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/events/$'
|
| '/api/events/$'
|
||||||
|
| '/api/spotify/callback'
|
||||||
|
| '/api/spotify/capture'
|
||||||
|
| '/api/spotify/playback'
|
||||||
|
| '/api/spotify/resume'
|
||||||
|
| '/api/spotify/token'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
fileServerRoutesById: FileServerRoutesById
|
fileServerRoutesById: FileServerRoutesById
|
||||||
@@ -295,6 +369,11 @@ export interface FileServerRouteTypes {
|
|||||||
export interface RootServerRouteChildren {
|
export interface RootServerRouteChildren {
|
||||||
ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute
|
ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute
|
||||||
ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute
|
ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute
|
||||||
|
ApiSpotifyCallbackServerRoute: typeof ApiSpotifyCallbackServerRoute
|
||||||
|
ApiSpotifyCaptureServerRoute: typeof ApiSpotifyCaptureServerRoute
|
||||||
|
ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute
|
||||||
|
ApiSpotifyResumeServerRoute: typeof ApiSpotifyResumeServerRoute
|
||||||
|
ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute
|
||||||
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
|
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
|
||||||
ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute
|
ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||||
}
|
}
|
||||||
@@ -336,6 +415,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedIndexRouteImport
|
preLoaderRoute: typeof AuthedIndexRouteImport
|
||||||
parentRoute: typeof AuthedRoute
|
parentRoute: typeof AuthedRoute
|
||||||
}
|
}
|
||||||
|
'/_authed/stats': {
|
||||||
|
id: '/_authed/stats'
|
||||||
|
path: '/stats'
|
||||||
|
fullPath: '/stats'
|
||||||
|
preLoaderRoute: typeof AuthedStatsRouteImport
|
||||||
|
parentRoute: typeof AuthedRoute
|
||||||
|
}
|
||||||
'/_authed/settings': {
|
'/_authed/settings': {
|
||||||
id: '/_authed/settings'
|
id: '/_authed/settings'
|
||||||
path: '/settings'
|
path: '/settings'
|
||||||
@@ -424,6 +510,41 @@ declare module '@tanstack/react-start/server' {
|
|||||||
preLoaderRoute: typeof ApiTournamentsUploadLogoServerRouteImport
|
preLoaderRoute: typeof ApiTournamentsUploadLogoServerRouteImport
|
||||||
parentRoute: typeof rootServerRouteImport
|
parentRoute: typeof rootServerRouteImport
|
||||||
}
|
}
|
||||||
|
'/api/spotify/token': {
|
||||||
|
id: '/api/spotify/token'
|
||||||
|
path: '/api/spotify/token'
|
||||||
|
fullPath: '/api/spotify/token'
|
||||||
|
preLoaderRoute: typeof ApiSpotifyTokenServerRouteImport
|
||||||
|
parentRoute: typeof rootServerRouteImport
|
||||||
|
}
|
||||||
|
'/api/spotify/resume': {
|
||||||
|
id: '/api/spotify/resume'
|
||||||
|
path: '/api/spotify/resume'
|
||||||
|
fullPath: '/api/spotify/resume'
|
||||||
|
preLoaderRoute: typeof ApiSpotifyResumeServerRouteImport
|
||||||
|
parentRoute: typeof rootServerRouteImport
|
||||||
|
}
|
||||||
|
'/api/spotify/playback': {
|
||||||
|
id: '/api/spotify/playback'
|
||||||
|
path: '/api/spotify/playback'
|
||||||
|
fullPath: '/api/spotify/playback'
|
||||||
|
preLoaderRoute: typeof ApiSpotifyPlaybackServerRouteImport
|
||||||
|
parentRoute: typeof rootServerRouteImport
|
||||||
|
}
|
||||||
|
'/api/spotify/capture': {
|
||||||
|
id: '/api/spotify/capture'
|
||||||
|
path: '/api/spotify/capture'
|
||||||
|
fullPath: '/api/spotify/capture'
|
||||||
|
preLoaderRoute: typeof ApiSpotifyCaptureServerRouteImport
|
||||||
|
parentRoute: typeof rootServerRouteImport
|
||||||
|
}
|
||||||
|
'/api/spotify/callback': {
|
||||||
|
id: '/api/spotify/callback'
|
||||||
|
path: '/api/spotify/callback'
|
||||||
|
fullPath: '/api/spotify/callback'
|
||||||
|
preLoaderRoute: typeof ApiSpotifyCallbackServerRouteImport
|
||||||
|
parentRoute: typeof rootServerRouteImport
|
||||||
|
}
|
||||||
'/api/events/$': {
|
'/api/events/$': {
|
||||||
id: '/api/events/$'
|
id: '/api/events/$'
|
||||||
path: '/api/events/$'
|
path: '/api/events/$'
|
||||||
@@ -471,6 +592,7 @@ const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren(
|
|||||||
interface AuthedRouteChildren {
|
interface AuthedRouteChildren {
|
||||||
AuthedAdminRoute: typeof AuthedAdminRouteWithChildren
|
AuthedAdminRoute: typeof AuthedAdminRouteWithChildren
|
||||||
AuthedSettingsRoute: typeof AuthedSettingsRoute
|
AuthedSettingsRoute: typeof AuthedSettingsRoute
|
||||||
|
AuthedStatsRoute: typeof AuthedStatsRoute
|
||||||
AuthedIndexRoute: typeof AuthedIndexRoute
|
AuthedIndexRoute: typeof AuthedIndexRoute
|
||||||
AuthedProfilePlayerIdRoute: typeof AuthedProfilePlayerIdRoute
|
AuthedProfilePlayerIdRoute: typeof AuthedProfilePlayerIdRoute
|
||||||
AuthedTeamsTeamIdRoute: typeof AuthedTeamsTeamIdRoute
|
AuthedTeamsTeamIdRoute: typeof AuthedTeamsTeamIdRoute
|
||||||
@@ -481,6 +603,7 @@ interface AuthedRouteChildren {
|
|||||||
const AuthedRouteChildren: AuthedRouteChildren = {
|
const AuthedRouteChildren: AuthedRouteChildren = {
|
||||||
AuthedAdminRoute: AuthedAdminRouteWithChildren,
|
AuthedAdminRoute: AuthedAdminRouteWithChildren,
|
||||||
AuthedSettingsRoute: AuthedSettingsRoute,
|
AuthedSettingsRoute: AuthedSettingsRoute,
|
||||||
|
AuthedStatsRoute: AuthedStatsRoute,
|
||||||
AuthedIndexRoute: AuthedIndexRoute,
|
AuthedIndexRoute: AuthedIndexRoute,
|
||||||
AuthedProfilePlayerIdRoute: AuthedProfilePlayerIdRoute,
|
AuthedProfilePlayerIdRoute: AuthedProfilePlayerIdRoute,
|
||||||
AuthedTeamsTeamIdRoute: AuthedTeamsTeamIdRoute,
|
AuthedTeamsTeamIdRoute: AuthedTeamsTeamIdRoute,
|
||||||
@@ -503,6 +626,11 @@ export const routeTree = rootRouteImport
|
|||||||
const rootServerRouteChildren: RootServerRouteChildren = {
|
const rootServerRouteChildren: RootServerRouteChildren = {
|
||||||
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute,
|
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute,
|
||||||
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute,
|
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute,
|
||||||
|
ApiSpotifyCallbackServerRoute: ApiSpotifyCallbackServerRoute,
|
||||||
|
ApiSpotifyCaptureServerRoute: ApiSpotifyCaptureServerRoute,
|
||||||
|
ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute,
|
||||||
|
ApiSpotifyResumeServerRoute: ApiSpotifyResumeServerRoute,
|
||||||
|
ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute,
|
||||||
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
|
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
|
||||||
ApiFilesCollectionRecordIdFileServerRoute:
|
ApiFilesCollectionRecordIdFileServerRoute:
|
||||||
ApiFilesCollectionRecordIdFileServerRoute,
|
ApiFilesCollectionRecordIdFileServerRoute,
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ export const Route = createRootRouteWithContext<{
|
|||||||
errorComponent: (props) => {
|
errorComponent: (props) => {
|
||||||
return (
|
return (
|
||||||
<RootDocument>
|
<RootDocument>
|
||||||
<DefaultCatchBoundary {...props} />
|
<Providers>
|
||||||
|
<DefaultCatchBoundary {...props} />
|
||||||
|
</Providers>
|
||||||
</RootDocument>
|
</RootDocument>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 { 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 }) => {
|
||||||
@@ -26,7 +27,7 @@ export const Route = createFileRoute("/_authed")({
|
|||||||
},
|
},
|
||||||
pendingComponent: () => (
|
pendingComponent: () => (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Loader size="xl" />
|
<FullScreenLoader />
|
||||||
</Layout>
|
</Layout>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,57 +1,90 @@
|
|||||||
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { tournamentQueries } from '@/features/tournaments/queries'
|
import {
|
||||||
import { ensureServerQueryData } from '@/lib/tanstack-query/utils/ensure'
|
tournamentQueries,
|
||||||
import SeedTournament from '@/features/tournaments/components/seed-tournament'
|
useTournament,
|
||||||
import { Container, Alert, Text } from '@mantine/core'
|
} from "@/features/tournaments/queries";
|
||||||
import { Info } from '@phosphor-icons/react'
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
|
import SeedTournament from "@/features/tournaments/components/seed-tournament";
|
||||||
|
import { Container } from "@mantine/core";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { BracketData } from "@/features/bracket/types";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
import BracketView from "@/features/bracket/components/bracket-view";
|
||||||
|
import { SpotifyControlsBar } from "@/features/spotify/components";
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/admin/tournaments/run/$id')({
|
export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
|
||||||
beforeLoad: async ({ context, params }) => {
|
beforeLoad: async ({ context, params }) => {
|
||||||
const { queryClient } = context
|
const { queryClient } = context;
|
||||||
const tournament = await ensureServerQueryData(
|
const tournament = await ensureServerQueryData(
|
||||||
queryClient,
|
queryClient,
|
||||||
tournamentQueries.details(params.id)
|
tournamentQueries.details(params.id)
|
||||||
)
|
);
|
||||||
if (!tournament) throw redirect({ to: '/admin/tournaments' })
|
if (!tournament) throw redirect({ to: "/admin/tournaments" });
|
||||||
return {
|
return {
|
||||||
tournament,
|
tournament,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
loader: ({ context }) => ({
|
loader: ({ context }) => ({
|
||||||
fullWidth: true,
|
fullWidth: true,
|
||||||
|
withPadding: false,
|
||||||
|
showSpotifyPanel: true,
|
||||||
header: {
|
header: {
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
title: `Run ${context.tournament.name}`,
|
title: `Run ${context.tournament.name}`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
})
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { tournament } = Route.useRouteContext()
|
const { id } = Route.useParams();
|
||||||
const router = useRouter()
|
const { data: tournament } = useTournament(id);
|
||||||
|
|
||||||
const handleSuccess = () => {
|
const bracket: BracketData = useMemo(() => {
|
||||||
router.navigate({
|
if (!tournament.matches || tournament.matches.length === 0) {
|
||||||
to: '/admin/tournaments/$id',
|
return { winners: [], losers: [] };
|
||||||
params: { id: tournament.id }
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Tournament:', tournament)
|
const winnersMap = new Map<number, Match[]>();
|
||||||
|
const losersMap = new Map<number, Match[]>();
|
||||||
|
|
||||||
|
tournament.matches
|
||||||
|
.sort((a, b) => a.lid - b.lid)
|
||||||
|
.forEach((match) => {
|
||||||
|
if (!match.is_losers_bracket) {
|
||||||
|
if (!winnersMap.has(match.round)) {
|
||||||
|
winnersMap.set(match.round, []);
|
||||||
|
}
|
||||||
|
winnersMap.get(match.round)!.push(match);
|
||||||
|
} else {
|
||||||
|
if (!losersMap.has(match.round)) {
|
||||||
|
losersMap.set(match.round, []);
|
||||||
|
}
|
||||||
|
losersMap.get(match.round)!.push(match);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const winners = Array.from(winnersMap.entries())
|
||||||
|
.sort(([a], [b]) => a - b)
|
||||||
|
.map(([, matches]) => matches);
|
||||||
|
|
||||||
|
const losers = Array.from(losersMap.entries())
|
||||||
|
.sort(([a], [b]) => a - b)
|
||||||
|
.map(([, matches]) => matches);
|
||||||
|
return { winners, losers };
|
||||||
|
}, [tournament.matches]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="md">
|
<Container size="md" px={0}>
|
||||||
{
|
<SpotifyControlsBar />
|
||||||
tournament.matches?.length ?
|
{tournament.matches?.length ? (
|
||||||
<p>Matches</p>
|
<BracketView bracket={bracket} showControls />
|
||||||
: (
|
) : (
|
||||||
<SeedTournament
|
<SeedTournament
|
||||||
tournamentId={tournament.id}
|
tournamentId={tournament.id}
|
||||||
teams={tournament.teams || []}
|
teams={tournament.teams || []}
|
||||||
onSuccess={handleSuccess}
|
/>
|
||||||
/>)
|
)}
|
||||||
}
|
|
||||||
</Container>
|
</Container>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,31 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { TrophyIcon } from "@phosphor-icons/react";
|
import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/queries";
|
||||||
import ListLink from "@/components/list-link";
|
import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament";
|
||||||
import { Box, Divider, Text } from "@mantine/core";
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/")({
|
export const Route = createFileRoute("/_authed/")({
|
||||||
component: Home,
|
component: Home,
|
||||||
loader: () => ({
|
beforeLoad: async ({ context }) => {
|
||||||
withPadding: false
|
const queryClient = context.queryClient;
|
||||||
})
|
const tournament = await ensureServerQueryData(queryClient, tournamentQueries.current())
|
||||||
|
|
||||||
|
return { tournament }
|
||||||
|
},
|
||||||
|
loader: ({ context }) => ({
|
||||||
|
withPadding: true,
|
||||||
|
header: {
|
||||||
|
title: context.tournament.name || "FLXN"
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
return (
|
const { data: tournament } = useCurrentTournament();
|
||||||
<>
|
const now = new Date();
|
||||||
<Box h='60vh' p="md">
|
|
||||||
<Text m='16vh' fw={500}>Some Content Here</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box>
|
if (new Date(tournament.start_time) > now) {
|
||||||
<Text pl='md'>Quick Links</Text>
|
return <UpcomingTournament tournament={tournament} />;
|
||||||
<Divider />
|
}
|
||||||
<ListLink label="All Tournaments" to="/tournaments" Icon={TrophyIcon} />
|
|
||||||
</Box>
|
return <p>Started Tournament</p>
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,16 @@ export const Route = createFileRoute("/_authed/profile/$playerId")({
|
|||||||
validateSearch: searchSchema,
|
validateSearch: searchSchema,
|
||||||
beforeLoad: async ({ params, context }) => {
|
beforeLoad: async ({ params, context }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
await prefetchServerQuery(
|
await Promise.all([
|
||||||
queryClient,
|
prefetchServerQuery(
|
||||||
playerQueries.details(params.playerId)
|
queryClient,
|
||||||
);
|
playerQueries.details(params.playerId)
|
||||||
|
),
|
||||||
|
prefetchServerQuery(
|
||||||
|
queryClient,
|
||||||
|
playerQueries.matches(params.playerId)
|
||||||
|
),
|
||||||
|
]);
|
||||||
},
|
},
|
||||||
loader: ({ params, context }) => ({
|
loader: ({ params, context }) => ({
|
||||||
header: {
|
header: {
|
||||||
@@ -24,6 +30,7 @@ export const Route = createFileRoute("/_authed/profile/$playerId")({
|
|||||||
settingsLink:
|
settingsLink:
|
||||||
context?.auth.user.id === params.playerId ? "/settings" : undefined,
|
context?.auth.user.id === params.playerId ? "/settings" : undefined,
|
||||||
},
|
},
|
||||||
|
withPadding: false,
|
||||||
refresh: [playerQueries.details(params.playerId).queryKey],
|
refresh: [playerQueries.details(params.playerId).queryKey],
|
||||||
}),
|
}),
|
||||||
component: () => {
|
component: () => {
|
||||||
|
|||||||
26
src/app/routes/_authed/stats.tsx
Normal file
26
src/app/routes/_authed/stats.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { playerQueries, useAllPlayerStats } from "@/features/players/queries";
|
||||||
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
|
import PlayerStatsTable from "@/features/players/components/player-stats-table";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authed/stats")({
|
||||||
|
component: Stats,
|
||||||
|
beforeLoad: async ({ context }) => {
|
||||||
|
const queryClient = context.queryClient;
|
||||||
|
await ensureServerQueryData(queryClient, playerQueries.allStats());
|
||||||
|
},
|
||||||
|
loader: () => ({
|
||||||
|
withPadding: true,
|
||||||
|
fullWidth: true,
|
||||||
|
header: {
|
||||||
|
title: "Player Stats"
|
||||||
|
},
|
||||||
|
refresh: [playerQueries.allStats().queryKey],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function Stats() {
|
||||||
|
const { data: playerStats } = useAllPlayerStats();
|
||||||
|
|
||||||
|
return <PlayerStatsTable playerStats={playerStats} />;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ export const ServerRoute = createServerFileRoute("/api/files/$collection/$record
|
|||||||
GET: async ({ params, request }) => {
|
GET: async ({ params, request }) => {
|
||||||
try {
|
try {
|
||||||
const { collection, recordId, file } = params;
|
const { collection, recordId, file } = params;
|
||||||
const pocketbaseUrl = process.env.VITE_POCKETBASE_URL || 'http://127.0.0.1:8090';
|
const pocketbaseUrl = process.env.POCKETBASE_URL || 'http://127.0.0.1:8090';
|
||||||
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
|
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
|
||||||
|
|
||||||
logger.info('File proxy', {
|
logger.info('File proxy', {
|
||||||
|
|||||||
135
src/app/routes/api/spotify/callback.ts
Normal file
135
src/app/routes/api/spotify/callback.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||||
|
import { SpotifyAuth } from '@/lib/spotify/auth'
|
||||||
|
|
||||||
|
const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID!
|
||||||
|
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!
|
||||||
|
const SPOTIFY_REDIRECT_URI = import.meta.env.VITE_SPOTIFY_REDIRECT_URI!
|
||||||
|
|
||||||
|
export const ServerRoute = createServerFileRoute('/api/spotify/callback').methods({
|
||||||
|
GET: async ({ request }: { request: Request }) => {
|
||||||
|
// Helper function to get return path from state parameter
|
||||||
|
const getReturnPath = (state: string | null): string => {
|
||||||
|
if (!state) return '/';
|
||||||
|
try {
|
||||||
|
const decodedState = JSON.parse(atob(state));
|
||||||
|
return decodedState.returnPath || '/';
|
||||||
|
} catch {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const code = url.searchParams.get('code')
|
||||||
|
const state = url.searchParams.get('state')
|
||||||
|
const error = url.searchParams.get('error')
|
||||||
|
|
||||||
|
const returnPath = getReturnPath(state);
|
||||||
|
|
||||||
|
// Check for OAuth errors
|
||||||
|
if (error) {
|
||||||
|
console.error('Spotify OAuth error:', error)
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
'Location': returnPath + '?spotify_error=' + encodeURIComponent(error),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
console.error('Missing code or state:', { code: !!code, state: !!state })
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
'Location': returnPath + '?spotify_error=missing_code_or_state',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Token exchange attempt:', {
|
||||||
|
client_id: SPOTIFY_CLIENT_ID,
|
||||||
|
redirect_uri: SPOTIFY_REDIRECT_URI,
|
||||||
|
has_code: !!code,
|
||||||
|
has_state: !!state,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Exchange code for tokens
|
||||||
|
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`,
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: SPOTIFY_REDIRECT_URI,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
const errorText = await tokenResponse.text()
|
||||||
|
console.error('Token exchange error:', {
|
||||||
|
status: tokenResponse.status,
|
||||||
|
statusText: tokenResponse.statusText,
|
||||||
|
body: errorText,
|
||||||
|
redirect_uri: SPOTIFY_REDIRECT_URI,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return more detailed error info
|
||||||
|
const errorParam = encodeURIComponent(`${tokenResponse.status}: ${errorText}`)
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
'Location': `${returnPath}?spotify_error=token_exchange_failed&details=${errorParam}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await tokenResponse.json()
|
||||||
|
|
||||||
|
console.log('Token exchange successful:', {
|
||||||
|
has_access_token: !!tokens.access_token,
|
||||||
|
has_refresh_token: !!tokens.refresh_token,
|
||||||
|
expires_in: tokens.expires_in,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Decoded return path:', returnPath);
|
||||||
|
|
||||||
|
// Create response with redirect to original path
|
||||||
|
const response = new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
'Location': returnPath + '?spotify_auth=success',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set secure cookies for tokens
|
||||||
|
const isSecure = process.env.NODE_ENV === 'production'
|
||||||
|
const cookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${tokens.expires_in}`
|
||||||
|
|
||||||
|
response.headers.append('Set-Cookie', `spotify_access_token=${tokens.access_token}; ${cookieOptions}`)
|
||||||
|
|
||||||
|
if (tokens.refresh_token) {
|
||||||
|
// Refresh token doesn't expire, set longer max age
|
||||||
|
const refreshCookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${60 * 60 * 24 * 30}` // 30 days
|
||||||
|
response.headers.append('Set-Cookie', `spotify_refresh_token=${tokens.refresh_token}; ${refreshCookieOptions}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Spotify callback error:', error)
|
||||||
|
// Try to get return path from query params if available, otherwise use default
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const state = url.searchParams.get('state');
|
||||||
|
const returnPath = getReturnPath(state);
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
'Location': returnPath + '?spotify_error=callback_failed',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
59
src/app/routes/api/spotify/capture.ts
Normal file
59
src/app/routes/api/spotify/capture.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||||
|
import { SpotifyWebApiClient } from '@/lib/spotify/client'
|
||||||
|
import type { SpotifyPlaybackSnapshot } from '@/lib/spotify/types'
|
||||||
|
|
||||||
|
export const ServerRoute = createServerFileRoute('/api/spotify/capture').methods({
|
||||||
|
POST: async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
// Get access token from cookies
|
||||||
|
const cookies = request.headers.get('Cookie') || ''
|
||||||
|
const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/)
|
||||||
|
|
||||||
|
if (!accessTokenMatch) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'No access token found' }),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = decodeURIComponent(accessTokenMatch[1])
|
||||||
|
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
||||||
|
|
||||||
|
// Create a snapshot of the current playback state
|
||||||
|
const snapshot = await spotifyClient.createPlaybackSnapshot()
|
||||||
|
|
||||||
|
if (!snapshot) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'No active playback to capture' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ snapshot }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Spotify capture error:', error)
|
||||||
|
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to capture playback state'
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: errorMessage }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
195
src/app/routes/api/spotify/playback.ts
Normal file
195
src/app/routes/api/spotify/playback.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||||
|
import { SpotifyWebApiClient } from '@/lib/spotify/client'
|
||||||
|
|
||||||
|
// Helper function to get access token from cookies
|
||||||
|
function getAccessTokenFromCookies(request: Request): string | null {
|
||||||
|
const cookieHeader = request.headers.get('cookie')
|
||||||
|
if (!cookieHeader) return null
|
||||||
|
|
||||||
|
const cookies = Object.fromEntries(
|
||||||
|
cookieHeader.split('; ').map(c => c.split('='))
|
||||||
|
)
|
||||||
|
|
||||||
|
return cookies.spotify_access_token || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerRoute = createServerFileRoute('/api/spotify/playback').methods({
|
||||||
|
POST: async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
const accessToken = getAccessTokenFromCookies(request)
|
||||||
|
if (!accessToken) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'No access token found' }),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { action, deviceId, volumePercent } = body
|
||||||
|
|
||||||
|
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'play':
|
||||||
|
await spotifyClient.play(deviceId)
|
||||||
|
break
|
||||||
|
case 'pause':
|
||||||
|
await spotifyClient.pause()
|
||||||
|
break
|
||||||
|
case 'next':
|
||||||
|
await spotifyClient.skipToNext()
|
||||||
|
break
|
||||||
|
case 'previous':
|
||||||
|
await spotifyClient.skipToPrevious()
|
||||||
|
break
|
||||||
|
case 'volume':
|
||||||
|
if (typeof volumePercent !== 'number') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'volumePercent must be a number' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await spotifyClient.setVolume(volumePercent)
|
||||||
|
break
|
||||||
|
case 'transfer':
|
||||||
|
if (!deviceId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'deviceId is required for transfer action' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await spotifyClient.transferPlayback(deviceId)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Invalid action' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Playback control error:', error)
|
||||||
|
|
||||||
|
// Handle specific Spotify API errors
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message.includes('NO_ACTIVE_DEVICE')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'No active device found. Please select a device first.' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message.includes('PREMIUM_REQUIRED')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Spotify Premium is required for playback control.' }),
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the full error details for debugging
|
||||||
|
console.error('Full error details:', {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
name: error.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Playback control failed', details: error instanceof Error ? error.message : 'Unknown error' }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// GET endpoint for retrieving current playback state and devices
|
||||||
|
GET: async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
const accessToken = getAccessTokenFromCookies(request)
|
||||||
|
if (!accessToken) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'No access token found' }),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const type = url.searchParams.get('type') // 'state' or 'devices'
|
||||||
|
|
||||||
|
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
||||||
|
|
||||||
|
if (type === 'devices') {
|
||||||
|
const devices = await spotifyClient.getDevices()
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ devices }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else if (type === 'state') {
|
||||||
|
const playbackState = await spotifyClient.getPlaybackState()
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ playbackState }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Return both by default
|
||||||
|
const [devices, playbackState] = await Promise.all([
|
||||||
|
spotifyClient.getDevices(),
|
||||||
|
spotifyClient.getPlaybackState(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ devices, playbackState }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get playback data error:', error)
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to get playback data' }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
72
src/app/routes/api/spotify/resume.ts
Normal file
72
src/app/routes/api/spotify/resume.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||||
|
import { SpotifyWebApiClient } from '@/lib/spotify/client'
|
||||||
|
import type { SpotifyPlaybackSnapshot } from '@/lib/spotify/types'
|
||||||
|
|
||||||
|
export const ServerRoute = createServerFileRoute('/api/spotify/resume').methods({
|
||||||
|
POST: async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
// Get access token from cookies
|
||||||
|
const cookies = request.headers.get('Cookie') || ''
|
||||||
|
const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/)
|
||||||
|
|
||||||
|
if (!accessTokenMatch) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'No access token found' }),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = decodeURIComponent(accessTokenMatch[1])
|
||||||
|
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
||||||
|
|
||||||
|
// Parse the request body to get the snapshot
|
||||||
|
const body = await request.json()
|
||||||
|
const { snapshot } = body as { snapshot: SpotifyPlaybackSnapshot }
|
||||||
|
|
||||||
|
if (!snapshot) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'No snapshot provided' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the playback state from the snapshot
|
||||||
|
await spotifyClient.restorePlaybackSnapshot(snapshot)
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Spotify resume error:', error)
|
||||||
|
|
||||||
|
let errorMessage = 'Failed to resume playback state'
|
||||||
|
|
||||||
|
// Handle common Spotify Premium requirement error
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message.includes('Premium') || error.message.includes('403')) {
|
||||||
|
errorMessage = 'Spotify Premium required for playback control'
|
||||||
|
} else {
|
||||||
|
errorMessage = error.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: errorMessage }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
127
src/app/routes/api/spotify/token.ts
Normal file
127
src/app/routes/api/spotify/token.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||||
|
|
||||||
|
const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID!
|
||||||
|
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!
|
||||||
|
|
||||||
|
export const ServerRoute = createServerFileRoute('/api/spotify/token').methods({
|
||||||
|
POST: async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { refresh_token } = body
|
||||||
|
|
||||||
|
if (!refresh_token) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'refresh_token is required' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh access token
|
||||||
|
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`,
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
const error = await tokenResponse.json()
|
||||||
|
console.error('Token refresh error:', error)
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to refresh token', details: error }),
|
||||||
|
{
|
||||||
|
status: tokenResponse.status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await tokenResponse.json()
|
||||||
|
|
||||||
|
// Return new tokens
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
expires_in: tokens.expires_in,
|
||||||
|
scope: tokens.scope,
|
||||||
|
token_type: tokens.token_type,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token refresh endpoint error:', error)
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Internal server error' }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// GET endpoint to retrieve current tokens from cookies
|
||||||
|
GET: async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
const cookieHeader = request.headers.get('cookie')
|
||||||
|
if (!cookieHeader) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'No cookies found' }),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookies = Object.fromEntries(
|
||||||
|
cookieHeader.split('; ').map((c: string) => c.split('='))
|
||||||
|
)
|
||||||
|
|
||||||
|
const accessToken = cookies.spotify_access_token
|
||||||
|
const refreshToken = cookies.spotify_refresh_token
|
||||||
|
|
||||||
|
if (!accessToken && !refreshToken) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'No Spotify tokens found' }),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
access_token: accessToken || null,
|
||||||
|
refresh_token: refreshToken || null,
|
||||||
|
has_tokens: !!(accessToken || refreshToken),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get tokens endpoint error:', error)
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Internal server error' }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -18,10 +18,21 @@ function RouteComponent() {
|
|||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
const redirect = urlParams.get('redirect')
|
const redirect = urlParams.get('redirect')
|
||||||
|
|
||||||
if (redirect) {
|
const isServerFunction = redirect && (
|
||||||
|
redirect.startsWith('_serverFn') ||
|
||||||
|
redirect.startsWith('api/') ||
|
||||||
|
redirect.includes('_serverFn')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (redirect && !isServerFunction) {
|
||||||
window.location.href = decodeURIComponent(redirect)
|
window.location.href = decodeURIComponent(redirect)
|
||||||
} else {
|
} else {
|
||||||
window.location.href = '/'
|
const referrer = document.referrer;
|
||||||
|
const referrerUrl = referrer && !referrer.includes('/_serverFn') && !referrer.includes('/api/')
|
||||||
|
? referrer
|
||||||
|
: '/';
|
||||||
|
|
||||||
|
window.location.href = referrerUrl;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const Avatar = ({
|
|||||||
color="initials"
|
color="initials"
|
||||||
size={size}
|
size={size}
|
||||||
radius={radius}
|
radius={radius}
|
||||||
w="fit-content"
|
w={size}
|
||||||
styles={{
|
styles={{
|
||||||
image: {
|
image: {
|
||||||
objectFit: "contain",
|
objectFit: "contain",
|
||||||
|
|||||||
57
src/components/countdown.tsx
Normal file
57
src/components/countdown.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import useNow from '@/hooks/use-now';
|
||||||
|
import { Text, Group } from '@mantine/core';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
interface CountdownProps {
|
||||||
|
date: Date;
|
||||||
|
label?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimeLeft {
|
||||||
|
days: number;
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateTimeLeft(targetDate: Date, currentTime = new Date()): TimeLeft {
|
||||||
|
const difference = targetDate.getTime() - currentTime.getTime();
|
||||||
|
|
||||||
|
if (difference <= 0) {
|
||||||
|
return { days: 0, hours: 0, minutes: 0, seconds: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
days: Math.floor(difference / (1000 * 60 * 60 * 24)),
|
||||||
|
hours: Math.floor((difference / (1000 * 60 * 60)) % 24),
|
||||||
|
minutes: Math.floor((difference / 1000 / 60) % 60),
|
||||||
|
seconds: Math.floor((difference / 1000) % 60)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Countdown({ date, label, color }: CountdownProps) {
|
||||||
|
const now = useNow();
|
||||||
|
const timeLeft = useMemo(() => calculateTimeLeft(date, now), [date, now]);
|
||||||
|
|
||||||
|
const formatTime = () => {
|
||||||
|
const pad = (num: number) => num.toString().padStart(2, '0');
|
||||||
|
|
||||||
|
if (timeLeft.days > 0) {
|
||||||
|
return `${timeLeft.days}d ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:${pad(timeLeft.seconds)}`;
|
||||||
|
} else {
|
||||||
|
return `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:${pad(timeLeft.seconds)}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap="xs">
|
||||||
|
{label && <Text size='sm' fw={500}>{label}:</Text>}
|
||||||
|
<Text size='sm' fw={600} c={color} ff="monospace">
|
||||||
|
{formatTime()}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Countdown;
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Container, ContainerProps } from "@mantine/core";
|
import { Container, ContainerProps, Box } from "@mantine/core";
|
||||||
import useRouterConfig from "@/features/core/hooks/use-router-config";
|
import useRouterConfig from "@/features/core/hooks/use-router-config";
|
||||||
|
import BackButton from "@/features/core/components/back-button";
|
||||||
|
import SettingsButton from "@/features/core/components/settings-button";
|
||||||
|
|
||||||
interface PageProps extends ContainerProps, React.PropsWithChildren {
|
interface PageProps extends ContainerProps, React.PropsWithChildren {
|
||||||
noPadding?: boolean;
|
noPadding?: boolean;
|
||||||
@@ -16,8 +18,15 @@ const Page = ({ children, noPadding, fullWidth, ...props }: PageProps) => {
|
|||||||
m={0}
|
m={0}
|
||||||
maw={fullWidth ? '100%' : 600}
|
maw={fullWidth ? '100%' : 600}
|
||||||
mx="auto"
|
mx="auto"
|
||||||
|
pos="relative"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
{header.collapsed && header.withBackButton && (
|
||||||
|
<BackButton offsetY={0} />
|
||||||
|
)}
|
||||||
|
{header.collapsed && header.settingsLink && (
|
||||||
|
<SettingsButton to={header.settingsLink} offsetY={0} />
|
||||||
|
)}
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Box, Container, useComputedColorScheme } from "@mantine/core";
|
import { Box, Container, Flex, Loader, useComputedColorScheme } from "@mantine/core";
|
||||||
import { PropsWithChildren, useEffect } from "react";
|
import { PropsWithChildren, Suspense, useEffect } from "react";
|
||||||
import { Drawer as VaulDrawer } from "vaul";
|
import { Drawer as VaulDrawer } from "vaul";
|
||||||
import { useMantineColorScheme } from "@mantine/core";
|
|
||||||
import styles from "./styles.module.css";
|
import styles from "./styles.module.css";
|
||||||
|
import FullScreenLoader from "../full-screen-loader";
|
||||||
|
|
||||||
interface DrawerProps extends PropsWithChildren {
|
interface DrawerProps extends PropsWithChildren {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -76,7 +76,13 @@ const Drawer: React.FC<DrawerProps> = ({
|
|||||||
/>
|
/>
|
||||||
<Container mah="fit-content" mx="auto" maw="28rem" px={0}>
|
<Container mah="fit-content" mx="auto" maw="28rem" px={0}>
|
||||||
<VaulDrawer.Title>{title}</VaulDrawer.Title>
|
<VaulDrawer.Title>{title}</VaulDrawer.Title>
|
||||||
{children}
|
<Suspense fallback={
|
||||||
|
<Flex justify='center' align='center' w='100%' h={400}>
|
||||||
|
<Loader size='lg' />
|
||||||
|
</Flex>
|
||||||
|
}>
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
</Container>
|
</Container>
|
||||||
</Container>
|
</Container>
|
||||||
</VaulDrawer.Content>
|
</VaulDrawer.Content>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Modal as MantineModal, Title } from "@mantine/core";
|
import { Flex, Loader, Modal as MantineModal, Title } from "@mantine/core";
|
||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren, Suspense } from "react";
|
||||||
|
|
||||||
interface ModalProps extends PropsWithChildren {
|
interface ModalProps extends PropsWithChildren {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -13,7 +13,15 @@ const Modal: React.FC<ModalProps> = ({ title, children, opened, onClose }) => (
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={<Title order={3}>{title}</Title>}
|
title={<Title order={3}>{title}</Title>}
|
||||||
>
|
>
|
||||||
{children}
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Flex justify="center" align="center" w="100%" h={400}>
|
||||||
|
<Loader size="lg" />
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
</MantineModal>
|
</MantineModal>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface SlidePanelProps {
|
|||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
submitText?: string;
|
submitText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
|
cancelColor?: string;
|
||||||
maxHeight?: string;
|
maxHeight?: string;
|
||||||
formProps?: Record<string, any>;
|
formProps?: Record<string, any>;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -28,6 +29,7 @@ const SlidePanel = ({
|
|||||||
onCancel,
|
onCancel,
|
||||||
submitText = "Submit",
|
submitText = "Submit",
|
||||||
cancelText = "Cancel",
|
cancelText = "Cancel",
|
||||||
|
cancelColor = "red",
|
||||||
maxHeight = "70vh",
|
maxHeight = "70vh",
|
||||||
formProps = {},
|
formProps = {},
|
||||||
loading = false,
|
loading = false,
|
||||||
@@ -114,7 +116,7 @@ const SlidePanel = ({
|
|||||||
{onCancel && (
|
{onCancel && (
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color={cancelColor}
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ function SwipeableTabs({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box style={{ touchAction: "pan-y" }}>
|
||||||
<Box
|
<Box
|
||||||
ref={setRootRef}
|
ref={setRootRef}
|
||||||
pos="sticky"
|
pos="sticky"
|
||||||
@@ -137,7 +137,7 @@ function SwipeableTabs({
|
|||||||
onClick={() => changeTab(index)}
|
onClick={() => changeTab(index)}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: "var(--mantine-spacing-sm) var(--mantine-spacing-md)",
|
padding: "var(--mantine-spacing-sm) var(--mantine-spacing-xs)",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
color:
|
color:
|
||||||
activeTab === index
|
activeTab === index
|
||||||
@@ -155,7 +155,7 @@ function SwipeableTabs({
|
|||||||
component="span"
|
component="span"
|
||||||
style={{
|
style={{
|
||||||
display: "inline-block",
|
display: "inline-block",
|
||||||
paddingInline: "1rem",
|
paddingInline: "0.5rem",
|
||||||
paddingBottom: "0.25rem",
|
paddingBottom: "0.25rem",
|
||||||
}}
|
}}
|
||||||
ref={setControlRef(index)}
|
ref={setControlRef(index)}
|
||||||
@@ -176,6 +176,7 @@ function SwipeableTabs({
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
height: carouselHeight === "auto" ? "auto" : `${carouselHeight}px`,
|
height: carouselHeight === "auto" ? "auto" : `${carouselHeight}px`,
|
||||||
transition: "height 300ms ease",
|
transition: "height 300ms ease",
|
||||||
|
touchAction: "pan-y",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tabs.map((tab, index) => (
|
{tabs.map((tab, index) => (
|
||||||
|
|||||||
429
src/contexts/spotify-context.tsx
Normal file
429
src/contexts/spotify-context.tsx
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
import { createContext, useCallback, useEffect, useState, PropsWithChildren } from 'react';
|
||||||
|
import { SpotifyAuth } from '@/lib/spotify/auth';
|
||||||
|
import { useAuth } from './auth-context';
|
||||||
|
import type {
|
||||||
|
SpotifyContextType,
|
||||||
|
SpotifyAuthState,
|
||||||
|
SpotifyDevice,
|
||||||
|
SpotifyPlaybackState,
|
||||||
|
SpotifyPlaybackSnapshot,
|
||||||
|
SpotifyTrack,
|
||||||
|
} from '@/lib/spotify/types';
|
||||||
|
|
||||||
|
const defaultSpotifyState: SpotifyAuthState = {
|
||||||
|
isAuthenticated: false,
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
expiresAt: null,
|
||||||
|
scopes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpotifyContext = createContext<SpotifyContextType | null>(null);
|
||||||
|
|
||||||
|
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
|
const { roles } = useAuth();
|
||||||
|
const isAdmin = roles?.includes('Admin') || false;
|
||||||
|
|
||||||
|
const [authState, setAuthState] = useState<SpotifyAuthState>(defaultSpotifyState);
|
||||||
|
|
||||||
|
const [currentTrack, setCurrentTrack] = useState<SpotifyTrack | null>(null);
|
||||||
|
const [playbackState, setPlaybackState] = useState<SpotifyPlaybackState | null>(null);
|
||||||
|
|
||||||
|
const [devices, setDevices] = useState<SpotifyDevice[]>([]);
|
||||||
|
const [activeDevice, setActiveDeviceState] = useState<SpotifyDevice | null>(null);
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [capturedState, setCapturedState] = useState<SpotifyPlaybackSnapshot | null>(null);
|
||||||
|
const [isCaptureLoading, setIsCaptureLoading] = useState(false);
|
||||||
|
const [isResumeLoading, setIsResumeLoading] = useState(false);
|
||||||
|
|
||||||
|
const spotifyAuth = new SpotifyAuth(
|
||||||
|
import.meta.env.VITE_SPOTIFY_CLIENT_ID!,
|
||||||
|
import.meta.env.VITE_SPOTIFY_REDIRECT_URI!
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAdmin) {
|
||||||
|
checkExistingAuth();
|
||||||
|
handleOAuthCallback();
|
||||||
|
}
|
||||||
|
}, [isAdmin]);
|
||||||
|
|
||||||
|
const checkExistingAuth = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/spotify/token', {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.access_token) {
|
||||||
|
setAuthState({
|
||||||
|
isAuthenticated: true,
|
||||||
|
accessToken: data.access_token,
|
||||||
|
refreshToken: data.refresh_token,
|
||||||
|
expiresAt: Date.now() + (3600 * 1000),
|
||||||
|
scopes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([getDevices(), refreshPlaybackState()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check existing auth:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOAuthCallback = () => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const spotifyAuth = urlParams.get('spotify_auth');
|
||||||
|
const error = urlParams.get('spotify_error');
|
||||||
|
const details = urlParams.get('details');
|
||||||
|
|
||||||
|
if (spotifyAuth === 'success') {
|
||||||
|
checkExistingAuth();
|
||||||
|
|
||||||
|
const newUrl = new URL(window.location.href);
|
||||||
|
newUrl.searchParams.delete('spotify_auth');
|
||||||
|
newUrl.searchParams.delete('state');
|
||||||
|
window.history.replaceState({}, '', newUrl.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
let errorMessage = `Authentication failed: ${error}`;
|
||||||
|
if (details) {
|
||||||
|
errorMessage += ` - ${decodeURIComponent(details)}`;
|
||||||
|
}
|
||||||
|
setError(errorMessage);
|
||||||
|
|
||||||
|
console.error('Spotify OAuth Error:', { error, details });
|
||||||
|
|
||||||
|
const newUrl = new URL(window.location.href);
|
||||||
|
newUrl.searchParams.delete('spotify_error');
|
||||||
|
newUrl.searchParams.delete('details');
|
||||||
|
window.history.replaceState({}, '', newUrl.toString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = useCallback(() => {
|
||||||
|
if (!isAdmin) return;
|
||||||
|
spotifyAuth.startAuthFlow(window.location.pathname);
|
||||||
|
}, [isAdmin, spotifyAuth]);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
setAuthState(defaultSpotifyState);
|
||||||
|
setCurrentTrack(null);
|
||||||
|
setPlaybackState(null);
|
||||||
|
setDevices([]);
|
||||||
|
setActiveDeviceState(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
document.cookie = 'spotify_access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
document.cookie = 'spotify_refresh_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const makeSpotifyRequest = async (endpoint: string, options: RequestInit = {}) => {
|
||||||
|
const response = await fetch(`/api/spotify/${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = 'Request failed';
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
errorMessage = errorData.error || errorMessage;
|
||||||
|
} catch {
|
||||||
|
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204 || response.headers.get('content-length') === '0') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
console.warn(`Non-JSON response from ${endpoint}:`, contentType);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to parse JSON response from ${endpoint}:`, error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const play = useCallback(async (deviceId?: string) => {
|
||||||
|
if (!authState.isAuthenticated) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await makeSpotifyRequest('playback', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ action: 'play', deviceId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(refreshPlaybackState, 500);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && !error.message.includes('JSON')) {
|
||||||
|
setError(error.message);
|
||||||
|
}
|
||||||
|
console.warn('Playback action completed with warning:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [authState.isAuthenticated]);
|
||||||
|
|
||||||
|
const pause = useCallback(async () => {
|
||||||
|
if (!authState.isAuthenticated) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await makeSpotifyRequest('playback', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ action: 'pause' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(refreshPlaybackState, 500);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && !error.message.includes('JSON')) {
|
||||||
|
setError(error.message);
|
||||||
|
}
|
||||||
|
console.warn('Playback action completed with warning:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [authState.isAuthenticated]);
|
||||||
|
|
||||||
|
const skipNext = useCallback(async () => {
|
||||||
|
if (!authState.isAuthenticated) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await makeSpotifyRequest('playback', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ action: 'next' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(refreshPlaybackState, 500);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && !error.message.includes('JSON')) {
|
||||||
|
setError(error.message);
|
||||||
|
}
|
||||||
|
console.warn('Playback action completed with warning:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [authState.isAuthenticated]);
|
||||||
|
|
||||||
|
const skipPrevious = useCallback(async () => {
|
||||||
|
if (!authState.isAuthenticated) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await makeSpotifyRequest('playback', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ action: 'previous' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(refreshPlaybackState, 500);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && !error.message.includes('JSON')) {
|
||||||
|
setError(error.message);
|
||||||
|
}
|
||||||
|
console.warn('Playback action completed with warning:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [authState.isAuthenticated]);
|
||||||
|
|
||||||
|
const setVolume = useCallback(async (volumePercent: number) => {
|
||||||
|
if (!authState.isAuthenticated) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await makeSpotifyRequest('playback', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ action: 'volume', volumePercent }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setError(error instanceof Error ? error.message : 'Failed to set volume');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [authState.isAuthenticated]);
|
||||||
|
|
||||||
|
const getDevices = useCallback(async () => {
|
||||||
|
if (!authState.isAuthenticated) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await makeSpotifyRequest('playback?type=devices');
|
||||||
|
setDevices(data.devices || []);
|
||||||
|
|
||||||
|
const active = data.devices?.find((d: SpotifyDevice) => d.is_active);
|
||||||
|
if (active) {
|
||||||
|
setActiveDeviceState(active);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError(error instanceof Error ? error.message : 'Failed to get devices');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [authState.isAuthenticated]);
|
||||||
|
|
||||||
|
const setActiveDevice = useCallback(async (deviceId: string) => {
|
||||||
|
if (!authState.isAuthenticated) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await makeSpotifyRequest('playback', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ action: 'transfer', deviceId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const device = devices.find(d => d.id === deviceId);
|
||||||
|
if (device) {
|
||||||
|
setActiveDeviceState(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(getDevices, 1000);
|
||||||
|
} catch (error) {
|
||||||
|
setError(error instanceof Error ? error.message : 'Failed to set active device');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [authState.isAuthenticated, devices]);
|
||||||
|
|
||||||
|
const refreshPlaybackState = useCallback(async () => {
|
||||||
|
if (!authState.isAuthenticated) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await makeSpotifyRequest('playback?type=state');
|
||||||
|
const state = data.playbackState;
|
||||||
|
|
||||||
|
setPlaybackState(state);
|
||||||
|
setCurrentTrack(state?.item || null);
|
||||||
|
|
||||||
|
if (state?.device) {
|
||||||
|
setActiveDeviceState(state.device);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to refresh playback state:', error);
|
||||||
|
}
|
||||||
|
}, [authState.isAuthenticated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authState.isAuthenticated) return;
|
||||||
|
|
||||||
|
const interval = setInterval(refreshPlaybackState, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [authState.isAuthenticated, refreshPlaybackState]);
|
||||||
|
|
||||||
|
const capturePlaybackState = useCallback(async () => {
|
||||||
|
if (!authState.isAuthenticated) return;
|
||||||
|
|
||||||
|
setIsCaptureLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await makeSpotifyRequest('capture', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.snapshot) {
|
||||||
|
setCapturedState(response.snapshot);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError(error instanceof Error ? error.message : 'Failed to capture playback state');
|
||||||
|
} finally {
|
||||||
|
setIsCaptureLoading(false);
|
||||||
|
}
|
||||||
|
}, [authState.isAuthenticated]);
|
||||||
|
|
||||||
|
const resumePlaybackState = useCallback(async () => {
|
||||||
|
if (!authState.isAuthenticated || !capturedState) return;
|
||||||
|
|
||||||
|
setIsResumeLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await makeSpotifyRequest('resume', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ snapshot: capturedState }),
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(refreshPlaybackState, 1000);
|
||||||
|
} catch (error) {
|
||||||
|
setError(error instanceof Error ? error.message : 'Failed to resume playback state');
|
||||||
|
} finally {
|
||||||
|
setIsResumeLoading(false);
|
||||||
|
}
|
||||||
|
}, [authState.isAuthenticated, capturedState, refreshPlaybackState]);
|
||||||
|
|
||||||
|
const clearCapturedState = useCallback(() => {
|
||||||
|
setCapturedState(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const contextValue: SpotifyContextType = {
|
||||||
|
...authState,
|
||||||
|
currentTrack,
|
||||||
|
playbackState,
|
||||||
|
devices,
|
||||||
|
activeDevice,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
// Capture/Resume state
|
||||||
|
capturedState,
|
||||||
|
isCaptureLoading,
|
||||||
|
isResumeLoading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
skipNext,
|
||||||
|
skipPrevious,
|
||||||
|
setVolume,
|
||||||
|
getDevices,
|
||||||
|
setActiveDevice,
|
||||||
|
refreshPlaybackState,
|
||||||
|
// Capture/Resume methods
|
||||||
|
capturePlaybackState,
|
||||||
|
resumePlaybackState,
|
||||||
|
clearCapturedState,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpotifyContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</SpotifyContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -19,7 +19,7 @@ const AdminPage = () => {
|
|||||||
label="Open Pocketbase"
|
label="Open Pocketbase"
|
||||||
Icon={DatabaseIcon}
|
Icon={DatabaseIcon}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.location.replace(import.meta.env.VITE_POCKETBASE_URL! + "/_/")
|
window.location.replace(process.env.POCKETBASE_URL! + "/_/")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ListLink
|
<ListLink
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { Text, ScrollArea } from "@mantine/core";
|
import { Text, ScrollArea } from "@mantine/core";
|
||||||
import { MatchCard } from "./match-card";
|
|
||||||
import { BracketData } from "../types";
|
import { BracketData } from "../types";
|
||||||
import { Bracket } from "./bracket";
|
import { Bracket } from "./bracket";
|
||||||
import useAppShellHeight from "@/hooks/use-appshell-height";
|
import useAppShellHeight from "@/hooks/use-appshell-height";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
|
||||||
interface BracketViewProps {
|
interface BracketViewProps {
|
||||||
bracket: BracketData;
|
bracket: BracketData;
|
||||||
onAnnounce?: (teamOne: any, teamTwo: any) => void;
|
showControls?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const BracketView: React.FC<BracketViewProps> = ({ bracket, onAnnounce }) => {
|
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls }) => {
|
||||||
const height = useAppShellHeight();
|
const height = useAppShellHeight();
|
||||||
const orders = useMemo(() => {
|
const orders = useMemo(() => {
|
||||||
const map: Record<number, number> = {};
|
const map: Record<number, number> = {};
|
||||||
@@ -32,14 +32,14 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, onAnnounce }) => {
|
|||||||
<Text fw={600} size="md" m={16}>
|
<Text fw={600} size="md" m={16}>
|
||||||
Winners Bracket
|
Winners Bracket
|
||||||
</Text>
|
</Text>
|
||||||
<Bracket rounds={bracket.winners} orders={orders} onAnnounce={onAnnounce} />
|
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} />
|
||||||
</div>
|
</div>
|
||||||
{bracket.losers && (
|
{bracket.losers && (
|
||||||
<div>
|
<div>
|
||||||
<Text fw={600} size="md" m={16}>
|
<Text fw={600} size="md" m={16}>
|
||||||
Losers Bracket
|
Losers Bracket
|
||||||
</Text>
|
</Text>
|
||||||
<Bracket rounds={bracket.losers} orders={orders} onAnnounce={onAnnounce} />
|
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ import { MatchCard } from "./match-card";
|
|||||||
interface BracketProps {
|
interface BracketProps {
|
||||||
rounds: Match[][];
|
rounds: Match[][];
|
||||||
orders: Record<number, number>;
|
orders: Record<number, number>;
|
||||||
onAnnounce?: (teamOne: any, teamTwo: any) => void;
|
showControls?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Bracket: React.FC<BracketProps> = ({
|
export const Bracket: React.FC<BracketProps> = ({
|
||||||
rounds,
|
rounds,
|
||||||
orders,
|
orders,
|
||||||
onAnnounce,
|
showControls,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Flex direction="row" gap={24} justify="left" p="xl">
|
<Flex direction="row" gap={24} justify="left">
|
||||||
{rounds.map((round, roundIndex) => (
|
{rounds.map((round, roundIndex) => (
|
||||||
<Flex
|
<Flex
|
||||||
key={roundIndex}
|
key={roundIndex}
|
||||||
@@ -23,20 +23,21 @@ export const Bracket: React.FC<BracketProps> = ({
|
|||||||
pos="relative"
|
pos="relative"
|
||||||
gap={24}
|
gap={24}
|
||||||
justify="space-around"
|
justify="space-around"
|
||||||
|
p={24}
|
||||||
>
|
>
|
||||||
{round
|
{round.map((match) =>
|
||||||
.filter((match) => !match.bye)
|
match.bye ? (
|
||||||
.map((match) => {
|
<div key={match.lid}></div>
|
||||||
return (
|
) : (
|
||||||
<div key={match.lid}>
|
<div key={match.lid}>
|
||||||
<MatchCard
|
<MatchCard
|
||||||
match={match}
|
match={match}
|
||||||
orders={orders}
|
orders={orders}
|
||||||
onAnnounce={onAnnounce}
|
showControls={showControls}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
})}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -1,26 +1,41 @@
|
|||||||
import { ActionIcon, Card, Flex, Text } from "@mantine/core";
|
import { ActionIcon, Card, Flex, Text, Stack, Indicator } from "@mantine/core";
|
||||||
import { PlayIcon } from "@phosphor-icons/react";
|
import { PlayIcon, PencilIcon, SpeakerHighIcon } from "@phosphor-icons/react";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { MatchSlot } from "./match-slot";
|
import { MatchSlot } from "./match-slot";
|
||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
import { MatchForm } from "./match-form";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||||
|
import { endMatch, startMatch } from "@/features/matches/server";
|
||||||
|
import { tournamentKeys } from "@/features/tournaments/queries";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
interface MatchCardProps {
|
interface MatchCardProps {
|
||||||
match: Match;
|
match: Match;
|
||||||
orders: Record<number, number>;
|
orders: Record<number, number>;
|
||||||
onAnnounce?: (teamOne: any, teamTwo: any) => void;
|
showControls?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MatchCard: React.FC<MatchCardProps> = ({
|
export const MatchCard: React.FC<MatchCardProps> = ({
|
||||||
match,
|
match,
|
||||||
orders,
|
orders,
|
||||||
onAnnounce,
|
showControls,
|
||||||
}) => {
|
}) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const editSheet = useSheet();
|
||||||
const homeSlot = useMemo(
|
const homeSlot = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
from: orders[match.home_from_lid],
|
from: orders[match.home_from_lid],
|
||||||
from_loser: match.home_from_loser,
|
from_loser: match.home_from_loser,
|
||||||
team: match.home,
|
team: match.home,
|
||||||
seed: match.home_seed,
|
seed: match.home_seed,
|
||||||
|
cups: match.status === "ended" ? match.home_cups : undefined,
|
||||||
|
isWinner:
|
||||||
|
match.status === "ended" &&
|
||||||
|
match.home_cups !== undefined &&
|
||||||
|
match.away_cups !== undefined &&
|
||||||
|
match.home_cups > match.away_cups,
|
||||||
}),
|
}),
|
||||||
[match]
|
[match]
|
||||||
);
|
);
|
||||||
@@ -30,69 +45,198 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
from_loser: match.away_from_loser,
|
from_loser: match.away_from_loser,
|
||||||
team: match.away,
|
team: match.away,
|
||||||
seed: match.away_seed,
|
seed: match.away_seed,
|
||||||
|
cups: match.status === "ended" ? match.away_cups : undefined,
|
||||||
|
isWinner:
|
||||||
|
match.status === "ended" &&
|
||||||
|
match.away_cups !== undefined &&
|
||||||
|
match.home_cups !== undefined &&
|
||||||
|
match.away_cups > match.home_cups,
|
||||||
}),
|
}),
|
||||||
[match]
|
[match]
|
||||||
);
|
);
|
||||||
|
|
||||||
const showAnnounce = useMemo(
|
const showToolbar = useMemo(
|
||||||
() => onAnnounce && match.home && match.away,
|
() => match.status === "ready" && showControls,
|
||||||
[onAnnounce, match.home, match.away]
|
[match.status, showControls]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAnnounce = useCallback(
|
const showEditButton = useMemo(
|
||||||
() => onAnnounce?.(match.home, match.away),
|
() => showControls && match.status === "started",
|
||||||
[match.home, match.away]
|
[showControls, match.status]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const start = useServerMutation({
|
||||||
|
mutationFn: startMatch,
|
||||||
|
successMessage: "Match started!",
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: tournamentKeys.details(match.tournament.id),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const end = useServerMutation({
|
||||||
|
mutationFn: endMatch,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: tournamentKeys.details(match.tournament.id),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleStart = useCallback(async () => {
|
||||||
|
await start.mutate({
|
||||||
|
data: match.id,
|
||||||
|
});
|
||||||
|
}, [match]);
|
||||||
|
|
||||||
|
const handleFormSubmit = useCallback(
|
||||||
|
async (data: {
|
||||||
|
home_cups: number;
|
||||||
|
away_cups: number;
|
||||||
|
ot_count: number;
|
||||||
|
}) => {
|
||||||
|
await end.mutate({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
matchId: match.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
editSheet.close();
|
||||||
|
},
|
||||||
|
[match.id, editSheet]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSpeakerClick = useCallback(() => {
|
||||||
|
if ("speechSynthesis" in window && match.home?.name && match.away?.name) {
|
||||||
|
const utterance = new SpeechSynthesisUtterance(
|
||||||
|
`${match.home.name} vs. ${match.away.name}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const voices = window.speechSynthesis.getVoices();
|
||||||
|
|
||||||
|
const preferredVoice =
|
||||||
|
voices.find(
|
||||||
|
(voice) =>
|
||||||
|
voice.lang.startsWith("en") && voice.name.includes("Daniel")
|
||||||
|
) ||
|
||||||
|
voices.find((voice) => voice.lang.startsWith("en") && voice.default);
|
||||||
|
|
||||||
|
if (preferredVoice) {
|
||||||
|
utterance.voice = preferredVoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
utterance.rate = 0.9;
|
||||||
|
utterance.volume = 0.8;
|
||||||
|
utterance.pitch = 1.0;
|
||||||
|
|
||||||
|
window.speechSynthesis.speak(utterance);
|
||||||
|
}
|
||||||
|
}, [match.home?.name, match.away?.name]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="row" align="center" justify="end" gap={8}>
|
<Flex direction="row" align="center" justify="end" gap={8}>
|
||||||
<Text c="dimmed" fw="bolder">
|
<Text c="dimmed" fw="bolder">
|
||||||
{match.order}
|
{match.order}
|
||||||
</Text>
|
</Text>
|
||||||
<Card
|
<Flex align="stretch">
|
||||||
withBorder
|
<Indicator
|
||||||
pos="relative"
|
inline
|
||||||
w={200}
|
processing={match.status === "started"}
|
||||||
style={{ overflow: "visible" }}
|
color="red"
|
||||||
data-match-lid={match.lid}
|
size={12}
|
||||||
>
|
disabled={match.status !== "started" || showEditButton}
|
||||||
<Card.Section withBorder p={0}>
|
>
|
||||||
<MatchSlot {...homeSlot} />
|
<Card
|
||||||
</Card.Section>
|
w={showToolbar || showEditButton ? 200 : 220}
|
||||||
|
withBorder
|
||||||
<Card.Section p={0} mb={-16}>
|
pos="relative"
|
||||||
<MatchSlot {...awaySlot} />
|
style={{ overflow: "visible" }}
|
||||||
</Card.Section>
|
data-match-lid={match.lid}
|
||||||
|
|
||||||
{match.reset && (
|
|
||||||
<Text
|
|
||||||
pos="absolute"
|
|
||||||
top={-20}
|
|
||||||
left={8}
|
|
||||||
size="xs"
|
|
||||||
c="dimmed"
|
|
||||||
fw="bold"
|
|
||||||
>
|
>
|
||||||
* If necessary
|
<Card.Section withBorder p={0}>
|
||||||
</Text>
|
<MatchSlot {...homeSlot} />
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
|
<Card.Section p={0} mb={-16}>
|
||||||
|
<MatchSlot {...awaySlot} />
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
|
{match.reset && (
|
||||||
|
<Text
|
||||||
|
pos="absolute"
|
||||||
|
top={-20}
|
||||||
|
left={8}
|
||||||
|
size="xs"
|
||||||
|
c="dimmed"
|
||||||
|
fw="bold"
|
||||||
|
>
|
||||||
|
* If necessary
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showControls && (
|
||||||
|
<ActionIcon
|
||||||
|
pos="absolute"
|
||||||
|
bottom={-2}
|
||||||
|
left={-26}
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={handleSpeakerClick}
|
||||||
|
>
|
||||||
|
<SpeakerHighIcon size={12} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Indicator>
|
||||||
|
|
||||||
|
{showToolbar && (
|
||||||
|
<Flex direction="column" justify="center" align="center">
|
||||||
|
<ActionIcon
|
||||||
|
color="green"
|
||||||
|
onClick={handleStart}
|
||||||
|
size="sm"
|
||||||
|
h="100%"
|
||||||
|
radius="sm"
|
||||||
|
ml={-4}
|
||||||
|
style={{
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlayIcon size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Flex>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showAnnounce && (
|
{showEditButton && (
|
||||||
<ActionIcon
|
<Flex direction="column" justify="center" align="center">
|
||||||
pos="absolute"
|
<ActionIcon
|
||||||
variant="filled"
|
color="blue"
|
||||||
color="green"
|
onClick={editSheet.open}
|
||||||
top={-20}
|
size="sm"
|
||||||
right={-12}
|
h="100%"
|
||||||
onClick={handleAnnounce}
|
radius="sm"
|
||||||
bd="none"
|
ml={-4}
|
||||||
style={{ boxShadow: "none" }}
|
style={{
|
||||||
size="xs"
|
borderTopLeftRadius: 0,
|
||||||
>
|
borderBottomLeftRadius: 0,
|
||||||
<PlayIcon size={12} />
|
}}
|
||||||
</ActionIcon>
|
>
|
||||||
|
<PencilIcon size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Flex>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Flex>
|
||||||
|
|
||||||
|
<Sheet title="Edit Match" {...editSheet.props}>
|
||||||
|
<MatchForm
|
||||||
|
match={match}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
onCancel={editSheet.close}
|
||||||
|
/>
|
||||||
|
</Sheet>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
156
src/features/bracket/components/match-form.tsx
Normal file
156
src/features/bracket/components/match-form.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { Button, TextInput, Stack, Group, Text, Flex, Divider, NumberInput } from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
|
||||||
|
interface MatchFormProps {
|
||||||
|
match: Match;
|
||||||
|
onSubmit: (data: {
|
||||||
|
home_cups: number;
|
||||||
|
away_cups: number;
|
||||||
|
ot_count: number;
|
||||||
|
}) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MatchForm: React.FC<MatchFormProps> = ({
|
||||||
|
match,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}) => {
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
home_cups: match.home_cups || 10,
|
||||||
|
away_cups: match.away_cups || 10,
|
||||||
|
ot_count: match.ot_count || 0,
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
home_cups: (value, values) => {
|
||||||
|
if (value === null || value === undefined) return "Home cups is required";
|
||||||
|
if (values.ot_count > 0) return null;
|
||||||
|
|
||||||
|
const homeCups = Number(value);
|
||||||
|
const awayCups = Number(values.away_cups);
|
||||||
|
|
||||||
|
if (homeCups !== 10 && awayCups !== 10) {
|
||||||
|
return "At least one team must have 10 cups";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both teams can't have 10 cups
|
||||||
|
if (homeCups === 10 && awayCups === 10) {
|
||||||
|
return "Both teams cannot have 10 cups";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
away_cups: (value, values) => {
|
||||||
|
if (value === null || value === undefined) return "Away cups is required";
|
||||||
|
if (values.ot_count > 0) return null;
|
||||||
|
|
||||||
|
const awayCups = Number(value);
|
||||||
|
const homeCups = Number(values.home_cups);
|
||||||
|
|
||||||
|
if (homeCups !== 10 && awayCups !== 10) {
|
||||||
|
return "At least one team must have 10 cups";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (homeCups === 10 && awayCups === 10) {
|
||||||
|
return "Both teams cannot have 10 cups";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
ot_count: (value) =>
|
||||||
|
value === null || value === undefined
|
||||||
|
? "Overtime count is required"
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
transformValues: (values) => ({
|
||||||
|
home_cups: Number(values.home_cups),
|
||||||
|
away_cups: Number(values.away_cups),
|
||||||
|
ot_count: Number(values.ot_count),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = form.onSubmit(() => {
|
||||||
|
const transformedValues = form.getTransformedValues();
|
||||||
|
onSubmit(transformedValues);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Flex mx='auto' direction='column' gap='md' miw={250}>
|
||||||
|
|
||||||
|
<Group gap="xs">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{match.home?.name} Cups
|
||||||
|
</Text>
|
||||||
|
{
|
||||||
|
match.home?.players.map(p => (<Text size='xs' c='dimmed'>
|
||||||
|
{p.first_name} {p.last_name}
|
||||||
|
</Text>))
|
||||||
|
}
|
||||||
|
</Stack>
|
||||||
|
<NumberInput
|
||||||
|
ml='auto'
|
||||||
|
min={0}
|
||||||
|
w={70}
|
||||||
|
ta="center"
|
||||||
|
key={form.key("home_cups")}
|
||||||
|
{...form.getInputProps("home_cups")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Group gap="xs">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{match.away?.name} Cups
|
||||||
|
</Text>
|
||||||
|
{
|
||||||
|
match.away?.players.map(p => (<Text size='xs' c='dimmed'>
|
||||||
|
{p.first_name} {p.last_name}
|
||||||
|
</Text>))
|
||||||
|
}
|
||||||
|
</Stack>
|
||||||
|
<NumberInput
|
||||||
|
ml='auto'
|
||||||
|
ta="center"
|
||||||
|
w={70}
|
||||||
|
min={0}
|
||||||
|
key={form.key("away_cups")}
|
||||||
|
{...form.getInputProps("away_cups")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
OT Count
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
ml='auto'
|
||||||
|
ta="center"
|
||||||
|
w={50}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
key={form.key("ot_count")}
|
||||||
|
{...form.getInputProps("ot_count")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Stack mt="md">
|
||||||
|
<Button type="submit">Update Match</Button>
|
||||||
|
<Button variant="subtle" color="red" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Flex, Text } from "@mantine/core";
|
import { Flex, Text } from "@mantine/core";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { CrownIcon } from "@phosphor-icons/react";
|
||||||
import { SeedBadge } from "./seed-badge";
|
import { SeedBadge } from "./seed-badge";
|
||||||
import { TeamInfo } from "@/features/teams/types";
|
import { TeamInfo } from "@/features/teams/types";
|
||||||
|
|
||||||
@@ -8,6 +9,8 @@ interface MatchSlotProps {
|
|||||||
from_loser?: boolean;
|
from_loser?: boolean;
|
||||||
team?: TeamInfo;
|
team?: TeamInfo;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
|
cups?: number;
|
||||||
|
isWinner?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MatchSlot: React.FC<MatchSlotProps> = ({
|
export const MatchSlot: React.FC<MatchSlotProps> = ({
|
||||||
@@ -15,21 +18,51 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
|||||||
from_loser,
|
from_loser,
|
||||||
team,
|
team,
|
||||||
seed,
|
seed,
|
||||||
|
cups,
|
||||||
|
isWinner
|
||||||
}) => (
|
}) => (
|
||||||
<Flex align="stretch">
|
<Flex align="stretch">
|
||||||
{seed && <SeedBadge seed={seed} />}
|
{(seed && seed > 0) ? <SeedBadge seed={seed} /> : undefined}
|
||||||
<Flex p="4px 8px">
|
<Flex p="4px 8px" w='100%' align="center">
|
||||||
{team ? (
|
<Flex align="center" gap={4} flex={1}>
|
||||||
<Text size="xs">{team.name}</Text>
|
{team ? (
|
||||||
) : from ? (
|
<>
|
||||||
<Text c="dimmed" size="xs">
|
<Text
|
||||||
{from_loser ? "Loser" : "Winner"} of Match {from}
|
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
|
||||||
</Text>
|
truncate
|
||||||
) : (
|
style={{ minWidth: 0, flex: 1 }}
|
||||||
<Text c="dimmed" size="xs">
|
>
|
||||||
TBD
|
{team.name}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
{isWinner && (
|
||||||
|
<CrownIcon
|
||||||
|
size={14}
|
||||||
|
weight="fill"
|
||||||
|
style={{
|
||||||
|
color: 'gold',
|
||||||
|
marginLeft: '2px',
|
||||||
|
marginTop: '-1px',
|
||||||
|
filter: 'drop-shadow(0 1px 1px rgba(0,0,0,0.3))',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : from ? (
|
||||||
|
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
{from_loser ? "Loser" : "Winner"} of Match {from}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
TBD
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
{
|
||||||
|
cups !== undefined ? (
|
||||||
|
<Text ta='center' w={15} fw="800" ml={4} size="xs">{cups}</Text>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Imports saved json dumps of bracket generation from a python script that I didn't prioritize converting to TS
|
* Imports saved json dumps of bracket generation from a python script that I didn't prioritize converting to TS
|
||||||
*/
|
*/
|
||||||
|
import b10 from "../../../../brackets/10.json";
|
||||||
|
import b11 from "../../../../brackets/11.json";
|
||||||
import b12 from "../../../../brackets/12.json";
|
import b12 from "../../../../brackets/12.json";
|
||||||
import b13 from "../../../../brackets/13.json";
|
import b13 from "../../../../brackets/13.json";
|
||||||
import b14 from "../../../../brackets/14.json";
|
import b14 from "../../../../brackets/14.json";
|
||||||
@@ -12,6 +14,8 @@ import b19 from "../../../../brackets/19.json";
|
|||||||
import b20 from "../../../../brackets/20.json";
|
import b20 from "../../../../brackets/20.json";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
10: b10,
|
||||||
|
11: b11,
|
||||||
12: b12,
|
12: b12,
|
||||||
13: b13,
|
13: b13,
|
||||||
14: b14,
|
14: b14,
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ const BackButton = ({ offsetY }: BackButtonProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
style={{ cursor: 'pointer', zIndex: 1000, transform: `translateY(-${offsetY}px)` }}
|
style={{ cursor: 'pointer', zIndex: 1000 }}
|
||||||
onClick={() => router.history.back()}
|
onClick={() => router.history.back()}
|
||||||
pos='absolute'
|
pos='absolute'
|
||||||
left={{ base: 0, sm: 100, md: 200, lg: 300 }}
|
left={16}
|
||||||
m={20}
|
top={0}
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon weight='bold' size={20} />
|
<ArrowLeftIcon weight='bold' size={20} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -13,15 +13,11 @@ const Header = ({ withBackButton, settingsLink, collapsed, title, scrollPosition
|
|||||||
}, [collapsed, scrollPosition.y]);
|
}, [collapsed, scrollPosition.y]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
|
||||||
{settingsLink && <SettingsButton to={settingsLink} offsetY={offsetY} />}
|
<Flex justify='center' align='center' h='100%' px='md'>
|
||||||
{withBackButton && <BackButton offsetY={offsetY} />}
|
<Title order={2}>{title}</Title>
|
||||||
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
|
</Flex>
|
||||||
<Flex justify='center' align='center' h='100%' px='md'>
|
</AppShell.Header>
|
||||||
<Title order={2}>{title}</Title>
|
|
||||||
</Flex>
|
|
||||||
</AppShell.Header>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
mah='100%'
|
mah='100%'
|
||||||
pb={{ base: 70, md: 0 }}
|
pb={{ base: 70, md: 0 }}
|
||||||
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
|
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
|
||||||
style={{ transition: 'none' }}
|
maw='100dvw'
|
||||||
|
style={{ transition: 'none', overflow: 'hidden' }}
|
||||||
>
|
>
|
||||||
<Pullable scrollPosition={scrollPosition} onScrollPositionChange={setScrollPosition}>
|
<Pullable scrollPosition={scrollPosition} onScrollPositionChange={setScrollPosition}>
|
||||||
<Page noPadding={!withPadding} fullWidth={fullWidth}>
|
<Page noPadding={!withPadding} fullWidth={fullWidth}>
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { AuthProvider } from "@/contexts/auth-context"
|
import { AuthProvider } from "@/contexts/auth-context"
|
||||||
|
import { SpotifyProvider } from "@/contexts/spotify-context"
|
||||||
import MantineProvider from "@/lib/mantine/mantine-provider"
|
import MantineProvider from "@/lib/mantine/mantine-provider"
|
||||||
import { Toaster } from "sonner"
|
import { Toaster } from "sonner"
|
||||||
|
|
||||||
const Providers = ({ children }: { children: React.ReactNode }) => {
|
const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<MantineProvider>
|
<SpotifyProvider>
|
||||||
<Toaster position='top-center' />
|
<MantineProvider>
|
||||||
{children}
|
<Toaster position='top-center' />
|
||||||
</MantineProvider>
|
{children}
|
||||||
|
</MantineProvider>
|
||||||
|
</SpotifyProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ const SettingsButton = ({ offsetY, to }: SettingButtonProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
style={{ cursor: 'pointer', zIndex: 1000, transform: `translateY(-${offsetY}px)` }}
|
style={{ cursor: 'pointer', zIndex: 1000 }}
|
||||||
onClick={() => navigate({ to })}
|
onClick={() => navigate({ to })}
|
||||||
pos='absolute'
|
pos='absolute'
|
||||||
right={{ base: 0, sm: 100, md: 200, lg: 300 }}
|
right={16}
|
||||||
m={20}
|
top={0}
|
||||||
>
|
>
|
||||||
<GearIcon weight='bold' size={20} />
|
<GearIcon weight='bold' size={20} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ export const useLinks = (userId: string | undefined, roles: string[]) =>
|
|||||||
Icon: HouseIcon
|
Icon: HouseIcon
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Leaderboard',
|
label: 'Statistics',
|
||||||
href: '/leaderboard',
|
href: '/stats',
|
||||||
Icon: RankingIcon
|
Icon: RankingIcon
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
radius='md'
|
radius='md'
|
||||||
>
|
>
|
||||||
<Stack align='center' gap='xs' mb='md'>
|
<Stack align='center' gap='xs' mb='md'>
|
||||||
<TrophyIcon size={150} />
|
<TrophyIcon size={75} />
|
||||||
<Title order={2} ta='center'>Welcome to FLXN</Title>
|
<Title order={4} ta='center'>Welcome to FLXN</Title>
|
||||||
</Stack>
|
</Stack>
|
||||||
{children}
|
{children}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
155
src/features/matches/components/match-card.tsx
Normal file
155
src/features/matches/components/match-card.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import {
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Paper,
|
||||||
|
ThemeIcon,
|
||||||
|
Indicator,
|
||||||
|
Box,
|
||||||
|
Badge,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { TrophyIcon, CrownIcon } from "@phosphor-icons/react";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Match } from "../types";
|
||||||
|
import Avatar from "@/components/avatar";
|
||||||
|
|
||||||
|
interface MatchCardProps {
|
||||||
|
match: Match;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MatchCard = ({ match }: MatchCardProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isHomeWin = match.home_cups > match.away_cups;
|
||||||
|
const isAwayWin = match.away_cups > match.home_cups;
|
||||||
|
const isStarted = match.status === "started";
|
||||||
|
|
||||||
|
const handleHomeTeamClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (match.home?.id) {
|
||||||
|
navigate({ to: `/teams/${match.home.id}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAwayTeamClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (match.away?.id) {
|
||||||
|
navigate({ to: `/teams/${match.away.id}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Indicator
|
||||||
|
disabled={!isStarted}
|
||||||
|
size={12}
|
||||||
|
color="red"
|
||||||
|
processing
|
||||||
|
position="top-end"
|
||||||
|
offset={2}
|
||||||
|
>
|
||||||
|
<Paper p="md" withBorder radius="md">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text size="xs" fw={600} lineClamp={1} c="dimmed">
|
||||||
|
{match.tournament.name}
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed">-</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Round {match.round}
|
||||||
|
{match.is_losers_bracket && " (Losers)"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Group gap="sm" style={{ flex: 1 }}>
|
||||||
|
<Box
|
||||||
|
style={{ position: "relative", cursor: "pointer" }}
|
||||||
|
onClick={handleHomeTeamClick}
|
||||||
|
>
|
||||||
|
<Avatar size={40} name={match.home?.name!} radius="sm" />
|
||||||
|
{isHomeWin && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: -10,
|
||||||
|
left: -4,
|
||||||
|
transform: "rotate(-25deg)",
|
||||||
|
color: "gold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CrownIcon size={16} weight="fill" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={600}
|
||||||
|
lineClamp={1}
|
||||||
|
style={{ minWidth: 0, flex: 1 }}
|
||||||
|
>
|
||||||
|
{match.home?.name!}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="sm" align="center">
|
||||||
|
<Text
|
||||||
|
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
|
||||||
|
style={{ position: "relative", cursor: "pointer" }}
|
||||||
|
onClick={handleAwayTeamClick}
|
||||||
|
>
|
||||||
|
<Avatar size={40} name={match.away?.name!} radius="sm" />
|
||||||
|
{isAwayWin && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: -10,
|
||||||
|
right: -4,
|
||||||
|
transform: "rotate(25deg)",
|
||||||
|
color: "gold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CrownIcon size={16} weight="fill" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Indicator>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MatchCard;
|
||||||
48
src/features/matches/components/match-list.tsx
Normal file
48
src/features/matches/components/match-list.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Stack, Text, ThemeIcon, Box } from "@mantine/core";
|
||||||
|
import { TrophyIcon } from "@phosphor-icons/react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Match } from "../types";
|
||||||
|
import MatchCard from "./match-card";
|
||||||
|
|
||||||
|
interface MatchListProps {
|
||||||
|
matches: Match[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MatchList = ({ matches }: MatchListProps) => {
|
||||||
|
const filteredMatches = matches?.filter(match =>
|
||||||
|
match.home && match.away && !match.bye && match.status != "tbd"
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
if (!filteredMatches.length) {
|
||||||
|
return (
|
||||||
|
<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 (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<AnimatePresence>
|
||||||
|
{filteredMatches.map((match, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={`match-${match.id}-${index}`}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.2, delay: index * 0.01 }}
|
||||||
|
>
|
||||||
|
<MatchCard match={match} />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MatchList;
|
||||||
@@ -17,37 +17,36 @@ export const generateTournamentBracket = createServerFn()
|
|||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
logger.info('Generating tournament bracket', { tournamentId, teamCount: orderedTeamIds.length });
|
logger.info("Generating tournament bracket", {
|
||||||
|
tournamentId,
|
||||||
|
teamCount: orderedTeamIds.length,
|
||||||
|
});
|
||||||
|
|
||||||
// Get tournament with teams
|
|
||||||
const tournament = await pbAdmin.getTournament(tournamentId);
|
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||||
if (!tournament) {
|
if (!tournament) {
|
||||||
throw new Error('Tournament not found');
|
throw new Error("Tournament not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if tournament already has matches
|
|
||||||
if (tournament.matches && tournament.matches.length > 0) {
|
if (tournament.matches && tournament.matches.length > 0) {
|
||||||
throw new Error('Tournament already has matches generated');
|
throw new Error("Tournament already has matches generated");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get bracket template based on team count
|
|
||||||
const teamCount = orderedTeamIds.length;
|
const teamCount = orderedTeamIds.length;
|
||||||
if (!Object.keys(brackets).includes(teamCount.toString())) {
|
if (!Object.keys(brackets).includes(teamCount.toString())) {
|
||||||
throw new Error(`Bracket not available for ${teamCount} teams`);
|
throw new Error(`Bracket not available for ${teamCount} teams`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bracketTemplate = brackets[teamCount as keyof typeof brackets] as any;
|
const bracketTemplate = brackets[
|
||||||
|
teamCount as keyof typeof brackets
|
||||||
|
] as any;
|
||||||
|
|
||||||
// Create seed to team mapping (index + 1 = seed)
|
|
||||||
const seedToTeamId = new Map<number, string>();
|
const seedToTeamId = new Map<number, string>();
|
||||||
orderedTeamIds.forEach((teamId, index) => {
|
orderedTeamIds.forEach((teamId, index) => {
|
||||||
seedToTeamId.set(index + 1, teamId);
|
seedToTeamId.set(index + 1, teamId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert bracket template to match records
|
|
||||||
const matchInputs: MatchInput[] = [];
|
const matchInputs: MatchInput[] = [];
|
||||||
|
|
||||||
// Process winners bracket
|
|
||||||
bracketTemplate.winners.forEach((round: any[]) => {
|
bracketTemplate.winners.forEach((round: any[]) => {
|
||||||
round.forEach((match: any) => {
|
round.forEach((match: any) => {
|
||||||
const matchInput: MatchInput = {
|
const matchInput: MatchInput = {
|
||||||
@@ -59,15 +58,15 @@ export const generateTournamentBracket = createServerFn()
|
|||||||
home_cups: 0,
|
home_cups: 0,
|
||||||
away_cups: 0,
|
away_cups: 0,
|
||||||
ot_count: 0,
|
ot_count: 0,
|
||||||
home_from_lid: match.home_from_lid,
|
home_from_lid: match.home_from_lid === null ? -1 : match.home_from_lid,
|
||||||
away_from_lid: match.away_from_lid,
|
away_from_lid: match.away_from_lid === null ? -1 : match.away_from_lid,
|
||||||
home_from_loser: match.home_from_loser || false,
|
home_from_loser: match.home_from_loser || false,
|
||||||
away_from_loser: match.away_from_loser || false,
|
away_from_loser: match.away_from_loser || false,
|
||||||
is_losers_bracket: false,
|
is_losers_bracket: false,
|
||||||
|
status: "tbd",
|
||||||
tournament: tournamentId,
|
tournament: tournamentId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Assign teams based on seeds
|
|
||||||
if (match.home_seed) {
|
if (match.home_seed) {
|
||||||
const teamId = seedToTeamId.get(match.home_seed);
|
const teamId = seedToTeamId.get(match.home_seed);
|
||||||
if (teamId) {
|
if (teamId) {
|
||||||
@@ -84,11 +83,14 @@ export const generateTournamentBracket = createServerFn()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (matchInput.home && matchInput.away) {
|
||||||
|
matchInput.status = "ready";
|
||||||
|
}
|
||||||
|
|
||||||
matchInputs.push(matchInput);
|
matchInputs.push(matchInput);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process losers bracket
|
|
||||||
bracketTemplate.losers.forEach((round: any[]) => {
|
bracketTemplate.losers.forEach((round: any[]) => {
|
||||||
round.forEach((match: any) => {
|
round.forEach((match: any) => {
|
||||||
const matchInput: MatchInput = {
|
const matchInput: MatchInput = {
|
||||||
@@ -100,31 +102,27 @@ export const generateTournamentBracket = createServerFn()
|
|||||||
home_cups: 0,
|
home_cups: 0,
|
||||||
away_cups: 0,
|
away_cups: 0,
|
||||||
ot_count: 0,
|
ot_count: 0,
|
||||||
home_from_lid: match.home_from_lid,
|
home_from_lid: match.home_from_lid === null ? -1 : match.home_from_lid,
|
||||||
away_from_lid: match.away_from_lid,
|
away_from_lid: match.away_from_lid === null ? -1 : match.away_from_lid,
|
||||||
home_from_loser: match.home_from_loser || false,
|
home_from_loser: match.home_from_loser || false,
|
||||||
away_from_loser: match.away_from_loser || false,
|
away_from_loser: match.away_from_loser || false,
|
||||||
is_losers_bracket: true,
|
is_losers_bracket: true,
|
||||||
|
status: "tbd",
|
||||||
tournament: tournamentId,
|
tournament: tournamentId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Losers bracket matches don't start with teams
|
|
||||||
// Teams come from winners bracket losses
|
|
||||||
|
|
||||||
matchInputs.push(matchInput);
|
matchInputs.push(matchInput);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create all matches
|
|
||||||
const createdMatches = await pbAdmin.createMatches(matchInputs);
|
const createdMatches = await pbAdmin.createMatches(matchInputs);
|
||||||
|
|
||||||
// Update tournament to include all match IDs in the matches relation
|
const matchIds = createdMatches.map((match) => match.id);
|
||||||
const matchIds = createdMatches.map(match => match.id);
|
|
||||||
await pbAdmin.updateTournamentMatches(tournamentId, matchIds);
|
await pbAdmin.updateTournamentMatches(tournamentId, matchIds);
|
||||||
|
|
||||||
logger.info('Tournament bracket generated', {
|
logger.info("Tournament bracket generated", {
|
||||||
tournamentId,
|
tournamentId,
|
||||||
matchCount: createdMatches.length
|
matchCount: createdMatches.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -134,3 +132,108 @@ export const generateTournamentBracket = createServerFn()
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const startMatch = createServerFn()
|
||||||
|
.validator(z.string())
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
|
.handler(async ({ data }) =>
|
||||||
|
toServerResult(async () => {
|
||||||
|
logger.info("Starting match", data);
|
||||||
|
|
||||||
|
let match = await pbAdmin.getMatch(data);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("Match not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
match = await pbAdmin.updateMatch(data, {
|
||||||
|
start_time: new Date().toISOString(),
|
||||||
|
status: "started",
|
||||||
|
});
|
||||||
|
|
||||||
|
return match;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const endMatchSchema = z.object({
|
||||||
|
matchId: z.string(),
|
||||||
|
home_cups: z.number(),
|
||||||
|
away_cups: z.number(),
|
||||||
|
ot_count: z.number(),
|
||||||
|
});
|
||||||
|
export const endMatch = createServerFn()
|
||||||
|
.validator(endMatchSchema)
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
|
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
|
||||||
|
toServerResult(async () => {
|
||||||
|
logger.info("Ending match", matchId);
|
||||||
|
|
||||||
|
let match = await pbAdmin.getMatch(matchId);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("Match not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
match = await pbAdmin.updateMatch(matchId, {
|
||||||
|
end_time: new Date().toISOString(),
|
||||||
|
status: "ended",
|
||||||
|
home_cups,
|
||||||
|
away_cups,
|
||||||
|
ot_count,
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchWinner = home_cups > away_cups ? match.home : match.away;
|
||||||
|
const matchLoser = home_cups < away_cups ? match.home : match.away;
|
||||||
|
if (!matchWinner || !matchLoser) throw new Error("Something went wrong");
|
||||||
|
|
||||||
|
console.log(matchWinner)
|
||||||
|
console.log(matchLoser)
|
||||||
|
|
||||||
|
// winner -> where to send match winner to, loser same
|
||||||
|
const { winner, loser } = await pbAdmin.getChildMatches(matchId);
|
||||||
|
|
||||||
|
console.log(winner, loser)
|
||||||
|
|
||||||
|
// reset match check
|
||||||
|
if (winner && winner.reset) {
|
||||||
|
const awayTeamWon = match.away === matchWinner;
|
||||||
|
|
||||||
|
if (!awayTeamWon) {
|
||||||
|
// Reset match is not necessary
|
||||||
|
logger.info("Deleting reset match", {
|
||||||
|
resetMatchId: winner.id,
|
||||||
|
currentMatchId: match.id,
|
||||||
|
reason: "not necessary",
|
||||||
|
});
|
||||||
|
|
||||||
|
await pbAdmin.deleteMatch(winner.id);
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// advance bracket
|
||||||
|
if (winner) {
|
||||||
|
await pbAdmin.updateMatch(winner.id, {
|
||||||
|
[winner.home_from_lid === match.lid ? "home" : "away"]: matchWinner.id,
|
||||||
|
status:
|
||||||
|
(winner.home_from_lid === match.lid && winner.away) ||
|
||||||
|
(winner.away_from_lid === match.lid && winner.home)
|
||||||
|
? "ready"
|
||||||
|
: "tbd",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loser) {
|
||||||
|
await pbAdmin.updateMatch(loser.id, {
|
||||||
|
[loser.home_from_lid === match.lid ? "home" : "away"]: matchLoser.id,
|
||||||
|
status:
|
||||||
|
(loser.home_from_lid === match.lid && loser.away) ||
|
||||||
|
(loser.away_from_lid === match.lid && loser.home)
|
||||||
|
? "ready"
|
||||||
|
: "tbd",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: send SSE
|
||||||
|
|
||||||
|
return match;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,38 +2,7 @@ import { z } from "zod";
|
|||||||
import { TeamInfo } from "../teams/types";
|
import { TeamInfo } from "../teams/types";
|
||||||
import { TournamentInfo } from "../tournaments/types";
|
import { TournamentInfo } from "../tournaments/types";
|
||||||
|
|
||||||
/**
|
export type MatchStatus = "tbd" | "ready" | "started" | "ended";
|
||||||
* class TMatchSlot(BaseModel):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Seed(TMatchSlot):
|
|
||||||
seed: int
|
|
||||||
|
|
||||||
|
|
||||||
class TBD(TMatchSlot):
|
|
||||||
parent: "TMatchBase"
|
|
||||||
loser: bool
|
|
||||||
|
|
||||||
|
|
||||||
class TMatchBase(BaseModel):
|
|
||||||
lid: int # local id
|
|
||||||
round: int
|
|
||||||
order: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class TMatch(TMatchBase):
|
|
||||||
home: Seed | TBD
|
|
||||||
away: Seed | TBD
|
|
||||||
reset: bool = False
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f'{self.order}'
|
|
||||||
|
|
||||||
|
|
||||||
class TBye(TMatchBase):
|
|
||||||
home: Seed | TBD
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface Match {
|
export interface Match {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -52,6 +21,7 @@ export interface Match {
|
|||||||
home_from_loser: boolean;
|
home_from_loser: boolean;
|
||||||
away_from_loser: boolean;
|
away_from_loser: boolean;
|
||||||
is_losers_bracket: boolean;
|
is_losers_bracket: boolean;
|
||||||
|
status: MatchStatus;
|
||||||
tournament: TournamentInfo;
|
tournament: TournamentInfo;
|
||||||
home?: TeamInfo;
|
home?: TeamInfo;
|
||||||
away?: TeamInfo;
|
away?: TeamInfo;
|
||||||
@@ -77,6 +47,7 @@ export const matchInputSchema = z.object({
|
|||||||
home_from_loser: z.boolean().optional().default(false),
|
home_from_loser: z.boolean().optional().default(false),
|
||||||
away_from_loser: z.boolean().optional().default(false),
|
away_from_loser: z.boolean().optional().default(false),
|
||||||
is_losers_bracket: z.boolean().optional().default(false),
|
is_losers_bracket: z.boolean().optional().default(false),
|
||||||
|
status: z.enum(["tbd", "ready", "started", "ended"]).optional().default("tbd"),
|
||||||
tournament: z.string().min(1),
|
tournament: z.string().min(1),
|
||||||
home: z.string().min(1).optional(),
|
home: z.string().min(1).optional(),
|
||||||
away: z.string().min(1).optional(),
|
away: z.string().min(1).optional(),
|
||||||
|
|||||||
418
src/features/players/components/player-stats-table.tsx
Normal file
418
src/features/players/components/player-stats-table.tsx
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Stack,
|
||||||
|
Group,
|
||||||
|
Box,
|
||||||
|
ThemeIcon,
|
||||||
|
Container,
|
||||||
|
Title,
|
||||||
|
ScrollArea,
|
||||||
|
Paper,
|
||||||
|
Popover,
|
||||||
|
ActionIcon,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
CaretUpIcon,
|
||||||
|
CaretDownIcon,
|
||||||
|
ChartBarIcon,
|
||||||
|
InfoIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import { PlayerStats } from "../types";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
interface PlayerStatsTableProps {
|
||||||
|
playerStats: PlayerStats[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortKey = keyof PlayerStats | "mmr";
|
||||||
|
type SortDirection = "asc" | "desc";
|
||||||
|
|
||||||
|
interface SortConfig {
|
||||||
|
key: SortKey;
|
||||||
|
direction: SortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
||||||
|
key: "mmr" as SortKey,
|
||||||
|
direction: "desc",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate MMR (Match Making Rating) based on multiple factors
|
||||||
|
const calculateMMR = (stat: PlayerStats): number => {
|
||||||
|
if (stat.matches === 0) return 0;
|
||||||
|
|
||||||
|
// Base score from win percentage (0-100)
|
||||||
|
const winScore = stat.win_percentage;
|
||||||
|
|
||||||
|
// Match confidence factor (more matches = more reliable)
|
||||||
|
// Cap at 20 matches for full confidence
|
||||||
|
const matchConfidence = Math.min(stat.matches / 20, 1);
|
||||||
|
|
||||||
|
// Performance metrics
|
||||||
|
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100); // Cap at 10 avg cups
|
||||||
|
const marginScore = stat.margin_of_victory
|
||||||
|
? Math.min(stat.margin_of_victory * 20, 50)
|
||||||
|
: 0; // Cap at 2.5 margin
|
||||||
|
|
||||||
|
// Volume bonus for active players (small bonus for playing more)
|
||||||
|
const volumeBonus = Math.min(stat.matches * 0.5, 10); // Max 10 point bonus
|
||||||
|
|
||||||
|
// Weighted calculation
|
||||||
|
const baseMMR =
|
||||||
|
winScore * 0.5 + // Win % is 50% of score
|
||||||
|
avgCupsScore * 0.25 + // Avg cups is 25% of score
|
||||||
|
marginScore * 0.15 + // Win margin is 15% of score
|
||||||
|
volumeBonus * 0.1; // Volume bonus is 10% of score
|
||||||
|
|
||||||
|
// Apply confidence factor (players with few matches get penalized)
|
||||||
|
const finalMMR = baseMMR * matchConfidence;
|
||||||
|
|
||||||
|
return Math.round(finalMMR * 10) / 10; // Round to 1 decimal
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSort = (key: SortKey) => {
|
||||||
|
setSortConfig((prev) => ({
|
||||||
|
key,
|
||||||
|
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortIcon = (key: SortKey) => {
|
||||||
|
if (sortConfig.key !== key) return null;
|
||||||
|
return sortConfig.direction === "desc" ? (
|
||||||
|
<CaretDownIcon size={14} />
|
||||||
|
) : (
|
||||||
|
<CaretUpIcon size={14} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredAndSortedStats = useMemo(() => {
|
||||||
|
let filtered = playerStats.filter((stat) =>
|
||||||
|
stat.player_name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return filtered.sort((a, b) => {
|
||||||
|
let aValue: number | string;
|
||||||
|
let bValue: number | string;
|
||||||
|
|
||||||
|
// Special handling for MMR
|
||||||
|
if (sortConfig.key === "mmr") {
|
||||||
|
aValue = calculateMMR(a);
|
||||||
|
bValue = calculateMMR(b);
|
||||||
|
} else {
|
||||||
|
aValue = a[sortConfig.key];
|
||||||
|
bValue = b[sortConfig.key];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof aValue === "number" && typeof bValue === "number") {
|
||||||
|
return sortConfig.direction === "desc"
|
||||||
|
? bValue - aValue
|
||||||
|
: aValue - bValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof aValue === "string" && typeof bValue === "string") {
|
||||||
|
return sortConfig.direction === "desc"
|
||||||
|
? bValue.localeCompare(aValue)
|
||||||
|
: aValue.localeCompare(bValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}, [playerStats, search, sortConfig]);
|
||||||
|
|
||||||
|
const formatPercentage = (value: number) => `${value.toFixed(1)}%`;
|
||||||
|
const formatDecimal = (value: number) => value.toFixed(2);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: "player_name" as SortKey, label: "Player", width: 175 },
|
||||||
|
{ key: "mmr" as SortKey, label: "MMR", width: 90 },
|
||||||
|
{ key: "win_percentage" as SortKey, label: "Win %", width: 110 },
|
||||||
|
{ key: "matches" as SortKey, label: "Matches", width: 90 },
|
||||||
|
{ key: "wins" as SortKey, label: "Wins", width: 80 },
|
||||||
|
{ key: "losses" as SortKey, label: "Losses", width: 80 },
|
||||||
|
{ key: "total_cups_made" as SortKey, label: "Cups Made", width: 110 },
|
||||||
|
{ key: "total_cups_against" as SortKey, label: "Cups Against", width: 120 },
|
||||||
|
{ key: "avg_cups_per_match" as SortKey, label: "Avg/Match", width: 100 },
|
||||||
|
{ key: "margin_of_victory" as SortKey, label: "Win Margin", width: 110 },
|
||||||
|
{ key: "margin_of_loss" as SortKey, label: "Loss Margin", width: 110 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const renderCellContent = (
|
||||||
|
stat: PlayerStats,
|
||||||
|
column: (typeof columns)[0],
|
||||||
|
index: number
|
||||||
|
) => {
|
||||||
|
switch (column.key) {
|
||||||
|
case "player_name":
|
||||||
|
return (
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{stat.player_name}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
case "mmr":
|
||||||
|
const mmr = calculateMMR(stat);
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text fw={700} size="md">
|
||||||
|
{mmr.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
case "win_percentage":
|
||||||
|
return <Text size="sm">{formatPercentage(stat.win_percentage)}</Text>;
|
||||||
|
case "wins":
|
||||||
|
return <Text fw={500}>{stat.wins}</Text>;
|
||||||
|
case "losses":
|
||||||
|
return <Text fw={500}>{stat.losses}</Text>;
|
||||||
|
case "total_cups_made":
|
||||||
|
return <Text fw={500}>{stat.total_cups_made}</Text>;
|
||||||
|
case "matches":
|
||||||
|
return <Text fw={500}>{stat.matches}</Text>;
|
||||||
|
case "avg_cups_per_match":
|
||||||
|
return <Text>{formatDecimal(stat.avg_cups_per_match)}</Text>;
|
||||||
|
case "margin_of_victory":
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
{stat.margin_of_victory
|
||||||
|
? formatDecimal(stat.margin_of_victory)
|
||||||
|
: "N/A"}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
case "margin_of_loss":
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
{stat.margin_of_loss ? formatDecimal(stat.margin_of_loss) : "N/A"}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <Text>{(stat as any)[column.key]}</Text>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (playerStats.length === 0) {
|
||||||
|
return (
|
||||||
|
<Container size="md">
|
||||||
|
<Stack align="center" gap="md" py="xl">
|
||||||
|
<ThemeIcon size="xl" variant="light" radius="md">
|
||||||
|
<ChartBarIcon size={32} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Title order={3} c="dimmed">
|
||||||
|
No Stats Available
|
||||||
|
</Title>
|
||||||
|
<Text c="dimmed" ta="center">
|
||||||
|
Player statistics will appear here once matches have been played.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="100%" px={0}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text ml="auto" size="xs" c="dimmed">
|
||||||
|
{filteredAndSortedStats.length} of {playerStats.length} players
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search players"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
leftSection={<MagnifyingGlassIcon size={16} />}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p={0} style={{ overflow: "hidden" }}>
|
||||||
|
<ScrollArea>
|
||||||
|
<Table
|
||||||
|
highlightOnHover
|
||||||
|
striped
|
||||||
|
withTableBorder={false}
|
||||||
|
style={{
|
||||||
|
minWidth: 1000,
|
||||||
|
borderRadius: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table.Thead
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--mantine-color-body)",
|
||||||
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table.Tr>
|
||||||
|
{columns.map((column, index) => (
|
||||||
|
<Table.Th
|
||||||
|
key={column.key}
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
userSelect: "none",
|
||||||
|
width: column.width,
|
||||||
|
minWidth: column.width,
|
||||||
|
padding: "12px 16px",
|
||||||
|
fontWeight: 600,
|
||||||
|
backgroundColor: "var(--mantine-color-body)",
|
||||||
|
borderBottom:
|
||||||
|
"2px solid var(--mantine-color-default-border)",
|
||||||
|
...(index === 0 && {
|
||||||
|
position: "sticky",
|
||||||
|
left: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
borderTopLeftRadius: "var(--mantine-radius-md)",
|
||||||
|
}),
|
||||||
|
...(index === columns.length - 1 && {
|
||||||
|
borderTopRightRadius: "var(--mantine-radius-md)",
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
onClick={() => handleSort(column.key)}
|
||||||
|
>
|
||||||
|
<Group
|
||||||
|
gap="xs"
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{ position: "relative" }}
|
||||||
|
>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{column.label}
|
||||||
|
</Text>
|
||||||
|
{column.key === "mmr" && (
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popover position="bottom" withArrow shadow="md">
|
||||||
|
<Popover.Target>
|
||||||
|
<ActionIcon variant="subtle" size="xs">
|
||||||
|
<InfoIcon size={12} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<Box maw={280}>
|
||||||
|
<Text size="sm" fw={500} mb="xs">
|
||||||
|
MMR Calculation:
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• Win Rate (50%)
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• Average Cups/Match (25%)
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• Average Win Margin (15%)
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• Match Volume Bonus (10%)
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mt="xs" c="dimmed">
|
||||||
|
* Confidence penalty applied for players
|
||||||
|
with <20 matches
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mt="xs" c="dimmed">
|
||||||
|
** Not an official rating
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
minWidth: 16,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getSortIcon(column.key)}
|
||||||
|
</Box>
|
||||||
|
{index === 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: "2px",
|
||||||
|
backgroundColor:
|
||||||
|
"var(--mantine-color-default-border)",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{filteredAndSortedStats.map((stat, index) => (
|
||||||
|
<motion.tr
|
||||||
|
key={stat.id}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.2, delay: index * 0.01 }}
|
||||||
|
style={{
|
||||||
|
borderBottom:
|
||||||
|
"1px solid var(--mantine-color-default-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{columns.map((column, columnIndex) => (
|
||||||
|
<Table.Td
|
||||||
|
key={`${stat.id}-${column.key}`}
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
...(columnIndex === 0 && {
|
||||||
|
position: "sticky",
|
||||||
|
left: 0,
|
||||||
|
backgroundColor: "var(--mantine-color-body)",
|
||||||
|
zIndex: 1,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
{renderCellContent(stat, column, index)}
|
||||||
|
{columnIndex === 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: "2px",
|
||||||
|
backgroundColor:
|
||||||
|
"var(--mantine-color-default-border)",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{filteredAndSortedStats.length === 0 && search && (
|
||||||
|
<Text ta="center" c="dimmed" py="xl">
|
||||||
|
No players found matching "{search}"
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlayerStatsTable;
|
||||||
@@ -20,12 +20,24 @@ const Header = ({ player }: HeaderProps) => {
|
|||||||
const owner = useMemo(() => authUser?.id === player.id, [authUser?.id, player.id]);
|
const owner = useMemo(() => authUser?.id === player.id, [authUser?.id, player.id]);
|
||||||
const name = useMemo(() => `${player.first_name} ${player.last_name}`, [player.first_name, player.last_name]);
|
const name = useMemo(() => `${player.first_name} ${player.last_name}`, [player.first_name, player.last_name]);
|
||||||
|
|
||||||
|
const fontSize = useMemo(() => {
|
||||||
|
const baseSize = 24;
|
||||||
|
const maxLength = 20;
|
||||||
|
|
||||||
|
if (name.length <= maxLength) {
|
||||||
|
return `${baseSize}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scaleFactor = Math.max(0.6, maxLength / name.length);
|
||||||
|
return `${Math.floor(baseSize * scaleFactor)}px`;
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex px='xl' w='100%' align='self-end' gap='md'>
|
<Flex px='lg' w='100%' align='self-end' gap='md'>
|
||||||
<Avatar name={name} size={125} />
|
<Avatar name={name} size={100} />
|
||||||
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
||||||
<Title ta='center' order={2}>{name}</Title>
|
<Title ta='center' style={{ fontSize, lineHeight: 1.2 }}>{name}</Title>
|
||||||
<ActionIcon display={owner ? 'block' : 'none'} radius='xl' variant='subtle' onClick={sheet.open}>
|
<ActionIcon display={owner ? 'block' : 'none'} radius='xl' variant='subtle' onClick={sheet.open}>
|
||||||
<PencilIcon size={20} />
|
<PencilIcon size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Box, Text } from "@mantine/core";
|
import { Box } from "@mantine/core";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
import { Player } from "@/features/players/types";
|
import { Player } from "@/features/players/types";
|
||||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
import { usePlayer } from "../../queries";
|
import { usePlayer, usePlayerMatches } from "../../queries";
|
||||||
import TeamList from "@/features/teams/components/team-list";
|
import TeamList from "@/features/teams/components/team-list";
|
||||||
|
import StatsOverview from "../stats-overview";
|
||||||
|
import MatchList from "@/features/matches/components/match-list";
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,14 +13,16 @@ interface ProfileProps {
|
|||||||
|
|
||||||
const Profile = ({ id }: ProfileProps) => {
|
const Profile = ({ id }: ProfileProps) => {
|
||||||
const { data: player } = usePlayer(id);
|
const { data: player } = usePlayer(id);
|
||||||
|
const { data: matches } = usePlayerMatches(id);
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
content: <Text p="md">Stats/Badges will go here</Text>,
|
content: <StatsOverview playerId={id} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Matches",
|
label: "Matches",
|
||||||
content: <Text p="md">Matches feed will go here</Text>,
|
content: <Box p="md"><MatchList matches={matches || []} /></Box>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Teams",
|
label: "Teams",
|
||||||
@@ -29,7 +33,7 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header player={player} />
|
<Header player={player} />
|
||||||
<Box m="sm" mt="lg">
|
<Box m='md' mt="lg">
|
||||||
<SwipeableTabs tabs={tabs} />
|
<SwipeableTabs tabs={tabs} />
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
270
src/features/players/components/stats-overview.tsx
Normal file
270
src/features/players/components/stats-overview.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import { Box, Grid, Text, Group, Stack, ThemeIcon, Card, Avatar, Progress, Badge, Divider } from "@mantine/core";
|
||||||
|
import { CrownIcon, XIcon, FireIcon, ShieldIcon, ChartLineUpIcon, ShieldCheckIcon, BoxingGloveIcon, Icon, TrendUpIcon, ArrowUpIcon, ArrowDownIcon } from "@phosphor-icons/react";
|
||||||
|
import { usePlayerStats } from "../queries";
|
||||||
|
|
||||||
|
interface StatsOverviewProps {
|
||||||
|
playerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatCard = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
suffix = "",
|
||||||
|
Icon,
|
||||||
|
variant = "default"
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | null;
|
||||||
|
suffix?: string;
|
||||||
|
Icon?: Icon;
|
||||||
|
variant?: "default" | "compact";
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
if (variant === "compact") {
|
||||||
|
return (
|
||||||
|
<Card p="xs" radius="md" withBorder>
|
||||||
|
<Group gap={2} justify="space-between" align="flex-start">
|
||||||
|
<Stack gap={2} flex={1}>
|
||||||
|
<Text size='xs' c="dimmed" fw={600} tt="uppercase" lts="0.3px">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="md" fw={700} lh={1}>
|
||||||
|
{value !== null ? `${value}${suffix}` : "—"}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{Icon && (
|
||||||
|
<ThemeIcon size="sm" variant="light" radius="md">
|
||||||
|
<Icon size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card p="sm" radius="md" withBorder>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lts="0.3px">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{Icon && (
|
||||||
|
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||||
|
<Icon size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Text size="xl" fw={700} lh={1}>
|
||||||
|
{value !== null ? `${value}${suffix}` : "—"}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatsOverview = ({ playerId }: StatsOverviewProps) => {
|
||||||
|
const { data: statsData } = usePlayerStats(playerId);
|
||||||
|
|
||||||
|
if (!statsData || statsData.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box p="sm">
|
||||||
|
<div style={{ padding: '12px', border: '1px solid var(--mantine-color-gray-3)', borderRadius: '4px', textAlign: 'center' }}>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No stats available yet
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overallStats = statsData.reduce(
|
||||||
|
(acc, stat) => ({
|
||||||
|
matches: acc.matches + stat.matches,
|
||||||
|
wins: acc.wins + stat.wins,
|
||||||
|
losses: acc.losses + stat.losses,
|
||||||
|
total_cups_made: acc.total_cups_made + stat.total_cups_made,
|
||||||
|
total_cups_against: acc.total_cups_against + stat.total_cups_against,
|
||||||
|
}),
|
||||||
|
{ matches: 0, wins: 0, losses: 0, total_cups_made: 0, total_cups_against: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const winPercentage = overallStats.matches > 0
|
||||||
|
? ((overallStats.wins / overallStats.matches) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const avgCupsPerMatch = overallStats.matches > 0
|
||||||
|
? (overallStats.total_cups_made / overallStats.matches)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const avgCupsAgainstPerMatch = overallStats.matches > 0
|
||||||
|
? (overallStats.total_cups_against / overallStats.matches)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const validMarginOfVictory = statsData.filter(stat => stat.margin_of_victory > 0);
|
||||||
|
const validMarginOfLoss = statsData.filter(stat => stat.margin_of_loss > 0);
|
||||||
|
|
||||||
|
const avgMarginOfVictory = validMarginOfVictory.length > 0
|
||||||
|
? (validMarginOfVictory.reduce((acc, stat) => acc + stat.margin_of_victory, 0) / validMarginOfVictory.length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const avgMarginOfLoss = validMarginOfLoss.length > 0
|
||||||
|
? (validMarginOfLoss.reduce((acc, stat) => acc + stat.margin_of_loss, 0) / validMarginOfLoss.length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const getWinRateColor = (rate: number) => {
|
||||||
|
if (rate >= 70) return "green";
|
||||||
|
if (rate >= 50) return "blue";
|
||||||
|
if (rate >= 30) return "orange";
|
||||||
|
return "red";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Stack gap="lg">
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="md" fw={600} c="dark">Match Statistics</Text>
|
||||||
|
<Grid gutter="xs">
|
||||||
|
<Grid.Col span={4}>
|
||||||
|
<StatCard
|
||||||
|
label="Matches"
|
||||||
|
value={overallStats.matches}
|
||||||
|
Icon={BoxingGloveIcon}
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={4}>
|
||||||
|
<StatCard
|
||||||
|
label="Wins"
|
||||||
|
value={overallStats.wins}
|
||||||
|
Icon={CrownIcon}
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={4}>
|
||||||
|
<StatCard
|
||||||
|
label="Losses"
|
||||||
|
value={overallStats.losses}
|
||||||
|
Icon={XIcon}
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="md" fw={600} c="dark">Metrics</Text>
|
||||||
|
<Grid gutter="xs">
|
||||||
|
<Grid.Col span={6}>
|
||||||
|
<StatCard
|
||||||
|
label="Cups Made"
|
||||||
|
value={overallStats.total_cups_made}
|
||||||
|
Icon={FireIcon}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={6}>
|
||||||
|
<StatCard
|
||||||
|
label="Cups Against"
|
||||||
|
value={overallStats.total_cups_against}
|
||||||
|
Icon={ShieldIcon}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={6}>
|
||||||
|
<StatCard
|
||||||
|
label="Avg Per Game"
|
||||||
|
value={parseFloat(avgCupsPerMatch.toFixed(1))}
|
||||||
|
Icon={ChartLineUpIcon}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={6}>
|
||||||
|
<StatCard
|
||||||
|
label="Avg Against"
|
||||||
|
value={parseFloat(avgCupsAgainstPerMatch.toFixed(1))}
|
||||||
|
Icon={ShieldCheckIcon}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={6}>
|
||||||
|
<StatCard
|
||||||
|
label="Win Margin"
|
||||||
|
value={avgMarginOfVictory > 0 ? parseFloat(avgMarginOfVictory.toFixed(1)) : null}
|
||||||
|
Icon={ArrowUpIcon}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={6}>
|
||||||
|
<StatCard
|
||||||
|
label="Loss Margin"
|
||||||
|
value={avgMarginOfLoss > 0 ? parseFloat(avgMarginOfLoss.toFixed(1)) : null}
|
||||||
|
Icon={ArrowDownIcon}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Team Performance */}
|
||||||
|
{statsData.length > 1 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="md" fw={600} c="dark">Team Performance</Text>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{statsData.map((stat) => {
|
||||||
|
const teamWinRate = (stat.wins / stat.matches) * 100;
|
||||||
|
return (
|
||||||
|
<Card key={stat.id} p="md" radius="md" withBorder>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Group gap="sm">
|
||||||
|
<Avatar
|
||||||
|
size="md"
|
||||||
|
color={getWinRateColor(teamWinRate)}
|
||||||
|
radius="lg"
|
||||||
|
>
|
||||||
|
{stat.player_name.split(' ').map(n => n[0]).join('')}
|
||||||
|
</Avatar>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="sm" fw={600}>{stat.player_name}</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{stat.matches} matches
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">•</Text>
|
||||||
|
<Text size="xs" c="green.6">
|
||||||
|
{stat.wins}W
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="red.6">
|
||||||
|
{stat.losses}L
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
<Stack gap={4} align="flex-end">
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color={getWinRateColor(teamWinRate)}
|
||||||
|
>
|
||||||
|
{teamWinRate.toFixed(0)}%
|
||||||
|
</Badge>
|
||||||
|
<Progress
|
||||||
|
value={teamWinRate}
|
||||||
|
size="xs"
|
||||||
|
w={60}
|
||||||
|
color={getWinRateColor(teamWinRate)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatsOverview;
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe } from "./server";
|
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches } 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'],
|
||||||
|
stats: (id: string) => ['players', 'stats', id],
|
||||||
|
allStats: ['players', 'stats', 'all'],
|
||||||
|
matches: (id: string) => ['players', 'matches', id],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const playerQueries = {
|
export const playerQueries = {
|
||||||
@@ -25,6 +28,18 @@ export const playerQueries = {
|
|||||||
queryKey: playerKeys.unassociated,
|
queryKey: playerKeys.unassociated,
|
||||||
queryFn: async () => await getUnassociatedPlayers()
|
queryFn: async () => await getUnassociatedPlayers()
|
||||||
}),
|
}),
|
||||||
|
stats: (id: string) => ({
|
||||||
|
queryKey: playerKeys.stats(id),
|
||||||
|
queryFn: async () => await getPlayerStats({ data: id })
|
||||||
|
}),
|
||||||
|
allStats: () => ({
|
||||||
|
queryKey: playerKeys.allStats,
|
||||||
|
queryFn: async () => await getAllPlayerStats()
|
||||||
|
}),
|
||||||
|
matches: (id: string) => ({
|
||||||
|
queryKey: playerKeys.matches(id),
|
||||||
|
queryFn: async () => await getPlayerMatches({ data: id })
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useMe = () => {
|
export const useMe = () => {
|
||||||
@@ -34,7 +49,18 @@ export const useMe = () => {
|
|||||||
queryFn,
|
queryFn,
|
||||||
options: {
|
options: {
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
refetchOnMount: true
|
refetchOnMount: true,
|
||||||
|
retry: (failureCount, error: any) => {
|
||||||
|
if (error?.response?.status === 401) {
|
||||||
|
const errorData = error?.response?.data;
|
||||||
|
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
|
||||||
|
const currentUrl = window.location.pathname + window.location.search;
|
||||||
|
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failureCount < 3;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -47,3 +73,12 @@ export const usePlayers = () =>
|
|||||||
|
|
||||||
export const useUnassociatedPlayers = () =>
|
export const useUnassociatedPlayers = () =>
|
||||||
useServerSuspenseQuery(playerQueries.unassociated());
|
useServerSuspenseQuery(playerQueries.unassociated());
|
||||||
|
|
||||||
|
export const usePlayerStats = (id: string) =>
|
||||||
|
useServerSuspenseQuery(playerQueries.stats(id));
|
||||||
|
|
||||||
|
export const useAllPlayerStats = () =>
|
||||||
|
useServerSuspenseQuery(playerQueries.allStats());
|
||||||
|
|
||||||
|
export const usePlayerMatches = (id: string) =>
|
||||||
|
useServerSuspenseQuery(playerQueries.matches(id));
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { setUserMetadata, superTokensFunctionMiddleware, getSessionContext } from "@/utils/supertokens";
|
import { setUserMetadata, superTokensFunctionMiddleware, getSessionContext } from "@/utils/supertokens";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { Player, playerInputSchema, playerUpdateSchema } from "@/features/players/types";
|
import { Player, playerInputSchema, playerUpdateSchema, PlayerStats } from "@/features/players/types";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { logger } from ".";
|
import { logger } from ".";
|
||||||
@@ -23,8 +24,7 @@ export const fetchMe = createServerFn()
|
|||||||
metadata: context.metadata
|
metadata: context.metadata
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// If getSessionContext throws (unauthenticated or redirect), return empty state
|
logger.info('fetchMe: Session error', error.message);
|
||||||
logger.info('fetchMe: No authenticated user or redirect needed');
|
|
||||||
return { user: undefined, roles: [], metadata: {} };
|
return { user: undefined, roles: [], metadata: {} };
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -120,3 +120,23 @@ export const getUnassociatedPlayers = createServerFn()
|
|||||||
.handler(async () =>
|
.handler(async () =>
|
||||||
toServerResult(pbAdmin.getUnassociatedPlayers)
|
toServerResult(pbAdmin.getUnassociatedPlayers)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getPlayerStats = createServerFn()
|
||||||
|
.validator(z.string())
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data }) =>
|
||||||
|
toServerResult<PlayerStats[]>(async () => await pbAdmin.getPlayerStats(data))
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getAllPlayerStats = createServerFn()
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async () =>
|
||||||
|
toServerResult<PlayerStats[]>(async () => await pbAdmin.getAllPlayerStats())
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getPlayerMatches = createServerFn()
|
||||||
|
.validator(z.string())
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data }) =>
|
||||||
|
toServerResult<Match[]>(async () => await pbAdmin.getPlayerMatches(data))
|
||||||
|
);
|
||||||
@@ -27,3 +27,18 @@ export const playerUpdateSchema = playerInputSchema.partial();
|
|||||||
|
|
||||||
export type PlayerInput = z.infer<typeof playerInputSchema>;
|
export type PlayerInput = z.infer<typeof playerInputSchema>;
|
||||||
export type PlayerUpdateInput = z.infer<typeof playerUpdateSchema>;
|
export type PlayerUpdateInput = z.infer<typeof playerUpdateSchema>;
|
||||||
|
|
||||||
|
export interface PlayerStats {
|
||||||
|
id: string;
|
||||||
|
player_id: string;
|
||||||
|
player_name: string;
|
||||||
|
matches: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
total_cups_made: number;
|
||||||
|
total_cups_against: number;
|
||||||
|
win_percentage: number;
|
||||||
|
avg_cups_per_match: number;
|
||||||
|
margin_of_victory: number;
|
||||||
|
margin_of_loss: number;
|
||||||
|
}
|
||||||
|
|||||||
2
src/features/spotify/components/index.ts
Normal file
2
src/features/spotify/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as SpotifyControlsBar } from './spotify-controls-bar';
|
||||||
|
export { default as SpotifySheet } from './spotify-sheet';
|
||||||
210
src/features/spotify/components/spotify-controls-bar.tsx
Normal file
210
src/features/spotify/components/spotify-controls-bar.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { ActionIcon, Box, Group, Loader, Text, Tooltip } from '@mantine/core';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
import {
|
||||||
|
PlayIcon,
|
||||||
|
PauseIcon,
|
||||||
|
SkipBackIcon,
|
||||||
|
SkipForwardIcon,
|
||||||
|
GearIcon,
|
||||||
|
SpotifyLogoIcon,
|
||||||
|
FloppyDiskIcon,
|
||||||
|
ClockCounterClockwiseIcon,
|
||||||
|
} from '@phosphor-icons/react';
|
||||||
|
import { useSpotify } from '@/lib/spotify/hooks';
|
||||||
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
|
import SpotifySheet from './spotify-sheet';
|
||||||
|
|
||||||
|
const SpotifyControlsBar = () => {
|
||||||
|
const { roles } = useAuth();
|
||||||
|
const isAdmin = roles?.includes('Admin') || false;
|
||||||
|
const [sheetOpened, { open: openSheet, close: closeSheet }] = useDisclosure(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isAuthenticated,
|
||||||
|
playbackState,
|
||||||
|
currentTrack,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
skipNext,
|
||||||
|
skipPrevious,
|
||||||
|
activeDevice,
|
||||||
|
capturedState,
|
||||||
|
isCaptureLoading,
|
||||||
|
isResumeLoading,
|
||||||
|
capturePlaybackState,
|
||||||
|
resumePlaybackState,
|
||||||
|
} = useSpotify();
|
||||||
|
|
||||||
|
if (!isAdmin) return null;
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<Box py="md" mb="md">
|
||||||
|
<Group justify="center" gap="sm">
|
||||||
|
<SpotifyLogoIcon size={24} color="var(--mantine-color-green-6)" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Connect Spotify
|
||||||
|
</Text>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
size="lg"
|
||||||
|
onClick={openSheet}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<GearIcon size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
<SpotifySheet opened={sheetOpened} onClose={closeSheet} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPlaying = playbackState?.is_playing || false;
|
||||||
|
const hasActiveDevice = !!activeDevice;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box py="md" mb="md">
|
||||||
|
<Group justify="center" gap="md" align="center">
|
||||||
|
{currentTrack && (
|
||||||
|
<Group gap="sm" style={{ maxWidth: 400 }}>
|
||||||
|
{currentTrack.album.images[2] && (
|
||||||
|
<img
|
||||||
|
src={currentTrack.album.images[2].url}
|
||||||
|
alt={currentTrack.album.name}
|
||||||
|
style={{
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 4,
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Text size="sm" fw={600} truncate>
|
||||||
|
{currentTrack.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{currentTrack.artists.map(a => a.name).join(', ')}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{currentTrack.album.name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group gap="xs">
|
||||||
|
<Tooltip label="Previous track">
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
size="lg"
|
||||||
|
onClick={skipPrevious}
|
||||||
|
disabled={!hasActiveDevice || isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<SkipBackIcon size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label={isPlaying ? 'Pause' : 'Play'}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="green"
|
||||||
|
size="xl"
|
||||||
|
onClick={() => isPlaying ? pause() : play()}
|
||||||
|
disabled={!hasActiveDevice || isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
{isPlaying ? <PauseIcon size={24} /> : <PlayIcon size={24} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label="Next track">
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
size="lg"
|
||||||
|
onClick={skipNext}
|
||||||
|
disabled={!hasActiveDevice || isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<SkipForwardIcon size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="xs">
|
||||||
|
<Tooltip label={'Capture current state'}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color={capturedState ? 'blue' : 'gray'}
|
||||||
|
size="lg"
|
||||||
|
onClick={capturePlaybackState}
|
||||||
|
disabled={!hasActiveDevice || isLoading || isCaptureLoading}
|
||||||
|
loading={isCaptureLoading}
|
||||||
|
>
|
||||||
|
<FloppyDiskIcon size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label={capturedState ? 'Restore captured state' : 'No state captured'}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color={capturedState ? 'blue' : 'gray'}
|
||||||
|
size="lg"
|
||||||
|
onClick={resumePlaybackState}
|
||||||
|
disabled={!capturedState || !hasActiveDevice || isLoading || isResumeLoading}
|
||||||
|
loading={isResumeLoading}
|
||||||
|
>
|
||||||
|
<ClockCounterClockwiseIcon size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="xs">
|
||||||
|
{activeDevice && (
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Playing on {activeDevice.name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tooltip label="Spotify settings">
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
size="lg"
|
||||||
|
onClick={openSheet}
|
||||||
|
>
|
||||||
|
<GearIcon size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Group justify="center" mt="xs">
|
||||||
|
<Text size="xs" c="red">
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAuthenticated && !hasActiveDevice && !isLoading && (
|
||||||
|
<Group justify="center" mt="xs">
|
||||||
|
<Text size="xs" c="orange">
|
||||||
|
No active device. Please select a device in settings.
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SpotifySheet opened={sheetOpened} onClose={closeSheet} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpotifyControlsBar;
|
||||||
193
src/features/spotify/components/spotify-sheet.tsx
Normal file
193
src/features/spotify/components/spotify-sheet.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
NativeSelect,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
SpotifyLogoIcon,
|
||||||
|
DevicesIcon,
|
||||||
|
SignOutIcon,
|
||||||
|
ArrowsClockwiseIcon
|
||||||
|
} from '@phosphor-icons/react';
|
||||||
|
import { useSpotify } from '@/lib/spotify/hooks';
|
||||||
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
|
import Sheet from '@/components/sheet/sheet';
|
||||||
|
|
||||||
|
interface SpotifySheetProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpotifySheet: React.FC<SpotifySheetProps> = ({ opened, onClose }) => {
|
||||||
|
const { roles } = useAuth();
|
||||||
|
const isAdmin = roles?.includes('Admin') || false;
|
||||||
|
|
||||||
|
const {
|
||||||
|
isAuthenticated,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
devices,
|
||||||
|
activeDevice,
|
||||||
|
currentTrack,
|
||||||
|
getDevices,
|
||||||
|
setActiveDevice,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useSpotify();
|
||||||
|
|
||||||
|
if (!isAdmin) return null;
|
||||||
|
|
||||||
|
const handleDeviceChange = (deviceId: string) => {
|
||||||
|
if (deviceId && deviceId !== activeDevice?.id) {
|
||||||
|
setActiveDevice(deviceId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshDevices = () => {
|
||||||
|
getDevices();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (opened: boolean) => {
|
||||||
|
if (!opened) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet
|
||||||
|
opened={opened}
|
||||||
|
onChange={handleChange}
|
||||||
|
title="Spotify Controls"
|
||||||
|
>
|
||||||
|
<Stack gap="lg">
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<Box>
|
||||||
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
|
Connect your Spotify account
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
leftSection={<SpotifyLogoIcon size={20} />}
|
||||||
|
color="green"
|
||||||
|
size="lg"
|
||||||
|
onClick={login}
|
||||||
|
loading={isLoading}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Connect with Spotify
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Current track display */}
|
||||||
|
{currentTrack && (
|
||||||
|
<>
|
||||||
|
<Box>
|
||||||
|
<Title order={5} mb="xs">Now Playing</Title>
|
||||||
|
<Group gap="md">
|
||||||
|
{currentTrack.album.images[2] && (
|
||||||
|
<img
|
||||||
|
src={currentTrack.album.images[2].url}
|
||||||
|
alt={currentTrack.album.name}
|
||||||
|
style={{ width: 64, height: 64, borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Box flex={1}>
|
||||||
|
<Text fw={600} size="sm" truncate>
|
||||||
|
{currentTrack.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{currentTrack.artists.map(a => a.name).join(', ')}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{currentTrack.album.name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Group justify="space-between" align="center" mb="sm">
|
||||||
|
<Title order={5}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<DevicesIcon size={20} />
|
||||||
|
Select Device
|
||||||
|
</Group>
|
||||||
|
</Title>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
onClick={refreshDevices}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<ArrowsClockwiseIcon size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{devices.length > 0 ? (
|
||||||
|
<NativeSelect
|
||||||
|
value={activeDevice?.id || ''}
|
||||||
|
onChange={(event) => handleDeviceChange(event.currentTarget.value)}
|
||||||
|
data={[
|
||||||
|
{ value: '', label: 'Select a device...' },
|
||||||
|
...devices.map(device => ({
|
||||||
|
value: device.id,
|
||||||
|
label: `${device.name} ${device.is_active ? '(Active)' : ''} - ${device.type}`,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No devices found.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeDevice && (
|
||||||
|
<Text size="xs" c="dimmed" mt="xs">
|
||||||
|
Active device: {activeDevice.name}
|
||||||
|
{activeDevice.volume_percent !== null &&
|
||||||
|
` (Volume: ${activeDevice.volume_percent}%)`
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Box>
|
||||||
|
<Text size="sm" c="red">
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Button
|
||||||
|
leftSection={<SignOutIcon size={18} />}
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
onClick={logout}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Disconnect Spotify
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpotifySheet;
|
||||||
229
src/features/teams/components/team-form.tsx
Normal file
229
src/features/teams/components/team-form.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { FileInput, Stack, TextInput, Textarea } from "@mantine/core";
|
||||||
|
import { useForm, UseFormInput } from "@mantine/form";
|
||||||
|
import { LinkIcon } from "@phosphor-icons/react";
|
||||||
|
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
|
||||||
|
import { TournamentInput } from "@/features/tournaments/types";
|
||||||
|
import { isNotEmpty } from "@mantine/form";
|
||||||
|
import useCreateTournament from "../hooks/use-create-team";
|
||||||
|
import useUpdateTournament from "../hooks/use-update-tournament";
|
||||||
|
import toast from "@/lib/sonner";
|
||||||
|
import { logger } from "..";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { tournamentKeys } from "@/features/tournaments/queries";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { DateTimePicker } from "@/components/date-time-picker";
|
||||||
|
|
||||||
|
interface TournamentFormProps {
|
||||||
|
close: () => void;
|
||||||
|
initialValues?: Partial<TournamentInput>;
|
||||||
|
tournamentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TournamentForm = ({
|
||||||
|
close,
|
||||||
|
initialValues,
|
||||||
|
tournamentId,
|
||||||
|
}: TournamentFormProps) => {
|
||||||
|
const isEditMode = !!tournamentId;
|
||||||
|
|
||||||
|
const config: UseFormInput<TournamentInput> = {
|
||||||
|
initialValues: {
|
||||||
|
name: initialValues?.name || "",
|
||||||
|
location: initialValues?.location || "",
|
||||||
|
desc: initialValues?.desc || "",
|
||||||
|
start_time: initialValues?.start_time || "",
|
||||||
|
enroll_time: initialValues?.enroll_time || "",
|
||||||
|
end_time: initialValues?.end_time || "",
|
||||||
|
logo: undefined,
|
||||||
|
},
|
||||||
|
onSubmitPreventDefault: "always",
|
||||||
|
validate: {
|
||||||
|
name: isNotEmpty("Name is required"),
|
||||||
|
location: isNotEmpty("Location is required"),
|
||||||
|
start_time: isNotEmpty("Start time is required"),
|
||||||
|
enroll_time: isNotEmpty("Enrollment time is required"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm(config);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { mutate: createTournament, isPending: createPending } =
|
||||||
|
useCreateTournament();
|
||||||
|
const { mutate: updateTournament, isPending: updatePending } =
|
||||||
|
useUpdateTournament(tournamentId || "");
|
||||||
|
|
||||||
|
const isPending = createPending || updatePending;
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (values: TournamentInput) => {
|
||||||
|
const { logo, ...tournamentData } = values;
|
||||||
|
|
||||||
|
const mutation = isEditMode ? updateTournament : createTournament;
|
||||||
|
const successMessage = isEditMode
|
||||||
|
? "Tournament updated successfully!"
|
||||||
|
: "Tournament created successfully!";
|
||||||
|
const errorMessage = isEditMode
|
||||||
|
? "Failed to update tournament"
|
||||||
|
: "Failed to create tournament";
|
||||||
|
|
||||||
|
mutation(tournamentData, {
|
||||||
|
onSuccess: async (tournament) => {
|
||||||
|
if (logo && tournament) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("tournamentId", tournament.id);
|
||||||
|
formData.append("logo", logo);
|
||||||
|
|
||||||
|
const response = await fetch("/api/tournaments/upload-logo", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || "Failed to upload logo");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: tournamentKeys.list });
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: tournamentKeys.details(result.tournament!.id),
|
||||||
|
});
|
||||||
|
queryClient.setQueryData(
|
||||||
|
tournamentKeys.details(result.tournament!.id),
|
||||||
|
result.tournament
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success(successMessage);
|
||||||
|
} catch (error: any) {
|
||||||
|
const logoErrorMessage = isEditMode
|
||||||
|
? `Tournament updated but logo upload failed: ${error.message}`
|
||||||
|
: `Tournament created but logo upload failed: ${error.message}`;
|
||||||
|
toast.error(logoErrorMessage);
|
||||||
|
logger.error("Tournament logo upload error", error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.success(successMessage);
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(`${errorMessage}: ${error.message}`);
|
||||||
|
logger.error(
|
||||||
|
`Tournament ${isEditMode ? "update" : "create"} error`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[isEditMode, createTournament, updateTournament, queryClient]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlidePanel
|
||||||
|
onSubmit={form.onSubmit(handleSubmit)}
|
||||||
|
onCancel={close}
|
||||||
|
submitText={isEditMode ? "Update Tournament" : "Create Tournament"}
|
||||||
|
cancelText="Cancel"
|
||||||
|
loading={isPending}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<TextInput
|
||||||
|
label="Name"
|
||||||
|
withAsterisk
|
||||||
|
key={form.key("name")}
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Location"
|
||||||
|
withAsterisk
|
||||||
|
key={form.key("location")}
|
||||||
|
{...form.getInputProps("location")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Description"
|
||||||
|
key={form.key("desc")}
|
||||||
|
{...form.getInputProps("desc")}
|
||||||
|
minRows={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FileInput
|
||||||
|
key={form.key("logo")}
|
||||||
|
accept="image/png,image/jpeg,image/gif,image/jpg"
|
||||||
|
label={isEditMode ? "Change Logo" : "Logo"}
|
||||||
|
leftSection={<LinkIcon size={16} />}
|
||||||
|
{...form.getInputProps("logo")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SlidePanelField
|
||||||
|
key={form.key("start_time")}
|
||||||
|
{...form.getInputProps("start_time")}
|
||||||
|
Component={DateTimePicker}
|
||||||
|
title="Select Start Date"
|
||||||
|
label="Start Date"
|
||||||
|
withAsterisk
|
||||||
|
formatValue={(date) =>
|
||||||
|
!date ? 'Select a time' :
|
||||||
|
new Date(date).toLocaleDateString("en-US", {
|
||||||
|
weekday: "short",
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
hour12: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SlidePanelField
|
||||||
|
key={form.key("enroll_time")}
|
||||||
|
{...form.getInputProps("enroll_time")}
|
||||||
|
Component={DateTimePicker}
|
||||||
|
title="Select Enrollment Due Date"
|
||||||
|
label="Enrollment Due"
|
||||||
|
withAsterisk
|
||||||
|
formatValue={(date) =>
|
||||||
|
!date ? 'Select a time' :
|
||||||
|
new Date(date).toLocaleDateString("en-US", {
|
||||||
|
weekday: "short",
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
hour12: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isEditMode && (
|
||||||
|
<SlidePanelField
|
||||||
|
key={form.key("end_time")}
|
||||||
|
{...form.getInputProps("end_time")}
|
||||||
|
Component={DateTimePicker}
|
||||||
|
title="Select End Date"
|
||||||
|
label="End Date (Optional)"
|
||||||
|
formatValue={(date) =>
|
||||||
|
!date ? 'Select a time' :
|
||||||
|
new Date(date).toLocaleDateString("en-US", {
|
||||||
|
weekday: "short",
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
hour12: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</SlidePanel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TournamentForm;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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;
|
||||||
43
src/features/teams/hooks/use-available-players.ts
Normal file
43
src/features/teams/hooks/use-available-players.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { listPlayers } from '@/features/players/server';
|
||||||
|
import { useServerSuspenseQuery } from '@/lib/tanstack-query/hooks';
|
||||||
|
|
||||||
|
export const playerKeys = {
|
||||||
|
all: ['players'] as const,
|
||||||
|
available: (excludedIds: string[] = []) => [...playerKeys.all, 'available', excludedIds] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const playerQueries = {
|
||||||
|
all: () => ({
|
||||||
|
queryKey: playerKeys.all,
|
||||||
|
queryFn: listPlayers
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAvailablePlayers = (excludedPlayerIds: string[] = []) => {
|
||||||
|
const { data: allPlayers } = useServerSuspenseQuery(playerQueries.all());
|
||||||
|
|
||||||
|
const availablePlayers = useMemo(() => {
|
||||||
|
if (!allPlayers) return [];
|
||||||
|
|
||||||
|
return allPlayers.filter(player =>
|
||||||
|
!excludedPlayerIds.includes(player.id) &&
|
||||||
|
player.first_name &&
|
||||||
|
player.last_name
|
||||||
|
);
|
||||||
|
}, [allPlayers, excludedPlayerIds]);
|
||||||
|
|
||||||
|
const playerOptions = useMemo(() =>
|
||||||
|
availablePlayers.map(player => ({
|
||||||
|
value: player.id,
|
||||||
|
label: `${player.first_name} ${player.last_name}`.trim() || 'Unnamed Player'
|
||||||
|
})),
|
||||||
|
[availablePlayers]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
availablePlayers,
|
||||||
|
playerOptions,
|
||||||
|
allPlayers
|
||||||
|
};
|
||||||
|
};
|
||||||
22
src/features/teams/hooks/use-create-team.ts
Normal file
22
src/features/teams/hooks/use-create-team.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { createTeam } from "@/features/teams/server";
|
||||||
|
import { TeamInput } from "@/features/teams/types";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||||
|
|
||||||
|
const useCreateTeam = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return useServerMutation({
|
||||||
|
mutationFn: (data: TeamInput) => createTeam({ data }),
|
||||||
|
onMutate: (data) => {
|
||||||
|
logger.info('Creating team', data);
|
||||||
|
},
|
||||||
|
onSuccess: (team) => {
|
||||||
|
navigate({ to: '/teams/$id', params: { id: team.id } });
|
||||||
|
},
|
||||||
|
successMessage: 'Team created successfully!',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCreateTeam;
|
||||||
17
src/features/teams/hooks/use-update-team.ts
Normal file
17
src/features/teams/hooks/use-update-team.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { updateTeam } from "@/features/teams/server";
|
||||||
|
import { TeamUpdateInput } from "@/features/teams/types";
|
||||||
|
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
const useUpdateTeam = (teamId: string) => {
|
||||||
|
return useServerMutation({
|
||||||
|
mutationFn: (data: TeamUpdateInput) =>
|
||||||
|
updateTeam({ data: { id: teamId, updates: data } }),
|
||||||
|
onMutate: (data) => {
|
||||||
|
logger.info('Updating team', { teamId, updates: Object.keys(data) });
|
||||||
|
},
|
||||||
|
successMessage: 'Team updated successfully!',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useUpdateTeam;
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensFunctionMiddleware, superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { z } from "zod";
|
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 { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
|
||||||
|
export const listTeamInfos = createServerFn()
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async () =>
|
||||||
|
toServerResult(() => pbAdmin.listTeamInfos())
|
||||||
|
);
|
||||||
|
|
||||||
export const getTeam = createServerFn()
|
export const getTeam = createServerFn()
|
||||||
.validator(z.string())
|
.validator(z.string())
|
||||||
@@ -10,3 +19,55 @@ export const getTeam = createServerFn()
|
|||||||
.handler(async ({ data: teamId }) =>
|
.handler(async ({ data: teamId }) =>
|
||||||
toServerResult(() => pbAdmin.getTeam(teamId))
|
toServerResult(() => pbAdmin.getTeam(teamId))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getTeamInfo = createServerFn()
|
||||||
|
.validator(z.string())
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data: teamId }) =>
|
||||||
|
toServerResult(() => pbAdmin.getTeamInfo(teamId))
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createTeam = createServerFn()
|
||||||
|
.validator(teamInputSchema)
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data, context }) =>
|
||||||
|
toServerResult(async () => {
|
||||||
|
const userId = context.userAuthId;
|
||||||
|
const isAdmin = context.roles.includes("Admin");
|
||||||
|
|
||||||
|
if (!isAdmin && !data.players.includes(userId)) {
|
||||||
|
throw new Error("You can only create teams that include yourself as a player");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Creating team", { name: data.name, userId, isAdmin });
|
||||||
|
return pbAdmin.createTeam(data);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const updateTeam = createServerFn()
|
||||||
|
.validator(z.object({
|
||||||
|
id: z.string(),
|
||||||
|
updates: teamUpdateSchema
|
||||||
|
}))
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data: { id, updates }, context }) =>
|
||||||
|
toServerResult(async () => {
|
||||||
|
const userId = context.userAuthId;
|
||||||
|
const isAdmin = context.roles.includes("Admin");
|
||||||
|
|
||||||
|
// Get the team to verify ownership
|
||||||
|
const team = await pbAdmin.getTeam(id);
|
||||||
|
if (!team) {
|
||||||
|
throw new Error("Team not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has permission to update this team
|
||||||
|
const isPlayerOnTeam = team.players.some(player => player.id === userId);
|
||||||
|
if (!isAdmin && !isPlayerOnTeam) {
|
||||||
|
throw new Error("You can only update teams that you are a member of");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Updating team", { teamId: id, userId, isAdmin });
|
||||||
|
return pbAdmin.updateTeam(id, updates);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface TeamInfo {
|
|||||||
name: string;
|
name: string;
|
||||||
primary_color: string;
|
primary_color: string;
|
||||||
accent_color: string;
|
accent_color: string;
|
||||||
|
logo?: string;
|
||||||
players: PlayerInfo[];
|
players: PlayerInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +51,38 @@ export const teamInputSchema = z
|
|||||||
song_start: z.number().int().optional(),
|
song_start: z.number().int().optional(),
|
||||||
song_end: z.number().int().optional(),
|
song_end: z.number().int().optional(),
|
||||||
song_image_url: z.url("Invalid song image URL").optional(),
|
song_image_url: z.url("Invalid song image URL").optional(),
|
||||||
|
players: z.array(z.string()).min(1, "At least one player is required").max(10, "Maximum 10 players allowed"),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.song_start && data.song_end) {
|
||||||
|
return data.song_end > data.song_start;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{ message: "Song end time must be after start time", path: ["song_end"] }
|
||||||
|
);
|
||||||
|
|
||||||
|
export const teamUpdateSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1, "Team name is required").max(100, "Name too long").optional(),
|
||||||
|
logo: z.file("Invalid logo").optional(),
|
||||||
|
primary_color: z
|
||||||
|
.string()
|
||||||
|
.regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)")
|
||||||
|
.optional(),
|
||||||
|
accent_color: z
|
||||||
|
.string()
|
||||||
|
.regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)")
|
||||||
|
.optional(),
|
||||||
|
song_id: z.string().max(255).optional(),
|
||||||
|
song_name: z.string().max(255).optional(),
|
||||||
|
song_artist: z.string().max(255).optional(),
|
||||||
|
song_album: z.string().max(255).optional(),
|
||||||
|
song_year: z.number().int().optional(),
|
||||||
|
song_start: z.number().int().optional(),
|
||||||
|
song_end: z.number().int().optional(),
|
||||||
|
song_image_url: z.url("Invalid song image URL").optional(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
@@ -62,4 +95,4 @@ export const teamInputSchema = z
|
|||||||
);
|
);
|
||||||
|
|
||||||
export type TeamInput = z.infer<typeof teamInputSchema>;
|
export type TeamInput = z.infer<typeof teamInputSchema>;
|
||||||
export type TeamUpdateInput = Partial<TeamInput>;
|
export type TeamUpdateInput = z.infer<typeof teamUpdateSchema>;
|
||||||
|
|||||||
29
src/features/tournaments/components/enroll-free-agent.tsx
Normal file
29
src/features/tournaments/components/enroll-free-agent.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import Button from "@/components/button";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
import { Text } from "@mantine/core";
|
||||||
|
|
||||||
|
const EnrollFreeAgent = () => {
|
||||||
|
const { open, isOpen, toggle } = useSheet();
|
||||||
|
const { user } = useAuth();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={open}>
|
||||||
|
Enroll As Free Agent
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Sheet title="Free Agent Enrollment" opened={isOpen} onChange={toggle}>
|
||||||
|
<Text size="md" mb="md">
|
||||||
|
Enrolling as a free agent will enter you in a pool of players wanting to play but don't have a teammate yet.
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" mb="md" c='dimmed'>
|
||||||
|
You will be automatically paired with a partner before the tournament starts, and you will be able to see your new team and set a walkout song in the app.
|
||||||
|
</Text>
|
||||||
|
<Button onClick={console.log}>Confirm</Button>
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EnrollFreeAgent;
|
||||||
63
src/features/tournaments/components/enroll-team/index.tsx
Normal file
63
src/features/tournaments/components/enroll-team/index.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import Button from "@/components/button";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
import { useMemo, useState, useCallback } from "react";
|
||||||
|
import TeamSelectionView from "./team-selection-view";
|
||||||
|
|
||||||
|
const EnrollTeam = () => {
|
||||||
|
const { open, isOpen, toggle } = useSheet();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const hasTeams = useMemo(() => !!user?.teams?.length, [user?.teams]);
|
||||||
|
const [selectedTeamId, setSelectedTeamId] = useState<string>();
|
||||||
|
const [showTeamForm, setShowTeamForm] = useState<boolean>(!hasTeams);
|
||||||
|
|
||||||
|
const teamOptions = useMemo(() =>
|
||||||
|
user?.teams?.map(team => ({
|
||||||
|
value: team.id,
|
||||||
|
label: team.name
|
||||||
|
})) || [],
|
||||||
|
[user?.teams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
setShowTeamForm(false);
|
||||||
|
setSelectedTeamId(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = useCallback((teamId: string | undefined) => {
|
||||||
|
setSelectedTeamId(teamId);
|
||||||
|
setShowTeamForm(true);
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={open}>
|
||||||
|
Enroll Your Team
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Sheet title={showTeamForm ? "Team Details" : "Enroll Team"} opened={isOpen} onChange={toggle}>
|
||||||
|
{showTeamForm ? (
|
||||||
|
<>
|
||||||
|
<p>Team Form {selectedTeamId === undefined ? "new team" : selectedTeamId}</p>
|
||||||
|
<Button variant="subtle" color="red" onClick={handleBack}>Back</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TeamSelectionView
|
||||||
|
options={teamOptions}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
<Button mt='sm' variant="subtle" color='red' size="sm" onClick={toggle}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EnrollTeam;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Stack, Button, Divider, Autocomplete, Group, ComboboxItem } from '@mantine/core';
|
||||||
|
import { PlusIcon } from '@phosphor-icons/react';
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
interface TeamSelectionViewProps {
|
||||||
|
options: ComboboxItem[];
|
||||||
|
onSelect: (teamId: string | undefined) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
|
||||||
|
options,
|
||||||
|
onSelect
|
||||||
|
}) => {
|
||||||
|
const [value, setValue] = useState<string>();
|
||||||
|
const selectedOption = useMemo(() => options.find(option => option.label === value), [value])
|
||||||
|
|
||||||
|
|
||||||
|
const handleCreateNewTeamClicked = () => onSelect(undefined);
|
||||||
|
const handleSelectExistingTeam = () => onSelect(selectedOption?.value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Button
|
||||||
|
leftSection={<PlusIcon weight="bold" />}
|
||||||
|
onClick={handleCreateNewTeamClicked}
|
||||||
|
variant="light"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Create New Team
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Divider my="sm" label="or" />
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Autocomplete
|
||||||
|
placeholder="Select one of your existing teams"
|
||||||
|
value={selectedOption?.label || ''}
|
||||||
|
onChange={setValue}
|
||||||
|
data={options.map(option => option.label)}
|
||||||
|
comboboxProps={{ withinPortal: false }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSelectExistingTeam}
|
||||||
|
disabled={!selectedOption}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Enroll Selected Team
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TeamSelectionView.displayName = 'TeamSelectionView';
|
||||||
|
|
||||||
|
export default TeamSelectionView;
|
||||||
@@ -25,6 +25,9 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
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 || []} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -17,20 +17,21 @@ import Avatar from "@/components/avatar";
|
|||||||
import { useBracketPreview } from "@/features/bracket/queries";
|
import { useBracketPreview } from "@/features/bracket/queries";
|
||||||
import { BracketData } from "@/features/bracket/types";
|
import { BracketData } from "@/features/bracket/types";
|
||||||
import BracketView from "@/features/bracket/components/bracket-view";
|
import BracketView from "@/features/bracket/components/bracket-view";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { tournamentKeys } from "../queries";
|
||||||
|
|
||||||
interface SeedTournamentProps {
|
interface SeedTournamentProps {
|
||||||
tournamentId: string;
|
tournamentId: string;
|
||||||
teams: TeamInfo[];
|
teams: TeamInfo[];
|
||||||
onSuccess?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SeedTournament: React.FC<SeedTournamentProps> = ({
|
const SeedTournament: React.FC<SeedTournamentProps> = ({
|
||||||
tournamentId,
|
tournamentId,
|
||||||
teams,
|
teams
|
||||||
onSuccess,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [orderedTeams, setOrderedTeams] = useState<TeamInfo[]>(teams);
|
const [orderedTeams, setOrderedTeams] = useState<TeamInfo[]>(teams);
|
||||||
const { data: bracketPreview } = useBracketPreview(teams.length);
|
const { data: bracketPreview } = useBracketPreview(teams.length);
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const bracket: BracketData = useMemo(
|
const bracket: BracketData = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -56,7 +57,9 @@ const SeedTournament: React.FC<SeedTournamentProps> = ({
|
|||||||
mutationFn: generateTournamentBracket,
|
mutationFn: generateTournamentBracket,
|
||||||
successMessage: "Tournament bracket generated successfully!",
|
successMessage: "Tournament bracket generated successfully!",
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
onSuccess?.();
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: tournamentKeys.details(tournamentId)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Badge, Card, Text, Image, Stack, Flex } from "@mantine/core";
|
import { Badge, Card, Text, Stack, Group, Box, ThemeIcon } from "@mantine/core";
|
||||||
import { Tournament } from "@/features/tournaments/types";
|
import { Tournament } from "@/features/tournaments/types";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { CaretRightIcon } from "@phosphor-icons/react";
|
import { TrophyIcon, CalendarIcon, MapPinIcon, UsersIcon } from "@phosphor-icons/react";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import Avatar from "@/components/avatar";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
interface TournamentCardProps {
|
interface TournamentCardProps {
|
||||||
tournament: Tournament;
|
tournament: Tournament;
|
||||||
@@ -10,64 +12,68 @@ interface TournamentCardProps {
|
|||||||
|
|
||||||
export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const displayDate = useMemo(() => {
|
const displayDate = useMemo(() => {
|
||||||
if (!tournament.start_time) return null;
|
if (!tournament.start_time) return null;
|
||||||
const date = new Date(tournament.start_time);
|
const date = new Date(tournament.start_time);
|
||||||
if (isNaN(date.getTime())) return null;
|
if (isNaN(date.getTime())) return null;
|
||||||
return date.toLocaleDateString("en-US", {
|
return date.toLocaleDateString(undefined, {
|
||||||
year: "numeric",
|
month: 'short',
|
||||||
month: "long",
|
day: 'numeric',
|
||||||
day: "numeric",
|
year: 'numeric'
|
||||||
});
|
});
|
||||||
}, [tournament.start_time]);
|
}, [tournament.start_time]);
|
||||||
|
|
||||||
|
const enrollmentDeadline = tournament.enroll_time
|
||||||
|
? new Date(tournament.enroll_time)
|
||||||
|
: new Date(tournament.start_time);
|
||||||
|
const isEnrollmentOpen = enrollmentDeadline > new Date();
|
||||||
|
const enrolledTeamsCount = tournament.teams?.length || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<motion.div
|
||||||
shadow="sm"
|
whileHover={{ y: -4, scale: 1.02 }}
|
||||||
padding="lg"
|
whileTap={{ scale: 0.98 }}
|
||||||
radius="md"
|
transition={{ type: "spring", stiffness: 300 }}
|
||||||
withBorder
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
onClick={() => navigate({ to: `/tournaments/${tournament.id}` })}
|
|
||||||
>
|
>
|
||||||
<Stack>
|
<Card
|
||||||
<Flex align="center" gap="md">
|
withBorder
|
||||||
<Image
|
radius="lg"
|
||||||
maw={100}
|
p="lg"
|
||||||
mah={100}
|
style={{
|
||||||
fit="contain"
|
cursor: "pointer",
|
||||||
src={
|
height: "100%",
|
||||||
tournament.logo
|
transition: "box-shadow 0.2s ease"
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
}}
|
||||||
: undefined
|
onClick={() => navigate({ to: `/tournaments/${tournament.id}` })}
|
||||||
}
|
onMouseEnter={(e) => {
|
||||||
alt={tournament.name}
|
e.currentTarget.style.boxShadow = "var(--mantine-shadow-md)";
|
||||||
fallbackSrc={"TODO"}
|
}}
|
||||||
/>
|
onMouseLeave={(e) => {
|
||||||
<Stack ta="center" mx="auto" gap="0">
|
e.currentTarget.style.boxShadow = "none";
|
||||||
<Text size="lg" fw={800}>
|
}}
|
||||||
{tournament.name} <CaretRightIcon size={12} weight="bold" />
|
>
|
||||||
|
<Stack gap="md" h="100%">
|
||||||
|
<Group gap="md" align="flex-start">
|
||||||
|
<Avatar
|
||||||
|
size={60}
|
||||||
|
radius="md"
|
||||||
|
name={tournament.name}
|
||||||
|
src={
|
||||||
|
tournament.logo
|
||||||
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TrophyIcon size={24} />
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<Text fw={700} size="lg" lineClamp={2} my='auto'>
|
||||||
|
{tournament.name}
|
||||||
</Text>
|
</Text>
|
||||||
{displayDate && (
|
</Group>
|
||||||
<Text c="dimmed" size="xs" fw={600}>
|
</Stack>
|
||||||
{displayDate}
|
</Card>
|
||||||
</Text>
|
</motion.div>
|
||||||
)}
|
|
||||||
<Stack gap={4} mt={4}>
|
|
||||||
{/* TODO: Add medalists when data is available */}
|
|
||||||
<Badge variant="dot" color="gold">
|
|
||||||
Longer Team Name Goes Here
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="dot" color="silver">
|
|
||||||
Some Team
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="dot" color="orange">
|
|
||||||
Medium Team Name
|
|
||||||
</Badge>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Flex>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ 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 { DateTimePicker } from "@mantine/dates";
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { DateTimePicker } from "@/components/date-time-picker";
|
||||||
|
|
||||||
interface TournamentFormProps {
|
interface TournamentFormProps {
|
||||||
close: () => void;
|
close: () => void;
|
||||||
@@ -166,6 +166,7 @@ const TournamentForm = ({
|
|||||||
label="Start Date"
|
label="Start Date"
|
||||||
withAsterisk
|
withAsterisk
|
||||||
formatValue={(date) =>
|
formatValue={(date) =>
|
||||||
|
!date ? 'Select a time' :
|
||||||
new Date(date).toLocaleDateString("en-US", {
|
new Date(date).toLocaleDateString("en-US", {
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@@ -186,6 +187,7 @@ const TournamentForm = ({
|
|||||||
label="Enrollment Due"
|
label="Enrollment Due"
|
||||||
withAsterisk
|
withAsterisk
|
||||||
formatValue={(date) =>
|
formatValue={(date) =>
|
||||||
|
!date ? 'Select a time' :
|
||||||
new Date(date).toLocaleDateString("en-US", {
|
new Date(date).toLocaleDateString("en-US", {
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@@ -206,17 +208,16 @@ const TournamentForm = ({
|
|||||||
title="Select End Date"
|
title="Select End Date"
|
||||||
label="End Date (Optional)"
|
label="End Date (Optional)"
|
||||||
formatValue={(date) =>
|
formatValue={(date) =>
|
||||||
date
|
!date ? 'Select a time' :
|
||||||
? new Date(date).toLocaleDateString("en-US", {
|
new Date(date).toLocaleDateString("en-US", {
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "numeric",
|
minute: "numeric",
|
||||||
hour12: true,
|
hour12: true,
|
||||||
})
|
})
|
||||||
: "Not set"
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { List, ListItem, Skeleton, Text } from "@mantine/core";
|
import { Stack, Skeleton, Text, Group, Box, ThemeIcon } from "@mantine/core";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
import { TournamentInfo } from "../types";
|
import { TournamentInfo } from "../types";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { TrophyIcon, CalendarIcon, MapPinIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
interface TournamentListProps {
|
interface TournamentListProps {
|
||||||
tournaments: TournamentInfo[];
|
tournaments: TournamentInfo[];
|
||||||
@@ -15,28 +17,130 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
|
|||||||
const handleClick = useCallback((tournamentId: string) =>
|
const handleClick = useCallback((tournamentId: string) =>
|
||||||
navigate({ to: `/tournaments/${tournamentId}` }), [navigate]);
|
navigate({ to: `/tournaments/${tournamentId}` }), [navigate]);
|
||||||
|
|
||||||
if (loading) return <List>
|
if (loading) {
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
return (
|
||||||
<ListItem py='xs' key={`skeleton-${i}`}
|
<Stack gap="sm">
|
||||||
icon={<Skeleton height={40} width={40} />}
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
>
|
<Box key={`skeleton-${i}`} p="md">
|
||||||
<Skeleton height={20} width={200} />
|
<Group gap="md">
|
||||||
</ListItem>
|
<Skeleton height={60} width={60} radius="md" />
|
||||||
))}
|
<Stack gap="xs" style={{ flex: 1 }}>
|
||||||
</List>
|
<Skeleton height={20} width="60%" />
|
||||||
|
<Skeleton height={16} width="40%" />
|
||||||
|
<Skeleton height={16} width="30%" />
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return <List>
|
if (!tournaments?.length) {
|
||||||
{tournaments?.map((tournament) => (
|
return (
|
||||||
<ListItem key={tournament.id}
|
<Box ta="center" py="xl">
|
||||||
py='xs'
|
<ThemeIcon size="xl" variant="light" radius="md" mb="md">
|
||||||
icon={<Avatar radius='xs' size={40} name={`${tournament.name}`} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />}
|
<TrophyIcon size={32} />
|
||||||
style={{ cursor: 'pointer' }}
|
</ThemeIcon>
|
||||||
onClick={() => handleClick(tournament.id)}
|
<Text c="dimmed" size="lg">
|
||||||
>
|
No tournaments found
|
||||||
<Text fw={500}>{`${tournament.name}`}</Text>
|
</Text>
|
||||||
</ListItem>
|
</Box>
|
||||||
))}
|
);
|
||||||
</List>
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<AnimatePresence>
|
||||||
|
{tournaments.map((tournament, index) => {
|
||||||
|
const startDate = tournament.start_time ? new Date(tournament.start_time) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={`tournament-${tournament.id}-${index}`}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.2, delay: index * 0.01 }}
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
|
border: "1px solid var(--mantine-color-gray-3)",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
|
onClick={() => handleClick(tournament.id)}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--mantine-color-gray-0)";
|
||||||
|
e.currentTarget.style.borderColor = "var(--mantine-primary-color-filled)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.borderColor = "var(--mantine-color-gray-3)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap="md" wrap="nowrap">
|
||||||
|
<Avatar
|
||||||
|
size={60}
|
||||||
|
radius="md"
|
||||||
|
name={tournament.name}
|
||||||
|
src={
|
||||||
|
tournament.logo
|
||||||
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
border: "2px solid var(--mantine-primary-color-light)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrophyIcon size={24} />
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text fw={600} size="lg" lineClamp={1}>
|
||||||
|
{tournament.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Group gap="lg" wrap="wrap">
|
||||||
|
{tournament.location && (
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon size="xs" variant="light" radius="sm">
|
||||||
|
<MapPinIcon size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||||
|
{tournament.location}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{startDate && !isNaN(startDate.getTime()) && (
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon size="xs" variant="light" radius="sm">
|
||||||
|
<CalendarIcon size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{startDate.toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TournamentList;
|
export default TournamentList;
|
||||||
|
|||||||
162
src/features/tournaments/components/upcoming-tournament.tsx
Normal file
162
src/features/tournaments/components/upcoming-tournament.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Tournament } from "../types";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Title,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import Avatar from "@/components/avatar";
|
||||||
|
import Countdown from "@/components/countdown";
|
||||||
|
import ListLink from "@/components/list-link";
|
||||||
|
import ListButton from "@/components/list-button";
|
||||||
|
import {
|
||||||
|
TrophyIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
MapPinIcon,
|
||||||
|
UsersIcon,
|
||||||
|
ListIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import EnrollTeam from "./enroll-team";
|
||||||
|
import EnrollFreeAgent from "./enroll-free-agent";
|
||||||
|
|
||||||
|
const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
||||||
|
tournament,
|
||||||
|
}) => {
|
||||||
|
const { user, roles } = useAuth();
|
||||||
|
|
||||||
|
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
|
||||||
|
const userTeam = useMemo(
|
||||||
|
() =>
|
||||||
|
tournament.teams?.find((team) =>
|
||||||
|
team.players?.some((player) => player.id === user?.id)
|
||||||
|
),
|
||||||
|
[tournament.teams, user?.id]
|
||||||
|
);
|
||||||
|
const isUserEnrolled = !!userTeam;
|
||||||
|
|
||||||
|
const enrollmentDeadline = tournament.enroll_time
|
||||||
|
? new Date(tournament.enroll_time)
|
||||||
|
: new Date(tournament.start_time);
|
||||||
|
const tournamentStart = new Date(tournament.start_time);
|
||||||
|
const isEnrollmentOpen = enrollmentDeadline > new Date();
|
||||||
|
|
||||||
|
const enrolledTeamsCount = tournament.teams?.length || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Group justify="space-around" align="flex-start" w='100%'>
|
||||||
|
<Group align="center" gap="lg">
|
||||||
|
<Avatar
|
||||||
|
name={tournament.name}
|
||||||
|
src={
|
||||||
|
tournament.logo
|
||||||
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
radius="md"
|
||||||
|
size={120}
|
||||||
|
px='md'
|
||||||
|
withBorder={false}
|
||||||
|
>
|
||||||
|
<TrophyIcon size={32} />
|
||||||
|
</Avatar>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{tournament.location && (
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||||
|
<MapPinIcon size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{tournament.location}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||||
|
<CalendarIcon size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{tournamentStart.toLocaleDateString(undefined, {
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}{" "}
|
||||||
|
at{" "}
|
||||||
|
{tournamentStart.toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||||
|
<UsersIcon size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{enrolledTeamsCount} teams enrolled
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{tournament.desc && <Text size="sm">{tournament.desc}</Text>}
|
||||||
|
|
||||||
|
<Card withBorder radius="md" p="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="xs" align="center">
|
||||||
|
<UsersIcon size={16} />
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
Enrollment
|
||||||
|
</Text>
|
||||||
|
{isEnrollmentOpen && (
|
||||||
|
<Box ml="auto">
|
||||||
|
<Countdown
|
||||||
|
date={enrollmentDeadline}
|
||||||
|
label="Time left"
|
||||||
|
color="yellow"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!isUserEnrolled && isEnrollmentOpen && (
|
||||||
|
<>
|
||||||
|
<EnrollTeam />
|
||||||
|
<Divider label="or" />
|
||||||
|
<EnrollFreeAgent />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Divider />
|
||||||
|
{isAdmin && (
|
||||||
|
<ListLink
|
||||||
|
label={`Manage ${tournament.name}`}
|
||||||
|
to={`/admin/tournaments/${tournament.id}`}
|
||||||
|
Icon={UsersIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ListButton label="View Rules" Icon={ListIcon} onClick={() => {}} />
|
||||||
|
<ListButton
|
||||||
|
label={`View Teams (${enrolledTeamsCount})`}
|
||||||
|
Icon={UsersIcon}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UpcomingTournament;
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { getTournament, getUnenrolledTeams, listTournaments } from "./server";
|
import { getCurrentTournament, getTournament, getUnenrolledTeams, listTournaments } from "./server";
|
||||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
|
|
||||||
export const tournamentKeys = {
|
export const tournamentKeys = {
|
||||||
list: ['tournaments', 'list'] as const,
|
list: ['tournaments', 'list'] as const,
|
||||||
details: (id: string) => ['tournaments', 'details', id] as const,
|
details: (id: string) => ['tournaments', 'details', id] as const,
|
||||||
|
current: ['tournaments', 'current'] as const,
|
||||||
unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const
|
unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,6 +17,10 @@ export const tournamentQueries = {
|
|||||||
queryKey: tournamentKeys.details(id),
|
queryKey: tournamentKeys.details(id),
|
||||||
queryFn: () => getTournament({ data: id })
|
queryFn: () => getTournament({ data: id })
|
||||||
}),
|
}),
|
||||||
|
current: () => ({
|
||||||
|
queryKey: tournamentKeys.current,
|
||||||
|
queryFn: getCurrentTournament
|
||||||
|
}),
|
||||||
unenrolled: (id: string) => ({
|
unenrolled: (id: string) => ({
|
||||||
queryKey: tournamentKeys.unenrolled(id),
|
queryKey: tournamentKeys.unenrolled(id),
|
||||||
queryFn: () => getUnenrolledTeams({ data: id })
|
queryFn: () => getUnenrolledTeams({ data: id })
|
||||||
@@ -28,5 +33,8 @@ export const useTournaments = () =>
|
|||||||
export const useTournament = (id: string) =>
|
export const useTournament = (id: string) =>
|
||||||
useServerSuspenseQuery(tournamentQueries.details(id));
|
useServerSuspenseQuery(tournamentQueries.details(id));
|
||||||
|
|
||||||
|
export const useCurrentTournament = () =>
|
||||||
|
useServerSuspenseQuery(tournamentQueries.current());
|
||||||
|
|
||||||
export const useUnenrolledTeams = (tournamentId: string) =>
|
export const useUnenrolledTeams = (tournamentId: string) =>
|
||||||
useServerSuspenseQuery(tournamentQueries.unenrolled(tournamentId));
|
useServerSuspenseQuery(tournamentQueries.unenrolled(tournamentId));
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ export const getTournament = createServerFn()
|
|||||||
toServerResult(() => pbAdmin.getTournament(tournamentId))
|
toServerResult(() => pbAdmin.getTournament(tournamentId))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getCurrentTournament = createServerFn()
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async () =>
|
||||||
|
toServerResult(() => pbAdmin.getMostRecentTournament())
|
||||||
|
);
|
||||||
|
|
||||||
export const enrollTeam = createServerFn()
|
export const enrollTeam = createServerFn()
|
||||||
.validator(z.object({
|
.validator(z.object({
|
||||||
tournamentId: z.string(),
|
tournamentId: z.string(),
|
||||||
|
|||||||
18
src/hooks/use-now.ts
Normal file
18
src/hooks/use-now.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const useNow = () => {
|
||||||
|
const [now, setNow] = useState(new Date());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
setNow(new Date());
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default useNow;
|
||||||
@@ -9,7 +9,7 @@ class PocketBaseAdminClient {
|
|||||||
public authPromise: Promise<void>;
|
public authPromise: Promise<void>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.pb = new PocketBase(import.meta.env.VITE_POCKETBASE_URL);
|
this.pb = new PocketBase(process.env.POCKETBASE_URL);
|
||||||
|
|
||||||
this.pb.beforeSend = (url, options) => {
|
this.pb.beforeSend = (url, options) => {
|
||||||
options.cache = "no-store";
|
options.cache = "no-store";
|
||||||
@@ -38,8 +38,8 @@ class PocketBaseAdminClient {
|
|||||||
await this.pb
|
await this.pb
|
||||||
.collection("_superusers")
|
.collection("_superusers")
|
||||||
.authWithPassword(
|
.authWithPassword(
|
||||||
import.meta.env.VITE_POCKETBASE_ADMIN_EMAIL!,
|
process.env.POCKETBASE_ADMIN_EMAIL!,
|
||||||
import.meta.env.VITE_POCKETBASE_ADMIN_PASSWORD!
|
process.env.POCKETBASE_ADMIN_PASSWORD!
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,38 @@
|
|||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import type { Match, MatchInput } from "@/features/matches/types";
|
import type { Match, MatchInput } from "@/features/matches/types";
|
||||||
import type PocketBase from "pocketbase";
|
import type PocketBase from "pocketbase";
|
||||||
|
import { transformMatch } from "../util/transform-types";
|
||||||
|
|
||||||
export function createMatchesService(pb: PocketBase) {
|
export function createMatchesService(pb: PocketBase) {
|
||||||
return {
|
return {
|
||||||
|
async getMatch(id: string): Promise<Match | null> {
|
||||||
|
logger.info("PocketBase | Getting match", id);
|
||||||
|
const result = await pb.collection("matches").getOne(id, {
|
||||||
|
expand: "tournament, home, away",
|
||||||
|
});
|
||||||
|
return transformMatch(result);
|
||||||
|
},
|
||||||
|
|
||||||
|
// match Ids where the current lid is home_from_lid or away_from_lid
|
||||||
|
async getChildMatches(matchId: string): Promise<{ winner: Match | undefined, loser: Match | undefined }> {
|
||||||
|
logger.info("PocketBase | Getting child matches", matchId);
|
||||||
|
const match = await this.getMatch(matchId);
|
||||||
|
if (!match) throw new Error("Match not found")
|
||||||
|
|
||||||
|
const result = await pb.collection("matches").getFullList({
|
||||||
|
filter: `tournament="${match.tournament.id}" && (home_from_lid = ${match.lid} || away_from_lid = ${match.lid}) && bye = false`,
|
||||||
|
expand: "tournament, home, away",
|
||||||
|
});
|
||||||
|
|
||||||
|
const winnerMatch = result.find(m => (m.home_from_lid === match.lid && !m.home_from_loser) || (m.away_from_lid === match.lid && !m.away_from_loser));
|
||||||
|
const loserMatch = result.find(m => (m.home_from_lid === match.lid && m.home_from_loser) || (m.away_from_lid === match.lid && m.away_from_loser));
|
||||||
|
|
||||||
|
return {
|
||||||
|
winner: winnerMatch ? transformMatch(winnerMatch) : undefined,
|
||||||
|
loser: loserMatch ? transformMatch(loserMatch) : undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async createMatch(data: MatchInput): Promise<Match> {
|
async createMatch(data: MatchInput): Promise<Match> {
|
||||||
logger.info("PocketBase | Creating match", data);
|
logger.info("PocketBase | Creating match", data);
|
||||||
const result = await pb.collection("matches").create<Match>(data);
|
const result = await pb.collection("matches").create<Match>(data);
|
||||||
@@ -11,17 +40,26 @@ export function createMatchesService(pb: PocketBase) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async createMatches(matches: MatchInput[]): Promise<Match[]> {
|
async createMatches(matches: MatchInput[]): Promise<Match[]> {
|
||||||
logger.info("PocketBase | Creating multiple matches", { count: matches.length });
|
logger.info("PocketBase | Creating multiple matches", {
|
||||||
|
count: matches.length,
|
||||||
|
});
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
matches.map(match => pb.collection("matches").create<Match>(match))
|
matches.map((match) => pb.collection("matches").create<Match>(match))
|
||||||
);
|
);
|
||||||
return results;
|
return results;
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateMatch(id: string, data: Partial<MatchInput>): Promise<Match> {
|
async updateMatch(id: string, data: Partial<MatchInput>): Promise<Match> {
|
||||||
logger.info("PocketBase | Updating match", { id, data });
|
logger.info("PocketBase | Updating match", { id, data });
|
||||||
const result = await pb.collection("matches").update<Match>(id, data);
|
const result = await pb.collection("matches").update<Match>(id, data, {
|
||||||
return result;
|
expand: 'home, away'
|
||||||
|
});
|
||||||
|
return transformMatch(result);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteMatch(id: string): Promise<void> {
|
||||||
|
logger.info("PocketBase | Deleting match", id);
|
||||||
|
await pb.collection("matches").delete(id);
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteMatchesByTournament(tournamentId: string): Promise<void> {
|
async deleteMatchesByTournament(tournamentId: string): Promise<void> {
|
||||||
@@ -32,7 +70,7 @@ export function createMatchesService(pb: PocketBase) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
matches.map(match => pb.collection("matches").delete(match.id))
|
matches.map((match) => pb.collection("matches").delete(match.id))
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import type {
|
|||||||
PlayerInfo,
|
PlayerInfo,
|
||||||
PlayerInput,
|
PlayerInput,
|
||||||
PlayerUpdateInput,
|
PlayerUpdateInput,
|
||||||
|
PlayerStats,
|
||||||
} from "@/features/players/types";
|
} from "@/features/players/types";
|
||||||
import { transformPlayer, transformPlayerInfo } from "@/lib/pocketbase/util/transform-types";
|
import type { Match } from "@/features/matches/types";
|
||||||
|
import { transformPlayer, transformPlayerInfo, transformMatch } from "@/lib/pocketbase/util/transform-types";
|
||||||
import PocketBase from "pocketbase";
|
import PocketBase from "pocketbase";
|
||||||
import { DataFetchOptions } from "./base";
|
import { DataFetchOptions } from "./base";
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ export function createPlayersService(pb: PocketBase) {
|
|||||||
async getPlayerByAuthId(authId: string): Promise<Player | null> {
|
async getPlayerByAuthId(authId: string): Promise<Player | null> {
|
||||||
const result = await pb.collection("players").getList<Player>(1, 1, {
|
const result = await pb.collection("players").getList<Player>(1, 1, {
|
||||||
filter: `auth_id = "${authId}"`,
|
filter: `auth_id = "${authId}"`,
|
||||||
|
expand: 'teams'
|
||||||
});
|
});
|
||||||
return result.items[0] ? transformPlayer(result.items[0]) : null;
|
return result.items[0] ? transformPlayer(result.items[0]) : null;
|
||||||
},
|
},
|
||||||
@@ -61,5 +64,43 @@ export function createPlayersService(pb: PocketBase) {
|
|||||||
});
|
});
|
||||||
return result.map(transformPlayer);
|
return result.map(transformPlayer);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getPlayerStats(playerId: string): Promise<PlayerStats[]> {
|
||||||
|
const result = await pb.collection("player_stats").getFullList<PlayerStats>({
|
||||||
|
filter: `player_id = "${playerId}"`,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAllPlayerStats(): Promise<PlayerStats[]> {
|
||||||
|
const result = await pb.collection("player_stats").getFullList<PlayerStats>({
|
||||||
|
sort: "-win_percentage,-total_cups_made",
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPlayerMatches(playerId: string): Promise<Match[]> {
|
||||||
|
const player = await pb.collection("players").getOne(playerId, {
|
||||||
|
expand: "teams",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!player.expand?.teams || player.expand.teams.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamIds = Array.isArray(player.expand.teams)
|
||||||
|
? player.expand.teams.map((team: any) => team.id)
|
||||||
|
: [player.expand.teams.id];
|
||||||
|
|
||||||
|
const teamFilter = teamIds.map(teamId => `home = "${teamId}" || away = "${teamId}"`).join(" || ");
|
||||||
|
|
||||||
|
const result = await pb.collection("matches").getFullList({
|
||||||
|
filter: `(${teamFilter}) && (status = "ended" || status = "started")`,
|
||||||
|
sort: "-created",
|
||||||
|
expand: "tournament,home,away",
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.map(transformMatch);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
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 { transformTeam, transformTeamInfo } from "@/lib/pocketbase/util/transform-types";
|
||||||
import { Team, TeamInfo } from "@/features/teams/types";
|
import { Team, TeamInfo, TeamInput, TeamUpdateInput } from "@/features/teams/types";
|
||||||
import { DataFetchOptions } from "./base";
|
|
||||||
|
|
||||||
export function createTeamsService(pb: PocketBase) {
|
export function createTeamsService(pb: PocketBase) {
|
||||||
return {
|
return {
|
||||||
async getTeamInfo(id: string): Promise<TeamInfo> {
|
async getTeamInfo(id: string): Promise<TeamInfo> {
|
||||||
logger.info("PocketBase | Getting team info", id);
|
logger.info("PocketBase | Getting team info", id);
|
||||||
const result = await pb.collection("teams").getOne(id, {
|
const result = await pb.collection("teams").getOne(id, {
|
||||||
fields: "id,name,primary_color,accent_color"
|
fields: "id,name,primary_color,accent_color,logo",
|
||||||
|
expand: "players"
|
||||||
});
|
});
|
||||||
return transformTeamInfo(result);
|
return transformTeamInfo(result);
|
||||||
},
|
},
|
||||||
@@ -17,10 +17,12 @@ export function createTeamsService(pb: PocketBase) {
|
|||||||
async listTeamInfos(): Promise<TeamInfo[]> {
|
async listTeamInfos(): Promise<TeamInfo[]> {
|
||||||
logger.info("PocketBase | Listing team infos");
|
logger.info("PocketBase | Listing team infos");
|
||||||
const result = await pb.collection("teams").getFullList({
|
const result = await pb.collection("teams").getFullList({
|
||||||
fields: "id,name,primary_color,accent_color"
|
fields: "id,name,primary_color,accent_color,logo",
|
||||||
|
expand: "players"
|
||||||
});
|
});
|
||||||
return result.map(transformTeamInfo);
|
return result.map(transformTeamInfo);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getTeam(id: string): Promise<Team | null> {
|
async getTeam(id: string): Promise<Team | null> {
|
||||||
logger.info("PocketBase | Getting team", id);
|
logger.info("PocketBase | Getting team", id);
|
||||||
const result = await pb.collection("teams").getOne(id, {
|
const result = await pb.collection("teams").getOne(id, {
|
||||||
@@ -28,5 +30,56 @@ export function createTeamsService(pb: PocketBase) {
|
|||||||
});
|
});
|
||||||
return transformTeam(result);
|
return transformTeam(result);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async createTeam(data: TeamInput): Promise<Team> {
|
||||||
|
logger.info("PocketBase | Creating team", data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const playerId of data.players) {
|
||||||
|
const playerExists = await pb.collection("players").getOne(playerId).catch(() => null);
|
||||||
|
if (!playerExists) {
|
||||||
|
throw new Error(`Player with ID ${playerId} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pb.collection("teams").create({
|
||||||
|
...data,
|
||||||
|
players: data.players
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const playerId of data.players) {
|
||||||
|
await pb.collection("players").update(playerId, {
|
||||||
|
"teams+": result.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformTeam(await pb.collection("teams").getOne(result.id, {
|
||||||
|
expand: "players, tournaments"
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("PocketBase | Error creating team", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateTeam(id: string, data: TeamUpdateInput): Promise<Team> {
|
||||||
|
logger.info("PocketBase | Updating team", { id, updates: Object.keys(data) });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingTeam = await pb.collection("teams").getOne(id).catch(() => null);
|
||||||
|
if (!existingTeam) {
|
||||||
|
throw new Error(`Team with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pb.collection("teams").update(id, data);
|
||||||
|
|
||||||
|
return transformTeam(await pb.collection("teams").getOne(result.id, {
|
||||||
|
expand: "players, tournaments"
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("PocketBase | Error updating team", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,21 @@ export function createTournamentsService(pb: PocketBase) {
|
|||||||
return {
|
return {
|
||||||
async getTournament(id: string): Promise<Tournament> {
|
async getTournament(id: string): Promise<Tournament> {
|
||||||
const result = await pb.collection("tournaments").getOne(id, {
|
const result = await pb.collection("tournaments").getOne(id, {
|
||||||
expand: "teams, teams.players, matches, matches.tournament",
|
expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players",
|
||||||
});
|
});
|
||||||
return transformTournament(result);
|
return transformTournament(result);
|
||||||
},
|
},
|
||||||
|
async getMostRecentTournament(): Promise<Tournament> {
|
||||||
|
const result = await pb
|
||||||
|
.collection("tournaments")
|
||||||
|
.getFirstListItem('',
|
||||||
|
{
|
||||||
|
expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away",
|
||||||
|
sort: "-created",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return transformTournament(result);
|
||||||
|
},
|
||||||
async listTournaments(): Promise<TournamentInfo[]> {
|
async listTournaments(): Promise<TournamentInfo[]> {
|
||||||
const result = await pb
|
const result = await pb
|
||||||
.collection("tournaments")
|
.collection("tournaments")
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function transformTeamInfo(record: any): TeamInfo {
|
|||||||
export const transformMatch = (record: any): Match => {
|
export const transformMatch = (record: any): Match => {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
order: record.name,
|
order: record.order,
|
||||||
lid: record.lid,
|
lid: record.lid,
|
||||||
reset: record.reset,
|
reset: record.reset,
|
||||||
round: record.round,
|
round: record.round,
|
||||||
@@ -43,9 +43,10 @@ export const transformMatch = (record: any): Match => {
|
|||||||
home_from_loser: record.home_from_loser,
|
home_from_loser: record.home_from_loser,
|
||||||
away_from_loser: record.away_from_loser,
|
away_from_loser: record.away_from_loser,
|
||||||
is_losers_bracket: record.is_losers_bracket,
|
is_losers_bracket: record.is_losers_bracket,
|
||||||
tournament: transformTournamentInfo(record.expand?.tournament),
|
status: record.status || "tbd",
|
||||||
home: record.expand?.home ? transformTeamInfo(record.expand.home) : undefined,
|
tournament: record.expand?.tournament ? transformTournamentInfo(record.expand?.tournament) : record.tournament,
|
||||||
away: record.expand?.away ? transformTeamInfo(record.expand.away) : undefined,
|
home: record.expand?.home ? transformTeamInfo(record.expand.home) : record.home,
|
||||||
|
away: record.expand?.away ? transformTeamInfo(record.expand.away) : record.away,
|
||||||
created: record.created,
|
created: record.created,
|
||||||
updated: record.updated,
|
updated: record.updated,
|
||||||
home_seed: record.home_seed,
|
home_seed: record.home_seed,
|
||||||
|
|||||||
74
src/lib/spotify/auth.ts
Normal file
74
src/lib/spotify/auth.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { PKCEState, SpotifyTokenResponse } from './types';
|
||||||
|
|
||||||
|
const SPOTIFY_AUTH_BASE = 'https://accounts.spotify.com';
|
||||||
|
const SPOTIFY_SCOPES = [
|
||||||
|
'user-read-playback-state',
|
||||||
|
'user-modify-playback-state',
|
||||||
|
'user-read-currently-playing',
|
||||||
|
'streaming',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
function generateRandomString(length: number): string {
|
||||||
|
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
const values = crypto.getRandomValues(new Uint8Array(length));
|
||||||
|
return values.reduce((acc, x) => acc + possible[x % possible.length], '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SpotifyAuth {
|
||||||
|
private clientId: string;
|
||||||
|
private redirectUri: string;
|
||||||
|
|
||||||
|
constructor(clientId: string, redirectUri: string) {
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.redirectUri = redirectUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
async startAuthFlow(returnPath: string = window.location.pathname): Promise<void> {
|
||||||
|
const randomState = generateRandomString(16);
|
||||||
|
|
||||||
|
const stateWithPath = btoa(JSON.stringify({
|
||||||
|
state: randomState,
|
||||||
|
returnPath: returnPath
|
||||||
|
}));
|
||||||
|
|
||||||
|
sessionStorage.setItem('spotify_state', randomState);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: this.clientId,
|
||||||
|
scope: SPOTIFY_SCOPES,
|
||||||
|
redirect_uri: this.redirectUri,
|
||||||
|
state: stateWithPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const authUrl = `${SPOTIFY_AUTH_BASE}/authorize?${params.toString()}`;
|
||||||
|
window.location.href = authUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshAccessToken(refreshToken: string): Promise<SpotifyTokenResponse> {
|
||||||
|
const response = await fetch('/api/spotify/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(`Token refresh failed: ${error.error || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
getReturnPath(): string {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
clearStoredData(): void {
|
||||||
|
sessionStorage.removeItem('spotify_state');
|
||||||
|
}
|
||||||
|
}
|
||||||
199
src/lib/spotify/client.ts
Normal file
199
src/lib/spotify/client.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import type {
|
||||||
|
SpotifyDevice,
|
||||||
|
SpotifyDevicesResponse,
|
||||||
|
SpotifyPlaybackState,
|
||||||
|
SpotifyPlaybackSnapshot,
|
||||||
|
SpotifyError,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const SPOTIFY_API_BASE = 'https://api.spotify.com/v1';
|
||||||
|
|
||||||
|
export class SpotifyWebApiClient {
|
||||||
|
private accessToken: string;
|
||||||
|
|
||||||
|
constructor(accessToken: string) {
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${SPOTIFY_API_BASE}${endpoint}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${this.accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
try {
|
||||||
|
const error: SpotifyError = await response.json();
|
||||||
|
throw new Error(`Spotify API Error: ${error.error?.message || 'Unknown error'}`);
|
||||||
|
} catch (parseError) {
|
||||||
|
throw new Error(`Spotify API Error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204 || response.status === 202) {
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = response.headers.get('content-length');
|
||||||
|
if (contentLength === '0') {
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
console.warn('Non-JSON response from Spotify API:', contentType, response.status);
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse Spotify API JSON response:', error);
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDevices(): Promise<SpotifyDevice[]> {
|
||||||
|
const response = await this.request<SpotifyDevicesResponse>('/me/player/devices');
|
||||||
|
return response.devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
async transferPlayback(deviceId: string, play: boolean = false): Promise<void> {
|
||||||
|
await this.request('/me/player', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
device_ids: [deviceId],
|
||||||
|
play,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlaybackState(): Promise<SpotifyPlaybackState | null> {
|
||||||
|
try {
|
||||||
|
return await this.request<SpotifyPlaybackState>('/me/player');
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes('204')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async play(deviceId?: string): Promise<void> {
|
||||||
|
const endpoint = deviceId ? `/me/player/play?device_id=${deviceId}` : '/me/player/play';
|
||||||
|
await this.request(endpoint, {
|
||||||
|
method: 'PUT',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async pause(): Promise<void> {
|
||||||
|
await this.request('/me/player/pause', {
|
||||||
|
method: 'PUT',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async skipToNext(): Promise<void> {
|
||||||
|
await this.request('/me/player/next', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async skipToPrevious(): Promise<void> {
|
||||||
|
await this.request('/me/player/previous', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setVolume(volumePercent: number): Promise<void> {
|
||||||
|
await this.request(`/me/player/volume?volume_percent=${volumePercent}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentUser(): Promise<{ id: string; display_name: string }> {
|
||||||
|
return this.request<{ id: string; display_name: string }>('/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAccessToken(accessToken: string): void {
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPlaybackSnapshot(): Promise<SpotifyPlaybackSnapshot | null> {
|
||||||
|
const state = await this.getPlaybackState();
|
||||||
|
if (!state || !state.item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot: SpotifyPlaybackSnapshot = {
|
||||||
|
contextUri: state.context?.uri,
|
||||||
|
trackUri: state.item.external_urls.spotify,
|
||||||
|
trackId: state.item.id,
|
||||||
|
positionMs: state.progress_ms || 0,
|
||||||
|
shuffleState: state.shuffle_state,
|
||||||
|
repeatState: state.repeat_state,
|
||||||
|
volumePercent: state.device.volume_percent || undefined,
|
||||||
|
deviceId: state.device.id,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
isPlaying: state.is_playing,
|
||||||
|
trackName: state.item.name,
|
||||||
|
artistName: state.item.artists.map(a => a.name).join(', '),
|
||||||
|
albumName: state.item.album.name,
|
||||||
|
albumImageUrl: state.item.album.images[2]?.url,
|
||||||
|
};
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async restorePlaybackSnapshot(snapshot: SpotifyPlaybackSnapshot): Promise<void> {
|
||||||
|
if (snapshot.deviceId) {
|
||||||
|
await this.transferPlayback(snapshot.deviceId, false);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
const playbackRequest: any = {
|
||||||
|
position_ms: snapshot.positionMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (snapshot.contextUri && snapshot.trackId) {
|
||||||
|
playbackRequest.context_uri = snapshot.contextUri;
|
||||||
|
playbackRequest.offset = { uri: `spotify:track:${snapshot.trackId}` };
|
||||||
|
} else if (snapshot.trackId) {
|
||||||
|
playbackRequest.uris = [`spotify:track:${snapshot.trackId}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = snapshot.deviceId
|
||||||
|
? `/me/player/play?device_id=${snapshot.deviceId}`
|
||||||
|
: '/me/player/play';
|
||||||
|
|
||||||
|
await this.request(endpoint, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(playbackRequest),
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
this.request(`/me/player/shuffle?state=${snapshot.shuffleState}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
}),
|
||||||
|
this.request(`/me/player/repeat?state=${snapshot.repeatState}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
}),
|
||||||
|
snapshot.volumePercent !== undefined
|
||||||
|
? this.setVolume(snapshot.volumePercent)
|
||||||
|
: Promise.resolve(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!snapshot.isPlaying) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
await this.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/lib/spotify/hooks.ts
Normal file
63
src/lib/spotify/hooks.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { SpotifyContext } from '@/contexts/spotify-context';
|
||||||
|
import type { SpotifyContextType } from './types';
|
||||||
|
|
||||||
|
export const useSpotify = (): SpotifyContextType => {
|
||||||
|
const context = useContext(SpotifyContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSpotify must be used within a SpotifyProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSpotifyAuth = () => {
|
||||||
|
const { isAuthenticated, login, logout } = useSpotify();
|
||||||
|
return { isAuthenticated, login, logout };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSpotifyPlayback = () => {
|
||||||
|
const {
|
||||||
|
playbackState,
|
||||||
|
currentTrack,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
skipNext,
|
||||||
|
skipPrevious,
|
||||||
|
setVolume,
|
||||||
|
refreshPlaybackState,
|
||||||
|
isLoading,
|
||||||
|
} = useSpotify();
|
||||||
|
|
||||||
|
return {
|
||||||
|
playbackState,
|
||||||
|
currentTrack,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
skipNext,
|
||||||
|
skipPrevious,
|
||||||
|
setVolume,
|
||||||
|
refreshPlaybackState,
|
||||||
|
isLoading,
|
||||||
|
isPlaying: playbackState?.is_playing || false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSpotifyDevices = () => {
|
||||||
|
const {
|
||||||
|
devices,
|
||||||
|
activeDevice,
|
||||||
|
getDevices,
|
||||||
|
setActiveDevice,
|
||||||
|
isLoading,
|
||||||
|
} = useSpotify();
|
||||||
|
|
||||||
|
return {
|
||||||
|
devices,
|
||||||
|
activeDevice,
|
||||||
|
getDevices,
|
||||||
|
setActiveDevice,
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
|
};
|
||||||
133
src/lib/spotify/types.ts
Normal file
133
src/lib/spotify/types.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
export interface SpotifyDevice {
|
||||||
|
id: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_private_session: boolean;
|
||||||
|
is_restricted: boolean;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
volume_percent: number | null;
|
||||||
|
supports_volume: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotifyTrack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artists: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
album: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
images: Array<{
|
||||||
|
url: string;
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
duration_ms: number;
|
||||||
|
external_urls: {
|
||||||
|
spotify: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotifyPlaybackState {
|
||||||
|
device: SpotifyDevice;
|
||||||
|
repeat_state: 'off' | 'context' | 'track';
|
||||||
|
shuffle_state: boolean;
|
||||||
|
context: {
|
||||||
|
type: string;
|
||||||
|
href: string;
|
||||||
|
external_urls: {
|
||||||
|
spotify: string;
|
||||||
|
};
|
||||||
|
uri: string;
|
||||||
|
} | null;
|
||||||
|
timestamp: number;
|
||||||
|
progress_ms: number | null;
|
||||||
|
is_playing: boolean;
|
||||||
|
item: SpotifyTrack | null;
|
||||||
|
currently_playing_type: 'track' | 'episode' | 'ad' | 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotifyTokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: 'Bearer';
|
||||||
|
scope: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_token?: string;
|
||||||
|
state?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotifyError {
|
||||||
|
error: {
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotifyAuthState {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
accessToken: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
expiresAt: number | null;
|
||||||
|
scopes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotifyContextType extends SpotifyAuthState {
|
||||||
|
currentTrack: SpotifyTrack | null;
|
||||||
|
playbackState: SpotifyPlaybackState | null;
|
||||||
|
devices: SpotifyDevice[];
|
||||||
|
activeDevice: SpotifyDevice | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
capturedState: SpotifyPlaybackSnapshot | null;
|
||||||
|
isCaptureLoading: boolean;
|
||||||
|
isResumeLoading: boolean;
|
||||||
|
|
||||||
|
login: () => void;
|
||||||
|
logout: () => void;
|
||||||
|
|
||||||
|
play: (deviceId?: string) => Promise<void>;
|
||||||
|
pause: () => Promise<void>;
|
||||||
|
skipNext: () => Promise<void>;
|
||||||
|
skipPrevious: () => Promise<void>;
|
||||||
|
setVolume: (volumePercent: number) => Promise<void>;
|
||||||
|
|
||||||
|
getDevices: () => Promise<void>;
|
||||||
|
setActiveDevice: (deviceId: string) => Promise<void>;
|
||||||
|
|
||||||
|
refreshPlaybackState: () => Promise<void>;
|
||||||
|
|
||||||
|
capturePlaybackState: () => Promise<void>;
|
||||||
|
resumePlaybackState: () => Promise<void>;
|
||||||
|
clearCapturedState: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PKCEState {
|
||||||
|
codeVerifier: string;
|
||||||
|
codeChallenge: string;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotifyDevicesResponse {
|
||||||
|
devices: SpotifyDevice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotifyPlaybackSnapshot {
|
||||||
|
contextUri?: string;
|
||||||
|
trackUri?: string;
|
||||||
|
trackId?: string;
|
||||||
|
positionMs: number;
|
||||||
|
shuffleState: boolean;
|
||||||
|
repeatState: 'off' | 'context' | 'track';
|
||||||
|
volumePercent?: number;
|
||||||
|
deviceId?: string;
|
||||||
|
timestamp: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
trackName?: string;
|
||||||
|
artistName?: string;
|
||||||
|
albumName?: string;
|
||||||
|
albumImageUrl?: string;
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ export const backendConfig = (): TypeInput => {
|
|||||||
framework: "custom",
|
framework: "custom",
|
||||||
supertokens: {
|
supertokens: {
|
||||||
connectionURI:
|
connectionURI:
|
||||||
import.meta.env.VITE_SUPERTOKENS_URI || "https://try.supertokens.io",
|
process.env.SUPERTOKENS_URI || "https://try.supertokens.io",
|
||||||
},
|
},
|
||||||
appInfo,
|
appInfo,
|
||||||
recipeList: [
|
recipeList: [
|
||||||
|
|||||||
@@ -23,16 +23,34 @@ export function useServerMutation<TData, TVariables = unknown>(
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
...mutationOptions,
|
...mutationOptions,
|
||||||
mutationFn: async (variables: TVariables) => {
|
mutationFn: async (variables: TVariables) => {
|
||||||
const result = await mutationFn(variables);
|
try {
|
||||||
|
const result = await mutationFn(variables);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
if (showErrorToast) {
|
if (showErrorToast) {
|
||||||
toast.error(result.error.userMessage);
|
toast.error(result.error.userMessage);
|
||||||
|
}
|
||||||
|
throw new Error(result.error.userMessage);
|
||||||
}
|
}
|
||||||
throw new Error(result.error.userMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 401) {
|
||||||
|
try {
|
||||||
|
const errorData = typeof error.response.data === 'string'
|
||||||
|
? JSON.parse(error.response.data)
|
||||||
|
: error.response.data;
|
||||||
|
|
||||||
|
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
|
||||||
|
const currentUrl = window.location.pathname + window.location.search;
|
||||||
|
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
|
||||||
|
throw new Error("SESSION_REFRESH_REQUIRED");
|
||||||
|
}
|
||||||
|
} catch (parseError) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onSuccess: (data, variables, context) => {
|
onSuccess: (data, variables, context) => {
|
||||||
if (showSuccessToast && successMessage) {
|
if (showSuccessToast && successMessage) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import twilio from "twilio";
|
import twilio from "twilio";
|
||||||
|
|
||||||
const accountSid = import.meta.env.VITE_TWILIO_ACCOUNT_SID;
|
const accountSid = process.env.TWILIO_ACCOUNT_SID!;
|
||||||
const authToken = import.meta.env.VITE_TWILIO_AUTH_TOKEN;
|
const authToken = process.env.TWILIO_AUTH_TOKEN!;
|
||||||
const serviceSid = import.meta.env.VITE_TWILIO_SERVICE_SID;
|
const serviceSid = process.env.TWILIO_SERVICE_SID!;
|
||||||
|
|
||||||
const client = twilio(accountSid, authToken);
|
const client = twilio(accountSid, authToken);
|
||||||
|
|
||||||
|
|||||||
@@ -70,10 +70,14 @@ export const verifySuperTokensSession = async (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSessionContext = async (request: Request): Promise<any> => {
|
export const getSessionContext = async (request: Request, options?: { isServerFunction?: boolean }): Promise<any> => {
|
||||||
const session = await verifySuperTokensSession(request);
|
const session = await verifySuperTokensSession(request);
|
||||||
|
|
||||||
if (session.context.session?.tryRefresh) {
|
if (session.context.session?.tryRefresh) {
|
||||||
|
if (options?.isServerFunction) {
|
||||||
|
throw new Error("SESSION_REFRESH_REQUIRED");
|
||||||
|
}
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const from = encodeURIComponent(url.pathname + url.search);
|
const from = encodeURIComponent(url.pathname + url.search);
|
||||||
throw redirect({
|
throw redirect({
|
||||||
@@ -107,22 +111,56 @@ export const superTokensFunctionMiddleware = createMiddleware({
|
|||||||
type: "function",
|
type: "function",
|
||||||
}).server(async ({ next, response }) => {
|
}).server(async ({ next, response }) => {
|
||||||
const request = getWebRequest();
|
const request = getWebRequest();
|
||||||
const context = await getSessionContext(request);
|
|
||||||
return next({ context });
|
try {
|
||||||
|
const context = await getSessionContext(request, { isServerFunction: true });
|
||||||
|
return next({ context });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === "SESSION_REFRESH_REQUIRED") {
|
||||||
|
throw new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "SESSION_REFRESH_REQUIRED",
|
||||||
|
message: "Session needs to be refreshed"
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const superTokensAdminFunctionMiddleware = createMiddleware({
|
export const superTokensAdminFunctionMiddleware = createMiddleware({
|
||||||
type: "function",
|
type: "function",
|
||||||
}).server(async ({ next }) => {
|
}).server(async ({ next }) => {
|
||||||
const request = getWebRequest();
|
const request = getWebRequest();
|
||||||
const context = await getSessionContext(request);
|
|
||||||
|
|
||||||
if (context.roles?.includes("Admin")) {
|
try {
|
||||||
return next({ context });
|
const context = await getSessionContext(request, { isServerFunction: true });
|
||||||
|
|
||||||
|
if (context.roles?.includes("Admin")) {
|
||||||
|
return next({ context });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error("Unauthorized user in admin function.", context);
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === "SESSION_REFRESH_REQUIRED") {
|
||||||
|
throw new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "SESSION_REFRESH_REQUIRED",
|
||||||
|
message: "Session needs to be refreshed"
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error("Unauthorized user in admin function.", context);
|
|
||||||
throw new Error("Unauthorized");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const fetchUserRoles = async (userAuthId: string) => {
|
export const fetchUserRoles = async (userAuthId: string) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user