82 Commits

Author SHA1 Message Date
yohlo
15ed78a9c5 name blurbs, skeleton and link 2025-10-16 15:39:46 -05:00
4b66a4bd13 found a little typo? 2025-10-16 13:00:42 -04:00
a04a78fbf8 Merge branch 'main' into caro/badges-stats 2025-10-16 11:51:12 -05:00
7fa3b06673 last I hope 2025-10-16 12:47:24 -04:00
694c58e449 I need to have a word with my autoformatter 2025-10-16 12:46:10 -04:00
7afe40775e goddammit 2025-10-16 12:44:09 -04:00
4f88fdd43a reverting random styling things? 2025-10-16 12:42:26 -04:00
fa98634402 working but sheet styling is ugly 2025-10-16 12:32:26 -04:00
yohlo
470b4ef99c regionals 2025-10-16 09:12:11 -05:00
yohlo
612f1f28bf skeleton for h2h 2025-10-13 14:18:54 -05:00
yohlo
168ef1b05d restore suspense boundary in stats 2025-10-13 13:00:29 -05:00
yohlo
939d1cee90 remove steps 2025-10-11 15:37:37 -05:00
yohlo
b59c7cd7b6 fix tabs on stats table 2025-10-11 15:34:12 -05:00
yohlo
2ed5ab6026 fix stats table 2025-10-11 15:23:33 -05:00
yohlo
d3379e54a4 player h2h 2025-10-11 14:47:03 -05:00
yohlo
43972b6a06 match h2h 2025-10-11 13:40:12 -05:00
yohlo
14c2eb2c02 try something different for stats scroll 2025-10-11 00:45:05 -05:00
yohlo
46943b6971 try something different for stats scroll 2025-10-11 00:43:28 -05:00
yohlo
f74d2daf9c try something different for stats scroll 2025-10-11 00:40:18 -05:00
yohlo
26c6343a89 no devtools 2025-10-11 00:34:58 -05:00
yohlo
127709bb6c more imporvmentes 2025-10-11 00:33:27 -05:00
yohlo
6a7d119d3e more stats 2025-10-11 00:29:29 -05:00
yohlo
4b534c86cd fixes, improvmeents 2025-10-10 23:44:27 -05:00
yohlo
f96f92c7c9 last activity for players 2025-10-10 16:03:51 -05:00
yohlo
97427718e8 activity for server result errors 2025-10-08 09:20:26 -05:00
yohlo
15bbca8b90 fix stats breaking new player profiles 2025-10-08 09:03:29 -05:00
yohlo
49bbd1611c quick fixes 2025-10-06 13:33:25 -05:00
yohlo
45db283bc5 open graph 2025-10-06 13:10:43 -05:00
yohlo
b673f9e072 open graph 2025-10-06 13:02:52 -05:00
yohlo
34d896947d manifest favicon 2025-10-06 12:53:32 -05:00
yohlo
42bd2542f3 quick fix noop context 2025-10-06 12:34:58 -05:00
yohlo
b2df869111 fix 2025-10-06 12:22:21 -05:00
yohlo
375e0bbfc8 improovments, more badge images 2025-10-06 11:53:39 -05:00
yohlo
147dc4e744 improvements 2025-10-06 09:03:50 -05:00
yohlo
b458872ac1 images in badge popover 2025-10-06 02:23:25 -05:00
yohlo
afd0b692fa Badge images! 2025-10-06 02:13:51 -05:00
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
yohlo
732afaf623 changes to twilio 2025-09-29 12:51:33 -05:00
yohlo
48aeaabeea improvements 2025-09-29 11:43:48 -05:00
yohlo
a4b9fe9065 updated bracket 2025-09-29 10:50:18 -05:00
yohlo
31e50af593 team logo compression, play around with style 2025-09-29 10:20:54 -05:00
yohlo
39053cadaa avatr contain 2025-09-26 12:55:04 -05:00
yohlo
ea6656aa33 avatar modal 2025-09-25 21:24:57 -05:00
yohlo
92c4987372 bun 2025-09-25 16:42:55 -05:00
yohlo
b3ebf46afa new typeahead 2025-09-25 16:11:54 -05:00
yohlo
c0ef535001 bug fixes 2025-09-25 15:49:09 -05:00
yohlo
81329e4354 fix refresh issue 2025-09-24 12:20:36 -05:00
yohlo
36f3bb77d4 updates 2025-09-24 11:02:56 -05:00
yohlo
6760ea46f9 update query integration 2025-09-24 08:04:09 -05:00
190 changed files with 8958 additions and 1054 deletions

1
.gitignore vendored
View File

@@ -20,3 +20,4 @@ yarn.lock
/scripts/ /scripts/
/pb_data/ /pb_data/
/.tanstack/ /.tanstack/
/dist/

BIN
Teams-2.xlsx Normal file

Binary file not shown.

1288
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "vite dev --host 0.0.0.0", "dev": "vite dev --host 0.0.0.0",
"build": "vite build && tsc --noEmit", "build": "vite build && tsc --noEmit",
"start": "vite start --host 0.0.0.0" "start": "bun run .output/server/index.mjs",
"start:node": "node .output/server/index.mjs"
}, },
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
@@ -19,6 +20,7 @@
"@mantine/tiptap": "^8.2.4", "@mantine/tiptap": "^8.2.4",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@svgmoji/noto": "^3.2.0", "@svgmoji/noto": "^3.2.0",
"@tanstack/react-devtools": "^0.7.6",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-devtools": "^5.66.0", "@tanstack/react-query-devtools": "^5.66.0",
"@tanstack/react-router": "^1.130.12", "@tanstack/react-router": "^1.130.12",
@@ -29,7 +31,9 @@
"@tiptap/pm": "^3.4.3", "@tiptap/pm": "^3.4.3",
"@tiptap/react": "^3.4.3", "@tiptap/react": "^3.4.3",
"@tiptap/starter-kit": "^3.4.3", "@tiptap/starter-kit": "^3.4.3",
"@types/bun": "^1.2.22",
"@types/ioredis": "^4.28.10", "@types/ioredis": "^4.28.10",
"browser-image-compression": "^2.0.2",
"dotenv": "^17.2.2", "dotenv": "^17.2.2",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.12", "framer-motion": "^12.23.12",
@@ -48,10 +52,12 @@
"supertokens-web-js": "^0.15.0", "supertokens-web-js": "^0.15.0",
"twilio": "^5.8.0", "twilio": "^5.8.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"xlsx": "^0.18.5",
"zod": "^4.0.15", "zod": "^4.0.15",
"zustand": "^5.0.7" "zustand": "^5.0.7"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/react-router-ssr-query": "^1.132.2",
"@tanstack/router-plugin": "^1.132.2", "@tanstack/router-plugin": "^1.132.2",
"@types/node": "^22.5.4", "@types/node": "^22.5.4",
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",

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

