34 Commits

Author SHA1 Message Date
yohlo
af0ec85811 remove file proxy logs 2025-10-04 22:42:36 -05:00
yohlo
d18d148d32 fix tournament card size 2025-10-04 22:42:00 -05:00
yohlo
95a50ee7a7 glitch effect avatar 2025-10-04 18:41:46 -05:00
yohlo
1ef786ea79 slide panel button margins 2025-10-03 02:51:27 -05:00
yohlo
47962a8681 fix slide panel 2025-10-03 02:49:06 -05:00
yohlo
2e6d3366e4 fix slide panel 2025-10-03 02:47:52 -05:00
yohlo
fafe5ca3ec improvements 2025-10-03 02:34:45 -05:00
yohlo
b52c79772f activities 2025-10-02 21:58:20 -05:00
yohlo
8579ec36ca bug fixes, new fonts, etc 2025-10-02 14:49:29 -05:00
yohlo
2dfb7c63d3 smoother team form close 2025-10-01 22:29:40 -05:00
yohlo
03b2b54c1f fix logo not updating 2025-10-01 22:27:32 -05:00
yohlo
0910f11228 pb refresh, profile refresh update 2025-10-01 21:34:59 -05:00
yohlo
a376f98fe7 badge redesign again 2025-10-01 21:28:27 -05:00
yohlo
1f4f66f8c5 badge redesign 2025-10-01 17:54:40 -05:00
yohlo
5729dab35f use popover over tooltip for badges 2025-10-01 14:14:03 -05:00
yohlo
c05fd5dc6d pb 2025-10-01 14:02:13 -05:00
yohlo
b9a42b4743 pb 2025-10-01 13:58:51 -05:00
yohlo
74e28cc2ac pb 2025-10-01 13:55:53 -05:00
yohlo
adf304b1e0 pb 2025-10-01 13:46:50 -05:00
yohlo
d18cdce15f pb 2025-10-01 13:46:41 -05:00
yohlo
aa87a9da5b pb 2025-10-01 13:45:33 -05:00
yohlo
6224404aa9 award badges 2025-10-01 13:42:09 -05:00
yohlo
654041b6b6 badges 2025-10-01 13:26:42 -05:00
yohlo
ce29c41bf3 remove bad badge collections 2025-09-30 18:10:51 -05:00
yohlo
63ea515a31 activity logging middleware 2025-09-30 10:47:02 -05:00
yohlo
8b1bbe213d test sse fixes 2025-09-29 21:35:38 -05:00
yohlo
ed538b7373 test sse fixes 2025-09-29 21:35:12 -05:00
yohlo
03e3bbcbc0 test sse fixes 2025-09-29 21:31:00 -05:00
yohlo
baf75eddba test sse fixes 2025-09-29 21:28:22 -05:00
yohlo
5094933302 update admin 2025-09-29 15:49:18 -05:00
yohlo
9564b46d45 quick fix 2025-09-29 15:42:00 -05:00
yohlo
ece5094f13 quick fix 2025-09-29 15:40:41 -05:00
yohlo
cfe1ee7171 passwordless fix 2025-09-29 15:14:41 -05:00
yohlo
3a41609a91 bug fixes, layout fixes 2025-09-29 15:13:41 -05:00
77 changed files with 3505 additions and 382 deletions

View 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);
})

View 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)
})

View 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)
})

View 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)
})

View 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)
})

View 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);
})

View 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);
})

View 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);
})

View 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);
})

View 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);
})

View 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)
})

View 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)
})

View 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)
})

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>

View 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 />;
}

View 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 />;
}

View File

@@ -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();

View File

@@ -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",
}, },
}); });
}, },

View File

@@ -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,

View File

@@ -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,

View 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;

View File

@@ -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>
<CaretRightIcon style={{ marginLeft: "auto" }} size={20} /> {loading ? (
<Loader size="sm" style={{ marginLeft: "auto" }} />
) : (
<CaretRightIcon style={{ marginLeft: "auto" }} size={20} />
)}
</Group> </Group>
</UnstyledButton> </UnstyledButton>
<Divider /> <Divider />

View File

@@ -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"
/> />
<panelConfig.Component <ScrollArea.Autosize w="100%" p={0} offsetScrollbars>
value={tempValue} <panelConfig.Component
onChange={setTempValue} value={tempValue}
{...(panelConfig.componentProps || {})} onChange={setTempValue}
/> {...(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>

View File

@@ -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',

View 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>
);
};

View File

@@ -0,0 +1,3 @@
export * from "./types";
export * from "./queries";
export { ActivitiesTable } from "./components/activities-table";

View 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));

View 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),
};
})
);

View File

@@ -0,0 +1 @@
export type { Activity, ActivityListResult, ActivitySearchParams } from "@/lib/pocketbase/services/activities";

View File

@@ -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}

View 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;

View 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;

View 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;

View 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());

View File

@@ -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))
);

View 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;
}

View File

@@ -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>
); );

View File

@@ -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' }}

View File

@@ -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} />

View File

@@ -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;
} }

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);

View File

@@ -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`;

View File

@@ -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",

View File

@@ -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;

View File

@@ -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>

View File

@@ -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}`);

View File

@@ -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');

View File

@@ -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>

View File

@@ -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);

View 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>
);
};

View File

@@ -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>
</> </>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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"

View File

@@ -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`}

View File

@@ -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>

View File

@@ -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

View File

@@ -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">

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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");
} }
}; };

View File

@@ -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;

View File

@@ -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 (

View File

@@ -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",
},
});

View File

@@ -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(); Object.assign(this, createPlayersService(this.pb));
Object.assign(this, createTeamsService(this.pb));
Object.assign(this, createTournamentsService(this.pb));
Object.assign(this, createMatchesService(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.authPromise.then(() => {
Object.assign(this, createPlayersService(this.pb)); this.startTokenRefresh();
Object.assign(this, createTeamsService(this.pb));
Object.assign(this, createTournamentsService(this.pb));
Object.assign(this, createMatchesService(this.pb));
Object.assign(this, createReactionsService(this.pb));
}); });
} }
private async authenticate() { private async authenticate() {
await this.pb try {
.collection("_superusers") await this.pb
.authWithPassword( .collection("_superusers")
process.env.POCKETBASE_ADMIN_EMAIL!, .authWithPassword(
process.env.POCKETBASE_ADMIN_PASSWORD! process.env.POCKETBASE_ADMIN_EMAIL!,
); 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>;
} }

View 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;
},
};
}

View 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),
};
},
};
}

View File

@@ -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;

View File

@@ -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,
};
}

View File

@@ -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",

View File

@@ -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
View 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;
}
});