Compare commits
34 Commits
upgrade
...
af0ec85811
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af0ec85811 | ||
|
|
d18d148d32 | ||
|
|
95a50ee7a7 | ||
|
|
1ef786ea79 | ||
|
|
47962a8681 | ||
|
|
2e6d3366e4 | ||
|
|
fafe5ca3ec | ||
|
|
b52c79772f | ||
|
|
8579ec36ca | ||
|
|
2dfb7c63d3 | ||
|
|
03b2b54c1f | ||
|
|
0910f11228 | ||
|
|
a376f98fe7 | ||
|
|
1f4f66f8c5 | ||
|
|
5729dab35f | ||
|
|
c05fd5dc6d | ||
|
|
b9a42b4743 | ||
|
|
74e28cc2ac | ||
|
|
adf304b1e0 | ||
|
|
d18cdce15f | ||
|
|
aa87a9da5b | ||
|
|
6224404aa9 | ||
|
|
654041b6b6 | ||
|
|
ce29c41bf3 | ||
|
|
63ea515a31 | ||
|
|
8b1bbe213d | ||
|
|
ed538b7373 | ||
|
|
03e3bbcbc0 | ||
|
|
baf75eddba | ||
|
|
5094933302 | ||
|
|
9564b46d45 | ||
|
|
ece5094f13 | ||
|
|
cfe1ee7171 | ||
|
|
3a41609a91 |
108
pb_migrations/1759244692_created_activities.js
Normal file
108
pb_migrations/1759244692_created_activities.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1579384326",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json4225120046",
|
||||||
|
"maxSize": 0,
|
||||||
|
"name": "arguments",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2551806565",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "player",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3293145029",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "user_agent",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_1262591861",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "activities",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1262591861");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
27
pb_migrations/1759245857_updated_activities.js
Normal file
27
pb_migrations/1759245857_updated_activities.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1262591861")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(5, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number2254405824",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "duration",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1262591861")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("number2254405824")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
43
pb_migrations/1759246171_updated_activities.js
Normal file
43
pb_migrations/1759246171_updated_activities.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1262591861")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(6, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool1862328242",
|
||||||
|
"name": "success",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(7, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1574812785",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "error",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1262591861")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("bool1862328242")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("text1574812785")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
27
pb_migrations/1759340868_updated_badges.js
Normal file
27
pb_migrations/1759340868_updated_badges.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1340419796")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(7, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number4113142680",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "order",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1340419796")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("number4113142680")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
46
pb_migrations/1759344923_updated_players.js
Normal file
46
pb_migrations/1759344923_updated_players.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_3072146508")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("relation2029409178")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("relation2813965191")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_3072146508")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(5, new Field({
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1340419796",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2029409178",
|
||||||
|
"maxSelect": 999,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "badges",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(6, new Field({
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1340419796",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2813965191",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "featured_badge",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
187
pb_migrations/1759344931_deleted_player_badges_view.js
Normal file
187
pb_migrations/1759344931_deleted_player_badges_view.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_5062686152");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2582050271",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "player_id",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1340419796",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation4154639100",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "badge_id",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_GhrR",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "badge_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_DEaW",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "badge_description",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_MHmw",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "badge_type",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"tournament_participation",
|
||||||
|
"tournament_placement",
|
||||||
|
"performance",
|
||||||
|
"overtime",
|
||||||
|
"match_milestone"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_11YE",
|
||||||
|
"max": 50,
|
||||||
|
"min": 0,
|
||||||
|
"name": "badge_icon",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_qAJu",
|
||||||
|
"max": 50,
|
||||||
|
"min": 0,
|
||||||
|
"name": "badge_color",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_giOf",
|
||||||
|
"name": "is_progressive",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json3212413036",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "current_progress",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json4171899439",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "target_progress",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json3435813110",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "is_earned",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_Q7lC",
|
||||||
|
"max": "",
|
||||||
|
"min": "",
|
||||||
|
"name": "earned_at",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "date"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_5062686152",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "player_badges_view",
|
||||||
|
"system": false,
|
||||||
|
"type": "view",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewQuery": "\n SELECT\n (p.id || '_' || b.id) as id,\n p.id as player_id,\n b.id as badge_id,\n b.name as badge_name,\n b.description as badge_description,\n b.type as badge_type,\n b.icon as badge_icon,\n b.color as badge_color,\n b.is_progressive,\n COALESCE(pbp.current_progress, 0) as current_progress,\n COALESCE(pbp.target_progress, b.progress_target, 1) as target_progress,\n COALESCE(pbp.is_earned, false) as is_earned,\n pbp.earned_at\n FROM players p\n CROSS JOIN badges b\n LEFT JOIN player_badge_progress pbp ON pbp.player_id = p.id AND pbp.badge_id = b.id\n ",
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
})
|
||||||
129
pb_migrations/1759344938_deleted_player_badge_progress.js
Normal file
129
pb_migrations/1759344938_deleted_player_badge_progress.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_4251874343");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2847519201",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 1,
|
||||||
|
"name": "player_id",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "pbc_1340419796",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation3948571039",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 1,
|
||||||
|
"name": "badge_id",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number1847293057",
|
||||||
|
"max": null,
|
||||||
|
"min": 0,
|
||||||
|
"name": "current_progress",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number2948571040",
|
||||||
|
"max": null,
|
||||||
|
"min": 1,
|
||||||
|
"name": "target_progress",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool3049672141",
|
||||||
|
"name": "is_earned",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "date1150773242",
|
||||||
|
"max": "",
|
||||||
|
"min": "",
|
||||||
|
"name": "earned_at",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_4251874343",
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX `idx_unique_player_badge` ON `player_badge_progress` (`player_id`, `badge_id`)"
|
||||||
|
],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "player_badge_progress",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
})
|
||||||
173
pb_migrations/1759344944_deleted_badges.js
Normal file
173
pb_migrations/1759344944_deleted_badges.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1340419796");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1579384326",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1843675174",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "description",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "select4029814376",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "type",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"tournament_participation",
|
||||||
|
"tournament_placement",
|
||||||
|
"performance",
|
||||||
|
"overtime",
|
||||||
|
"match_milestone"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json1578432567",
|
||||||
|
"maxSize": 2000000,
|
||||||
|
"name": "criteria",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3928475610",
|
||||||
|
"max": 50,
|
||||||
|
"min": 0,
|
||||||
|
"name": "icon",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1847293056",
|
||||||
|
"max": 50,
|
||||||
|
"min": 0,
|
||||||
|
"name": "color",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number4113142680",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "order",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool2847519203",
|
||||||
|
"name": "is_progressive",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number2948571038",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "progress_target",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_1340419796",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "badges",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
})
|
||||||
145
pb_migrations/1759345060_created_badges.js
Normal file
145
pb_migrations/1759345060_created_badges.js
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1579384326",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text2324736937",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "key",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1843675174",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "description",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json3055524737",
|
||||||
|
"maxSize": 0,
|
||||||
|
"name": "criteria",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "select2363381545",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "type",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"manual",
|
||||||
|
"match",
|
||||||
|
"tournament"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool3646955747",
|
||||||
|
"name": "progressive",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number4113142680",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "order",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_1340419796",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "badges",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1340419796");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
104
pb_migrations/1759345122_created_player_badge_progress.js
Normal file
104
pb_migrations/1759345122_created_player_badge_progress.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1340419796",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation4277159965",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "badge",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2551806565",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "player",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number570552902",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "progress",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool2625885481",
|
||||||
|
"name": "earned",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_1063824264",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "player_badge_progress",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1063824264");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
20
pb_migrations/1759345318_updated_player_badge_progress.js
Normal file
20
pb_migrations/1759345318_updated_player_badge_progress.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1063824264")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"name": "badge_progress"
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1063824264")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"name": "player_badge_progress"
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
29
pb_migrations/1759594431_updated_tournaments.js
Normal file
29
pb_migrations/1759594431_updated_tournaments.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(10, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "file538556518",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"maxSize": 0,
|
||||||
|
"mimeTypes": [],
|
||||||
|
"name": "glitch_logo",
|
||||||
|
"presentable": false,
|
||||||
|
"protected": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"thumbs": [],
|
||||||
|
"type": "file"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("file538556518")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
42
pb_migrations/1759594880_updated_tournaments.js
Normal file
42
pb_migrations/1759594880_updated_tournaments.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(10, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "file538556518",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"maxSize": 6000000,
|
||||||
|
"mimeTypes": [],
|
||||||
|
"name": "glitch_logo",
|
||||||
|
"presentable": false,
|
||||||
|
"protected": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"thumbs": [],
|
||||||
|
"type": "file"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(10, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "file538556518",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"maxSize": 0,
|
||||||
|
"mimeTypes": [],
|
||||||
|
"name": "glitch_logo",
|
||||||
|
"presentable": false,
|
||||||
|
"protected": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"thumbs": [],
|
||||||
|
"type": "file"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
@@ -330,6 +330,8 @@ async function startServer() {
|
|||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: PORT,
|
port: PORT,
|
||||||
|
|
||||||
|
idleTimeout: 255,
|
||||||
|
|
||||||
routes: {
|
routes: {
|
||||||
// Serve static assets (preloaded or on-demand)
|
// Serve static assets (preloaded or on-demand)
|
||||||
...routes,
|
...routes,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_aut
|
|||||||
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
|
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
|
||||||
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
|
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
|
||||||
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
|
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
|
||||||
|
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
|
||||||
|
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
|
||||||
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
||||||
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
||||||
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
|
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
|
||||||
@@ -161,6 +163,16 @@ const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({
|
|||||||
path: '/preview',
|
path: '/preview',
|
||||||
getParentRoute: () => AuthedAdminRoute,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthedAdminBadgesRoute = AuthedAdminBadgesRouteImport.update({
|
||||||
|
id: '/badges',
|
||||||
|
path: '/badges',
|
||||||
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthedAdminActivitiesRoute = AuthedAdminActivitiesRouteImport.update({
|
||||||
|
id: '/activities',
|
||||||
|
path: '/activities',
|
||||||
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
|
} as any)
|
||||||
const AuthedAdminTournamentsIndexRoute =
|
const AuthedAdminTournamentsIndexRoute =
|
||||||
AuthedAdminTournamentsIndexRouteImport.update({
|
AuthedAdminTournamentsIndexRouteImport.update({
|
||||||
id: '/tournaments/',
|
id: '/tournaments/',
|
||||||
@@ -206,6 +218,8 @@ export interface FileRoutesByFullPath {
|
|||||||
'/settings': typeof AuthedSettingsRoute
|
'/settings': typeof AuthedSettingsRoute
|
||||||
'/stats': typeof AuthedStatsRoute
|
'/stats': typeof AuthedStatsRoute
|
||||||
'/': typeof AuthedIndexRoute
|
'/': typeof AuthedIndexRoute
|
||||||
|
'/admin/activities': typeof AuthedAdminActivitiesRoute
|
||||||
|
'/admin/badges': typeof AuthedAdminBadgesRoute
|
||||||
'/admin/preview': typeof AuthedAdminPreviewRoute
|
'/admin/preview': typeof AuthedAdminPreviewRoute
|
||||||
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||||
@@ -236,6 +250,8 @@ export interface FileRoutesByTo {
|
|||||||
'/settings': typeof AuthedSettingsRoute
|
'/settings': typeof AuthedSettingsRoute
|
||||||
'/stats': typeof AuthedStatsRoute
|
'/stats': typeof AuthedStatsRoute
|
||||||
'/': typeof AuthedIndexRoute
|
'/': typeof AuthedIndexRoute
|
||||||
|
'/admin/activities': typeof AuthedAdminActivitiesRoute
|
||||||
|
'/admin/badges': typeof AuthedAdminBadgesRoute
|
||||||
'/admin/preview': typeof AuthedAdminPreviewRoute
|
'/admin/preview': typeof AuthedAdminPreviewRoute
|
||||||
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||||
@@ -269,6 +285,8 @@ export interface FileRoutesById {
|
|||||||
'/_authed/settings': typeof AuthedSettingsRoute
|
'/_authed/settings': typeof AuthedSettingsRoute
|
||||||
'/_authed/stats': typeof AuthedStatsRoute
|
'/_authed/stats': typeof AuthedStatsRoute
|
||||||
'/_authed/': typeof AuthedIndexRoute
|
'/_authed/': typeof AuthedIndexRoute
|
||||||
|
'/_authed/admin/activities': typeof AuthedAdminActivitiesRoute
|
||||||
|
'/_authed/admin/badges': typeof AuthedAdminBadgesRoute
|
||||||
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute
|
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute
|
||||||
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||||
@@ -302,6 +320,8 @@ export interface FileRouteTypes {
|
|||||||
| '/settings'
|
| '/settings'
|
||||||
| '/stats'
|
| '/stats'
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/admin/activities'
|
||||||
|
| '/admin/badges'
|
||||||
| '/admin/preview'
|
| '/admin/preview'
|
||||||
| '/profile/$playerId'
|
| '/profile/$playerId'
|
||||||
| '/teams/$teamId'
|
| '/teams/$teamId'
|
||||||
@@ -332,6 +352,8 @@ export interface FileRouteTypes {
|
|||||||
| '/settings'
|
| '/settings'
|
||||||
| '/stats'
|
| '/stats'
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/admin/activities'
|
||||||
|
| '/admin/badges'
|
||||||
| '/admin/preview'
|
| '/admin/preview'
|
||||||
| '/profile/$playerId'
|
| '/profile/$playerId'
|
||||||
| '/teams/$teamId'
|
| '/teams/$teamId'
|
||||||
@@ -364,6 +386,8 @@ export interface FileRouteTypes {
|
|||||||
| '/_authed/settings'
|
| '/_authed/settings'
|
||||||
| '/_authed/stats'
|
| '/_authed/stats'
|
||||||
| '/_authed/'
|
| '/_authed/'
|
||||||
|
| '/_authed/admin/activities'
|
||||||
|
| '/_authed/admin/badges'
|
||||||
| '/_authed/admin/preview'
|
| '/_authed/admin/preview'
|
||||||
| '/_authed/profile/$playerId'
|
| '/_authed/profile/$playerId'
|
||||||
| '/_authed/teams/$teamId'
|
| '/_authed/teams/$teamId'
|
||||||
@@ -576,6 +600,20 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedAdminPreviewRouteImport
|
preLoaderRoute: typeof AuthedAdminPreviewRouteImport
|
||||||
parentRoute: typeof AuthedAdminRoute
|
parentRoute: typeof AuthedAdminRoute
|
||||||
}
|
}
|
||||||
|
'/_authed/admin/badges': {
|
||||||
|
id: '/_authed/admin/badges'
|
||||||
|
path: '/badges'
|
||||||
|
fullPath: '/admin/badges'
|
||||||
|
preLoaderRoute: typeof AuthedAdminBadgesRouteImport
|
||||||
|
parentRoute: typeof AuthedAdminRoute
|
||||||
|
}
|
||||||
|
'/_authed/admin/activities': {
|
||||||
|
id: '/_authed/admin/activities'
|
||||||
|
path: '/activities'
|
||||||
|
fullPath: '/admin/activities'
|
||||||
|
preLoaderRoute: typeof AuthedAdminActivitiesRouteImport
|
||||||
|
parentRoute: typeof AuthedAdminRoute
|
||||||
|
}
|
||||||
'/_authed/admin/tournaments/': {
|
'/_authed/admin/tournaments/': {
|
||||||
id: '/_authed/admin/tournaments/'
|
id: '/_authed/admin/tournaments/'
|
||||||
path: '/tournaments'
|
path: '/tournaments'
|
||||||
@@ -622,6 +660,8 @@ declare module '@tanstack/react-router' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AuthedAdminRouteChildren {
|
interface AuthedAdminRouteChildren {
|
||||||
|
AuthedAdminActivitiesRoute: typeof AuthedAdminActivitiesRoute
|
||||||
|
AuthedAdminBadgesRoute: typeof AuthedAdminBadgesRoute
|
||||||
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
||||||
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
||||||
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
|
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
|
||||||
@@ -631,6 +671,8 @@ interface AuthedAdminRouteChildren {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
||||||
|
AuthedAdminActivitiesRoute: AuthedAdminActivitiesRoute,
|
||||||
|
AuthedAdminBadgesRoute: AuthedAdminBadgesRoute,
|
||||||
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
||||||
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
||||||
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const Route = createRootRouteWithContext<{
|
|||||||
{
|
{
|
||||||
name: "viewport",
|
name: "viewport",
|
||||||
content:
|
content:
|
||||||
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=overlays-content",
|
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
@@ -63,7 +63,17 @@ export const Route = createRootRouteWithContext<{
|
|||||||
{ rel: 'stylesheet', href: mantineCssUrl },
|
{ rel: 'stylesheet', href: mantineCssUrl },
|
||||||
{ rel: 'stylesheet', href: mantineCarouselCssUrl },
|
{ rel: 'stylesheet', href: mantineCarouselCssUrl },
|
||||||
{ rel: 'stylesheet', href: mantineDatesCssUrl },
|
{ rel: 'stylesheet', href: mantineDatesCssUrl },
|
||||||
{ rel: 'stylesheet', href: mantineTiptapCssUrl }
|
{ rel: 'stylesheet', href: mantineTiptapCssUrl },
|
||||||
|
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||||
|
{
|
||||||
|
rel: "preconnect",
|
||||||
|
href: "https://fonts.gstatic.com",
|
||||||
|
crossOrigin: "anonymous",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: "stylesheet",
|
||||||
|
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=League+Spartan:wght@100..900&display=swap",
|
||||||
|
}
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
errorComponent: (props) => {
|
errorComponent: (props) => {
|
||||||
@@ -122,8 +132,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||||||
{...mantineHtmlProps}
|
{...mantineHtmlProps}
|
||||||
style={{
|
style={{
|
||||||
overflowX: "hidden",
|
overflowX: "hidden",
|
||||||
overflowY: "hidden",
|
height: "100%",
|
||||||
position: "fixed",
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -135,9 +144,10 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||||||
<body
|
<body
|
||||||
style={{
|
style={{
|
||||||
overflowX: "hidden",
|
overflowX: "hidden",
|
||||||
overflowY: "hidden",
|
height: "100%",
|
||||||
position: "fixed",
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="app">{children}</div>
|
<div className="app">{children}</div>
|
||||||
|
|||||||
24
src/app/routes/_authed/admin/activities.tsx
Normal file
24
src/app/routes/_authed/admin/activities.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||||
|
import { ActivitiesTable, activityQueries } from "@/features/activities";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authed/admin/activities")({
|
||||||
|
component: Stats,
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
const queryClient = context.queryClient;
|
||||||
|
prefetchServerQuery(queryClient, activityQueries.search());
|
||||||
|
},
|
||||||
|
loader: () => ({
|
||||||
|
withPadding: false,
|
||||||
|
fullWidth: true,
|
||||||
|
header: {
|
||||||
|
title: "Activities",
|
||||||
|
withBackButton: true,
|
||||||
|
},
|
||||||
|
refresh: [activityQueries.search().queryKey],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function Stats() {
|
||||||
|
return <ActivitiesTable />;
|
||||||
|
}
|
||||||
10
src/app/routes/_authed/admin/badges.tsx
Normal file
10
src/app/routes/_authed/admin/badges.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import AwardBadges from "@/features/admin/components/award-badges";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authed/admin/badges")({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <AwardBadges />;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { badgeKeys, badgeQueries } from "@/features/badges/queries";
|
||||||
import Profile from "@/features/players/components/profile";
|
import Profile from "@/features/players/components/profile";
|
||||||
import HeaderSkeleton from "@/features/players/components/profile/header-skeleton";
|
import HeaderSkeleton from "@/features/players/components/profile/header-skeleton";
|
||||||
import ProfileSkeleton from "@/features/players/components/profile/skeleton";
|
import ProfileSkeleton from "@/features/players/components/profile/skeleton";
|
||||||
@@ -24,6 +25,14 @@ export const Route = createFileRoute("/_authed/profile/$playerId")({
|
|||||||
queryClient,
|
queryClient,
|
||||||
playerQueries.matches(params.playerId)
|
playerQueries.matches(params.playerId)
|
||||||
),
|
),
|
||||||
|
prefetchServerQuery(
|
||||||
|
queryClient,
|
||||||
|
playerQueries.stats(params.playerId)
|
||||||
|
),
|
||||||
|
prefetchServerQuery(
|
||||||
|
queryClient,
|
||||||
|
badgeQueries.playerBadges(params.playerId)
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
loader: ({ params, context }) => ({
|
loader: ({ params, context }) => ({
|
||||||
@@ -34,7 +43,7 @@ export const Route = createFileRoute("/_authed/profile/$playerId")({
|
|||||||
context?.auth.user.id === params.playerId ? "/settings" : undefined,
|
context?.auth.user.id === params.playerId ? "/settings" : undefined,
|
||||||
},
|
},
|
||||||
withPadding: false,
|
withPadding: false,
|
||||||
refresh: [playerKeys.details(params.playerId), playerKeys.matches(params.playerId), playerKeys.stats(params.playerId)],
|
refresh: [playerKeys.details(params.playerId), playerKeys.matches(params.playerId), playerKeys.stats(params.playerId), badgeKeys.playerBadges(params.playerId)],
|
||||||
}),
|
}),
|
||||||
component: () => {
|
component: () => {
|
||||||
const { playerId } = Route.useParams();
|
const { playerId } = Route.useParams();
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ import { serverEvents, type ServerEvent } from "@/lib/events/emitter";
|
|||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
|
|
||||||
|
let activeConnections = 0;
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/events/$")({
|
export const Route = createFileRoute("/api/events/$")({
|
||||||
server: {
|
server: {
|
||||||
middleware: [superTokensRequestMiddleware],
|
middleware: [superTokensRequestMiddleware],
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: ({ request, context }) => {
|
GET: ({ request }) => {
|
||||||
|
activeConnections++;
|
||||||
|
const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
||||||
|
logger.info(`ServerEvents | New connection ${connectionId}. Active: ${activeConnections}`);
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
|
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
|
||||||
@@ -17,6 +22,10 @@ export const Route = createFileRoute("/api/events/$")({
|
|||||||
logger.info("ServerEvents | Event received", event);
|
logger.info("ServerEvents | Event received", event);
|
||||||
const message = `data: ${JSON.stringify(event)}\n\n`;
|
const message = `data: ${JSON.stringify(event)}\n\n`;
|
||||||
try {
|
try {
|
||||||
|
if (!controller.desiredSize || controller.desiredSize <= 0) {
|
||||||
|
logger.warn("ServerEvents | Stream closed, skipping event");
|
||||||
|
return;
|
||||||
|
}
|
||||||
controller.enqueue(new TextEncoder().encode(message));
|
controller.enqueue(new TextEncoder().encode(message));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("ServerEvents | Error sending SSE message", error);
|
logger.error("ServerEvents | Error sending SSE message", error);
|
||||||
@@ -29,16 +38,34 @@ export const Route = createFileRoute("/api/events/$")({
|
|||||||
|
|
||||||
const pingInterval = setInterval(() => {
|
const pingInterval = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
const pingMessage = `data: ${JSON.stringify({ type: "ping" })}\n\n`;
|
if (!controller.desiredSize || controller.desiredSize <= 0) {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pingMessage = `data: ${JSON.stringify({ type: "ping", timestamp: Date.now() })}\n\n`;
|
||||||
controller.enqueue(new TextEncoder().encode(pingMessage));
|
controller.enqueue(new TextEncoder().encode(pingMessage));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
logger.error("ServerEvents | Ping interval error", e);
|
||||||
clearInterval(pingInterval);
|
clearInterval(pingInterval);
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 15000);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const heartbeatMessage = `data: ${JSON.stringify({ type: "heartbeat", timestamp: Date.now() })}\n\n`;
|
||||||
|
controller.enqueue(new TextEncoder().encode(heartbeatMessage));
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("ServerEvents | Heartbeat error", e);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
|
activeConnections--;
|
||||||
serverEvents.off("test", handleEvent);
|
serverEvents.off("test", handleEvent);
|
||||||
|
serverEvents.off("match", handleEvent);
|
||||||
|
serverEvents.off("reaction", handleEvent);
|
||||||
clearInterval(pingInterval);
|
clearInterval(pingInterval);
|
||||||
|
logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${activeConnections}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
request.signal?.addEventListener("abort", cleanup);
|
request.signal?.addEventListener("abort", cleanup);
|
||||||
@@ -49,10 +76,14 @@ export const Route = createFileRoute("/api/events/$")({
|
|||||||
return new Response(stream, {
|
return new Response(stream, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
Connection: "keep-alive",
|
"Connection": "keep-alive",
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Access-Control-Allow-Headers": "Cache-Control",
|
"Access-Control-Allow-Headers": "Cache-Control",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
"X-Proxy-Buffering": "no",
|
||||||
|
"Proxy-Buffering": "off",
|
||||||
|
"Transfer-Encoding": "chunked",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,13 +13,6 @@ export const Route = createFileRoute(
|
|||||||
process.env.POCKETBASE_URL || "http://127.0.0.1:8090";
|
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", {
|
|
||||||
collection,
|
|
||||||
recordId,
|
|
||||||
file,
|
|
||||||
targetUrl: fileUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(fileUrl, {
|
const response = await fetch(fileUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -81,12 +74,6 @@ export const Route = createFileRoute(
|
|||||||
"Range, If-None-Match, If-Modified-Since"
|
"Range, If-None-Match, If-Modified-Since"
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info("File proxy response", {
|
|
||||||
status: response.status,
|
|
||||||
contentType: response.headers.get("content-type"),
|
|
||||||
contentLength: response.headers.get("content-length"),
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
|
|||||||
@@ -107,10 +107,9 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
|
|||||||
const pbFormData = new FormData();
|
const pbFormData = new FormData();
|
||||||
pbFormData.append("logo", logoFile);
|
pbFormData.append("logo", logoFile);
|
||||||
|
|
||||||
const updatedTeam = await pbAdmin.updateTeam(
|
await pbAdmin.updateTeam(teamId, pbFormData as any);
|
||||||
teamId,
|
const updatedTeam = await pbAdmin.getTeam(teamId);
|
||||||
pbFormData as any
|
if (!updatedTeam) throw new Error("Failed to fetch updated team");
|
||||||
);
|
|
||||||
|
|
||||||
logger.info("Team logo uploaded successfully", {
|
logger.info("Team logo uploaded successfully", {
|
||||||
teamId,
|
teamId,
|
||||||
|
|||||||
179
src/components/glitch-avatar.tsx
Normal file
179
src/components/glitch-avatar.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Paper, Box } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
Avatar as MantineAvatar,
|
||||||
|
AvatarProps as MantineAvatarProps,
|
||||||
|
} from "@mantine/core";
|
||||||
|
|
||||||
|
interface GlitchAvatarProps
|
||||||
|
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
||||||
|
name: string;
|
||||||
|
src?: string;
|
||||||
|
glitchSrc?: string;
|
||||||
|
size?: number;
|
||||||
|
radius?: string | number;
|
||||||
|
withBorder?: boolean;
|
||||||
|
contain?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
px?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GlitchAvatar = ({
|
||||||
|
name,
|
||||||
|
src,
|
||||||
|
glitchSrc,
|
||||||
|
size = 35,
|
||||||
|
radius = "100%",
|
||||||
|
withBorder = true,
|
||||||
|
contain = false,
|
||||||
|
children,
|
||||||
|
px,
|
||||||
|
...props
|
||||||
|
}: GlitchAvatarProps) => {
|
||||||
|
const [showGlitch, setShowGlitch] = useState(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!glitchSrc) return;
|
||||||
|
|
||||||
|
const scheduleNextGlitch = () => {
|
||||||
|
const delay = Math.random() * 10000 + 5000;
|
||||||
|
return setTimeout(() => {
|
||||||
|
setShowGlitch(true);
|
||||||
|
setIsPlaying(true);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowGlitch(false);
|
||||||
|
setIsPlaying(false);
|
||||||
|
scheduleNextGlitch();
|
||||||
|
}, 4000);
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutId = scheduleNextGlitch();
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [glitchSrc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
const handleEnded = () => {
|
||||||
|
setShowGlitch(false);
|
||||||
|
setIsPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener("ended", handleEnded);
|
||||||
|
return () => video.removeEventListener("ended", handleEnded);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
video.load();
|
||||||
|
}, [glitchSrc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !showGlitch || !isPlaying) return;
|
||||||
|
|
||||||
|
video.currentTime = 0;
|
||||||
|
video.play().catch((err) => {
|
||||||
|
console.error("Failed to play glitch", err);
|
||||||
|
});
|
||||||
|
}, [showGlitch, isPlaying]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius:
|
||||||
|
typeof radius === "number"
|
||||||
|
? `${radius + 8}px`
|
||||||
|
: "calc(var(--mantine-radius-md) + 8px)",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
opacity: showGlitch ? 0 : 1,
|
||||||
|
transition: "opacity 0.05s ease-in-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
py={size / 12.5}
|
||||||
|
px={size / 20}
|
||||||
|
bg="var(--mantine-color-default-border)"
|
||||||
|
radius={radius}
|
||||||
|
withBorder={false}
|
||||||
|
style={{
|
||||||
|
cursor: "default",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MantineAvatar
|
||||||
|
alt={name}
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
color="initials"
|
||||||
|
size={size}
|
||||||
|
radius={radius}
|
||||||
|
w={size}
|
||||||
|
styles={{
|
||||||
|
image: {
|
||||||
|
objectFit: contain ? "contain" : "cover",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
src={src}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MantineAvatar>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{glitchSrc && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "8px",
|
||||||
|
left: "8px",
|
||||||
|
opacity: showGlitch ? 1 : 0,
|
||||||
|
visibility: showGlitch ? "visible" : "hidden",
|
||||||
|
transition: "opacity 0.05s ease-in-out",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
py={size / 12.5}
|
||||||
|
px={size / 20}
|
||||||
|
bg="var(--mantine-color-default-border)"
|
||||||
|
radius={radius}
|
||||||
|
withBorder={false}
|
||||||
|
style={{
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={glitchSrc}
|
||||||
|
style={{
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
objectFit: contain ? "contain" : "cover",
|
||||||
|
borderRadius: typeof radius === "number" ? `${radius}px` : radius,
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="auto"
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlitchAvatar;
|
||||||
@@ -1,22 +1,33 @@
|
|||||||
import { Divider, Group, Text, UnstyledButton } from "@mantine/core";
|
import { Divider, Group, Loader, Text, UnstyledButton } from "@mantine/core";
|
||||||
import { CaretRightIcon, Icon } from "@phosphor-icons/react";
|
import { CaretRightIcon, Icon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
interface ListButtonProps {
|
interface ListButtonProps {
|
||||||
label: string;
|
label: string;
|
||||||
Icon: Icon;
|
Icon: Icon;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ListButton = ({ label, onClick, Icon }: ListButtonProps) => {
|
const ListButton = ({ label, onClick, Icon, loading }: ListButtonProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<UnstyledButton w="100%" p="md" component={"button"} onClick={onClick}>
|
<UnstyledButton
|
||||||
|
w="100%"
|
||||||
|
p="md"
|
||||||
|
component={"button"}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
<Group>
|
<Group>
|
||||||
<Icon weight="bold" size={20} />
|
<Icon weight="bold" size={20} />
|
||||||
<Text fw={500} size="md">
|
<Text fw={500} size="md">
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
|
{loading ? (
|
||||||
|
<Loader size="sm" style={{ marginLeft: "auto" }} />
|
||||||
|
) : (
|
||||||
<CaretRightIcon style={{ marginLeft: "auto" }} size={20} />
|
<CaretRightIcon style={{ marginLeft: "auto" }} size={20} />
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ActionIcon,
|
ActionIcon,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Divider,
|
Divider,
|
||||||
|
Stack,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
||||||
import { useState, ReactNode } from "react";
|
import { useState, ReactNode } from "react";
|
||||||
@@ -69,6 +70,7 @@ const SlidePanel = ({
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
@@ -167,11 +169,17 @@ const SlidePanel = ({
|
|||||||
bg="var(--mantine-color-dimmed)"
|
bg="var(--mantine-color-dimmed)"
|
||||||
my="xs"
|
my="xs"
|
||||||
/>
|
/>
|
||||||
|
<ScrollArea.Autosize w="100%" p={0} offsetScrollbars>
|
||||||
<panelConfig.Component
|
<panelConfig.Component
|
||||||
value={tempValue}
|
value={tempValue}
|
||||||
onChange={setTempValue}
|
onChange={setTempValue}
|
||||||
{...(panelConfig.componentProps || {})}
|
{...(panelConfig.componentProps || {})}
|
||||||
/>
|
/>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
<Stack mt="auto" w="100%" gap={2}>
|
||||||
|
<Button mt="md" onClick={handleConfirm}>Confirm</Button>
|
||||||
|
<Button variant="subtle" onClick={closePanel} mt="sm" color="red">Cancel</Button>
|
||||||
|
</Stack>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface TypeaheadProps<T> {
|
|||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
initialValue?: string;
|
initialValue?: string;
|
||||||
|
maxHeight?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Typeahead = <T,>({
|
const Typeahead = <T,>({
|
||||||
@@ -26,7 +27,8 @@ const Typeahead = <T,>({
|
|||||||
placeholder = "Search...",
|
placeholder = "Search...",
|
||||||
debounceMs = 300,
|
debounceMs = 300,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
initialValue = ""
|
initialValue = "",
|
||||||
|
maxHeight = 200,
|
||||||
}: TypeaheadProps<T>) => {
|
}: TypeaheadProps<T>) => {
|
||||||
const [searchQuery, setSearchQuery] = useState(initialValue);
|
const [searchQuery, setSearchQuery] = useState(initialValue);
|
||||||
const [searchResults, setSearchResults] = useState<TypeaheadOption<T>[]>([]);
|
const [searchResults, setSearchResults] = useState<TypeaheadOption<T>[]>([]);
|
||||||
@@ -36,13 +38,7 @@ const Typeahead = <T,>({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const debouncedSearch = useDebouncedCallback(async (query: string) => {
|
const performSearch = async (query: string) => {
|
||||||
if (!query.trim()) {
|
|
||||||
setSearchResults([]);
|
|
||||||
setIsOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const results = await searchFn(query);
|
const results = await searchFn(query);
|
||||||
@@ -56,7 +52,9 @@ const Typeahead = <T,>({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, debounceMs);
|
};
|
||||||
|
|
||||||
|
const debouncedSearch = useDebouncedCallback(performSearch, debounceMs);
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
const handleSearchChange = (value: string) => {
|
||||||
setSearchQuery(value);
|
setSearchQuery(value);
|
||||||
@@ -114,8 +112,12 @@ const Typeahead = <T,>({
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(event) => handleSearchChange(event.currentTarget.value)}
|
onChange={(event) => handleSearchChange(event.currentTarget.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => {
|
onFocus={async () => {
|
||||||
if (searchResults.length > 0) setIsOpen(true);
|
if (searchResults.length > 0) {
|
||||||
|
setIsOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await performSearch(searchQuery);
|
||||||
}}
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
rightSection={isLoading ? <Loader size="xs" /> : null}
|
rightSection={isLoading ? <Loader size="xs" /> : null}
|
||||||
@@ -133,7 +135,7 @@ const Typeahead = <T,>({
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
maxHeight: '160px',
|
maxHeight,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
WebkitOverflowScrolling: 'touch',
|
WebkitOverflowScrolling: 'touch',
|
||||||
touchAction: 'pan-y',
|
touchAction: 'pan-y',
|
||||||
|
|||||||
386
src/features/activities/components/activities-table.tsx
Normal file
386
src/features/activities/components/activities-table.tsx
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
import { useState, useMemo, memo } from "react";
|
||||||
|
import {
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Stack,
|
||||||
|
Group,
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
UnstyledButton,
|
||||||
|
Select,
|
||||||
|
Pagination,
|
||||||
|
Code,
|
||||||
|
Alert,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
CaretUpIcon,
|
||||||
|
CaretDownIcon,
|
||||||
|
CheckIcon,
|
||||||
|
XIcon,
|
||||||
|
ChecksIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import { Activity, ActivitySearchParams } from "../types";
|
||||||
|
import { useActivities } from "../queries";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
|
||||||
|
interface ActivityListItemProps {
|
||||||
|
activity: Activity;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActivityListItem = memo(({ activity, onClick }: ActivityListItemProps) => {
|
||||||
|
const playerName = typeof activity.player === "object" && activity.player
|
||||||
|
? `${activity.player.first_name} ${activity.player.last_name}`
|
||||||
|
: "System";
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
w="100%"
|
||||||
|
p="md"
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
borderRadius: 0,
|
||||||
|
transition: "background-color 0.15s ease",
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" w="100%">
|
||||||
|
<Stack gap={4} flex={1}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{activity.name}
|
||||||
|
</Text>
|
||||||
|
{activity.success ? (
|
||||||
|
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
||||||
|
) : (
|
||||||
|
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Group gap="md">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{playerName}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{activity.duration}ms
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatDate(activity.created)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{activity.error && (
|
||||||
|
<Text size="xs" c="red" lineClamp={1}>
|
||||||
|
{activity.error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ActivityListItem.displayName = "ActivityListItem";
|
||||||
|
|
||||||
|
interface ActivityDetailsSheetProps {
|
||||||
|
activity: Activity | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetailsSheetProps) => {
|
||||||
|
if (!activity) return null;
|
||||||
|
|
||||||
|
const playerName = typeof activity.player === "object" && activity.player
|
||||||
|
? `${activity.player.first_name} ${activity.player.last_name}`
|
||||||
|
: "System";
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet title="Activity Details" opened={isOpen} onChange={onClose}>
|
||||||
|
<Stack gap="md" p="md">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Function Name
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{activity.name}</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Status
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
{activity.success ? (
|
||||||
|
<>
|
||||||
|
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
||||||
|
<Text size="sm" c="green">
|
||||||
|
Success
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
||||||
|
<Text size="sm" c="red">
|
||||||
|
Failed
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Player
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{playerName}</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Duration
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{activity.duration}ms</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Created
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{formatDate(activity.created)}</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{activity.user_agent && (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
User Agent
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" style={{ wordBreak: "break-word" }}>
|
||||||
|
{activity.user_agent}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activity.error && (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Error Message
|
||||||
|
</Text>
|
||||||
|
<Alert color="red" variant="light">
|
||||||
|
<Text size="sm" style={{ wordBreak: "break-word" }}>
|
||||||
|
{activity.error}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activity.arguments && (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Arguments
|
||||||
|
</Text>
|
||||||
|
<Code block style={{ fontSize: "11px" }}>
|
||||||
|
{JSON.stringify(activity.arguments, null, 2)}
|
||||||
|
</Code>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ActivityDetailsSheet.displayName = "ActivityDetailsSheet";
|
||||||
|
|
||||||
|
const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any) => {
|
||||||
|
const { data: result } = useActivities(searchParams);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack gap={0}>
|
||||||
|
{result.items.map((activity: Activity, index: number) => (
|
||||||
|
<Box key={activity.id}>
|
||||||
|
<ActivityListItem
|
||||||
|
activity={activity}
|
||||||
|
onClick={() => onActivityClick(activity)}
|
||||||
|
/>
|
||||||
|
{index < result.items.length - 1 && <Divider />}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{result.items.length === 0 && (
|
||||||
|
<Text ta="center" c="dimmed" py="xl">
|
||||||
|
No activities found
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.totalPages > 1 && (
|
||||||
|
<Group justify="center" py="md">
|
||||||
|
<Pagination
|
||||||
|
total={result.totalPages}
|
||||||
|
value={page}
|
||||||
|
onChange={setPage}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ActivitiesTable = () => {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [successFilter, setSuccessFilter] = useState<string | null>(null);
|
||||||
|
const [sortBy, setSortBy] = useState("-created");
|
||||||
|
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isOpen: detailsOpened,
|
||||||
|
open: openDetails,
|
||||||
|
close: closeDetails,
|
||||||
|
} = useSheet();
|
||||||
|
|
||||||
|
const searchParams: ActivitySearchParams = useMemo(
|
||||||
|
() => ({
|
||||||
|
page,
|
||||||
|
perPage: 100,
|
||||||
|
name: search || undefined,
|
||||||
|
success: successFilter === "success" ? true : successFilter === "failure" ? false : undefined,
|
||||||
|
sortBy,
|
||||||
|
}),
|
||||||
|
[page, search, successFilter, sortBy]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: result } = useActivities(searchParams);
|
||||||
|
|
||||||
|
const handleSort = (field: string) => {
|
||||||
|
setSortBy((prev) => {
|
||||||
|
if (prev === field) return `-${field}`;
|
||||||
|
if (prev === `-${field}`) return field;
|
||||||
|
return `-${field}`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortIcon = (field: string) => {
|
||||||
|
if (sortBy === field) return <CaretUpIcon size={14} />;
|
||||||
|
if (sortBy === `-${field}`) return <CaretDownIcon size={14} />;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActivityClick = (activity: Activity) => {
|
||||||
|
setSelectedActivity(activity);
|
||||||
|
openDetails();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDetails = () => {
|
||||||
|
setSelectedActivity(null);
|
||||||
|
closeDetails();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="100%" px={0}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Stack gap="xs" px="md">
|
||||||
|
<TextInput
|
||||||
|
placeholder="serverFn name"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.currentTarget.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
leftSection={<MagnifyingGlassIcon size={16} />}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Select
|
||||||
|
placeholder="Status"
|
||||||
|
value={successFilter}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSuccessFilter(value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
data={[
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
{ value: "success", label: "Success" },
|
||||||
|
{ value: "failure", label: "Failure" },
|
||||||
|
]}
|
||||||
|
clearable
|
||||||
|
size="sm"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Group px="md" justify="space-between" align="center">
|
||||||
|
<Text size="10px" lh={0} c="dimmed">
|
||||||
|
{result.totalItems} total activities
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Sort:
|
||||||
|
</Text>
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => handleSort("created")}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={sortBy.includes("created") ? 600 : 400}
|
||||||
|
c={sortBy.includes("created") ? "dark" : "dimmed"}
|
||||||
|
>
|
||||||
|
Date
|
||||||
|
</Text>
|
||||||
|
{getSortIcon("created")}
|
||||||
|
</UnstyledButton>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
•
|
||||||
|
</Text>
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => handleSort("duration")}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={sortBy.includes("duration") ? 600 : 400}
|
||||||
|
c={sortBy.includes("duration") ? "dark" : "dimmed"}
|
||||||
|
>
|
||||||
|
Duration
|
||||||
|
</Text>
|
||||||
|
{getSortIcon("duration")}
|
||||||
|
</UnstyledButton>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ActivitiesResults
|
||||||
|
searchParams={searchParams}
|
||||||
|
page={page}
|
||||||
|
setPage={setPage}
|
||||||
|
onActivityClick={handleActivityClick}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<ActivityDetailsSheet
|
||||||
|
activity={selectedActivity}
|
||||||
|
isOpen={detailsOpened}
|
||||||
|
onClose={handleCloseDetails}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
3
src/features/activities/index.ts
Normal file
3
src/features/activities/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./types";
|
||||||
|
export * from "./queries";
|
||||||
|
export { ActivitiesTable } from "./components/activities-table";
|
||||||
17
src/features/activities/queries.ts
Normal file
17
src/features/activities/queries.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
|
import { searchActivities } from "./server";
|
||||||
|
import { ActivitySearchParams } from "./types";
|
||||||
|
|
||||||
|
export const activityKeys = {
|
||||||
|
search: (params: ActivitySearchParams) => ['activities', 'search', params] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const activityQueries = {
|
||||||
|
search: (params: ActivitySearchParams = {}) => ({
|
||||||
|
queryKey: activityKeys.search(params),
|
||||||
|
queryFn: () => searchActivities({ data: params }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useActivities = (params: ActivitySearchParams = {}) =>
|
||||||
|
useServerSuspenseQuery(activityQueries.search(params));
|
||||||
29
src/features/activities/server.ts
Normal file
29
src/features/activities/server.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
|
import { transformActivity } from "@/lib/pocketbase/util/transform-types";
|
||||||
|
import { Activity, ActivityListResult, ActivitySearchParams } from "./types";
|
||||||
|
|
||||||
|
const activitySearchParamsSchema = z.object({
|
||||||
|
page: z.number().optional(),
|
||||||
|
perPage: z.number().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
player: z.string().optional(),
|
||||||
|
success: z.boolean().optional(),
|
||||||
|
sortBy: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const searchActivities = createServerFn()
|
||||||
|
.inputValidator(activitySearchParamsSchema)
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
|
.handler(async ({ data }) =>
|
||||||
|
toServerResult<ActivityListResult>(async () => {
|
||||||
|
const result = await pbAdmin.searchActivities(data);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
items: result.items.map(transformActivity),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
1
src/features/activities/types.ts
Normal file
1
src/features/activities/types.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type { Activity, ActivityListResult, ActivitySearchParams } from "@/lib/pocketbase/services/activities";
|
||||||
@@ -4,10 +4,25 @@ import {
|
|||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
TreeStructureIcon,
|
TreeStructureIcon,
|
||||||
TrophyIcon,
|
TrophyIcon,
|
||||||
|
MedalIcon,
|
||||||
|
CrownIcon,
|
||||||
|
ListIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import ListButton from "@/components/list-button";
|
import ListButton from "@/components/list-button";
|
||||||
|
import { migrateBadgeProgress } from "@/features/badges/server";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
const AdminPage = () => {
|
const AdminPage = () => {
|
||||||
|
const [isMigrating, setIsMigrating] = useState(false);
|
||||||
|
|
||||||
|
const handleMigrateBadges = async () => {
|
||||||
|
if (isMigrating) return;
|
||||||
|
|
||||||
|
setIsMigrating(true);
|
||||||
|
await migrateBadgeProgress();
|
||||||
|
setIsMigrating(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List p="0">
|
<List p="0">
|
||||||
<ListLink
|
<ListLink
|
||||||
@@ -15,6 +30,22 @@ const AdminPage = () => {
|
|||||||
Icon={TrophyIcon}
|
Icon={TrophyIcon}
|
||||||
to="/admin/tournaments"
|
to="/admin/tournaments"
|
||||||
/>
|
/>
|
||||||
|
<ListLink
|
||||||
|
label="Award Badges"
|
||||||
|
Icon={CrownIcon}
|
||||||
|
to="/admin/badges"
|
||||||
|
/>
|
||||||
|
<ListButton
|
||||||
|
label="Migrate Badge Progress"
|
||||||
|
Icon={MedalIcon}
|
||||||
|
onClick={handleMigrateBadges}
|
||||||
|
loading={isMigrating}
|
||||||
|
/>
|
||||||
|
<ListLink
|
||||||
|
label="Activities"
|
||||||
|
Icon={ListIcon}
|
||||||
|
to="/admin/activities"
|
||||||
|
/>
|
||||||
<ListButton
|
<ListButton
|
||||||
label="Open Pocketbase"
|
label="Open Pocketbase"
|
||||||
Icon={DatabaseIcon}
|
Icon={DatabaseIcon}
|
||||||
|
|||||||
114
src/features/admin/components/award-badges.tsx
Normal file
114
src/features/admin/components/award-badges.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Box, Card, Text, Select, Button, Group, Stack, Badge, Divider } from "@mantine/core";
|
||||||
|
import { awardManualBadge } from "@/features/badges/server";
|
||||||
|
import { useAllBadges } from "@/features/badges/queries";
|
||||||
|
import toast from "@/lib/sonner";
|
||||||
|
import { usePlayers } from "@/features/players/queries";
|
||||||
|
|
||||||
|
const AwardBadges = () => {
|
||||||
|
const { data: players } = usePlayers();
|
||||||
|
const { data: allBadges } = useAllBadges();
|
||||||
|
|
||||||
|
const [selectedPlayerId, setSelectedPlayerId] = useState<string | null>(null);
|
||||||
|
const [selectedBadgeId, setSelectedBadgeId] = useState<string | null>(null);
|
||||||
|
const [isAwarding, setIsAwarding] = useState(false);
|
||||||
|
|
||||||
|
const manualBadges = allBadges.filter((badge) => badge.type === "manual");
|
||||||
|
|
||||||
|
const handleAwardBadge = async () => {
|
||||||
|
if (!selectedPlayerId || !selectedBadgeId) return;
|
||||||
|
|
||||||
|
setIsAwarding(true);
|
||||||
|
try {
|
||||||
|
await awardManualBadge({
|
||||||
|
data: {
|
||||||
|
playerId: selectedPlayerId,
|
||||||
|
badgeId: selectedBadgeId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedPlayer = players.find((p) => p.id === selectedPlayerId);
|
||||||
|
const playerName = selectedPlayer
|
||||||
|
? `${selectedPlayer.first_name} ${selectedPlayer.last_name}`
|
||||||
|
: "Player";
|
||||||
|
|
||||||
|
toast.success(`Badge awarded to ${playerName}`);
|
||||||
|
|
||||||
|
setSelectedPlayerId(null);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to award badge");
|
||||||
|
} finally {
|
||||||
|
setIsAwarding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const playerOptions = players.map((player) => ({
|
||||||
|
value: player.id,
|
||||||
|
label: `${player.first_name} ${player.last_name}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const badgeOptions = manualBadges.map((badge) => ({
|
||||||
|
value: badge.id,
|
||||||
|
label: badge.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const selectedBadge = manualBadges.find((b) => b.id === selectedBadgeId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box p="md">
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Box>
|
||||||
|
<Text size="lg" fw={600} mb="xs">
|
||||||
|
Award Manual Badge
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Badge Type"
|
||||||
|
placeholder="Select a badge"
|
||||||
|
data={badgeOptions}
|
||||||
|
value={selectedBadgeId}
|
||||||
|
onChange={setSelectedBadgeId}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedBadgeId && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label="Select Player"
|
||||||
|
placeholder="Choose a player"
|
||||||
|
data={playerOptions}
|
||||||
|
value={selectedPlayerId}
|
||||||
|
onChange={setSelectedPlayerId}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
size="md"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleAwardBadge}
|
||||||
|
disabled={!selectedPlayerId}
|
||||||
|
loading={isAwarding}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
Award Badge
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AwardBadges;
|
||||||
47
src/features/badges/components/badge-showcase-skeleton.tsx
Normal file
47
src/features/badges/components/badge-showcase-skeleton.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Box, Skeleton, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
const BadgeShowcaseSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<Box mb="lg">
|
||||||
|
<Box
|
||||||
|
px="md"
|
||||||
|
style={{
|
||||||
|
maxHeight: '220px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(85px, 1fr))',
|
||||||
|
gap: 'var(--mantine-spacing-md)',
|
||||||
|
paddingBottom: 'var(--mantine-spacing-sm)',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
aspectRatio: '1',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton
|
||||||
|
height="100%"
|
||||||
|
radius="12px"
|
||||||
|
style={{
|
||||||
|
aspectRatio: '1',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BadgeShowcaseSkeleton;
|
||||||
267
src/features/badges/components/badge-showcase.tsx
Normal file
267
src/features/badges/components/badge-showcase.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { Box, Text, Popover, Progress, Title } from "@mantine/core";
|
||||||
|
import { usePlayerBadges, useAllBadges } from "../queries";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { Badge, BadgeProgress } from "../types";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { MedalIcon, LockKeyIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
interface BadgeShowcaseProps {
|
||||||
|
playerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BadgeDisplay {
|
||||||
|
badge: Badge;
|
||||||
|
progress?: BadgeProgress;
|
||||||
|
earned: boolean;
|
||||||
|
progressText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { data: badgeProgress } = usePlayerBadges(playerId);
|
||||||
|
const { data: allBadges } = useAllBadges();
|
||||||
|
|
||||||
|
const isCurrentUser = user?.id === playerId;
|
||||||
|
|
||||||
|
const badgesToDisplay = useMemo(() => {
|
||||||
|
const displays: BadgeDisplay[] = [];
|
||||||
|
|
||||||
|
if (isCurrentUser) {
|
||||||
|
for (const badge of allBadges) {
|
||||||
|
const progress = badgeProgress.find(bp => bp.badge.id === badge.id);
|
||||||
|
const earned = progress?.earned || false;
|
||||||
|
|
||||||
|
if (badge.type === 'manual' && !earned) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let progressText = "";
|
||||||
|
if (progress) {
|
||||||
|
const target = getTargetProgress(badge);
|
||||||
|
progressText = `${progress.progress} / ${target}`;
|
||||||
|
} else {
|
||||||
|
const target = getTargetProgress(badge);
|
||||||
|
progressText = `0 / ${target}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
displays.push({
|
||||||
|
badge,
|
||||||
|
progress,
|
||||||
|
earned,
|
||||||
|
progressText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
displays.sort((a, b) => {
|
||||||
|
if (a.earned && !b.earned) return -1;
|
||||||
|
if (!a.earned && b.earned) return 1;
|
||||||
|
return a.badge.order - b.badge.order;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const earnedProgress = badgeProgress.filter(bp => bp.earned);
|
||||||
|
for (const progress of earnedProgress) {
|
||||||
|
const badge: Badge = {
|
||||||
|
...progress.badge,
|
||||||
|
criteria: {},
|
||||||
|
created: progress.created,
|
||||||
|
updated: progress.updated,
|
||||||
|
};
|
||||||
|
|
||||||
|
const target = getTargetProgress(badge);
|
||||||
|
displays.push({
|
||||||
|
badge,
|
||||||
|
progress,
|
||||||
|
earned: true,
|
||||||
|
progressText: `${progress.progress} / ${target}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
displays.sort((a, b) => a.badge.order - b.badge.order);
|
||||||
|
}
|
||||||
|
|
||||||
|
return displays;
|
||||||
|
}, [allBadges, badgeProgress, isCurrentUser]);
|
||||||
|
|
||||||
|
if (badgesToDisplay.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box mb="lg">
|
||||||
|
<Box
|
||||||
|
px="md"
|
||||||
|
style={{
|
||||||
|
maxHeight: '220px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(85px, 1fr))',
|
||||||
|
gap: 'var(--mantine-spacing-md)',
|
||||||
|
paddingBottom: 'var(--mantine-spacing-sm)',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{badgesToDisplay.map((display) => {
|
||||||
|
const isStackableBadge = ['winner_badge', 'silver_medal_badge', 'bronze_medal_badge'].includes(display.badge.key);
|
||||||
|
const stackCount = display.earned && isStackableBadge
|
||||||
|
? (display.progress?.progress || 0)
|
||||||
|
: 1;
|
||||||
|
const showStack = stackCount > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover key={display.badge.id} width={220} position="top" withArrow shadow="md">
|
||||||
|
<Popover.Target>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showStack && (
|
||||||
|
<>
|
||||||
|
{[...Array(Math.min(stackCount - 1, 2))].map((_, i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
aspectRatio: '1',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '2px solid var(--mantine-primary-color-5)',
|
||||||
|
position: 'absolute',
|
||||||
|
top: `${(i + 1) * 3}px`,
|
||||||
|
left: `${(i + 1) * 3}px`,
|
||||||
|
right: `-${(i + 1) * 3}px`,
|
||||||
|
bottom: `-${(i + 1) * 3}px`,
|
||||||
|
opacity: 0.4 - (i * 0.15),
|
||||||
|
zIndex: -(i + 1),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
aspectRatio: '1',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-7))',
|
||||||
|
border: display.earned
|
||||||
|
? '2px solid var(--mantine-primary-color-6)'
|
||||||
|
: '2px dashed var(--mantine-primary-color-4)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '4px',
|
||||||
|
padding: 'var(--mantine-spacing-xs)',
|
||||||
|
position: 'relative',
|
||||||
|
boxShadow: display.earned
|
||||||
|
? '0 0 0 1px color-mix(in srgb, var(--mantine-primary-color-6) 20%, transparent)'
|
||||||
|
: 'none',
|
||||||
|
opacity: display.earned ? 1 : 0.4,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{display.earned ? (
|
||||||
|
<MedalIcon
|
||||||
|
size={32}
|
||||||
|
weight="fill"
|
||||||
|
color="var(--mantine-primary-color-6)"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LockKeyIcon
|
||||||
|
size={28}
|
||||||
|
weight="regular"
|
||||||
|
color="var(--mantine-color-dimmed)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showStack && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '4px',
|
||||||
|
right: '4px',
|
||||||
|
color: 'var(--mantine-primary-color-6)',
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
x{stackCount}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Title
|
||||||
|
order={6}
|
||||||
|
fw={display.earned ? 600 : 500}
|
||||||
|
ta="center"
|
||||||
|
c={display.earned ? undefined : 'dimmed'}
|
||||||
|
style={{ lineHeight: 1.1 }}
|
||||||
|
>
|
||||||
|
{display.badge.name}
|
||||||
|
</Title>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<Box>
|
||||||
|
<Title order={5}>
|
||||||
|
{display.badge.name}
|
||||||
|
</Title>
|
||||||
|
<Text size="xs" c="dimmed" mb={isCurrentUser ? "sm" : undefined}>
|
||||||
|
{display.badge.description}
|
||||||
|
</Text>
|
||||||
|
{isCurrentUser && (
|
||||||
|
<Box>
|
||||||
|
<Box mb="xs" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Text size="xs" fw={500} c="dimmed">
|
||||||
|
Progress
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" fw={600} c="dimmed">
|
||||||
|
{display.progressText}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Progress
|
||||||
|
value={(display.progress?.progress || 0) / getTargetProgress(display.badge) * 100}
|
||||||
|
size="sm"
|
||||||
|
radius="sm"
|
||||||
|
color={display.earned ? "green" : undefined}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTargetProgress(badge: Badge): number {
|
||||||
|
const criteria = badge.criteria;
|
||||||
|
return (
|
||||||
|
criteria.matches_played ||
|
||||||
|
criteria.tournament_wins ||
|
||||||
|
criteria.tournaments_attended ||
|
||||||
|
criteria.overtime_matches ||
|
||||||
|
criteria.overtime_wins ||
|
||||||
|
criteria.consecutive_wins ||
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BadgeShowcase;
|
||||||
24
src/features/badges/queries.ts
Normal file
24
src/features/badges/queries.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
|
import { getPlayerBadges, getAllBadges } from "./server";
|
||||||
|
|
||||||
|
export const badgeKeys = {
|
||||||
|
playerBadges: (playerId: string) => ['badges', 'player', playerId],
|
||||||
|
allBadges: () => ['badges', 'all'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const badgeQueries = {
|
||||||
|
playerBadges: (playerId: string) => ({
|
||||||
|
queryKey: badgeKeys.playerBadges(playerId),
|
||||||
|
queryFn: async () => await getPlayerBadges({ data: playerId })
|
||||||
|
}),
|
||||||
|
allBadges: () => ({
|
||||||
|
queryKey: badgeKeys.allBadges(),
|
||||||
|
queryFn: async () => await getAllBadges()
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePlayerBadges = (playerId: string) =>
|
||||||
|
useServerSuspenseQuery(badgeQueries.playerBadges(playerId));
|
||||||
|
|
||||||
|
export const useAllBadges = () =>
|
||||||
|
useServerSuspenseQuery(badgeQueries.allBadges());
|
||||||
@@ -1,4 +1,34 @@
|
|||||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const getPlayerBadges = createServerFn()
|
||||||
|
.inputValidator(z.string())
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data: playerId }) =>
|
||||||
|
toServerResult(() => pbAdmin.getPlayerBadgeProgress(playerId))
|
||||||
|
);
|
||||||
|
|
||||||
|
export const migrateBadgeProgress = createServerFn()
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
|
.handler(async () =>
|
||||||
|
toServerResult(() => pbAdmin.migrateBadgeProgress())
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getAllBadges = createServerFn()
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async () =>
|
||||||
|
toServerResult(() => pbAdmin.listBadges())
|
||||||
|
);
|
||||||
|
|
||||||
|
export const awardManualBadge = createServerFn()
|
||||||
|
.inputValidator(z.object({
|
||||||
|
playerId: z.string(),
|
||||||
|
badgeId: z.string(),
|
||||||
|
}))
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
|
.handler(async ({ data }) =>
|
||||||
|
toServerResult(() => pbAdmin.awardManualBadge(data.playerId, data.badgeId))
|
||||||
|
);
|
||||||
|
|||||||
25
src/features/badges/types.ts
Normal file
25
src/features/badges/types.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export interface BadgeInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
type: "manual" | "match" | "tournament";
|
||||||
|
progressive: boolean;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Badge extends BadgeInfo {
|
||||||
|
criteria: Record<string, any>;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BadgeProgress {
|
||||||
|
id: string;
|
||||||
|
badge: BadgeInfo;
|
||||||
|
player: string;
|
||||||
|
progress: number;
|
||||||
|
earned: boolean;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
@@ -8,11 +8,12 @@ const Header = ({ collapsed, title, withBackButton }: HeaderProps) => {
|
|||||||
return (
|
return (
|
||||||
<AppShell.Header
|
<AppShell.Header
|
||||||
id='app-header'
|
id='app-header'
|
||||||
display={collapsed ? 'none' : 'block'}
|
display={collapsed ? 'none' : 'flex'}
|
||||||
|
style={{ alignItems: 'center', justifyContent: 'center' }}
|
||||||
>
|
>
|
||||||
{ withBackButton && <BackButton /> }
|
{ withBackButton && <BackButton /> }
|
||||||
<Flex justify='center' align='center' h='100%' px='md'>
|
<Flex justify='center' px='md' mt={8}>
|
||||||
<Title order={2}>{title}</Title>
|
<Title order={1}>{title?.toLocaleUpperCase()}</Title>
|
||||||
</Flex>
|
</Flex>
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,14 +31,18 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
pos='relative'
|
pos='relative'
|
||||||
h='100dvh'
|
h='100dvh'
|
||||||
mah='100dvh'
|
mah='100dvh'
|
||||||
// style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
|
style={{
|
||||||
|
height: `${viewport.height}px`,
|
||||||
|
minHeight: '100dvh',
|
||||||
|
// top: viewport.top
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Header {...header} />
|
<Header {...header} />
|
||||||
<AppShell.Main
|
<AppShell.Main
|
||||||
pos='relative'
|
pos='relative'
|
||||||
h='100%'
|
h='100%'
|
||||||
mah='100%'
|
mah='100%'
|
||||||
pb={{ base: 65, md: 0 }}
|
pb={{ base: 65, sm: 0 }}
|
||||||
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
|
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
|
||||||
maw='100dvw'
|
maw='100dvw'
|
||||||
style={{ transition: 'none', overflow: 'hidden' }}
|
style={{ transition: 'none', overflow: 'hidden' }}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const Navbar = () => {
|
|||||||
// boxShadow: `5px 5px ${boxShadowColor}`, borderColor
|
// boxShadow: `5px 5px ${boxShadowColor}`, borderColor
|
||||||
|
|
||||||
if (isMobile) return (
|
if (isMobile) return (
|
||||||
<Paper component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1.5rem)' pos='fixed' m='0.75rem' bottom='0' style={{ zIndex: 10 }}>
|
<Paper component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1rem)' pos='fixed' m='0.5rem' bottom='0' style={{ zIndex: 10 }}>
|
||||||
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
<NavLink key={link.href} {...link} />
|
<NavLink key={link.href} {...link} />
|
||||||
|
|||||||
@@ -19,11 +19,17 @@ const useVisualViewportSize = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!windowExists) return;
|
if (!windowExists) return;
|
||||||
|
|
||||||
|
setSize();
|
||||||
|
|
||||||
window.visualViewport?.addEventListener('resize', setSize, eventListerOptions);
|
window.visualViewport?.addEventListener('resize', setSize, eventListerOptions);
|
||||||
|
window.visualViewport?.addEventListener('scroll', setSize, eventListerOptions);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.visualViewport?.removeEventListener('resize', setSize);
|
window.visualViewport?.removeEventListener('resize', setSize);
|
||||||
|
window.visualViewport?.removeEventListener('scroll', setSize);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setSize]);
|
||||||
|
|
||||||
return windowSize;
|
return windowSize;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import GlitchAvatar from '@/components/glitch-avatar';
|
||||||
import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size';
|
import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size';
|
||||||
|
import { useCurrentTournament } from '@/features/tournaments/queries';
|
||||||
import { AppShell, Flex, Paper, em, Title, Stack } from '@mantine/core';
|
import { AppShell, Flex, Paper, em, Title, Stack } from '@mantine/core';
|
||||||
import { useMediaQuery, useViewportSize } from '@mantine/hooks';
|
import { useMediaQuery, useViewportSize } from '@mantine/hooks';
|
||||||
import { TrophyIcon } from '@phosphor-icons/react';
|
import { TrophyIcon } from '@phosphor-icons/react';
|
||||||
@@ -8,6 +10,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
const isMobile = useMediaQuery(`(max-width: ${em(450)})`);
|
const isMobile = useMediaQuery(`(max-width: ${em(450)})`);
|
||||||
const visualViewport = useVisualViewportSize();
|
const visualViewport = useVisualViewportSize();
|
||||||
const viewport = useViewportSize();
|
const viewport = useViewportSize();
|
||||||
|
const { data: tournament } = useCurrentTournament();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
@@ -31,8 +34,27 @@ 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={75} />
|
<GlitchAvatar
|
||||||
<Title order={4} ta='center'>Welcome to FLXN</Title>
|
name={tournament.name}
|
||||||
|
contain
|
||||||
|
src={
|
||||||
|
tournament.logo
|
||||||
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
glitchSrc={
|
||||||
|
tournament.glitch_logo
|
||||||
|
? `/api/files/tournaments/${tournament.id}/${tournament.glitch_logo}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
radius="md"
|
||||||
|
size={250}
|
||||||
|
px="xs"
|
||||||
|
withBorder={false}
|
||||||
|
>
|
||||||
|
<TrophyIcon size={32} />
|
||||||
|
</GlitchAvatar>
|
||||||
|
<Title order={1} ta='center'>Welcome to FLXN</Title>
|
||||||
</Stack>
|
</Stack>
|
||||||
{children}
|
{children}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface MatchListProps {
|
|||||||
const MatchList = ({ matches }: MatchListProps) => {
|
const MatchList = ({ matches }: MatchListProps) => {
|
||||||
const filteredMatches = matches?.filter(match =>
|
const filteredMatches = matches?.filter(match =>
|
||||||
match.home && match.away && !match.bye && match.status != "tbd"
|
match.home && match.away && !match.bye && match.status != "tbd"
|
||||||
) || [];
|
).sort((a, b) => a.start_time < b.start_time ? 1 : -1) || [];
|
||||||
|
|
||||||
if (!filteredMatches.length) {
|
if (!filteredMatches.length) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { MatchInput } from "@/features/matches/types";
|
|||||||
import { serverEvents } from "@/lib/events/emitter";
|
import { serverEvents } from "@/lib/events/emitter";
|
||||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { PlayerInfo } from "../players/types";
|
import { PlayerInfo } from "../players/types";
|
||||||
|
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||||
|
|
||||||
const orderedTeamsSchema = z.object({
|
const orderedTeamsSchema = z.object({
|
||||||
tournamentId: z.string(),
|
tournamentId: z.string(),
|
||||||
@@ -17,7 +18,7 @@ const orderedTeamsSchema = z.object({
|
|||||||
|
|
||||||
export const generateTournamentBracket = createServerFn()
|
export const generateTournamentBracket = createServerFn()
|
||||||
.inputValidator(orderedTeamsSchema)
|
.inputValidator(orderedTeamsSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
logger.info("Generating tournament bracket", {
|
logger.info("Generating tournament bracket", {
|
||||||
@@ -138,7 +139,7 @@ export const generateTournamentBracket = createServerFn()
|
|||||||
|
|
||||||
export const startMatch = createServerFn()
|
export const startMatch = createServerFn()
|
||||||
.inputValidator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
logger.info("Starting match", data);
|
logger.info("Starting match", data);
|
||||||
@@ -171,7 +172,7 @@ const endMatchSchema = z.object({
|
|||||||
});
|
});
|
||||||
export const endMatch = createServerFn()
|
export const endMatch = createServerFn()
|
||||||
.inputValidator(endMatchSchema)
|
.inputValidator(endMatchSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
|
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
logger.info("Ending match", matchId);
|
logger.info("Ending match", matchId);
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ const Header = ({ player }: HeaderProps) => {
|
|||||||
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 fontSize = useMemo(() => {
|
||||||
const baseSize = 24;
|
const baseSize = 28;
|
||||||
const maxLength = 20;
|
const maxLength = 24;
|
||||||
|
|
||||||
if (name.length <= maxLength) {
|
if (name.length <= maxLength) {
|
||||||
return `${baseSize}px`;
|
return `${baseSize}px`;
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Box } from "@mantine/core";
|
import { Box, Stack, Text, Divider } from "@mantine/core";
|
||||||
|
import { Suspense } from "react";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
|
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
|
||||||
import TeamList from "@/features/teams/components/team-list";
|
import TeamList from "@/features/teams/components/team-list";
|
||||||
import StatsOverview from "@/components/stats-overview";
|
import StatsOverview from "@/components/stats-overview";
|
||||||
import MatchList from "@/features/matches/components/match-list";
|
import MatchList from "@/features/matches/components/match-list";
|
||||||
|
import BadgeShowcase from "@/features/badges/components/badge-showcase";
|
||||||
|
import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton";
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,7 +21,19 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
content: <StatsOverview statsData={stats} isLoading={statsLoading} />,
|
content: <>
|
||||||
|
<Stack px="md">
|
||||||
|
<Text size="md" fw={700}>Badges</Text>
|
||||||
|
<Suspense fallback={<BadgeShowcaseSkeleton />}>
|
||||||
|
<BadgeShowcase playerId={id} />
|
||||||
|
</Suspense>
|
||||||
|
</Stack>
|
||||||
|
<Divider my="md" />
|
||||||
|
<Stack>
|
||||||
|
<Text px="md" size="md" fw={700}>Statistics</Text>
|
||||||
|
<StatsOverview statsData={stats} isLoading={statsLoading} />
|
||||||
|
</Stack>
|
||||||
|
</>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Matches",
|
label: "Matches",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { z } from "zod";
|
|||||||
import { logger } from ".";
|
import { logger } from ".";
|
||||||
import { getRequest } from "@tanstack/react-start/server";
|
import { getRequest } from "@tanstack/react-start/server";
|
||||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
|
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||||
|
|
||||||
export const fetchMe = createServerFn()
|
export const fetchMe = createServerFn()
|
||||||
.handler(async () =>
|
.handler(async () =>
|
||||||
@@ -46,7 +47,7 @@ export const getPlayer = createServerFn()
|
|||||||
|
|
||||||
export const updatePlayer = createServerFn()
|
export const updatePlayer = createServerFn()
|
||||||
.inputValidator(playerUpdateSchema)
|
.inputValidator(playerUpdateSchema)
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userAuthId = context.userAuthId;
|
const userAuthId = context.userAuthId;
|
||||||
@@ -98,7 +99,7 @@ export const createPlayer = createServerFn()
|
|||||||
|
|
||||||
export const associatePlayer = createServerFn()
|
export const associatePlayer = createServerFn()
|
||||||
.inputValidator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userAuthId = context.userAuthId;
|
const userAuthId = context.userAuthId;
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Box,
|
Box,
|
||||||
Stack,
|
Stack,
|
||||||
Divider
|
Divider,
|
||||||
|
Title
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useTeam } from "../queries";
|
import { useTeam } from "../queries";
|
||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
@@ -56,9 +57,9 @@ const TeamCard = ({ teamId }: TeamCardProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text size="md" fw={600} lineClamp={1} mb={2}>
|
<Title order={5} lineClamp={1}>
|
||||||
{team.name}
|
{team.name}
|
||||||
</Text>
|
</Title>
|
||||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||||
{team.players?.map(p => `${p.first_name} ${p.last_name}`).join(', ')}
|
{team.players?.map(p => `${p.first_name} ${p.last_name}`).join(', ')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -106,11 +106,7 @@ const TeamForm = ({
|
|||||||
|
|
||||||
mutation(teamData, {
|
mutation(teamData, {
|
||||||
onSuccess: async (team: any) => {
|
onSuccess: async (team: any) => {
|
||||||
queryClient.invalidateQueries({ queryKey: teamKeys.list });
|
close();
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: teamKeys.details(team.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (logo && team) {
|
if (logo && team) {
|
||||||
try {
|
try {
|
||||||
let processedLogo = logo;
|
let processedLogo = logo;
|
||||||
@@ -152,7 +148,12 @@ const TeamForm = ({
|
|||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
|
console.log("Logo upload result:", result);
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: teamKeys.list });
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: teamKeys.details(team.id),
|
||||||
|
});
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
tournamentKeys.details(result.team!.id),
|
tournamentKeys.details(result.team!.id),
|
||||||
result.team
|
result.team
|
||||||
@@ -164,12 +165,16 @@ const TeamForm = ({
|
|||||||
toast.error(logoErrorMessage);
|
toast.error(logoErrorMessage);
|
||||||
logger.error("Team logo upload error", error);
|
logger.error("Team logo upload error", error);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
queryClient.invalidateQueries({ queryKey: teamKeys.list });
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: teamKeys.details(team.id),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (team && team.id) {
|
if (team && team.id) {
|
||||||
onSubmit?.(team.id)
|
onSubmit?.(team.id)
|
||||||
}
|
}
|
||||||
close();
|
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast.error(`${errorMessage}: ${error.message}`);
|
toast.error(`${errorMessage}: ${error.message}`);
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ interface SongSearchProps {
|
|||||||
|
|
||||||
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
|
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
|
||||||
const searchSpotifyTracks = async (query: string): Promise<TypeaheadOption<SpotifyTrack>[]> => {
|
const searchSpotifyTracks = async (query: string): Promise<TypeaheadOption<SpotifyTrack>[]> => {
|
||||||
if (!query.trim()) return [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/spotify/search?q=${encodeURIComponent(query)}`);
|
// Use a default search term when query is empty to show popular tracks
|
||||||
|
const searchTerm = query.trim() || 'top hits';
|
||||||
|
const response = await fetch(`/api/spotify/search?q=${encodeURIComponent(searchTerm)}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Search failed');
|
throw new Error('Search failed');
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const Header = ({ name, logo, id }: HeaderProps) => {
|
|||||||
src={logo && id ? `/api/files/teams/${id}/${logo}` : undefined}
|
src={logo && id ? `/api/files/teams/${id}/${logo}` : undefined}
|
||||||
/>
|
/>
|
||||||
<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}>
|
<Title ta="center" order={1}>
|
||||||
{name}
|
{name}
|
||||||
</Title>
|
</Title>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
|||||||
import { teamInputSchema, teamUpdateSchema } from "./types";
|
import { teamInputSchema, teamUpdateSchema } from "./types";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { Match } from "../matches/types";
|
import { Match } from "../matches/types";
|
||||||
|
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||||
|
|
||||||
|
|
||||||
export const listTeamInfos = createServerFn()
|
export const listTeamInfos = createServerFn()
|
||||||
@@ -30,7 +31,7 @@ export const getTeamInfo = createServerFn()
|
|||||||
|
|
||||||
export const createTeam = createServerFn()
|
export const createTeam = createServerFn()
|
||||||
.inputValidator(teamInputSchema)
|
.inputValidator(teamInputSchema)
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data, context }) =>
|
.handler(async ({ data, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
@@ -50,7 +51,7 @@ export const updateTeam = createServerFn()
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
updates: teamUpdateSchema
|
updates: teamUpdateSchema
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data: { id, updates }, context }) =>
|
.handler(async ({ data: { id, updates }, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
@@ -61,10 +62,10 @@ export const updateTeam = createServerFn()
|
|||||||
throw new Error("Team not found");
|
throw new Error("Team not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPlayerOnTeam = team.players.some(player => player.id === userId);
|
//const isPlayerOnTeam = team.players.some(player => player.id === userId);
|
||||||
if (!isAdmin && !isPlayerOnTeam) {
|
//if (!isAdmin && !isPlayerOnTeam) {
|
||||||
throw new Error("You can only update teams that you are a member of");
|
// throw new Error("You can only update teams that you are a member of");
|
||||||
}
|
// }
|
||||||
|
|
||||||
logger.info("Updating team", { teamId: id, userId, isAdmin });
|
logger.info("Updating team", { teamId: id, userId, isAdmin });
|
||||||
return pbAdmin.updateTeam(id, updates);
|
return pbAdmin.updateTeam(id, updates);
|
||||||
|
|||||||
102
src/features/tournaments/components/podium.tsx
Normal file
102
src/features/tournaments/components/podium.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { Stack, Group, Text, ThemeIcon, Box, Center } from "@mantine/core";
|
||||||
|
import { CrownIcon, MedalIcon } from "@phosphor-icons/react";
|
||||||
|
import { Tournament } from "../types";
|
||||||
|
|
||||||
|
interface PodiumProps {
|
||||||
|
tournament: Tournament;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Podium = ({ tournament }: PodiumProps) => {
|
||||||
|
if (!tournament.first_place) return;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" px="md">
|
||||||
|
{tournament.first_place && (
|
||||||
|
<Group
|
||||||
|
gap="md"
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--mantine-color-yellow-light)',
|
||||||
|
borderRadius: 'var(--mantine-radius-md)',
|
||||||
|
border: '3px solid var(--mantine-color-yellow-outline)',
|
||||||
|
boxShadow: 'var(--mantine-shadow-md)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeIcon size="xl" color="yellow" variant="light" radius="xl">
|
||||||
|
<CrownIcon size={24} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={4} style={{ flex: 1 }}>
|
||||||
|
<Text size="md" fw={600}>
|
||||||
|
{tournament.first_place.name}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
{tournament.first_place.players?.map((player) => (
|
||||||
|
<Text key={player.id} size="sm" c="dimmed">
|
||||||
|
{player.first_name} {player.last_name}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tournament.second_place && (
|
||||||
|
<Group
|
||||||
|
gap="md"
|
||||||
|
p="xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--mantine-color-default)',
|
||||||
|
borderRadius: 'var(--mantine-radius-md)',
|
||||||
|
border: '2px solid var(--mantine-color-default-border)',
|
||||||
|
boxShadow: 'var(--mantine-shadow-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeIcon size="lg" color="gray" variant="light" radius="xl">
|
||||||
|
<MedalIcon size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={4} style={{ flex: 1 }}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{tournament.second_place.name}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
{tournament.second_place.players?.map((player) => (
|
||||||
|
<Text key={player.id} size="xs" c="dimmed">
|
||||||
|
{player.first_name} {player.last_name}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tournament.third_place && (
|
||||||
|
<Group
|
||||||
|
gap="md"
|
||||||
|
p="xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--mantine-color-orange-light)',
|
||||||
|
borderRadius: 'var(--mantine-radius-md)',
|
||||||
|
border: '2px solid var(--mantine-color-orange-outline)',
|
||||||
|
boxShadow: 'var(--mantine-shadow-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeIcon size="lg" color="orange" variant="light" radius="xl">
|
||||||
|
<MedalIcon size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={4} style={{ flex: 1 }}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{tournament.third_place.name}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
{tournament.third_place.players?.map((player) => (
|
||||||
|
<Text key={player.id} size="xs" c="dimmed">
|
||||||
|
{player.first_name} {player.last_name}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,9 +11,9 @@ const Header = ({ tournament }: HeaderProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'>
|
<Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'>
|
||||||
<Avatar contain name={tournament.name} radius={0} withBorder={false} size={150} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />
|
<Avatar contain name={tournament.name} radius="sm" size={150} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />
|
||||||
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
||||||
<Title ta='center' order={2}>{tournament.name}</Title>
|
<Title ta='center' order={1}>{tournament.name}</Title>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
|
import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
|
||||||
import { Tournament } from "../../types";
|
import { Tournament } from "../../types";
|
||||||
import Avatar from "@/components/avatar";
|
import { CalendarIcon, MapPinIcon, TrophyIcon } from "@phosphor-icons/react";
|
||||||
import {
|
|
||||||
CalendarIcon,
|
|
||||||
MapPinIcon,
|
|
||||||
TrophyIcon,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import GlitchAvatar from "@/components/glitch-avatar";
|
||||||
|
|
||||||
const Header = ({ tournament }: { tournament: Tournament }) => {
|
const Header = ({ tournament }: { tournament: Tournament }) => {
|
||||||
const tournamentStart = useMemo(
|
const tournamentStart = useMemo(
|
||||||
@@ -16,7 +12,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack px="sm" align="center" gap={0}>
|
<Stack px="sm" align="center" gap={0}>
|
||||||
<Avatar
|
<GlitchAvatar
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
contain
|
contain
|
||||||
src={
|
src={
|
||||||
@@ -24,13 +20,18 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
glitchSrc={
|
||||||
|
tournament.glitch_logo
|
||||||
|
? `/api/files/tournaments/${tournament.id}/${tournament.glitch_logo}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
radius="md"
|
radius="md"
|
||||||
size={200}
|
size={250}
|
||||||
px="xs"
|
px="xs"
|
||||||
withBorder={false}
|
withBorder={false}
|
||||||
>
|
>
|
||||||
<TrophyIcon size={24} />
|
<TrophyIcon size={32} />
|
||||||
</Avatar>
|
</GlitchAvatar>
|
||||||
<Flex gap="xs" direction="row" wrap="wrap" justify="space-around">
|
<Flex gap="xs" direction="row" wrap="wrap" justify="space-around">
|
||||||
{tournament.location && (
|
{tournament.location && (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { useAuth } from "@/contexts/auth-context";
|
|||||||
import { Box, Divider, Stack, Text, Card, Center } from "@mantine/core";
|
import { Box, Divider, Stack, Text, Card, Center } from "@mantine/core";
|
||||||
import { Carousel } from "@mantine/carousel";
|
import { Carousel } from "@mantine/carousel";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
import { TreeStructureIcon, UsersIcon, ClockIcon } from "@phosphor-icons/react";
|
import { TreeStructureIcon, UsersIcon, ClockIcon, TrophyIcon } from "@phosphor-icons/react";
|
||||||
import TeamListButton from "../upcoming-tournament/team-list-button";
|
import TeamListButton from "../upcoming-tournament/team-list-button";
|
||||||
import RulesListButton from "../upcoming-tournament/rules-list-button";
|
import RulesListButton from "../upcoming-tournament/rules-list-button";
|
||||||
import MatchCard from "@/features/matches/components/match-card";
|
import MatchCard from "@/features/matches/components/match-card";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
|
import { Podium } from "../podium";
|
||||||
|
|
||||||
const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||||
tournament,
|
tournament,
|
||||||
@@ -22,6 +23,20 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
[tournament.matches]
|
[tournament.matches]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isTournamentOver = useMemo(() => {
|
||||||
|
const matches = tournament.matches || [];
|
||||||
|
if (matches.length === 0) return false;
|
||||||
|
|
||||||
|
const nonByeMatches = matches.filter((match) => !(match.status === 'tbd' && match.bye === true));
|
||||||
|
if (nonByeMatches.length === 0) return false;
|
||||||
|
|
||||||
|
const finalsMatch = nonByeMatches.reduce((highest, current) =>
|
||||||
|
(!highest || current.lid > highest.lid) ? current : highest
|
||||||
|
);
|
||||||
|
|
||||||
|
return finalsMatch?.status === 'ended';
|
||||||
|
}, [tournament.matches]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Header tournament={tournament} />
|
<Header tournament={tournament} />
|
||||||
@@ -42,6 +57,10 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
))}
|
))}
|
||||||
</Carousel>
|
</Carousel>
|
||||||
</Box>
|
</Box>
|
||||||
|
) : isTournamentOver ? (
|
||||||
|
<Box px="lg" w="100%">
|
||||||
|
<Podium tournament={tournament} />
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Card withBorder radius="lg" p="xl" mx="md">
|
<Card withBorder radius="lg" p="xl" mx="md">
|
||||||
<Center>
|
<Center>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
Badge,
|
Badge,
|
||||||
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { TournamentInfo } from "@/features/tournaments/types";
|
import { TournamentInfo } from "@/features/tournaments/types";
|
||||||
import {
|
import {
|
||||||
@@ -27,14 +28,6 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
|||||||
w="100%"
|
w="100%"
|
||||||
onClick={() => navigate({ to: `/tournaments/${tournament.id}` })}
|
onClick={() => navigate({ to: `/tournaments/${tournament.id}` })}
|
||||||
style={{ borderRadius: "var(--mantine-radius-md)" }}
|
style={{ borderRadius: "var(--mantine-radius-md)" }}
|
||||||
styles={{
|
|
||||||
root: {
|
|
||||||
"&:hover": {
|
|
||||||
transform: "translateY(-2px)",
|
|
||||||
transition: "transform 0.15s ease",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
withBorder
|
withBorder
|
||||||
@@ -45,19 +38,11 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
|||||||
transition: "all 0.15s ease",
|
transition: "all 0.15s ease",
|
||||||
border: "1px solid var(--mantine-color-default-border)",
|
border: "1px solid var(--mantine-color-default-border)",
|
||||||
}}
|
}}
|
||||||
styles={{
|
|
||||||
root: {
|
|
||||||
"&:hover": {
|
|
||||||
borderColor: "var(--mantine-primary-color-filled)",
|
|
||||||
boxShadow: "var(--mantine-shadow-sm)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Group gap="md" align="center">
|
<Group gap="md" align="center">
|
||||||
<Avatar
|
<Avatar
|
||||||
size={90}
|
size={75}
|
||||||
radius="sm"
|
radius="sm"
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
contain
|
contain
|
||||||
@@ -70,14 +55,14 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
|||||||
<TrophyIcon size={20} />
|
<TrophyIcon size={20} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text fw={600} size="lg" lineClamp={2}>
|
<Title mb={-6} order={3} lineClamp={2}>
|
||||||
{tournament.name}
|
{tournament.name}
|
||||||
</Text>
|
</Title>
|
||||||
{(tournament.first_place || tournament.second_place || tournament.third_place) && (
|
{(tournament.first_place || tournament.second_place || tournament.third_place) && (
|
||||||
<Stack gap={6} >
|
<Stack gap={6} >
|
||||||
{tournament.first_place && (
|
{tournament.first_place && (
|
||||||
<Badge
|
<Badge
|
||||||
size="md"
|
size="sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color="yellow"
|
color="yellow"
|
||||||
@@ -95,7 +80,7 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
|||||||
)}
|
)}
|
||||||
{tournament.second_place && (
|
{tournament.second_place && (
|
||||||
<Badge
|
<Badge
|
||||||
size="md"
|
size="sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
color="gray"
|
color="gray"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
@@ -112,7 +97,7 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
|||||||
)}
|
)}
|
||||||
{tournament.third_place && (
|
{tournament.third_place && (
|
||||||
<Badge
|
<Badge
|
||||||
size="md"
|
size="sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
color="orange"
|
color="orange"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Tournament } from "@/features/tournaments/types";
|
|||||||
import { CrownIcon, MedalIcon, TreeStructureIcon } from "@phosphor-icons/react";
|
import { CrownIcon, MedalIcon, TreeStructureIcon } from "@phosphor-icons/react";
|
||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
|
import { Podium } from "./podium";
|
||||||
|
|
||||||
interface TournamentStatsProps {
|
interface TournamentStatsProps {
|
||||||
tournament: Tournament;
|
tournament: Tournament;
|
||||||
@@ -40,118 +41,12 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
|||||||
});
|
});
|
||||||
}, [tournament.team_stats]);
|
}, [tournament.team_stats]);
|
||||||
|
|
||||||
const renderPodium = () => {
|
|
||||||
if (!isComplete || !tournament.first_place) {
|
|
||||||
return (
|
|
||||||
<Box p="md">
|
|
||||||
<Center>
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
Podium will appear here when the tournament is over
|
|
||||||
</Text>
|
|
||||||
</Center>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="xs" px="md">
|
|
||||||
{tournament.first_place && (
|
|
||||||
<Group
|
|
||||||
gap="md"
|
|
||||||
p="md"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--mantine-color-yellow-light)',
|
|
||||||
borderRadius: 'var(--mantine-radius-md)',
|
|
||||||
border: '3px solid var(--mantine-color-yellow-outline)',
|
|
||||||
boxShadow: 'var(--mantine-shadow-md)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ThemeIcon size="xl" color="yellow" variant="light" radius="xl">
|
|
||||||
<CrownIcon size={24} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Stack gap={4} style={{ flex: 1 }}>
|
|
||||||
<Text size="md" fw={600}>
|
|
||||||
{tournament.first_place.name}
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs">
|
|
||||||
{tournament.first_place.players?.map((player) => (
|
|
||||||
<Text key={player.id} size="sm" c="dimmed">
|
|
||||||
{player.first_name} {player.last_name}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tournament.second_place && (
|
|
||||||
<Group
|
|
||||||
gap="md"
|
|
||||||
p="xs"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--mantine-color-default)',
|
|
||||||
borderRadius: 'var(--mantine-radius-md)',
|
|
||||||
border: '2px solid var(--mantine-color-default-border)',
|
|
||||||
boxShadow: 'var(--mantine-shadow-sm)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ThemeIcon size="lg" color="gray" variant="light" radius="xl">
|
|
||||||
<MedalIcon size={20} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Stack gap={4} style={{ flex: 1 }}>
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{tournament.second_place.name}
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs">
|
|
||||||
{tournament.second_place.players?.map((player) => (
|
|
||||||
<Text key={player.id} size="xs" c="dimmed">
|
|
||||||
{player.first_name} {player.last_name}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tournament.third_place && (
|
|
||||||
<Group
|
|
||||||
gap="md"
|
|
||||||
p="xs"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--mantine-color-orange-light)',
|
|
||||||
borderRadius: 'var(--mantine-radius-md)',
|
|
||||||
border: '2px solid var(--mantine-color-orange-outline)',
|
|
||||||
boxShadow: 'var(--mantine-shadow-sm)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ThemeIcon size="lg" color="orange" variant="light" radius="xl">
|
|
||||||
<MedalIcon size={18} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Stack gap={4} style={{ flex: 1 }}>
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{tournament.third_place.name}
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs">
|
|
||||||
{tournament.third_place.players?.map((player) => (
|
|
||||||
<Text key={player.id} size="xs" c="dimmed">
|
|
||||||
{player.first_name} {player.last_name}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const teamStatsWithCalculations = useMemo(() => {
|
const teamStatsWithCalculations = useMemo(() => {
|
||||||
return sortedTeamStats.map((stat, index) => ({
|
return sortedTeamStats.map((stat) => ({
|
||||||
...stat,
|
...stat,
|
||||||
index,
|
|
||||||
winPercentage: stat.matches > 0 ? (stat.wins / stat.matches) * 100 : 0,
|
winPercentage: stat.matches > 0 ? (stat.wins / stat.matches) * 100 : 0,
|
||||||
avgCupsPerMatch: stat.matches > 0 ? stat.total_cups_made / stat.matches : 0,
|
avgCupsPerMatch: stat.matches > 0 ? stat.total_cups_made / stat.matches : 0,
|
||||||
}));
|
})).sort((a, b) => b.winPercentage - a.winPercentage);;
|
||||||
}, [sortedTeamStats]);
|
}, [sortedTeamStats]);
|
||||||
|
|
||||||
const renderTeamStatsTable = () => {
|
const renderTeamStatsTable = () => {
|
||||||
@@ -170,23 +65,14 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
|||||||
return (
|
return (
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text px="md" size="lg" fw={600}>Results</Text>
|
<Text px="md" size="lg" fw={600}>Results</Text>
|
||||||
{teamStatsWithCalculations.map((stat) => {
|
<Text px="md" c="dimmed" size="xs" fw={500}>Sorted by win percentage</Text>
|
||||||
|
{teamStatsWithCalculations.map((stat, index) => {
|
||||||
return (
|
return (
|
||||||
<Box key={stat.id}>
|
<Box key={stat.id}>
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
w="100%"
|
w="100%"
|
||||||
p="md"
|
p="md"
|
||||||
style={{
|
style={{ borderRadius: 0 }}
|
||||||
borderRadius: 0,
|
|
||||||
transition: "background-color 0.15s ease",
|
|
||||||
}}
|
|
||||||
styles={{
|
|
||||||
root: {
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'var(--mantine-color-gray-0)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Group justify="space-between" align="center" w="100%">
|
<Group justify="space-between" align="center" w="100%">
|
||||||
<Group gap="sm" align="center">
|
<Group gap="sm" align="center">
|
||||||
@@ -194,12 +80,12 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
|||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Group gap='xs'>
|
<Group gap='xs'>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
#{stat.index + 1}
|
#{index + 1}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
{stat.team_name}
|
{stat.team_name}
|
||||||
</Text>
|
</Text>
|
||||||
{stat.index === 0 && isComplete && (
|
{index === 0 && isComplete && (
|
||||||
<ThemeIcon size="xs" color="yellow" variant="light" radius="xl">
|
<ThemeIcon size="xs" color="yellow" variant="light" radius="xl">
|
||||||
<CrownIcon size={12} />
|
<CrownIcon size={12} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
@@ -259,7 +145,7 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
{stat.index < teamStatsWithCalculations.length - 1 && <Divider />}
|
{index < teamStatsWithCalculations.length - 1 && <Divider />}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -270,7 +156,7 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
|||||||
return (
|
return (
|
||||||
<Container size="100%" px={0}>
|
<Container size="100%" px={0}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{renderPodium()}
|
<Podium tournament={tournament} />
|
||||||
<ListLink
|
<ListLink
|
||||||
label={`View Bracket`}
|
label={`View Bracket`}
|
||||||
to={`/tournaments/${tournament.id}/bracket`}
|
to={`/tournaments/${tournament.id}/bracket`}
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
|
|||||||
<Sheet title="Free Agent Enrollment" opened={isOpen} onChange={toggle}>
|
<Sheet title="Free Agent Enrollment" opened={isOpen} onChange={toggle}>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="md">
|
<Text size="md">
|
||||||
Enrolling as a free agent will enter you in a pool of players wanting to play but don't have a teammate yet.
|
Enrolling as a free agent adds you to a pool of players looking for teammates.
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c='dimmed'>
|
<Text size="sm" c='dimmed'>
|
||||||
You will be able to see a list of other enrolled free agents, as well as their contact information for organizing your team and walkout song. By enrolling, your phone number will be visible to other free agents.
|
Once enrolled, you can view other free agents and their phone number in order to coordinate teams and walkout songs.
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Note: this does not guarantee you a spot in the tournament. One person from your team must enroll in the app and choose a walkout song in order to secure a spot.
|
Important: Enrolling as a free agent does not guarantee a tournament spot. To secure a spot, one team member must register through the app and select a walkout song.
|
||||||
</Text>
|
</Text>
|
||||||
<Button onClick={handleEnroll}>Confirm</Button>
|
<Button onClick={handleEnroll}>Confirm</Button>
|
||||||
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
|
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
|
|||||||
const [selectedTeam, setSelectedTeam] = React.useState<ComboboxItem | null>(null);
|
const [selectedTeam, setSelectedTeam] = React.useState<ComboboxItem | null>(null);
|
||||||
|
|
||||||
const searchTeams = async (query: string): Promise<TypeaheadOption<ComboboxItem>[]> => {
|
const searchTeams = async (query: string): Promise<TypeaheadOption<ComboboxItem>[]> => {
|
||||||
if (!query.trim()) return [];
|
|
||||||
|
|
||||||
const filtered = options.filter(option =>
|
const filtered = options.filter(option =>
|
||||||
option.label.toLowerCase().includes(query.toLowerCase())
|
option.label.toLowerCase().includes(query.toLowerCase())
|
||||||
);
|
);
|
||||||
@@ -66,6 +64,7 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
|
|||||||
searchFn={searchTeams}
|
searchFn={searchTeams}
|
||||||
renderOption={renderTeamOption}
|
renderOption={renderTeamOption}
|
||||||
format={formatTeam}
|
format={formatTeam}
|
||||||
|
maxHeight={80}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
|
import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
|
||||||
import { Tournament } from "../../types";
|
import { Tournament } from "../../types";
|
||||||
import Avatar from "@/components/avatar";
|
import GlitchAvatar from "@/components/glitch-avatar";
|
||||||
import {
|
import {
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
@@ -16,8 +16,8 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack align="center" gap={0}>
|
<Stack align="center" gap={16}>
|
||||||
<Avatar
|
<GlitchAvatar
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
contain
|
contain
|
||||||
src={
|
src={
|
||||||
@@ -25,13 +25,18 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
glitchSrc={
|
||||||
|
tournament.glitch_logo
|
||||||
|
? `/api/files/tournaments/${tournament.id}/${tournament.glitch_logo}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
radius="md"
|
radius="md"
|
||||||
size={300}
|
size={300}
|
||||||
px="xs"
|
px="xs"
|
||||||
withBorder={false}
|
withBorder={false}
|
||||||
>
|
>
|
||||||
<TrophyIcon size={32} />
|
<TrophyIcon size={32} />
|
||||||
</Avatar>
|
</GlitchAvatar>
|
||||||
<Flex gap="xs" direction="column" justify="space-around">
|
<Flex gap="xs" direction="column" justify="space-around">
|
||||||
{tournament.location && (
|
{tournament.location && (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Suspense, useCallback, useMemo } from "react";
|
import { Suspense, useCallback, useMemo } from "react";
|
||||||
import { Tournament } from "../../types";
|
import { Tournament } from "../../types";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { Box, Button, Card, Divider, Group, Stack, Text } from "@mantine/core";
|
import { Box, Button, Card, Divider, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
import Countdown from "@/components/countdown";
|
import Countdown from "@/components/countdown";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
import ListButton from "@/components/list-button";
|
import ListButton from "@/components/list-button";
|
||||||
@@ -56,11 +56,11 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
|
|
||||||
<Card withBorder radius="lg" p="lg">
|
<Card withBorder radius="lg" p="lg">
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Group mb="sm" gap="xs" align="center">
|
<Group gap="xs" align="center">
|
||||||
<UsersIcon size={16} />
|
<UsersIcon size={16} />
|
||||||
<Text size="sm" fw={500}>
|
<Title mt={4} order={5}>
|
||||||
Enrollment
|
Enrollment
|
||||||
</Text>
|
</Title>
|
||||||
{isEnrollmentOpen && (
|
{isEnrollmentOpen && (
|
||||||
<Box ml="auto">
|
<Box ml="auto">
|
||||||
<Countdown
|
<Countdown
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { tournamentInputSchema } from "@/features/tournaments/types";
|
|||||||
import { logger } from ".";
|
import { logger } from ".";
|
||||||
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 { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||||
|
import { fa } from "zod/v4/locales";
|
||||||
|
|
||||||
export const listTournaments = createServerFn()
|
export const listTournaments = createServerFn()
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
@@ -14,7 +16,7 @@ export const listTournaments = createServerFn()
|
|||||||
|
|
||||||
export const createTournament = createServerFn()
|
export const createTournament = createServerFn()
|
||||||
.inputValidator(tournamentInputSchema)
|
.inputValidator(tournamentInputSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult(() => pbAdmin.createTournament(data))
|
toServerResult(() => pbAdmin.createTournament(data))
|
||||||
);
|
);
|
||||||
@@ -24,7 +26,7 @@ export const updateTournament = createServerFn()
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
updates: tournamentInputSchema.partial()
|
updates: tournamentInputSchema.partial()
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult(() => pbAdmin.updateTournament(data.id, data.updates))
|
toServerResult(() => pbAdmin.updateTournament(data.id, data.updates))
|
||||||
);
|
);
|
||||||
@@ -48,7 +50,7 @@ export const enrollTeam = createServerFn()
|
|||||||
tournamentId: z.string(),
|
tournamentId: z.string(),
|
||||||
teamId: z.string()
|
teamId: z.string()
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
@@ -57,10 +59,18 @@ export const enrollTeam = createServerFn()
|
|||||||
const team = await pbAdmin.getTeam(teamId);
|
const team = await pbAdmin.getTeam(teamId);
|
||||||
if (!team) { throw new Error('Team not found'); }
|
if (!team) { throw new Error('Team not found'); }
|
||||||
|
|
||||||
const isPlayerOnTeam = team.players?.some(player => player.id === userId);
|
//const isPlayerOnTeam = team.players?.some(player => player.id === userId);
|
||||||
|
|
||||||
if (!isPlayerOnTeam && !isAdmin) {
|
//if (!isPlayerOnTeam && !isAdmin) {
|
||||||
throw new Error('You do not have permission to enroll this team');
|
// throw new Error('You do not have permission to enroll this team');
|
||||||
|
//}
|
||||||
|
|
||||||
|
const freeAgents = await pbAdmin.getFreeAgents(tournamentId);
|
||||||
|
for (const player of team.players || []) {
|
||||||
|
const isFreeAgent = freeAgents.some(fa => fa.player?.id === player.id);
|
||||||
|
if (isFreeAgent) {
|
||||||
|
await pbAdmin.unenrollFreeAgent(player.id, tournamentId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Enrolling team in tournament', { tournamentId, teamId, userId });
|
logger.info('Enrolling team in tournament', { tournamentId, teamId, userId });
|
||||||
@@ -74,7 +84,7 @@ export const unenrollTeam = createServerFn()
|
|||||||
tournamentId: z.string(),
|
tournamentId: z.string(),
|
||||||
teamId: z.string()
|
teamId: z.string()
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
||||||
toServerResult(() => pbAdmin.unenrollTeam(tournamentId, teamId))
|
toServerResult(() => pbAdmin.unenrollTeam(tournamentId, teamId))
|
||||||
);
|
);
|
||||||
@@ -95,7 +105,7 @@ export const getFreeAgents = createServerFn()
|
|||||||
|
|
||||||
export const enrollFreeAgent = createServerFn()
|
export const enrollFreeAgent = createServerFn()
|
||||||
.inputValidator(z.object({ phone: z.string(), tournamentId: z.string() }))
|
.inputValidator(z.object({ phone: z.string(), tournamentId: z.string() }))
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userAuthId = context.userAuthId;
|
const userAuthId = context.userAuthId;
|
||||||
@@ -109,7 +119,7 @@ export const enrollFreeAgent = createServerFn()
|
|||||||
|
|
||||||
export const unenrollFreeAgent = createServerFn()
|
export const unenrollFreeAgent = createServerFn()
|
||||||
.inputValidator(z.object({ tournamentId: z.string() }))
|
.inputValidator(z.object({ tournamentId: z.string() }))
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userAuthId = context.userAuthId;
|
const userAuthId = context.userAuthId;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface TournamentInfo {
|
|||||||
start_time?: string;
|
start_time?: string;
|
||||||
end_time?: string;
|
end_time?: string;
|
||||||
logo?: string;
|
logo?: string;
|
||||||
|
glitch_logo?: string;
|
||||||
first_place?: TeamInfo;
|
first_place?: TeamInfo;
|
||||||
second_place?: TeamInfo;
|
second_place?: TeamInfo;
|
||||||
third_place?: TeamInfo;
|
third_place?: TeamInfo;
|
||||||
@@ -37,6 +38,7 @@ export interface Tournament {
|
|||||||
desc?: string;
|
desc?: string;
|
||||||
rules?: string;
|
rules?: string;
|
||||||
logo?: string;
|
logo?: string;
|
||||||
|
glitch_logo?: string;
|
||||||
enroll_time?: string;
|
enroll_time?: string;
|
||||||
start_time: string;
|
start_time: string;
|
||||||
end_time?: string;
|
end_time?: string;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const eventHandlers: Record<string, EventHandler> = {
|
|||||||
logger.info("New Connection");
|
logger.info("New Connection");
|
||||||
},
|
},
|
||||||
"ping": () => {},
|
"ping": () => {},
|
||||||
|
"heartbeat": () => {},
|
||||||
"match": (event, queryClient) => {
|
"match": (event, queryClient) => {
|
||||||
queryClient.invalidateQueries(tournamentQueries.details(event.tournamentId))
|
queryClient.invalidateQueries(tournamentQueries.details(event.tournamentId))
|
||||||
queryClient.invalidateQueries(tournamentQueries.current())
|
queryClient.invalidateQueries(tournamentQueries.current())
|
||||||
@@ -73,15 +74,15 @@ export function useServerEvents() {
|
|||||||
logger.error("SSE connection error", error);
|
logger.error("SSE connection error", error);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
|
|
||||||
if (shouldConnectRef.current && retryCountRef.current < 5) {
|
if (shouldConnectRef.current && retryCountRef.current < 10) {
|
||||||
retryCountRef.current += 1;
|
retryCountRef.current += 1;
|
||||||
const delay = Math.min(
|
const delay = Math.min(
|
||||||
1000 * Math.pow(2, retryCountRef.current - 1),
|
1000 * Math.pow(1.5, retryCountRef.current - 1),
|
||||||
30000
|
15000
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`SSE reconnection attempt ${retryCountRef.current}/5 in ${delay}ms`
|
`SSE reconnection attempt ${retryCountRef.current}/10 in ${delay}ms`
|
||||||
);
|
);
|
||||||
|
|
||||||
timeoutRef.current = setTimeout(() => {
|
timeoutRef.current = setTimeout(() => {
|
||||||
@@ -89,7 +90,7 @@ export function useServerEvents() {
|
|||||||
connectEventSource();
|
connectEventSource();
|
||||||
}
|
}
|
||||||
}, delay);
|
}, delay);
|
||||||
} else if (retryCountRef.current >= 5) {
|
} else if (retryCountRef.current >= 10) {
|
||||||
logger.error("SSE max reconnection attempts reached");
|
logger.error("SSE max reconnection attempts reached");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,23 @@ import { EventEmitter } from "events";
|
|||||||
|
|
||||||
export const serverEvents = new EventEmitter();
|
export const serverEvents = new EventEmitter();
|
||||||
|
|
||||||
|
serverEvents.setMaxListeners(50);
|
||||||
|
|
||||||
|
// Debug logging for listener count
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
setInterval(() => {
|
||||||
|
const listenerCounts = {
|
||||||
|
test: serverEvents.listenerCount('test'),
|
||||||
|
match: serverEvents.listenerCount('match'),
|
||||||
|
reaction: serverEvents.listenerCount('reaction'),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (listenerCounts.test > 0 || listenerCounts.match > 0 || listenerCounts.reaction > 0) {
|
||||||
|
console.log('ServerEvents listener count:', listenerCounts);
|
||||||
|
}
|
||||||
|
}, 30000); // Log every 30 seconds in development
|
||||||
|
}
|
||||||
|
|
||||||
export type TestEvent = {
|
export type TestEvent = {
|
||||||
type: "test";
|
type: "test";
|
||||||
playerId: string;
|
playerId: string;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const commonInputStyles = {
|
|||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
defaultRadius: "sm",
|
defaultRadius: "sm",
|
||||||
|
fontFamily: '"Inter", sans-serif',
|
||||||
|
headings: { fontFamily: '"League Spartan", sans-serif' },
|
||||||
components: {
|
components: {
|
||||||
TextInput: {
|
TextInput: {
|
||||||
styles: commonInputStyles,
|
styles: commonInputStyles,
|
||||||
@@ -55,7 +57,7 @@ const MantineProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
setIsHydrated(true);
|
setIsHydrated(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const colorScheme = isHydrated ? metadata.colorScheme || "dark" : "dark";
|
const colorScheme = isHydrated ? metadata.colorScheme || "auto" : "auto";
|
||||||
const primaryColor = isHydrated ? metadata.accentColor || "blue" : "blue";
|
const primaryColor = isHydrated ? metadata.accentColor || "blue" : "blue";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import { Card, Container, createTheme, Paper, rem, Select } from "@mantine/core";
|
|
||||||
import type { MantineThemeOverride } from "@mantine/core";
|
|
||||||
|
|
||||||
const CONTAINER_SIZES: Record<string, string> = {
|
|
||||||
xxs: rem("200px"),
|
|
||||||
xs: rem("300px"),
|
|
||||||
sm: rem("400px"),
|
|
||||||
md: rem("500px"),
|
|
||||||
lg: rem("600px"),
|
|
||||||
xl: rem("1400px"),
|
|
||||||
xxl: rem("1600px"),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const defaultTheme: MantineThemeOverride = createTheme({
|
|
||||||
scale: 1.1,
|
|
||||||
autoContrast: true,
|
|
||||||
fontSizes: {
|
|
||||||
xs: rem("12px"),
|
|
||||||
sm: rem("14px"),
|
|
||||||
md: rem("16px"),
|
|
||||||
lg: rem("18px"),
|
|
||||||
xl: rem("20px"),
|
|
||||||
"2xl": rem("24px"),
|
|
||||||
"3xl": rem("30px"),
|
|
||||||
"4xl": rem("36px"),
|
|
||||||
"5xl": rem("48px"),
|
|
||||||
},
|
|
||||||
spacing: {
|
|
||||||
"3xs": rem("4px"),
|
|
||||||
"2xs": rem("8px"),
|
|
||||||
xs: rem("10px"),
|
|
||||||
sm: rem("12px"),
|
|
||||||
md: rem("16px"),
|
|
||||||
lg: rem("20px"),
|
|
||||||
xl: rem("24px"),
|
|
||||||
"2xl": rem("28px"),
|
|
||||||
"3xl": rem("32px"),
|
|
||||||
},
|
|
||||||
primaryColor: "red",
|
|
||||||
components: {
|
|
||||||
Container: Container.extend({
|
|
||||||
vars: (_, { size, fluid }) => ({
|
|
||||||
root: {
|
|
||||||
"--container-size": fluid
|
|
||||||
? "100%"
|
|
||||||
: size !== undefined && size in CONTAINER_SIZES
|
|
||||||
? CONTAINER_SIZES[size]
|
|
||||||
: rem(size),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
Paper: Paper.extend({
|
|
||||||
defaultProps: {
|
|
||||||
p: "md",
|
|
||||||
shadow: "xl",
|
|
||||||
radius: "md",
|
|
||||||
withBorder: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
Card: Card.extend({
|
|
||||||
defaultProps: {
|
|
||||||
p: "xl",
|
|
||||||
shadow: "xl",
|
|
||||||
radius: "var(--mantine-radius-default)",
|
|
||||||
withBorder: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Select: Select.extend({
|
|
||||||
defaultProps: {
|
|
||||||
checkIconPosition: "right",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
other: {
|
|
||||||
style: "mantine",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -4,17 +4,31 @@ import { createTournamentsService } from "./services/tournaments";
|
|||||||
import { createTeamsService } from "./services/teams";
|
import { createTeamsService } from "./services/teams";
|
||||||
import { createMatchesService } from "./services/matches";
|
import { createMatchesService } from "./services/matches";
|
||||||
import { createReactionsService } from "./services/reactions";
|
import { createReactionsService } from "./services/reactions";
|
||||||
|
import { createActivitiesService } from "./services/activities";
|
||||||
|
import { createBadgesService } from "./services/badges";
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
class PocketBaseAdminClient {
|
class PocketBaseAdminClient {
|
||||||
private pb: PocketBase;
|
private pb: PocketBase;
|
||||||
public authPromise: Promise<void>;
|
public authPromise: Promise<void>;
|
||||||
|
private refreshInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.pb = new PocketBase(process.env.POCKETBASE_URL);
|
this.pb = new PocketBase(process.env.POCKETBASE_URL);
|
||||||
|
|
||||||
this.pb.beforeSend = (url, options) => {
|
this.pb.beforeSend = async (url, options) => {
|
||||||
|
await this.authPromise;
|
||||||
|
|
||||||
|
if (this.pb.authStore.isValid && this.isTokenExpiringSoon()) {
|
||||||
|
try {
|
||||||
|
await this.refreshAuth();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh admin token, re-authenticating:', error);
|
||||||
|
await this.authenticate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
options.cache = "no-store";
|
options.cache = "no-store";
|
||||||
options.headers = {
|
options.headers = {
|
||||||
...options.headers,
|
...options.headers,
|
||||||
@@ -27,24 +41,79 @@ class PocketBaseAdminClient {
|
|||||||
};
|
};
|
||||||
this.pb.autoCancellation(false);
|
this.pb.autoCancellation(false);
|
||||||
|
|
||||||
this.authPromise = this.authenticate();
|
|
||||||
|
|
||||||
this.authPromise.then(() => {
|
|
||||||
Object.assign(this, createPlayersService(this.pb));
|
Object.assign(this, createPlayersService(this.pb));
|
||||||
Object.assign(this, createTeamsService(this.pb));
|
Object.assign(this, createTeamsService(this.pb));
|
||||||
Object.assign(this, createTournamentsService(this.pb));
|
Object.assign(this, createTournamentsService(this.pb));
|
||||||
Object.assign(this, createMatchesService(this.pb));
|
Object.assign(this, createMatchesService(this.pb));
|
||||||
Object.assign(this, createReactionsService(this.pb));
|
Object.assign(this, createReactionsService(this.pb));
|
||||||
|
Object.assign(this, createActivitiesService(this.pb));
|
||||||
|
Object.assign(this, createBadgesService(this.pb));
|
||||||
|
|
||||||
|
this.authPromise = this.authenticate();
|
||||||
|
this.authPromise.then(() => {
|
||||||
|
this.startTokenRefresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async authenticate() {
|
private async authenticate() {
|
||||||
|
try {
|
||||||
await this.pb
|
await this.pb
|
||||||
.collection("_superusers")
|
.collection("_superusers")
|
||||||
.authWithPassword(
|
.authWithPassword(
|
||||||
process.env.POCKETBASE_ADMIN_EMAIL!,
|
process.env.POCKETBASE_ADMIN_EMAIL!,
|
||||||
process.env.POCKETBASE_ADMIN_PASSWORD!
|
process.env.POCKETBASE_ADMIN_PASSWORD!
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to authenticate PocketBase admin:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshAuth() {
|
||||||
|
try {
|
||||||
|
await this.pb.collection("_superusers").authRefresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh PocketBase admin token:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isTokenExpiringSoon(): boolean {
|
||||||
|
if (!this.pb.authStore.token) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(this.pb.authStore.token.split('.')[1]));
|
||||||
|
const expiresAt = payload.exp * 1000;
|
||||||
|
const now = Date.now();
|
||||||
|
const fiveMinutes = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
return expiresAt - now < fiveMinutes;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startTokenRefresh() {
|
||||||
|
this.refreshInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await this.refreshAuth();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Periodic token refresh failed, re-authenticating:', error);
|
||||||
|
try {
|
||||||
|
await this.authenticate();
|
||||||
|
} catch (authError) {
|
||||||
|
console.error('Re-authentication failed:', authError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 10 * 60 * 1000);
|
||||||
|
|
||||||
|
if (typeof process !== 'undefined') {
|
||||||
|
process.on('beforeExit', () => {
|
||||||
|
if (this.refreshInterval) {
|
||||||
|
clearInterval(this.refreshInterval);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +123,9 @@ interface AdminClient
|
|||||||
ReturnType<typeof createTeamsService>,
|
ReturnType<typeof createTeamsService>,
|
||||||
ReturnType<typeof createTournamentsService>,
|
ReturnType<typeof createTournamentsService>,
|
||||||
ReturnType<typeof createMatchesService>,
|
ReturnType<typeof createMatchesService>,
|
||||||
ReturnType<typeof createReactionsService> {
|
ReturnType<typeof createReactionsService>,
|
||||||
|
ReturnType<typeof createActivitiesService>,
|
||||||
|
ReturnType<typeof createBadgesService> {
|
||||||
authPromise: Promise<void>;
|
authPromise: Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
115
src/lib/pocketbase/services/activities.ts
Normal file
115
src/lib/pocketbase/services/activities.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import PocketBase from "pocketbase";
|
||||||
|
import { PlayerInfo } from "@/features/players/types";
|
||||||
|
|
||||||
|
export interface Activity {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
player?: string | PlayerInfo;
|
||||||
|
duration: number;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
arguments?: any;
|
||||||
|
user_agent?: string;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityInput {
|
||||||
|
name: string;
|
||||||
|
player?: string;
|
||||||
|
duration: number;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
arguments?: any;
|
||||||
|
user_agent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityListResult {
|
||||||
|
items: Activity[];
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalItems: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivitySearchParams {
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
name?: string;
|
||||||
|
player?: string;
|
||||||
|
success?: boolean;
|
||||||
|
sortBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createActivitiesService(pb: PocketBase) {
|
||||||
|
return {
|
||||||
|
async createActivity(data: ActivityInput): Promise<Activity> {
|
||||||
|
const result = await pb.collection("activities").create<Activity>(data);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
async searchActivities(params: ActivitySearchParams = {}): Promise<ActivityListResult> {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
perPage = 100,
|
||||||
|
name,
|
||||||
|
player,
|
||||||
|
success,
|
||||||
|
sortBy = "-created"
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const filters: string[] = [];
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
filters.push(`name ~ "${name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player) {
|
||||||
|
filters.push(`player = "${player}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success !== undefined) {
|
||||||
|
filters.push(`success = ${success}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterString = filters.length > 0 ? filters.join(" && ") : "";
|
||||||
|
|
||||||
|
const result = await pb.collection("activities").getList<Activity>(page, perPage, {
|
||||||
|
filter: filterString,
|
||||||
|
sort: sortBy,
|
||||||
|
expand: "player",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: result.items,
|
||||||
|
page: result.page,
|
||||||
|
perPage: result.perPage,
|
||||||
|
totalPages: result.totalPages,
|
||||||
|
totalItems: result.totalItems,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRecentActivities(limit: number = 100): Promise<Activity[]> {
|
||||||
|
const result = await pb.collection("activities").getList<Activity>(1, limit, {
|
||||||
|
sort: "-created",
|
||||||
|
});
|
||||||
|
return result.items;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getActivitiesByUser(userId: string, limit: number = 50): Promise<Activity[]> {
|
||||||
|
const result = await pb.collection("activities").getList<Activity>(1, limit, {
|
||||||
|
filter: `player = "${userId}"`,
|
||||||
|
sort: "-created",
|
||||||
|
});
|
||||||
|
return result.items;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getActivitiesByFunction(functionName: string, limit: number = 50): Promise<Activity[]> {
|
||||||
|
const result = await pb.collection("activities").getList<Activity>(1, limit, {
|
||||||
|
filter: `name = "${functionName}"`,
|
||||||
|
sort: "-created",
|
||||||
|
});
|
||||||
|
return result.items;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
435
src/lib/pocketbase/services/badges.ts
Normal file
435
src/lib/pocketbase/services/badges.ts
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
import PocketBase from "pocketbase";
|
||||||
|
import { Badge, BadgeProgress } from "@/features/badges/types";
|
||||||
|
import { transformBadge, transformBadgeProgress } from "@/lib/pocketbase/util/transform-types";
|
||||||
|
|
||||||
|
export interface PlayerStats {
|
||||||
|
player_id: string;
|
||||||
|
matches: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
total_cups_made: number;
|
||||||
|
total_cups_against: number;
|
||||||
|
margin_of_victory: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBadgesService(pb: PocketBase) {
|
||||||
|
return {
|
||||||
|
async getBadge(id: string): Promise<Badge> {
|
||||||
|
const result = await pb.collection("badges").getOne(id);
|
||||||
|
return transformBadge(result);
|
||||||
|
},
|
||||||
|
|
||||||
|
async listBadges(): Promise<Badge[]> {
|
||||||
|
const results = await pb.collection("badges").getFullList({
|
||||||
|
sort: 'name',
|
||||||
|
});
|
||||||
|
return results.map(transformBadge);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBadgeProgress(id: string): Promise<BadgeProgress> {
|
||||||
|
const result = await pb.collection("badge_progress").getOne(id, {
|
||||||
|
expand: 'badge,player',
|
||||||
|
});
|
||||||
|
return transformBadgeProgress(result);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPlayerBadgeProgress(playerId: string): Promise<BadgeProgress[]> {
|
||||||
|
const results = await pb.collection("badge_progress").getFullList({
|
||||||
|
filter: `player = "${playerId}"`,
|
||||||
|
expand: 'badge',
|
||||||
|
});
|
||||||
|
return results.map(transformBadgeProgress);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createBadgeProgress(data: {
|
||||||
|
badge: string;
|
||||||
|
player: string;
|
||||||
|
progress: number;
|
||||||
|
earned: boolean;
|
||||||
|
}): Promise<BadgeProgress> {
|
||||||
|
return await pb.collection("badge_progress").create<BadgeProgress>(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateBadgeProgress(id: string, data: {
|
||||||
|
progress?: number;
|
||||||
|
earned?: boolean;
|
||||||
|
}): Promise<BadgeProgress> {
|
||||||
|
return await pb.collection("badge_progress").update<BadgeProgress>(id, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteBadgeProgress(id: string): Promise<boolean> {
|
||||||
|
await pb.collection("badge_progress").delete(id);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async clearAllBadgeProgress(): Promise<number> {
|
||||||
|
const existingProgress = await pb.collection("badge_progress").getFullList({
|
||||||
|
filter: 'badge.type != "manual"',
|
||||||
|
});
|
||||||
|
for (const progress of existingProgress) {
|
||||||
|
await pb.collection("badge_progress").delete(progress.id);
|
||||||
|
}
|
||||||
|
return existingProgress.length;
|
||||||
|
},
|
||||||
|
|
||||||
|
async calculateBadgeProgress(playerId: string, badge: Badge): Promise<number> {
|
||||||
|
if (badge.type === "manual") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (badge.type === "match") {
|
||||||
|
return await this.calculateMatchBadgeProgress(playerId, badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (badge.type === "tournament") {
|
||||||
|
return await this.calculateTournamentBadgeProgress(playerId, badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
async calculateMatchBadgeProgress(playerId: string, badge: Badge): Promise<number> {
|
||||||
|
const criteria = badge.criteria;
|
||||||
|
|
||||||
|
const stats = await pb.collection("player_stats").getFirstListItem<PlayerStats>(
|
||||||
|
`player_id = "${playerId}"`
|
||||||
|
).catch(() => null);
|
||||||
|
|
||||||
|
if (!stats) return 0;
|
||||||
|
|
||||||
|
if (criteria.matches_played !== undefined) {
|
||||||
|
return stats.matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.overtime_matches !== undefined || criteria.overtime_wins !== undefined) {
|
||||||
|
const matches = await pb.collection("matches").getFullList({
|
||||||
|
filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended" && ot_count > 0`,
|
||||||
|
expand: 'home,away,home.players,away.players',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (criteria.overtime_matches !== undefined) {
|
||||||
|
return matches.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.overtime_wins !== undefined) {
|
||||||
|
const overtimeWins = matches.filter(m => {
|
||||||
|
const isHome = m.expand?.home?.expand?.players?.some((p: any) => p.id === playerId) ||
|
||||||
|
m.expand?.home?.players?.includes(playerId);
|
||||||
|
const isAway = m.expand?.away?.expand?.players?.some((p: any) => p.id === playerId) ||
|
||||||
|
m.expand?.away?.players?.includes(playerId);
|
||||||
|
|
||||||
|
if (isHome) {
|
||||||
|
return m.home_cups > m.away_cups;
|
||||||
|
} else if (isAway) {
|
||||||
|
return m.away_cups > m.home_cups;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return overtimeWins.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.margin_of_victory !== undefined) {
|
||||||
|
const matches = await pb.collection("matches").getFullList({
|
||||||
|
filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended"`,
|
||||||
|
expand: 'home,away,home.players,away.players',
|
||||||
|
});
|
||||||
|
|
||||||
|
const bigWins = matches.filter(m => {
|
||||||
|
const isHome = m.expand?.home?.expand?.players?.some((p: any) => p.id === playerId) ||
|
||||||
|
m.expand?.home?.players?.includes(playerId);
|
||||||
|
const isAway = m.expand?.away?.expand?.players?.some((p: any) => p.id === playerId) ||
|
||||||
|
m.expand?.away?.players?.includes(playerId);
|
||||||
|
|
||||||
|
if (isHome && m.home_cups > m.away_cups) {
|
||||||
|
return (m.home_cups - m.away_cups) >= criteria.margin_of_victory;
|
||||||
|
} else if (isAway && m.away_cups > m.home_cups) {
|
||||||
|
return (m.away_cups - m.home_cups) >= criteria.margin_of_victory;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return bigWins.length > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
async calculateTournamentBadgeProgress(playerId: string, badge: Badge): Promise<number> {
|
||||||
|
const criteria = badge.criteria;
|
||||||
|
|
||||||
|
const matches = await pb.collection("matches").getFullList({
|
||||||
|
filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended"`,
|
||||||
|
expand: 'tournament,home,away,home.players,away.players',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tournamentIds = new Set(matches.map(m => m.tournament));
|
||||||
|
const tournamentsAttended = tournamentIds.size;
|
||||||
|
|
||||||
|
if (criteria.tournaments_attended !== undefined) {
|
||||||
|
return tournamentsAttended;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.tournament_wins !== undefined) {
|
||||||
|
if (tournamentIds.size === 0) return 0;
|
||||||
|
|
||||||
|
let tournamentWins = 0;
|
||||||
|
|
||||||
|
for (const tournamentId of tournamentIds) {
|
||||||
|
const tournamentMatches = await pb.collection("matches").getFullList({
|
||||||
|
filter: `tournament = "${tournamentId}" && status = "ended"`,
|
||||||
|
expand: 'home,away,home.players,away.players',
|
||||||
|
});
|
||||||
|
|
||||||
|
const winnersMatches = tournamentMatches.filter(m => !m.is_losers_bracket);
|
||||||
|
const finalsMatch = winnersMatches.reduce((highest: any, current: any) =>
|
||||||
|
(!highest || current.lid > highest.lid) ? current : highest, null);
|
||||||
|
|
||||||
|
if (finalsMatch && finalsMatch.status === 'ended') {
|
||||||
|
const finalsWinnerId = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.home : finalsMatch.away;
|
||||||
|
|
||||||
|
const winningTeam = finalsMatch.expand?.[finalsWinnerId === finalsMatch.home ? 'home' : 'away'];
|
||||||
|
const winningPlayers = winningTeam?.expand?.players || winningTeam?.players || [];
|
||||||
|
|
||||||
|
const playerWon = winningPlayers.some((p: any) =>
|
||||||
|
(typeof p === 'string' ? p : p.id) === playerId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (playerWon) {
|
||||||
|
tournamentWins++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tournamentWins;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.placement !== undefined && typeof criteria.placement === 'number') {
|
||||||
|
let placementCount = 0;
|
||||||
|
|
||||||
|
for (const tournamentId of tournamentIds) {
|
||||||
|
const tournamentMatches = await pb.collection("matches").getFullList({
|
||||||
|
filter: `tournament = "${tournamentId}" && status = "ended"`,
|
||||||
|
expand: 'home,away,home.players,away.players',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (criteria.placement === 2) {
|
||||||
|
const winnersMatches = tournamentMatches.filter(m => !m.is_losers_bracket);
|
||||||
|
const finalsMatch = winnersMatches.reduce((highest: any, current: any) =>
|
||||||
|
(!highest || current.lid > highest.lid) ? current : highest, null);
|
||||||
|
|
||||||
|
if (finalsMatch && finalsMatch.status === 'ended') {
|
||||||
|
const finalsLoserId = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.away : finalsMatch.home;
|
||||||
|
|
||||||
|
const losingTeam = finalsMatch.expand?.[finalsLoserId === finalsMatch.home ? 'home' : 'away'];
|
||||||
|
const losingPlayers = losingTeam?.expand?.players || losingTeam?.players || [];
|
||||||
|
|
||||||
|
const playerLost = losingPlayers.some((p: any) =>
|
||||||
|
(typeof p === 'string' ? p : p.id) === playerId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (playerLost) {
|
||||||
|
placementCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.placement === 3) {
|
||||||
|
const losersMatches = tournamentMatches.filter(m => m.is_losers_bracket);
|
||||||
|
const losersFinale = losersMatches.reduce((highest: any, current: any) =>
|
||||||
|
(!highest || current.lid > highest.lid) ? current : highest, null);
|
||||||
|
|
||||||
|
if (losersFinale && losersFinale.status === 'ended') {
|
||||||
|
const losersFinaleLoserId = (losersFinale.home_cups > losersFinale.away_cups) ? losersFinale.away : losersFinale.home;
|
||||||
|
|
||||||
|
const losingTeam = losersFinale.expand?.[losersFinaleLoserId === losersFinale.home ? 'home' : 'away'];
|
||||||
|
const losingPlayers = losingTeam?.expand?.players || losingTeam?.players || [];
|
||||||
|
|
||||||
|
const playerLost = losingPlayers.some((p: any) =>
|
||||||
|
(typeof p === 'string' ? p : p.id) === playerId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (playerLost) {
|
||||||
|
placementCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return placementCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.tournament_record !== undefined) {
|
||||||
|
const tournaments = await pb.collection("tournaments").getFullList({
|
||||||
|
sort: 'start_time',
|
||||||
|
});
|
||||||
|
|
||||||
|
let timesWent02 = 0;
|
||||||
|
|
||||||
|
for (const tournamentId of tournamentIds) {
|
||||||
|
const tournament = tournaments.find(t => t.id === tournamentId);
|
||||||
|
if (!tournament) continue;
|
||||||
|
|
||||||
|
const tournamentMatches = matches.filter(m => m.tournament === tournamentId);
|
||||||
|
|
||||||
|
let wins = 0;
|
||||||
|
let losses = 0;
|
||||||
|
|
||||||
|
for (const match of tournamentMatches) {
|
||||||
|
const isHome = match.expand?.home?.expand?.players?.some((p: any) => p.id === playerId) ||
|
||||||
|
match.expand?.home?.players?.includes(playerId);
|
||||||
|
const isAway = match.expand?.away?.expand?.players?.some((p: any) => p.id === playerId) ||
|
||||||
|
match.expand?.away?.players?.includes(playerId);
|
||||||
|
|
||||||
|
if (isHome && match.home_cups > match.away_cups) {
|
||||||
|
wins++;
|
||||||
|
} else if (isAway && match.away_cups > match.home_cups) {
|
||||||
|
wins++;
|
||||||
|
} else {
|
||||||
|
losses++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = `${wins}-${losses}`;
|
||||||
|
|
||||||
|
if (record === criteria.tournament_record) {
|
||||||
|
if (criteria.won_previous !== undefined && criteria.won_previous === true) {
|
||||||
|
const currentIndex = tournaments.findIndex(t => t.id === tournamentId);
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
const previousTournament = tournaments[currentIndex - 1];
|
||||||
|
if (previousTournament.winner_id === playerId) {
|
||||||
|
timesWent02++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
timesWent02++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return timesWent02 > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.consecutive_wins !== undefined) {
|
||||||
|
const tournaments = await pb.collection("tournaments").getFullList({
|
||||||
|
sort: 'start_time',
|
||||||
|
});
|
||||||
|
|
||||||
|
let consecutiveWins = 0;
|
||||||
|
let maxConsecutiveWins = 0;
|
||||||
|
|
||||||
|
for (const tournament of tournaments) {
|
||||||
|
if (!tournamentIds.has(tournament.id)) continue;
|
||||||
|
|
||||||
|
if (tournament.winner_id === playerId) {
|
||||||
|
consecutiveWins++;
|
||||||
|
maxConsecutiveWins = Math.max(maxConsecutiveWins, consecutiveWins);
|
||||||
|
} else {
|
||||||
|
consecutiveWins = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxConsecutiveWins >= criteria.consecutive_wins ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTargetProgress(badge: Badge): number {
|
||||||
|
if (badge.type === "manual") {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const criteria = badge.criteria;
|
||||||
|
|
||||||
|
return (
|
||||||
|
criteria.matches_played ||
|
||||||
|
criteria.tournament_wins ||
|
||||||
|
criteria.tournaments_attended ||
|
||||||
|
criteria.overtime_matches ||
|
||||||
|
criteria.overtime_wins ||
|
||||||
|
criteria.consecutive_wins ||
|
||||||
|
1
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async awardManualBadge(playerId: string, badgeId: string): Promise<BadgeProgress> {
|
||||||
|
// Get or create badge progress record
|
||||||
|
const existingProgress = await pb.collection("badge_progress").getFirstListItem(
|
||||||
|
`player = "${playerId}" && badge = "${badgeId}"`,
|
||||||
|
{ expand: 'badge' }
|
||||||
|
).catch(() => null);
|
||||||
|
|
||||||
|
if (existingProgress) {
|
||||||
|
// Update existing progress to mark as earned
|
||||||
|
const updated = await pb.collection("badge_progress").update(existingProgress.id, {
|
||||||
|
progress: 1,
|
||||||
|
earned: true,
|
||||||
|
}, { expand: 'badge' });
|
||||||
|
return transformBadgeProgress(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new progress record
|
||||||
|
const created = await pb.collection("badge_progress").create({
|
||||||
|
badge: badgeId,
|
||||||
|
player: playerId,
|
||||||
|
progress: 1,
|
||||||
|
earned: true,
|
||||||
|
}, { expand: 'badge' });
|
||||||
|
return transformBadgeProgress(created);
|
||||||
|
},
|
||||||
|
|
||||||
|
async migrateBadgeProgress(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
playersProcessed: number;
|
||||||
|
progressRecordsCreated: number;
|
||||||
|
totalBadgesEarned: number;
|
||||||
|
averageBadgesPerPlayer: string;
|
||||||
|
}> {
|
||||||
|
await this.clearAllBadgeProgress();
|
||||||
|
|
||||||
|
const badges = await this.listBadges();
|
||||||
|
|
||||||
|
const playerStats = await pb.collection("player_stats").getFullList<PlayerStats>();
|
||||||
|
const uniquePlayers = new Set(playerStats.map(s => s.player_id));
|
||||||
|
|
||||||
|
let totalProgressRecords = 0;
|
||||||
|
let totalBadgesEarned = 0;
|
||||||
|
|
||||||
|
for (const playerId of uniquePlayers) {
|
||||||
|
for (const badge of badges) {
|
||||||
|
try {
|
||||||
|
const progress = await this.calculateBadgeProgress(playerId, badge);
|
||||||
|
const target = this.getTargetProgress(badge);
|
||||||
|
const earned = progress >= target;
|
||||||
|
|
||||||
|
if (progress > 0 || earned) {
|
||||||
|
await this.createBadgeProgress({
|
||||||
|
badge: badge.id,
|
||||||
|
player: playerId,
|
||||||
|
progress: progress,
|
||||||
|
earned: earned,
|
||||||
|
});
|
||||||
|
|
||||||
|
totalProgressRecords++;
|
||||||
|
|
||||||
|
if (earned) {
|
||||||
|
totalBadgesEarned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error processing badge "${badge.name}" for player ${playerId}:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
playersProcessed: uniquePlayers.size,
|
||||||
|
progressRecordsCreated: totalProgressRecords,
|
||||||
|
totalBadgesEarned: totalBadgesEarned,
|
||||||
|
averageBadgesPerPlayer: (totalBadgesEarned / uniquePlayers.size).toFixed(2),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -72,9 +72,17 @@ export function createTeamsService(pb: PocketBase) {
|
|||||||
|
|
||||||
const result = await pb.collection("teams").update(id, data);
|
const result = await pb.collection("teams").update(id, data);
|
||||||
|
|
||||||
return transformTeam(await pb.collection("teams").getOne(result.id, {
|
if (data instanceof FormData && data.has('logo')) {
|
||||||
expand: "players, tournaments"
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
}));
|
}
|
||||||
|
|
||||||
|
const updated = await pb.collection("teams").getOne(result.id, {
|
||||||
|
expand: "players, tournaments",
|
||||||
|
// @ts-ignore - Add cache busting
|
||||||
|
$cancelKey: Date.now().toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
return transformTeam(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("PocketBase | Error updating team", error);
|
logger.error("PocketBase | Error updating team", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Match } from "@/features/matches/types";
|
|||||||
import { Player, PlayerInfo } from "@/features/players/types";
|
import { Player, PlayerInfo } from "@/features/players/types";
|
||||||
import { Team, TeamInfo } from "@/features/teams/types";
|
import { Team, TeamInfo } from "@/features/teams/types";
|
||||||
import { Tournament, TournamentInfo } from "@/features/tournaments/types";
|
import { Tournament, TournamentInfo } from "@/features/tournaments/types";
|
||||||
|
import { Badge, BadgeInfo, BadgeProgress } from "@/features/badges/types";
|
||||||
|
import { Activity } from "../services/activities";
|
||||||
|
|
||||||
// pocketbase does this weird thing with relations where it puts them under a seperate "expand" field
|
// pocketbase does this weird thing with relations where it puts them under a seperate "expand" field
|
||||||
// this file transforms raw pocketbase results to our types
|
// this file transforms raw pocketbase results to our types
|
||||||
@@ -57,9 +59,7 @@ export const transformMatch = (record: any, isAdmin: boolean = false): Match =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const transformTournamentInfo = (record: any): TournamentInfo => {
|
export const transformTournamentInfo = (record: any): TournamentInfo => {
|
||||||
// Check if tournament is complete by looking at matches
|
|
||||||
const matches = record.expand?.matches || [];
|
const matches = record.expand?.matches || [];
|
||||||
// Filter out bye matches (tbd status with bye=true) when checking completion
|
|
||||||
const nonByeMatches = matches.filter((match: any) => !(match.status === 'tbd' && match.bye === true));
|
const nonByeMatches = matches.filter((match: any) => !(match.status === 'tbd' && match.bye === true));
|
||||||
const isComplete = nonByeMatches.length > 0 && nonByeMatches.every((match: any) => match.status === 'ended');
|
const isComplete = nonByeMatches.length > 0 && nonByeMatches.every((match: any) => match.status === 'ended');
|
||||||
|
|
||||||
@@ -106,6 +106,7 @@ export const transformTournamentInfo = (record: any): TournamentInfo => {
|
|||||||
start_time: record.start_time,
|
start_time: record.start_time,
|
||||||
end_time: record.end_time,
|
end_time: record.end_time,
|
||||||
logo: record.logo,
|
logo: record.logo,
|
||||||
|
glitch_logo: record.glitch_logo,
|
||||||
first_place,
|
first_place,
|
||||||
second_place,
|
second_place,
|
||||||
third_place,
|
third_place,
|
||||||
@@ -256,6 +257,7 @@ export function transformTournament(record: any, isAdmin: boolean = false): Tour
|
|||||||
desc: record.desc,
|
desc: record.desc,
|
||||||
rules: record.rules,
|
rules: record.rules,
|
||||||
logo: record.logo,
|
logo: record.logo,
|
||||||
|
glitch_logo: record.glitch_logo,
|
||||||
enroll_time: record.enroll_time,
|
enroll_time: record.enroll_time,
|
||||||
start_time: record.start_time,
|
start_time: record.start_time,
|
||||||
end_time: record.end_time,
|
end_time: record.end_time,
|
||||||
@@ -278,3 +280,51 @@ export function transformReaction(record: any) {
|
|||||||
match: record.match
|
match: record.match
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function transformBadgeInfo(record: any): BadgeInfo {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
key: record.key,
|
||||||
|
description: record.description,
|
||||||
|
type: record.type,
|
||||||
|
progressive: record.progressive,
|
||||||
|
order: record.order ?? 999,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformBadge(record: any): Badge {
|
||||||
|
return {
|
||||||
|
...transformBadgeInfo(record),
|
||||||
|
criteria: record.criteria,
|
||||||
|
created: record.created,
|
||||||
|
updated: record.updated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformBadgeProgress(record: any): BadgeProgress {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
badge: record.expand?.badge ? transformBadgeInfo(record.expand.badge) : record.badge,
|
||||||
|
player: record.player,
|
||||||
|
progress: record.progress,
|
||||||
|
earned: record.earned,
|
||||||
|
created: record.created,
|
||||||
|
updated: record.updated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformActivity(record: any): Activity {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
player: record.expand?.player ? transformPlayerInfo(record.expand.player) : record.player,
|
||||||
|
duration: record.duration,
|
||||||
|
success: record.success,
|
||||||
|
error: record.error,
|
||||||
|
arguments: record.arguments,
|
||||||
|
user_agent: record.user_agent,
|
||||||
|
created: record.created,
|
||||||
|
updated: record.updated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import UserRoles from "supertokens-node/recipe/userroles";
|
|||||||
import { appInfo } from "./config";
|
import { appInfo } from "./config";
|
||||||
import PasswordlessDevelopmentMode from "./recipes/passwordless-development-mode";
|
import PasswordlessDevelopmentMode from "./recipes/passwordless-development-mode";
|
||||||
import { logger } from "./";
|
import { logger } from "./";
|
||||||
import passwordlessTwilioVerify from "./recipes/passwordless-twilio-verify";
|
import PasswordlessTwilioVerify from "./recipes/passwordless-twilio-verify";
|
||||||
|
|
||||||
export const backendConfig = (): TypeInput => {
|
export const backendConfig = (): TypeInput => {
|
||||||
return {
|
return {
|
||||||
@@ -17,7 +17,8 @@ export const backendConfig = (): TypeInput => {
|
|||||||
},
|
},
|
||||||
appInfo,
|
appInfo,
|
||||||
recipeList: [
|
recipeList: [
|
||||||
passwordlessTwilioVerify.init(),
|
//PasswordlessTwilioVerify.init(),
|
||||||
|
PasswordlessDevelopmentMode.init(),
|
||||||
Session.init({
|
Session.init({
|
||||||
cookieSameSite: "lax",
|
cookieSameSite: "lax",
|
||||||
cookieSecure: import.meta.env.NODE_ENV === "production",
|
cookieSecure: import.meta.env.NODE_ENV === "production",
|
||||||
|
|||||||
@@ -1,19 +1,31 @@
|
|||||||
import twilio, { type Twilio } from "twilio";
|
import twilio, { type Twilio } from "twilio";
|
||||||
|
|
||||||
const accountSid = process.env.TWILIO_ACCOUNT_SID!;
|
|
||||||
const authToken = process.env.TWILIO_AUTH_TOKEN!;
|
|
||||||
const serviceSid = process.env.TWILIO_SERVICE_SID!;
|
|
||||||
|
|
||||||
let client: Twilio;
|
let client: Twilio;
|
||||||
|
|
||||||
|
function getEnvVars() {
|
||||||
|
const accountSid = process.env.TWILIO_ACCOUNT_SID;
|
||||||
|
const authToken = process.env.TWILIO_AUTH_TOKEN;
|
||||||
|
const serviceSid = process.env.TWILIO_SERVICE_SID;
|
||||||
|
|
||||||
|
if (!accountSid || !authToken || !serviceSid) {
|
||||||
|
throw new Error(`Missing env vars. accountSid: ${!!accountSid}, authToken: ${!!authToken}, serviceSid: ${!!serviceSid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { accountSid, authToken, serviceSid };
|
||||||
|
}
|
||||||
|
|
||||||
function getTwilioClient() {
|
function getTwilioClient() {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
|
const { accountSid, authToken } = getEnvVars();
|
||||||
client = twilio(accountSid, authToken);
|
client = twilio(accountSid, authToken);
|
||||||
}
|
}
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function sendVerifyCode(phoneNumber: string, code: string) {
|
export async function sendVerifyCode(phoneNumber: string, code: string) {
|
||||||
|
const { serviceSid } = getEnvVars();
|
||||||
|
|
||||||
const twilioClient = getTwilioClient();
|
const twilioClient = getTwilioClient();
|
||||||
|
|
||||||
const verification = await twilioClient!.verify.v2
|
const verification = await twilioClient!.verify.v2
|
||||||
@@ -32,6 +44,7 @@ export async function sendVerifyCode(phoneNumber: string, code: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateVerify(sid: string) {
|
export async function updateVerify(sid: string) {
|
||||||
|
const { serviceSid } = getEnvVars();
|
||||||
const twilioClient = getTwilioClient();
|
const twilioClient = getTwilioClient();
|
||||||
|
|
||||||
const verification = await twilioClient!.verify.v2
|
const verification = await twilioClient!.verify.v2
|
||||||
|
|||||||
54
src/utils/activities.ts
Normal file
54
src/utils/activities.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
|
import { createMiddleware } from "@tanstack/react-start";
|
||||||
|
import { getRequest } from "@tanstack/react-start/server";
|
||||||
|
|
||||||
|
export const serverFnLoggingMiddleware = createMiddleware({
|
||||||
|
type: "function",
|
||||||
|
}).server(async ({ next, data, functionId, context }) => {
|
||||||
|
const request = getRequest();
|
||||||
|
|
||||||
|
const serverFnName = functionId.split('--')[1]?.split('_')[0] || 'unknown';
|
||||||
|
const userId = (context as any)?.metadata?.player_id || 'unknown';
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await next();
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pbAdmin.authPromise;
|
||||||
|
await pbAdmin.createActivity({
|
||||||
|
name: serverFnName,
|
||||||
|
player: userId !== 'unknown' ? userId : undefined,
|
||||||
|
duration,
|
||||||
|
success: true,
|
||||||
|
arguments: data,
|
||||||
|
user_agent: request.headers.get('user-agent') || undefined,
|
||||||
|
});
|
||||||
|
} catch (activityError) {
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pbAdmin.authPromise;
|
||||||
|
await pbAdmin.createActivity({
|
||||||
|
name: serverFnName,
|
||||||
|
player: userId !== 'unknown' ? userId : undefined,
|
||||||
|
duration,
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
arguments: data,
|
||||||
|
user_agent: request.headers.get('user-agent') || undefined,
|
||||||
|
});
|
||||||
|
} catch (activityError) {
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user