@@ -0,0 +1,26 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3072146508")
// add field
collection.fields.addAt(5, new Field({
"hidden": false,
"id": "date3558165700",
"max": "",
"min": "",
"name": "last_activity",
"presentable": false,
"required": false,
"system": false,
"type": "date"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3072146508")
// remove field
collection.fields.removeById("date3558165700")
return app.save(collection)
})

View File

@@ -0,0 +1,24 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1568971955")
// add field
collection.fields.addAt(14, new Field({
"hidden": false,
"id": "bool3523658193",
"name": "private",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1568971955")
// remove field
collection.fields.removeById("bool3523658193")
return app.save(collection)
})

View File

@@ -0,0 +1,24 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// add field
collection.fields.addAt(12, new Field({
"hidden": false,
"id": "bool3403970290",
"name": "regional",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// remove field
collection.fields.removeById("bool3403970290")
return app.save(collection)
})

View File

@@ -0,0 +1,31 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// add field
collection.fields.addAt(13, new Field({
"hidden": false,
"id": "select3736761055",
"maxSelect": 1,
"name": "format",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"single_elim",
"double_elim",
"groups",
"swiss"
]
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// remove field
collection.fields.removeById("select3736761055")
return app.save(collection)
})

View File

@@ -0,0 +1,165 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3208210256",
"max": 0,
"min": 0,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "pbc_3072146508",
"hidden": false,
"id": "relation2582050271",
"maxSelect": 1,
"minSelect": 0,
"name": "player_id",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "json4231605813",
"maxSize": 1,
"name": "player_name",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "number103159226",
"max": null,
"min": null,
"name": "matches",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number3837590211",
"max": null,
"min": null,
"name": "tournaments",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "json2732118329",
"maxSize": 1,
"name": "wins",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json724428801",
"maxSize": 1,
"name": "losses",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3154249934",
"maxSize": 1,
"name": "total_cups_made",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3227208027",
"maxSize": 1,
"name": "total_cups_against",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json2379943496",
"maxSize": 1,
"name": "win_percentage",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3165107022",
"maxSize": 1,
"name": "avg_cups_per_match",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3041953980",
"maxSize": 1,
"name": "margin_of_victory",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json1531431708",
"maxSize": 1,
"name": "margin_of_loss",
"presentable": false,
"required": false,
"system": false,
"type": "json"
}
],
"id": "pbc_4086490894",
"indexes": [],
"listRule": null,
"name": "player_regional_stats",
"system": false,
"type": "view",
"updateRule": null,
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n COUNT(DISTINCT m.tournament) as tournaments,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended' AND\n tour.regional = true\n GROUP BY p.id",
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_4086490894");
return app.delete(collection);
})

View File

@@ -0,0 +1,165 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3208210256",
"max": 0,
"min": 0,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "pbc_3072146508",
"hidden": false,
"id": "relation2582050271",
"maxSelect": 1,
"minSelect": 0,
"name": "player_id",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "json4231605813",
"maxSize": 1,
"name": "player_name",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "number103159226",
"max": null,
"min": null,
"name": "matches",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number3837590211",
"max": null,
"min": null,
"name": "tournaments",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "json2732118329",
"maxSize": 1,
"name": "wins",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json724428801",
"maxSize": 1,
"name": "losses",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3154249934",
"maxSize": 1,
"name": "total_cups_made",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3227208027",
"maxSize": 1,
"name": "total_cups_against",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json2379943496",
"maxSize": 1,
"name": "win_percentage",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3165107022",
"maxSize": 1,
"name": "avg_cups_per_match",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3041953980",
"maxSize": 1,
"name": "margin_of_victory",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json1531431708",
"maxSize": 1,
"name": "margin_of_loss",
"presentable": false,
"required": false,
"system": false,
"type": "json"
}
],
"id": "pbc_15286826",
"indexes": [],
"listRule": null,
"name": "player_mainline_stats",
"system": false,
"type": "view",
"updateRule": null,
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n COUNT(DISTINCT m.tournament) as tournaments,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended' AND\n (tour.regional = false OR tour.regional IS NULL)\n GROUP BY p.id",
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_15286826");
return app.delete(collection);
})

View File

@@ -0,0 +1,48 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// update field
collection.fields.addAt(13, new Field({
"hidden": false,
"id": "select3736761055",
"maxSelect": 1,
"name": "format",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"single_elim",
"double_elim",
"groups",
"swiss",
"swiss_bracket",
"round_robin"
]
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// update field
collection.fields.addAt(13, new Field({
"hidden": false,
"id": "select3736761055",
"maxSelect": 1,
"name": "format",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"single_elim",
"double_elim",
"groups",
"swiss"
]
}))
return app.save(collection)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 832 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -1,14 +1,14 @@
{ {
"name": "", "name": "FLXN IX",
"short_name": "", "short_name": "FLXN",
"icons": [ "icons": [
{ {
"src": "/android-chrome-192x192.png", "src": "/favicon.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "/android-chrome-512x512.png", "src": "/favicon.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

366
server.ts Normal file
View File

@@ -0,0 +1,366 @@
/**
* TanStack Start Production Server with Bun
*
* A high-performance production server for TanStack Start applications that
* implements intelligent static asset loading with configurable memory management.
*
* Features:
* - Hybrid loading strategy (preload small files, serve large files on-demand)
* - Configurable file filtering with include/exclude patterns
* - Memory-efficient response generation
* - Production-ready caching headers
*
* Environment Variables:
*
* PORT (number)
* - Server port number
* - Default: 3000
*
* STATIC_PRELOAD_MAX_BYTES (number)
* - Maximum file size in bytes to preload into memory
* - Files larger than this will be served on-demand from disk
* - Default: 5242880 (5MB)
* - Example: STATIC_PRELOAD_MAX_BYTES=5242880 (5MB)
*
* STATIC_PRELOAD_INCLUDE (string)
* - Comma-separated list of glob patterns for files to include
* - If specified, only matching files are eligible for preloading
* - Patterns are matched against filenames only, not full paths
* - Example: STATIC_PRELOAD_INCLUDE="*.js,*.css,*.woff2"
*
* STATIC_PRELOAD_EXCLUDE (string)
* - Comma-separated list of glob patterns for files to exclude
* - Applied after include patterns
* - Patterns are matched against filenames only, not full paths
* - Example: STATIC_PRELOAD_EXCLUDE="*.map,*.txt"
*
* STATIC_PRELOAD_VERBOSE (boolean)
* - Enable detailed logging of loaded and skipped files
* - Default: false
* - Set to "true" to enable verbose output
*
* Usage:
* bun run server.ts
*/
import { readdir } from 'node:fs/promises'
import { join } from 'node:path'
// Configuration
const PORT = Number(process.env.PORT ?? 3000)
const CLIENT_DIR = './dist/client'
const SERVER_ENTRY = './dist/server/server.js'
// Preloading configuration from environment variables
const MAX_PRELOAD_BYTES = Number(
process.env.STATIC_PRELOAD_MAX_BYTES ?? 5 * 1024 * 1024, // 5MB default
)
// Parse comma-separated include patterns (no defaults)
const INCLUDE_PATTERNS = (process.env.STATIC_PRELOAD_INCLUDE ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map(globToRegExp)
// Parse comma-separated exclude patterns (no defaults)
const EXCLUDE_PATTERNS = (process.env.STATIC_PRELOAD_EXCLUDE ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map(globToRegExp)
// Verbose logging flag
const VERBOSE = process.env.STATIC_PRELOAD_VERBOSE === 'true'
/**
* Convert a simple glob pattern to a regular expression
* Supports * wildcard for matching any characters
*/
function globToRegExp(glob: string): RegExp {
// Escape regex special chars except *, then replace * with .*
const escaped = glob
.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&')
.replace(/\*/g, '.*')
return new RegExp(`^${escaped}$`, 'i')
}
/**
* Metadata for preloaded static assets
*/
interface AssetMetadata {
route: string
size: number
type: string
}
/**
* Result of static asset preloading process
*/
interface PreloadResult {
routes: Record<string, () => Response>
loaded: Array<AssetMetadata>
skipped: Array<AssetMetadata>
}
/**
* Check if a file should be included based on configured patterns
*/
function shouldInclude(relativePath: string): boolean {
const fileName = relativePath.split(/[/\\]/).pop() ?? relativePath
// If include patterns are specified, file must match at least one
if (INCLUDE_PATTERNS.length > 0) {
if (!INCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
return false
}
}
// If exclude patterns are specified, file must not match any
if (EXCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
return false
}
return true
}
/**
* Build static routes with intelligent preloading strategy
* Small files are loaded into memory, large files are served on-demand
*/
async function buildStaticRoutes(clientDir: string): Promise<PreloadResult> {
const routes: Record<string, () => Response> = {}
const loaded: Array<AssetMetadata> = []
const skipped: Array<AssetMetadata> = []
console.log(`📦 Loading static assets from ${clientDir}...`)
console.log(
` Max preload size: ${(MAX_PRELOAD_BYTES / 1024 / 1024).toFixed(2)} MB`,
)
if (INCLUDE_PATTERNS.length > 0) {
console.log(
` Include patterns: ${process.env.STATIC_PRELOAD_INCLUDE ?? ''}`,
)
}
if (EXCLUDE_PATTERNS.length > 0) {
console.log(
` Exclude patterns: ${process.env.STATIC_PRELOAD_EXCLUDE ?? ''}`,
)
}
let totalPreloadedBytes = 0
try {
// Read all files recursively
const files = await readdir(clientDir, { recursive: true })
for (const relativePath of files) {
const filepath = join(clientDir, relativePath)
const route = '/' + relativePath.replace(/\\/g, '/') // Handle Windows paths
try {
// Get file metadata
const file = Bun.file(filepath)
// Skip if file doesn't exist or is empty
if (!(await file.exists()) || file.size === 0) {
continue
}
const metadata: AssetMetadata = {
route,
size: file.size,
type: file.type || 'application/octet-stream',
}
// Determine if file should be preloaded
const matchesPattern = shouldInclude(relativePath)
const withinSizeLimit = file.size <= MAX_PRELOAD_BYTES
if (matchesPattern && withinSizeLimit) {
// Preload small files into memory
const bytes = await file.bytes()
routes[route] = () =>
new Response(bytes, {
headers: {
'Content-Type': metadata.type,
'Cache-Control': 'public, max-age=31536000, immutable',
},
})
loaded.push({ ...metadata, size: bytes.byteLength })
totalPreloadedBytes += bytes.byteLength
} else {
// Serve large or filtered files on-demand
routes[route] = () => {
const fileOnDemand = Bun.file(filepath)
return new Response(fileOnDemand, {
headers: {
'Content-Type': metadata.type,
'Cache-Control': 'public, max-age=3600',
},
})
}
skipped.push(metadata)
}
} catch (error: unknown) {
if (error instanceof Error && error.name !== 'EISDIR') {
console.error(`❌ Failed to load ${filepath}:`, error)
}
}
}
// Always show file overview in Vite-like format first
if (loaded.length > 0 || skipped.length > 0) {
const allFiles = [...loaded, ...skipped].sort((a, b) =>
a.route.localeCompare(b.route),
)
// Calculate max path length for alignment
const maxPathLength = Math.min(
Math.max(...allFiles.map((f) => f.route.length)),
60,
)
// Format file size with KB and gzip estimation
const formatFileSize = (bytes: number) => {
const kb = bytes / 1024
// Rough gzip estimation (typically 30-70% compression)
const gzipKb = kb * 0.35
return {
size: kb < 100 ? kb.toFixed(2) : kb.toFixed(1),
gzip: gzipKb < 100 ? gzipKb.toFixed(2) : gzipKb.toFixed(1),
}
}
if (loaded.length > 0) {
console.log('\n📁 Preloaded into memory:')
loaded
.sort((a, b) => a.route.localeCompare(b.route))
.forEach((file) => {
const { size, gzip } = formatFileSize(file.size)
const paddedPath = file.route.padEnd(maxPathLength)
const sizeStr = `${size.padStart(7)} kB`
const gzipStr = `gzip: ${gzip.padStart(6)} kB`
console.log(` ${paddedPath} ${sizeStr}${gzipStr}`)
})
}
if (skipped.length > 0) {
console.log('\n💾 Served on-demand:')
skipped
.sort((a, b) => a.route.localeCompare(b.route))
.forEach((file) => {
const { size, gzip } = formatFileSize(file.size)
const paddedPath = file.route.padEnd(maxPathLength)
const sizeStr = `${size.padStart(7)} kB`
const gzipStr = `gzip: ${gzip.padStart(6)} kB`
console.log(` ${paddedPath} ${sizeStr}${gzipStr}`)
})
}
// Show detailed verbose info if enabled
if (VERBOSE) {
console.log('\n📊 Detailed file information:')
allFiles.forEach((file) => {
const isPreloaded = loaded.includes(file)
const status = isPreloaded ? '[MEMORY]' : '[ON-DEMAND]'
const reason =
!isPreloaded && file.size > MAX_PRELOAD_BYTES
? ' (too large)'
: !isPreloaded
? ' (filtered)'
: ''
console.log(
` ${status.padEnd(12)} ${file.route} - ${file.type}${reason}`,
)
})
}
}
// Log summary after the file list
console.log() // Empty line for separation
if (loaded.length > 0) {
console.log(
`✅ Preloaded ${String(loaded.length)} files (${(totalPreloadedBytes / 1024 / 1024).toFixed(2)} MB) into memory`,
)
} else {
console.log(' No files preloaded into memory')
}
if (skipped.length > 0) {
const tooLarge = skipped.filter((f) => f.size > MAX_PRELOAD_BYTES).length
const filtered = skipped.length - tooLarge
console.log(
` ${String(skipped.length)} files will be served on-demand (${String(tooLarge)} too large, ${String(filtered)} filtered)`,
)
}
} catch (error) {
console.error(`❌ Failed to load static files from ${clientDir}:`, error)
}
return { routes, loaded, skipped }
}
/**
* Start the production server
*/
async function startServer() {
console.log('🚀 Starting production server...')
// Load TanStack Start server handler
let handler: { fetch: (request: Request) => Response | Promise<Response> }
try {
const serverModule = (await import(SERVER_ENTRY)) as {
default: { fetch: (request: Request) => Response | Promise<Response> }
}
handler = serverModule.default
console.log('✅ TanStack Start handler loaded')
} catch (error) {
console.error('❌ Failed to load server handler:', error)
process.exit(1)
}
// Build static routes with intelligent preloading
const { routes } = await buildStaticRoutes(CLIENT_DIR)
// Create Bun server
const server = Bun.serve({
port: PORT,
idleTimeout: 255,
routes: {
// Serve static assets (preloaded or on-demand)
...routes,
// Fallback to TanStack Start handler for all other routes
'/*': (request) => {
try {
return handler.fetch(request)
} catch (error) {
console.error('Server handler error:', error)
return new Response('Internal Server Error', { status: 500 })
}
},
},
// Global error handler
error(error) {
console.error('Uncaught server error:', error)
return new Response('Internal Server Error', { status: 500 })
},
})
console.log(
`\n🚀 Server running at http://localhost:${String(server.port)}\n`,
)
}
// Start the server
startServer().catch((error: unknown) => {
console.error('Failed to start server:', error)
process.exit(1)
})

View File

@@ -16,6 +16,7 @@ import { Route as AuthedRouteImport } from './routes/_authed'
import { Route as AuthedIndexRouteImport } from './routes/_authed/index' import { Route as AuthedIndexRouteImport } from './routes/_authed/index'
import { Route as AuthedStatsRouteImport } from './routes/_authed/stats' import { Route as AuthedStatsRouteImport } from './routes/_authed/stats'
import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings' import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings'
import { Route as AuthedBadgesRouteImport } from './routes/_authed/badges'
import { Route as AuthedAdminRouteImport } from './routes/_authed/admin' import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index' import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index' import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
@@ -33,6 +34,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'
@@ -74,6 +77,11 @@ const AuthedSettingsRoute = AuthedSettingsRouteImport.update({
path: '/settings', path: '/settings',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedBadgesRoute = AuthedBadgesRouteImport.update({
id: '/badges',
path: '/badges',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedAdminRoute = AuthedAdminRouteImport.update({ const AuthedAdminRoute = AuthedAdminRouteImport.update({
id: '/admin', id: '/admin',
path: '/admin', path: '/admin',
@@ -161,6 +169,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/',
@@ -203,9 +221,12 @@ export interface FileRoutesByFullPath {
'/logout': typeof LogoutRoute '/logout': typeof LogoutRoute
'/refresh-session': typeof RefreshSessionRoute '/refresh-session': typeof RefreshSessionRoute
'/admin': typeof AuthedAdminRouteWithChildren '/admin': typeof AuthedAdminRouteWithChildren
'/badges': typeof AuthedBadgesRoute
'/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
@@ -233,9 +254,12 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/logout': typeof LogoutRoute '/logout': typeof LogoutRoute
'/refresh-session': typeof RefreshSessionRoute '/refresh-session': typeof RefreshSessionRoute
'/badges': typeof AuthedBadgesRoute
'/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
@@ -266,9 +290,12 @@ export interface FileRoutesById {
'/logout': typeof LogoutRoute '/logout': typeof LogoutRoute
'/refresh-session': typeof RefreshSessionRoute '/refresh-session': typeof RefreshSessionRoute
'/_authed/admin': typeof AuthedAdminRouteWithChildren '/_authed/admin': typeof AuthedAdminRouteWithChildren
'/_authed/badges': typeof AuthedBadgesRoute
'/_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
@@ -299,9 +326,12 @@ export interface FileRouteTypes {
| '/logout' | '/logout'
| '/refresh-session' | '/refresh-session'
| '/admin' | '/admin'
| '/badges'
| '/settings' | '/settings'
| '/stats' | '/stats'
| '/' | '/'
| '/admin/activities'
| '/admin/badges'
| '/admin/preview' | '/admin/preview'
| '/profile/$playerId' | '/profile/$playerId'
| '/teams/$teamId' | '/teams/$teamId'
@@ -329,9 +359,12 @@ export interface FileRouteTypes {
| '/login' | '/login'
| '/logout' | '/logout'
| '/refresh-session' | '/refresh-session'
| '/badges'
| '/settings' | '/settings'
| '/stats' | '/stats'
| '/' | '/'
| '/admin/activities'
| '/admin/badges'
| '/admin/preview' | '/admin/preview'
| '/profile/$playerId' | '/profile/$playerId'
| '/teams/$teamId' | '/teams/$teamId'
@@ -361,9 +394,12 @@ export interface FileRouteTypes {
| '/logout' | '/logout'
| '/refresh-session' | '/refresh-session'
| '/_authed/admin' | '/_authed/admin'
| '/_authed/badges'
| '/_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'
@@ -457,6 +493,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedSettingsRouteImport preLoaderRoute: typeof AuthedSettingsRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
'/_authed/badges': {
id: '/_authed/badges'
path: '/badges'
fullPath: '/badges'
preLoaderRoute: typeof AuthedBadgesRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/admin': { '/_authed/admin': {
id: '/_authed/admin' id: '/_authed/admin'
path: '/admin' path: '/admin'
@@ -576,6 +619,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 +679,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 +690,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,
@@ -645,6 +706,7 @@ const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren(
interface AuthedRouteChildren { interface AuthedRouteChildren {
AuthedAdminRoute: typeof AuthedAdminRouteWithChildren AuthedAdminRoute: typeof AuthedAdminRouteWithChildren
AuthedBadgesRoute: typeof AuthedBadgesRoute
AuthedSettingsRoute: typeof AuthedSettingsRoute AuthedSettingsRoute: typeof AuthedSettingsRoute
AuthedStatsRoute: typeof AuthedStatsRoute AuthedStatsRoute: typeof AuthedStatsRoute
AuthedIndexRoute: typeof AuthedIndexRoute AuthedIndexRoute: typeof AuthedIndexRoute
@@ -657,6 +719,7 @@ interface AuthedRouteChildren {
const AuthedRouteChildren: AuthedRouteChildren = { const AuthedRouteChildren: AuthedRouteChildren = {
AuthedAdminRoute: AuthedAdminRouteWithChildren, AuthedAdminRoute: AuthedAdminRouteWithChildren,
AuthedBadgesRoute: AuthedBadgesRoute,
AuthedSettingsRoute: AuthedSettingsRoute, AuthedSettingsRoute: AuthedSettingsRoute,
AuthedStatsRoute: AuthedStatsRoute, AuthedStatsRoute: AuthedStatsRoute,
AuthedIndexRoute: AuthedIndexRoute, AuthedIndexRoute: AuthedIndexRoute,
@@ -695,6 +758,7 @@ import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start' import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' { declare module '@tanstack/react-start' {
interface Register { interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>> router: Awaited<ReturnType<typeof getRouter>>
} }
} }

View File

@@ -1,6 +1,6 @@
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
import { createRouter as createTanStackRouter } from "@tanstack/react-router"; import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routerWithQueryClient } from "@tanstack/react-router-with-query"; import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
import { routeTree } from "./routeTree.gen"; import { routeTree } from "./routeTree.gen";
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary"; import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config"; import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
@@ -13,29 +13,33 @@ export function getRouter() {
gcTime: 5 * 60 * 1000, // 5 minutes gcTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: "always", refetchOnReconnect: "always",
retry: 3, retry: 1,
}, },
}, },
}); });
return routerWithQueryClient( const router = createTanStackRouter({
createTanStackRouter({ routeTree,
routeTree, context: {
context: { queryClient,
queryClient, auth: undefined!,
auth: undefined!, header: defaultHeaderConfig,
header: defaultHeaderConfig, refresh: [],
refresh: [], withPadding: true,
withPadding: true, fullWidth: false,
fullWidth: false, },
}, defaultPreload: "intent",
defaultPreload: "intent", defaultErrorComponent: DefaultCatchBoundary,
defaultErrorComponent: DefaultCatchBoundary, scrollRestoration: true,
scrollRestoration: true, defaultViewTransition: false,
defaultViewTransition: false, });
}),
setupRouterSsrQueryIntegration({
router,
queryClient queryClient
); })
return router;
} }
declare module "@tanstack/react-router" { declare module "@tanstack/react-router" {

View File

@@ -30,6 +30,7 @@ export const Route = createRootRouteWithContext<{
fullWidth: boolean; fullWidth: boolean;
}>()({ }>()({
head: () => ({ head: () => ({
title: "FLXN IX",
meta: [ meta: [
{ {
charSet: "utf-8", charSet: "utf-8",
@@ -37,33 +38,49 @@ 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",
}, },
{ property: 'og:title', content: 'FLXN IX' },
{ property: 'og:description', content: 'Register for FLXN IX and view FLXN stats' },
{ property: 'og:url', content: 'https://flexxon.app' },
{ property: 'og:type', content: 'website' },
{ property: 'og:site_name', content: 'FLXN IX' },
{ property: 'og:image', content: 'https://flexxon.app/favicon.png' },
], ],
links: [ links: [
{ {
rel: "apple-touch-icon", rel: "apple-touch-icon",
sizes: "180x180", sizes: "180x180",
href: "/apple-touch-icon.png", href: "/favicon.png",
}, },
{ {
rel: "icon", rel: "icon",
type: "image/png", type: "image/png",
sizes: "32x32", sizes: "32x32",
href: "/favicon-32x32.png", href: "/favicon.png",
}, },
{ {
rel: "icon", rel: "icon",
type: "image/png", type: "image/png",
sizes: "16x16", sizes: "16x16",
href: "/favicon-16x16.png", href: "/favicon.png",
}, },
{ rel: "manifest", href: "/site.webmanifest" }, { rel: "manifest", href: "/site.webmanifest" },
{ rel: "icon", href: "/favicon.ico" }, { rel: "icon", href: "/favicon.ico" },
{ rel: 'stylesheet', href: mantineCssUrl }, { rel: 'stylesheet', href: 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) => {
@@ -83,12 +100,20 @@ export const Route = createRootRouteWithContext<{
return {}; return {};
} }
// https://github.com/TanStack/router/discussions/3531 if (location.pathname === '/login' || location.pathname === '/logout') {
const auth = await ensureServerQueryData( return {};
context.queryClient, }
playerQueries.auth()
); try {
return { auth }; // https://github.com/TanStack/router/discussions/3531
const auth = await ensureServerQueryData(
context.queryClient,
playerQueries.auth()
);
return { auth };
} catch (error) {
return {};
}
}, },
pendingComponent: () => <Providers><FullScreenLoader /></Providers>, pendingComponent: () => <Providers><FullScreenLoader /></Providers>,
}); });
@@ -114,8 +139,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%",
}} }}
> >
@@ -127,9 +151,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,45 @@
import { createFileRoute } from "@tanstack/react-router";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { ActivitiesTable, activityQueries } from "@/features/activities";
import { PlayersActivityTable, playerQueries } from "@/features/players";
import { Tabs } from "@mantine/core";
import { useState } from "react";
export const Route = createFileRoute("/_authed/admin/activities")({
component: Stats,
beforeLoad: ({ context }) => {
const queryClient = context.queryClient;
prefetchServerQuery(queryClient, activityQueries.search());
prefetchServerQuery(queryClient, playerQueries.activity());
},
loader: () => ({
withPadding: false,
fullWidth: true,
header: {
title: "Activities",
withBackButton: true,
},
refresh: [activityQueries.search().queryKey, playerQueries.activity().queryKey],
}),
});
function Stats() {
const [activeTab, setActiveTab] = useState<string | null>("server-functions");
return (
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List mb='md'>
<Tabs.Tab value="server-functions">Server Functions</Tabs.Tab>
<Tabs.Tab value="player-activity">Player Activity</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="server-functions">
<ActivitiesTable />
</Tabs.Panel>
<Tabs.Panel value="player-activity">
<PlayersActivityTable />
</Tabs.Panel>
</Tabs>
);
}

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

@@ -11,6 +11,7 @@ import { BracketData } from "@/features/bracket/types";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
import BracketView from "@/features/bracket/components/bracket-view"; import BracketView from "@/features/bracket/components/bracket-view";
import { SpotifyControlsBar } from "@/features/spotify/components"; import { SpotifyControlsBar } from "@/features/spotify/components";
import { useAuth } from "@/contexts/auth-context";
export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({ export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
@@ -39,6 +40,8 @@ export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
function RouteComponent() { function RouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { data: tournament } = useTournament(id); const { data: tournament } = useTournament(id);
const { roles } = useAuth();
const isAdmin = roles?.includes('Admin') || false;
const bracket: BracketData = useMemo(() => { const bracket: BracketData = useMemo(() => {
if (!tournament.matches || tournament.matches.length === 0) { if (!tournament.matches || tournament.matches.length === 0) {
@@ -76,7 +79,7 @@ function RouteComponent() {
return ( return (
<Container size="md" px={0}> <Container size="md" px={0}>
<SpotifyControlsBar /> { isAdmin && <SpotifyControlsBar />}
{tournament.matches?.length ? ( {tournament.matches?.length ? (
<BracketView bracket={bracket} showControls /> <BracketView bracket={bracket} showControls />
) : ( ) : (

View File

@@ -0,0 +1,34 @@
import BadgeStatsTable from '@/features/badges/components/badge-stats-table';
import BadgeStatsTableSkeleton from '@/features/badges/components/badge-stats-table-skeleton';
import { badgeQueries, useAllBadges } from '@/features/badges/queries';
import PlayerStatsTableSkeleton from '@/features/players/components/player-stats-table-skeleton';
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
import { createFileRoute } from '@tanstack/react-router';
import { Suspense } from 'react';
export const Route = createFileRoute('/_authed/badges')({
component: Badges,
beforeLoad: ({ context }) => {
const queryClient = context.queryClient;
prefetchServerQuery(queryClient, badgeQueries.allBadges());
},
loader: () => ({
withPadding: false,
fullWidth: true,
header: {
title: 'All Badges',
withBackButton: true,
},
refresh: [badgeQueries.allBadges().queryKey],
}),
});
function Badges() {
return (
<Suspense fallback={<BadgeStatsTableSkeleton />}>
<div>
<BadgeStatsTable />
</div>
</Suspense>
);
}

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

@@ -1,15 +1,19 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { playerQueries } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
import PlayerStatsTable from "@/features/players/components/player-stats-table"; import PlayerStatsTable from "@/features/players/components/player-stats-table";
import { Suspense } from "react"; import { Suspense, useState, useDeferredValue } from "react";
import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton"; import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import LeagueHeadToHead from "@/features/players/components/league-head-to-head";
import { Box, Loader, Tabs, Button, Group, Container, Stack } from "@mantine/core";
export const Route = createFileRoute("/_authed/stats")({ export const Route = createFileRoute("/_authed/stats")({
component: Stats, component: Stats,
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
const queryClient = context.queryClient; const queryClient = context.queryClient;
prefetchServerQuery(queryClient, playerQueries.allStats()); prefetchServerQuery(queryClient, playerQueries.allStats('all'));
prefetchServerQuery(queryClient, playerQueries.allStats('mainline'));
prefetchServerQuery(queryClient, playerQueries.allStats('regional'));
}, },
loader: () => ({ loader: () => ({
withPadding: false, withPadding: false,
@@ -17,12 +21,62 @@ export const Route = createFileRoute("/_authed/stats")({
header: { header: {
title: "Player Stats" title: "Player Stats"
}, },
refresh: [playerQueries.allStats().queryKey], refresh: [playerQueries.allStats().queryKey],
}), }),
}); });
function Stats() { function Stats() {
return <Suspense fallback={<PlayerStatsTableSkeleton />}> const [viewType, setViewType] = useState<'all' | 'mainline' | 'regional'>('all');
<PlayerStatsTable /> const deferredViewType = useDeferredValue(viewType);
</Suspense>; const isStale = viewType !== deferredViewType;
return (
<Tabs defaultValue="stats">
<Tabs.List grow>
<Tabs.Tab value="stats">Stats</Tabs.Tab>
<Tabs.Tab value="h2h">Head to Head</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="stats">
<Container size="100%" px={0} mt="md">
<Stack gap="xs">
<Group gap="xs" px="md" justify="center">
<Button
variant={viewType === 'all' ? 'filled' : 'light'}
size="compact-xs"
onClick={() => setViewType('all')}
>
All
</Button>
<Button
variant={viewType === 'mainline' ? 'filled' : 'light'}
size="compact-xs"
onClick={() => setViewType('mainline')}
>
Mainline
</Button>
<Button
variant={viewType === 'regional' ? 'filled' : 'light'}
size="compact-xs"
onClick={() => setViewType('regional')}
>
Regional
</Button>
</Group>
<Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
<Suspense key={deferredViewType} fallback={<PlayerStatsTableSkeleton hideFilters />}>
<PlayerStatsTable viewType={deferredViewType} />
</Suspense>
</Box>
</Stack>
</Container>
</Tabs.Panel>
<Tabs.Panel value="h2h">
<Suspense fallback={<Box w='100vw' py='xl'><Loader ml='45vw' /></Box>}>
<LeagueHeadToHead />
</Suspense>
</Tabs.Panel>
</Tabs>
);
} }

View File

@@ -10,7 +10,6 @@ import { useMemo } from "react";
import { BracketData } from "@/features/bracket/types"; import { BracketData } from "@/features/bracket/types";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
import BracketView from "@/features/bracket/components/bracket-view"; import BracketView from "@/features/bracket/components/bracket-view";
import { SpotifyControlsBar } from "@/features/spotify/components";
export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {

View File

@@ -3,13 +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 }) => {
logger.info("ServerEvents | New connection", context?.userAuthId); 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`;
@@ -19,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);
@@ -31,30 +38,37 @@ 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);
controller.close();
} }
}, 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);
try { logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${activeConnections}`);
logger.info(
"ServerEvents | Closing connection",
context?.userAuthId
);
controller.close();
} catch (e) {
logger.error("ServerEvents | Error closing controller", e);
}
}; };
request.signal?.addEventListener("abort", cleanup); request.signal?.addEventListener("abort", cleanup);
return cleanup; return cleanup;
}, },
}); });
@@ -62,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

@@ -10,16 +10,9 @@ export const Route = createFileRoute(
try { try {
const { collection, recordId, file } = params; const { collection, recordId, file } = params;
const pocketbaseUrl = const pocketbaseUrl =
import.meta.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

@@ -1,9 +1,8 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { SpotifyAuth } from "@/lib/spotify/auth";
const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID!; const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!;
const SPOTIFY_CLIENT_SECRET = import.meta.env.SPOTIFY_CLIENT_SECRET!; const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
const SPOTIFY_REDIRECT_URI = import.meta.env.VITE_SPOTIFY_REDIRECT_URI!; const SPOTIFY_REDIRECT_URI = process.env.VITE_SPOTIFY_REDIRECT_URI!;
export const Route = createFileRoute("/api/spotify/callback")({ export const Route = createFileRoute("/api/spotify/callback")({
server: { server: {
@@ -112,7 +111,7 @@ export const Route = createFileRoute("/api/spotify/callback")({
}); });
const isSecure = import.meta.env.NODE_ENV === "production"; const isSecure = import.meta.env.NODE_ENV === "production";
const cookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${tokens.expires_in}`; const cookieOptions = `HttpOnly; ${isSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${tokens.expires_in}`;
response.headers.append( response.headers.append(
"Set-Cookie", "Set-Cookie",
@@ -120,7 +119,7 @@ export const Route = createFileRoute("/api/spotify/callback")({
); );
if (tokens.refresh_token) { if (tokens.refresh_token) {
const refreshCookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${60 * 60 * 24 * 30}`; // 30 days const refreshCookieOptions = `HttpOnly; ${isSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${60 * 60 * 24 * 30}`; // 30 days
response.headers.append( response.headers.append(
"Set-Cookie", "Set-Cookie",
`spotify_refresh_token=${tokens.refresh_token}; ${refreshCookieOptions}` `spotify_refresh_token=${tokens.refresh_token}; ${refreshCookieOptions}`

View File

@@ -1,8 +1,8 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
async function getClientCredentialsToken(): Promise<string> { async function getClientCredentialsToken(): Promise<string> {
const clientId = import.meta.env.VITE_SPOTIFY_CLIENT_ID; const clientId = process.env.VITE_SPOTIFY_CLIENT_ID;
const clientSecret = import.meta.env.SPOTIFY_CLIENT_SECRET; const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
if (!clientId || !clientSecret) { if (!clientId || !clientSecret) {
throw new Error("Missing Spotify client credentials"); throw new Error("Missing Spotify client credentials");

View File

@@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID!; const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!;
const SPOTIFY_CLIENT_SECRET = import.meta.env.SPOTIFY_CLIENT_SECRET!; const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
export const Route = createFileRoute("/api/spotify/token")({ export const Route = createFileRoute("/api/spotify/token")({
server: { server: {

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

@@ -1,38 +1,33 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useEffect } from 'react' import { useEffect, useRef } from 'react'
import FullScreenLoader from '@/components/full-screen-loader' import FullScreenLoader from '@/components/full-screen-loader'
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session' import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'
import { resetRefreshFlag } from '@/lib/supertokens/client'
export const Route = createFileRoute('/refresh-session')({ export const Route = createFileRoute('/refresh-session')({
component: RouteComponent, component: RouteComponent,
}) })
// https://supertokens.com/docs/additional-verification/session-verification/ssr?uiType=custom
function RouteComponent() { function RouteComponent() {
const hasAttemptedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (hasAttemptedRef.current) return;
hasAttemptedRef.current = true;
const handleRefresh = async () => { const handleRefresh = async () => {
try { try {
resetRefreshFlag();
const refreshed = await attemptRefreshingSession() const refreshed = await attemptRefreshingSession()
if (refreshed) { if (refreshed) {
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search)
const redirect = urlParams.get('redirect') const redirect = urlParams.get('redirect')
const isServerFunction = redirect && ( if (redirect && !redirect.includes('_serverFn') && !redirect.includes('/api/')) {
redirect.startsWith('_serverFn') ||
redirect.startsWith('api/') ||
redirect.includes('_serverFn')
);
if (redirect && !isServerFunction) {
window.location.href = decodeURIComponent(redirect) window.location.href = decodeURIComponent(redirect)
} else { } else {
const referrer = document.referrer; window.location.href = '/';
const referrerUrl = referrer && !referrer.includes('/_serverFn') && !referrer.includes('/api/')
? referrer
: '/';
window.location.href = referrerUrl;
} }
} else { } else {
window.location.href = '/login' window.location.href = '/login'
@@ -42,8 +37,7 @@ function RouteComponent() {
} }
} }
const timeout = setTimeout(handleRefresh, 100) setTimeout(handleRefresh, 100)
return () => clearTimeout(timeout)
}, []) }, [])
return <FullScreenLoader /> return <FullScreenLoader />

View File

@@ -2,14 +2,23 @@ import {
Avatar as MantineAvatar, Avatar as MantineAvatar,
AvatarProps as MantineAvatarProps, AvatarProps as MantineAvatarProps,
Paper, Paper,
Modal,
Image,
Group,
Text,
ActionIcon,
} from "@mantine/core"; } from "@mantine/core";
import { useState } from "react";
import { XIcon } from "@phosphor-icons/react";
interface AvatarProps interface AvatarProps
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> { extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
name: string; name?: string;
size?: number; size?: number;
radius?: string | number; radius?: string | number;
withBorder?: boolean; withBorder?: boolean;
disableFullscreen?: boolean;
contain?: boolean;
} }
const Avatar = ({ const Avatar = ({
@@ -17,26 +26,122 @@ const Avatar = ({
size = 35, size = 35,
radius = "100%", radius = "100%",
withBorder = true, withBorder = true,
disableFullscreen = false,
contain = false,
...props ...props
}: AvatarProps) => { }: AvatarProps) => {
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
const hasImage = Boolean(props.src);
const handleAvatarClick = () => {
if (hasImage && !disableFullscreen) {
setIsFullscreenOpen(true);
}
};
return ( return (
<Paper p={size / 20} radius={radius} withBorder={withBorder}> <>
<MantineAvatar <Paper
alt={name} p={size / 20}
key={name}
name={name}
color="initials"
size={size}
radius={radius} radius={radius}
w={size} withBorder={withBorder}
style={{
cursor: hasImage && !disableFullscreen ? 'pointer' : 'default',
transition: 'transform 0.15s ease',
}}
onMouseEnter={(e) => {
if (hasImage && !disableFullscreen) {
e.currentTarget.style.transform = 'scale(1.02)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
}}
onClick={handleAvatarClick}
>
<MantineAvatar
alt={name}
key={name}
name={name}
color="initials"
size={size}
radius={radius}
w={size}
styles={{
image: {
objectFit: contain ? 'contain' : 'cover',
},
}}
{...props}
/>
</Paper>
<Modal
opened={isFullscreenOpen}
onClose={() => setIsFullscreenOpen(false)}
size="auto"
centered
withCloseButton={false}
overlayProps={{
backgroundOpacity: 0.9,
blur: 2,
}}
styles={{ styles={{
image: { content: {
objectFit: "contain", background: 'transparent',
border: 'none',
},
body: {
padding: 0,
}, },
}} }}
{...props} >
/> <div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
</Paper> <ActionIcon
variant="filled"
color="dark"
size="lg"
radius="xl"
style={{
position: 'absolute',
top: -10,
right: -10,
zIndex: 1000,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
}}
onClick={() => setIsFullscreenOpen(false)}
>
<XIcon size={18} color="white" />
</ActionIcon>
<Image
src={props.src}
alt={name}
fit="contain"
style={{
borderRadius: 8,
maxWidth: '90vw',
maxHeight: '90vh',
}}
/>
<Group
justify="center"
mt="md"
style={{
position: 'absolute',
bottom: -50,
left: '50%',
transform: 'translateX(-50%)',
}}
>
<Text c="white" size="sm" fw={500}>
{name}
</Text>
</Group>
</div>
</Modal>
</>
); );
}; };

View File

@@ -10,5 +10,4 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
return <MantineButton fullWidth ref={ref} {...props} />; return <MantineButton fullWidth ref={ref} {...props} />;
}); });
Button.displayName = "Button";
export default Button; export default Button;

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

@@ -1,5 +1,10 @@
import { Box, Container, Flex, Loader, useComputedColorScheme } from "@mantine/core"; import {
import { PropsWithChildren, Suspense, useEffect, useRef } from "react"; Box,
Container,
Title,
useComputedColorScheme,
} from "@mantine/core";
import { PropsWithChildren, useEffect, useRef } from "react";
import { Drawer as VaulDrawer } from "vaul"; import { Drawer as VaulDrawer } from "vaul";
import styles from "./styles.module.css"; import styles from "./styles.module.css";
@@ -17,6 +22,11 @@ const Drawer: React.FC<DrawerProps> = ({
}) => { }) => {
const colorScheme = useComputedColorScheme("light"); const colorScheme = useComputedColorScheme("light");
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const openedRef = useRef(opened);
useEffect(() => {
openedRef.current = opened;
}, [opened]);
useEffect(() => { useEffect(() => {
const appElement = document.querySelector(".app") as HTMLElement; const appElement = document.querySelector(".app") as HTMLElement;
@@ -57,33 +67,56 @@ const Drawer: React.FC<DrawerProps> = ({
appElement.classList.remove("drawer-scaling"); appElement.classList.remove("drawer-scaling");
themeColorMeta.content = currentColors.normal; themeColorMeta.content = currentColors.normal;
}; };
}, [opened, colorScheme]); }, [opened]);
useEffect(() => { useEffect(() => {
if (!opened || !contentRef.current) return; if (!opened || !contentRef.current) return;
const resizeObserver = new ResizeObserver(() => { const updateDrawerHeight = () => {
if (contentRef.current) { if (contentRef.current) {
const drawerContent = contentRef.current.closest('[data-vaul-drawer-wrapper]'); const drawerContent = contentRef.current;
if (drawerContent) { const visualViewport = window.visualViewport;
(drawerContent as HTMLElement).style.height = 'auto';
(drawerContent as HTMLElement).offsetHeight; if (visualViewport) {
const availableHeight = visualViewport.height;
const maxDrawerHeight = Math.min(
availableHeight * 0.75,
window.innerHeight * 0.75
);
drawerContent.style.maxHeight = `${maxDrawerHeight}px`;
} else {
drawerContent.style.maxHeight = "75vh";
} }
} }
}); };
resizeObserver.observe(contentRef.current); updateDrawerHeight();
if (window.visualViewport) {
window.visualViewport.addEventListener("resize", updateDrawerHeight);
}
return () => { return () => {
resizeObserver.disconnect(); if (window.visualViewport) {
window.visualViewport.removeEventListener("resize", updateDrawerHeight);
}
}; };
}, [opened, children]); }, [opened]);
return ( return (
<VaulDrawer.Root open={opened} onOpenChange={onChange}> <VaulDrawer.Root
repositionInputs={false}
open={opened}
onOpenChange={onChange}
>
<VaulDrawer.Portal> <VaulDrawer.Portal>
<VaulDrawer.Overlay className={styles.drawerOverlay} /> <VaulDrawer.Overlay className={styles.drawerOverlay} />
<VaulDrawer.Content className={styles.drawerContent} aria-describedby="drawer" ref={contentRef}> <VaulDrawer.Content
className={styles.drawerContent}
aria-describedby="drawer"
ref={contentRef}
>
<Container flex={1} p="md"> <Container flex={1} p="md">
<Box <Box
mb="sm" mb="sm"
@@ -95,14 +128,10 @@ const Drawer: React.FC<DrawerProps> = ({
style={{ borderRadius: "9999px" }} style={{ borderRadius: "9999px" }}
/> />
<Container mx="auto" maw="28rem" px={0}> <Container mx="auto" maw="28rem" px={0}>
<VaulDrawer.Title>{title}</VaulDrawer.Title> <VaulDrawer.Title>
<Suspense fallback={ <Title order={2}>{title}</Title>
<Flex justify='center' align='center' w='100%' h={400}> </VaulDrawer.Title>
<Loader size='lg' /> {children}
</Flex>
}>
{children}
</Suspense>
</Container> </Container>
</Container> </Container>
</VaulDrawer.Content> </VaulDrawer.Content>

View File

@@ -1,8 +1,8 @@
import { PropsWithChildren, useCallback } from "react"; import { PropsWithChildren, Suspense, useCallback } from "react";
import { useIsMobile } from "@/hooks/use-is-mobile"; import { useIsMobile } from "@/hooks/use-is-mobile";
import Drawer from "./drawer"; import Drawer from "./drawer";
import Modal from "./modal"; import Modal from "./modal";
import { ScrollArea } from "@mantine/core"; import { ScrollArea, Flex, Loader } from "@mantine/core";
interface SheetProps extends PropsWithChildren { interface SheetProps extends PropsWithChildren {
title?: string; title?: string;
@@ -16,6 +16,8 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
const SheetComponent = isMobile ? Drawer : Modal; const SheetComponent = isMobile ? Drawer : Modal;
if (!opened) return null;
return ( return (
<SheetComponent <SheetComponent
title={title} title={title}
@@ -23,14 +25,20 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
onChange={onChange} onChange={onChange}
onClose={handleClose} onClose={handleClose}
> >
<ScrollArea <Suspense fallback={
style={{ flex: 1 }} <Flex justify='center' align='center' w='100%' style={{ minHeight: '25vh' }}>
scrollbarSize={8} <Loader size='lg' />
scrollbars="y" </Flex>
type="scroll" }>
> <ScrollArea.Autosize
{children} style={{ flex: 1, maxHeight: '75dvh' }}
</ScrollArea> scrollbarSize={8}
scrollbars="y"
type="scroll"
>
{children}
</ScrollArea.Autosize>
</Suspense>
</SheetComponent> </SheetComponent>
); );
}; };

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

@@ -13,11 +13,10 @@
margin-top: 24px; margin-top: 24px;
height: auto !important; height: auto !important;
min-height: fit-content; min-height: fit-content;
max-height: 100dvh;
position: fixed; position: fixed;
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
outline: none; outline: none;
transition: height 0.2s ease-out; transition: height 0.2s ease-out, max-height 0.2s ease-out;
} }

View File

@@ -101,10 +101,10 @@ const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) =>
{ label: "Losses", value: overallStats.losses, Icon: XIcon }, { label: "Losses", value: overallStats.losses, Icon: XIcon },
{ label: "Cups Made", value: overallStats.total_cups_made, Icon: FireIcon }, { label: "Cups Made", value: overallStats.total_cups_made, Icon: FireIcon },
{ label: "Cups Against", value: overallStats.total_cups_against, Icon: ShieldIcon }, { label: "Cups Against", value: overallStats.total_cups_against, Icon: ShieldIcon },
{ label: "Avg Cups Per Game", value: avgCupsPerMatch > 0 ? avgCupsPerMatch : null, Icon: ChartLineUpIcon }, { label: "Avg Cups Per Match", value: avgCupsPerMatch >= 0 ? avgCupsPerMatch : null, Icon: ChartLineUpIcon },
{ label: "Avg Cups Against", value: avgCupsAgainstPerMatch > 0 ? avgCupsAgainstPerMatch : null, Icon: ShieldCheckIcon }, { label: "Avg Cups Against", value: avgCupsAgainstPerMatch >= 0 ? avgCupsAgainstPerMatch : null, Icon: ShieldCheckIcon },
{ label: "Avg Win Margin", value: avgMarginOfVictory > 0 ? avgMarginOfVictory : null, Icon: ArrowUpIcon }, { label: "Avg Win Margin", value: avgMarginOfVictory >= 0 ? avgMarginOfVictory : null, Icon: ArrowUpIcon },
{ label: "Avg Loss Margin", value: avgMarginOfLoss > 0 ? avgMarginOfLoss : null, Icon: ArrowDownIcon }, { label: "Avg Loss Margin", value: avgMarginOfLoss >= 0 ? avgMarginOfLoss : null, Icon: ArrowDownIcon },
]; ];
return ( return (
@@ -133,7 +133,7 @@ export const StatsSkeleton = () => {
{ label: "Losses", Icon: XIcon }, { label: "Losses", Icon: XIcon },
{ label: "Cups Made", Icon: FireIcon }, { label: "Cups Made", Icon: FireIcon },
{ label: "Cups Against", Icon: ShieldIcon }, { label: "Cups Against", Icon: ShieldIcon },
{ label: "Avg Cups Per Game", Icon: ChartLineUpIcon }, { label: "Avg Cups Per Match", Icon: ChartLineUpIcon },
{ label: "Avg Cups Against", Icon: ShieldCheckIcon }, { label: "Avg Cups Against", Icon: ShieldCheckIcon },
{ label: "Avg Win Margin", Icon: ArrowUpIcon }, { label: "Avg Win Margin", Icon: ArrowUpIcon },
{ label: "Avg Loss Margin", Icon: ArrowDownIcon }, { label: "Avg Loss Margin", Icon: ArrowDownIcon },

View File

@@ -18,6 +18,7 @@ interface TabItem {
interface SwipeableTabsProps { interface SwipeableTabsProps {
tabs: TabItem[]; tabs: TabItem[];
defaultTab?: number; defaultTab?: number;
mb?: string | number;
onTabChange?: (index: number, tab: TabItem) => void; onTabChange?: (index: number, tab: TabItem) => void;
} }
@@ -25,6 +26,7 @@ function SwipeableTabs({
tabs, tabs,
defaultTab = 0, defaultTab = 0,
onTabChange, onTabChange,
mb,
}: SwipeableTabsProps) { }: SwipeableTabsProps) {
const router = useRouter(); const router = useRouter();
const search = router.state.location.search as any; const search = router.state.location.search as any;
@@ -144,7 +146,7 @@ function SwipeableTabs({
style={{ style={{
display: "flex", display: "flex",
paddingInline: "var(--mantine-spacing-md)", paddingInline: "var(--mantine-spacing-md)",
marginBottom: "var(--mantine-spacing-md)", marginBottom: mb !== undefined ? mb : "var(--mantine-spacing-md)",
zIndex: 100, zIndex: 100,
backgroundColor: "var(--mantine-color-body)", backgroundColor: "var(--mantine-color-body)",
}} }}
@@ -205,11 +207,13 @@ function SwipeableTabs({
height: carouselHeight === "auto" ? "auto" : `${carouselHeight}px`, height: carouselHeight === "auto" ? "auto" : `${carouselHeight}px`,
transition: "height 300ms ease", transition: "height 300ms ease",
touchAction: "pan-y", touchAction: "pan-y",
width: "100%",
maxWidth: "100vw",
}} }}
> >
{tabs.map((tab, index) => ( {tabs.map((tab, index) => (
<Carousel.Slide key={`${tab.label}-content-${index}`}> <Carousel.Slide key={`${tab.label}-content-${index}`}>
<Box ref={setSlideRef(index)} style={{ height: "auto" }}> <Box ref={setSlideRef(index)} style={{ height: "auto", width: "100%", maxWidth: "100vw", overflow: "hidden" }}>
{tab.content} {tab.content}
</Box> </Box>
</Carousel.Slide> </Carousel.Slide>

View File

@@ -0,0 +1,177 @@
import { useState, useRef, useEffect, ReactNode } from "react";
import { TextInput, Loader, Paper, Stack, Box, Text } from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks";
export interface TypeaheadOption<T = any> {
id: string;
data: T;
}
export interface TypeaheadProps<T> {
onSelect: (option: TypeaheadOption<T>) => void;
searchFn: (query: string) => Promise<TypeaheadOption<T>[]>;
renderOption: (option: TypeaheadOption<T>, isSelected?: boolean) => ReactNode;
format?: (option: TypeaheadOption<T>) => string;
placeholder?: string;
debounceMs?: number;
disabled?: boolean;
initialValue?: string;
maxHeight?: number | string;
}
const Typeahead = <T,>({
onSelect,
searchFn,
renderOption,
format,
placeholder = "Search...",
debounceMs = 300,
disabled = false,
initialValue = "",
maxHeight = 200,
}: TypeaheadProps<T>) => {
const [searchQuery, setSearchQuery] = useState(initialValue);
const [searchResults, setSearchResults] = useState<TypeaheadOption<T>[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const performSearch = async (query: string) => {
setIsLoading(true);
try {
const results = await searchFn(query);
setSearchResults(results);
setIsOpen(results.length > 0);
setSelectedIndex(-1);
} catch (error) {
console.error('Search failed:', error);
setSearchResults([]);
setIsOpen(false);
} finally {
setIsLoading(false);
}
};
const debouncedSearch = useDebouncedCallback(performSearch, debounceMs);
const handleSearchChange = (value: string) => {
setSearchQuery(value);
debouncedSearch(value);
};
const handleOptionSelect = (option: TypeaheadOption<T>) => {
onSelect(option);
const displayValue = format ? format(option) : String(option.data);
setSearchQuery(displayValue);
setIsOpen(false);
setSelectedIndex(-1);
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen || searchResults.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev => (prev < searchResults.length - 1 ? prev + 1 : prev));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev));
break;
case 'Enter':
e.preventDefault();
if (selectedIndex >= 0 && searchResults[selectedIndex]) {
handleOptionSelect(searchResults[selectedIndex]);
}
break;
case 'Escape':
setIsOpen(false);
setSelectedIndex(-1);
break;
}
};
return (
<Box ref={containerRef} pos="relative" w="100%">
<TextInput
ref={inputRef}
value={searchQuery}
onChange={(event) => handleSearchChange(event.currentTarget.value)}
onKeyDown={handleKeyDown}
onFocus={async () => {
if (searchResults.length > 0) {
setIsOpen(true);
return;
}
await performSearch(searchQuery);
}}
placeholder={placeholder}
rightSection={isLoading ? <Loader size="xs" /> : null}
disabled={disabled}
/>
{isOpen && (
<Paper
shadow="md"
p={0}
bd="1px solid var(--mantine-color-dimmed)"
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 9999,
maxHeight,
overflowY: 'auto',
WebkitOverflowScrolling: 'touch',
touchAction: 'pan-y',
borderTop: 0,
borderTopLeftRadius: 0,
borderTopRightRadius: 0
}}
onTouchMove={(e) => e.stopPropagation()}
>
{searchResults.length > 0 ? (
<Stack gap={0}>
{searchResults.map((option, index) => (
<Box
key={option.id}
style={{
cursor: 'pointer',
backgroundColor: selectedIndex === index ? 'var(--mantine-color-gray-1)' : 'transparent',
}}
onClick={() => handleOptionSelect(option)}
onMouseEnter={() => setSelectedIndex(index)}
>
{renderOption(option, selectedIndex === index)}
</Box>
))}
</Stack>
) : (
<Box p="md">
<Text size="sm" c="dimmed" ta="center">
{searchQuery.trim() ? 'No results found' : 'Start typing to search...'}
</Text>
</Box>
)}
</Paper>
)}
</Box>
);
};
export default Typeahead;

View File

@@ -58,13 +58,13 @@ export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
const value = useMemo( const value = useMemo(
() => ({ () => ({
user: data?.user || defaultAuthData.user, user: data?.user,
metadata: data?.metadata || defaultAuthData.metadata, metadata: data?.metadata || { accentColor: "blue" as MantineColor, colorScheme: "dark" as MantineColorScheme },
roles: data?.roles || defaultAuthData.roles, roles: data?.roles || [],
phone: data?.phone || "", phone: data?.phone || "",
set, set,
}), }),
[data, defaultAuthData] [data, set]
); );
return <AuthContext value={value}>{children}</AuthContext>; return <AuthContext value={value}>{children}</AuthContext>;

View File

@@ -0,0 +1,382 @@
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>
);
});
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>
);
});
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,22 +4,53 @@ 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> <List p="0">
<ListLink <ListLink
label="Manage Tournaments" label="Manage Tournaments"
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}
onClick={() => onClick={() =>
window.location.replace(import.meta.env.POCKETBASE_URL! + "/_/") window.location.replace(process.env.POCKETBASE_URL! + "/_/")
} }
/> />
<ListLink <ListLink

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

@@ -5,7 +5,7 @@ import ListLink from "@/components/list-link";
const ManageTournaments = () => { const ManageTournaments = () => {
const { data: tournaments } = useTournaments(); const { data: tournaments } = useTournaments();
return ( return (
<List> <List p="0">
{tournaments.map((t) => ( {tournaments.map((t) => (
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} /> <ListLink label={t.name} to={`/admin/tournaments/${t.id}`} />
))} ))}

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;

Some files were not shown because too many files have changed in this diff Show More