47 Commits

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

1
.gitignore vendored
View File

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

1245
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" "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",
@@ -24,12 +25,15 @@
"@tanstack/react-router": "^1.130.12", "@tanstack/react-router": "^1.130.12",
"@tanstack/react-router-devtools": "^1.130.13", "@tanstack/react-router-devtools": "^1.130.13",
"@tanstack/react-router-with-query": "^1.130.12", "@tanstack/react-router-with-query": "^1.130.12",
"@tanstack/react-start": "^1.130.15", "@tanstack/react-start": "^1.132.2",
"@tanstack/react-virtual": "^3.13.12", "@tanstack/react-virtual": "^3.13.12",
"@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",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.12", "framer-motion": "^12.23.12",
"ioredis": "^5.7.0", "ioredis": "^5.7.0",
@@ -51,6 +55,8 @@
"zustand": "^5.0.7" "zustand": "^5.0.7"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/react-router-ssr-query": "^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",
"@types/react": "^19.0.8", "@types/react": "^19.0.8",
@@ -63,7 +69,7 @@
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.3.5", "vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
} }
} }

View File

@@ -0,0 +1,108 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "json4225120046",
"maxSize": 0,
"name": "arguments",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"cascadeDelete": false,
"collectionId": "pbc_3072146508",
"hidden": false,
"id": "relation2551806565",
"maxSelect": 1,
"minSelect": 0,
"name": "player",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3293145029",
"max": 0,
"min": 0,
"name": "user_agent",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_1262591861",
"indexes": [],
"listRule": null,
"name": "activities",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1262591861");
return app.delete(collection);
})

View File

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

View File

@@ -0,0 +1,43 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1262591861")
// add field
collection.fields.addAt(6, new Field({
"hidden": false,
"id": "bool1862328242",
"name": "success",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
}))
// add field
collection.fields.addAt(7, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text1574812785",
"max": 0,
"min": 0,
"name": "error",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1262591861")
// remove field
collection.fields.removeById("bool1862328242")
// remove field
collection.fields.removeById("text1574812785")
return app.save(collection)
})

View File

@@ -0,0 +1,27 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1340419796")
// add field
collection.fields.addAt(7, new Field({
"hidden": false,
"id": "number4113142680",
"max": null,
"min": null,
"name": "order",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1340419796")
// remove field
collection.fields.removeById("number4113142680")
return app.save(collection)
})

View File

@@ -0,0 +1,46 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3072146508")
// remove field
collection.fields.removeById("relation2029409178")
// remove field
collection.fields.removeById("relation2813965191")
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3072146508")
// add field
collection.fields.addAt(5, new Field({
"cascadeDelete": false,
"collectionId": "pbc_1340419796",
"hidden": false,
"id": "relation2029409178",
"maxSelect": 999,
"minSelect": 0,
"name": "badges",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
// add field
collection.fields.addAt(6, new Field({
"cascadeDelete": false,
"collectionId": "pbc_1340419796",
"hidden": false,
"id": "relation2813965191",
"maxSelect": 1,
"minSelect": 0,
"name": "featured_badge",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
return app.save(collection)
})

View File

@@ -0,0 +1,187 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_5062686152");
return app.delete(collection);
}, (app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3208210256",
"max": 0,
"min": 0,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "pbc_3072146508",
"hidden": false,
"id": "relation2582050271",
"maxSelect": 1,
"minSelect": 0,
"name": "player_id",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"cascadeDelete": false,
"collectionId": "pbc_1340419796",
"hidden": false,
"id": "relation4154639100",
"maxSelect": 1,
"minSelect": 0,
"name": "badge_id",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "_clone_GhrR",
"max": 0,
"min": 0,
"name": "badge_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "_clone_DEaW",
"max": 0,
"min": 0,
"name": "badge_description",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "_clone_MHmw",
"maxSelect": 1,
"name": "badge_type",
"presentable": false,
"required": true,
"system": false,
"type": "select",
"values": [
"tournament_participation",
"tournament_placement",
"performance",
"overtime",
"match_milestone"
]
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "_clone_11YE",
"max": 50,
"min": 0,
"name": "badge_icon",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "_clone_qAJu",
"max": 50,
"min": 0,
"name": "badge_color",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "_clone_giOf",
"name": "is_progressive",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "json3212413036",
"maxSize": 1,
"name": "current_progress",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json4171899439",
"maxSize": 1,
"name": "target_progress",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3435813110",
"maxSize": 1,
"name": "is_earned",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "_clone_Q7lC",
"max": "",
"min": "",
"name": "earned_at",
"presentable": false,
"required": false,
"system": false,
"type": "date"
}
],
"id": "pbc_5062686152",
"indexes": [],
"listRule": null,
"name": "player_badges_view",
"system": false,
"type": "view",
"updateRule": null,
"viewQuery": "\n SELECT\n (p.id || '_' || b.id) as id,\n p.id as player_id,\n b.id as badge_id,\n b.name as badge_name,\n b.description as badge_description,\n b.type as badge_type,\n b.icon as badge_icon,\n b.color as badge_color,\n b.is_progressive,\n COALESCE(pbp.current_progress, 0) as current_progress,\n COALESCE(pbp.target_progress, b.progress_target, 1) as target_progress,\n COALESCE(pbp.is_earned, false) as is_earned,\n pbp.earned_at\n FROM players p\n CROSS JOIN badges b\n LEFT JOIN player_badge_progress pbp ON pbp.player_id = p.id AND pbp.badge_id = b.id\n ",
"viewRule": null
});
return app.save(collection);
})

View File

@@ -0,0 +1,129 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_4251874343");
return app.delete(collection);
}, (app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "pbc_3072146508",
"hidden": false,
"id": "relation2847519201",
"maxSelect": 1,
"minSelect": 1,
"name": "player_id",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"cascadeDelete": true,
"collectionId": "pbc_1340419796",
"hidden": false,
"id": "relation3948571039",
"maxSelect": 1,
"minSelect": 1,
"name": "badge_id",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "number1847293057",
"max": null,
"min": 0,
"name": "current_progress",
"onlyInt": false,
"presentable": false,
"required": true,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number2948571040",
"max": null,
"min": 1,
"name": "target_progress",
"onlyInt": false,
"presentable": false,
"required": true,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "bool3049672141",
"name": "is_earned",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "date1150773242",
"max": "",
"min": "",
"name": "earned_at",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_4251874343",
"indexes": [
"CREATE UNIQUE INDEX `idx_unique_player_badge` ON `player_badge_progress` (`player_id`, `badge_id`)"
],
"listRule": null,
"name": "player_badge_progress",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
})

View File

@@ -0,0 +1,173 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1340419796");
return app.delete(collection);
}, (app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1843675174",
"max": 0,
"min": 0,
"name": "description",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "select4029814376",
"maxSelect": 1,
"name": "type",
"presentable": false,
"required": true,
"system": false,
"type": "select",
"values": [
"tournament_participation",
"tournament_placement",
"performance",
"overtime",
"match_milestone"
]
},
{
"hidden": false,
"id": "json1578432567",
"maxSize": 2000000,
"name": "criteria",
"presentable": false,
"required": true,
"system": false,
"type": "json"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3928475610",
"max": 50,
"min": 0,
"name": "icon",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1847293056",
"max": 50,
"min": 0,
"name": "color",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number4113142680",
"max": null,
"min": null,
"name": "order",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "bool2847519203",
"name": "is_progressive",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "number2948571038",
"max": null,
"min": null,
"name": "progress_target",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_1340419796",
"indexes": [],
"listRule": null,
"name": "badges",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
})

View File

@@ -0,0 +1,145 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2324736937",
"max": 0,
"min": 0,
"name": "key",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1843675174",
"max": 0,
"min": 0,
"name": "description",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "json3055524737",
"maxSize": 0,
"name": "criteria",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "select2363381545",
"maxSelect": 1,
"name": "type",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"manual",
"match",
"tournament"
]
},
{
"hidden": false,
"id": "bool3646955747",
"name": "progressive",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "number4113142680",
"max": null,
"min": null,
"name": "order",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_1340419796",
"indexes": [],
"listRule": null,
"name": "badges",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1340419796");
return app.delete(collection);
})

View File

@@ -0,0 +1,104 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "pbc_1340419796",
"hidden": false,
"id": "relation4277159965",
"maxSelect": 1,
"minSelect": 0,
"name": "badge",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"cascadeDelete": false,
"collectionId": "pbc_3072146508",
"hidden": false,
"id": "relation2551806565",
"maxSelect": 1,
"minSelect": 0,
"name": "player",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "number570552902",
"max": null,
"min": null,
"name": "progress",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "bool2625885481",
"name": "earned",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_1063824264",
"indexes": [],
"listRule": null,
"name": "player_badge_progress",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1063824264");
return app.delete(collection);
})

View File

@@ -0,0 +1,20 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1063824264")
// update collection data
unmarshal({
"name": "badge_progress"
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1063824264")
// update collection data
unmarshal({
"name": "player_badge_progress"
}, collection)
return app.save(collection)
})

View File

@@ -0,0 +1,29 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// add field
collection.fields.addAt(10, new Field({
"hidden": false,
"id": "file538556518",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [],
"name": "glitch_logo",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// remove field
collection.fields.removeById("file538556518")
return app.save(collection)
})

View File

@@ -0,0 +1,42 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// update field
collection.fields.addAt(10, new Field({
"hidden": false,
"id": "file538556518",
"maxSelect": 1,
"maxSize": 6000000,
"mimeTypes": [],
"name": "glitch_logo",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// update field
collection.fields.addAt(10, new Field({
"hidden": false,
"id": "file538556518",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [],
"name": "glitch_logo",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
}))
return app.save(collection)
})

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

@@ -8,8 +8,6 @@
// You should NOT make any changes in this file as it will be overwritten. // You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { createServerRootRoute } from '@tanstack/react-start/server'
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as RefreshSessionRouteImport } from './routes/refresh-session' import { Route as RefreshSessionRouteImport } from './routes/refresh-session'
import { Route as LogoutRouteImport } from './routes/logout' import { Route as LogoutRouteImport } from './routes/logout'
@@ -21,28 +19,28 @@ import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings'
import { Route as AuthedAdminRouteImport } from './routes/_authed/admin' import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index' import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index' import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
import { Route as ApiTournamentsUploadLogoRouteImport } from './routes/api/tournaments/upload-logo'
import { Route as ApiTeamsUploadLogoRouteImport } from './routes/api/teams/upload-logo'
import { Route as ApiSpotifyTokenRouteImport } from './routes/api/spotify/token'
import { Route as ApiSpotifySearchRouteImport } from './routes/api/spotify/search'
import { Route as ApiSpotifyResumeRouteImport } from './routes/api/spotify/resume'
import { Route as ApiSpotifyPlaybackRouteImport } from './routes/api/spotify/playback'
import { Route as ApiSpotifyCaptureRouteImport } from './routes/api/spotify/capture'
import { Route as ApiSpotifyCallbackRouteImport } from './routes/api/spotify/callback'
import { Route as ApiEventsSplatRouteImport } from './routes/api/events.$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId' import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
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'
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id' import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams' import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
import { ServerRoute as ApiTeamsUploadLogoServerRouteImport } from './routes/api/teams/upload-logo'
import { ServerRoute as ApiSpotifyTokenServerRouteImport } from './routes/api/spotify/token'
import { ServerRoute as ApiSpotifySearchServerRouteImport } from './routes/api/spotify/search'
import { ServerRoute as ApiSpotifyResumeServerRouteImport } from './routes/api/spotify/resume'
import { ServerRoute as ApiSpotifyPlaybackServerRouteImport } from './routes/api/spotify/playback'
import { ServerRoute as ApiSpotifyCaptureServerRouteImport } from './routes/api/spotify/capture'
import { ServerRoute as ApiSpotifyCallbackServerRouteImport } from './routes/api/spotify/callback'
import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$'
import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$'
import { ServerRoute as ApiFilesCollectionRecordIdFileServerRouteImport } from './routes/api/files/$collection/$recordId/$file'
const rootServerRouteImport = createServerRootRoute()
const RefreshSessionRoute = RefreshSessionRouteImport.update({ const RefreshSessionRoute = RefreshSessionRouteImport.update({
id: '/refresh-session', id: '/refresh-session',
@@ -93,6 +91,57 @@ const AuthedAdminIndexRoute = AuthedAdminIndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const ApiTournamentsUploadLogoRoute =
ApiTournamentsUploadLogoRouteImport.update({
id: '/api/tournaments/upload-logo',
path: '/api/tournaments/upload-logo',
getParentRoute: () => rootRouteImport,
} as any)
const ApiTeamsUploadLogoRoute = ApiTeamsUploadLogoRouteImport.update({
id: '/api/teams/upload-logo',
path: '/api/teams/upload-logo',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSpotifyTokenRoute = ApiSpotifyTokenRouteImport.update({
id: '/api/spotify/token',
path: '/api/spotify/token',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSpotifySearchRoute = ApiSpotifySearchRouteImport.update({
id: '/api/spotify/search',
path: '/api/spotify/search',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSpotifyResumeRoute = ApiSpotifyResumeRouteImport.update({
id: '/api/spotify/resume',
path: '/api/spotify/resume',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSpotifyPlaybackRoute = ApiSpotifyPlaybackRouteImport.update({
id: '/api/spotify/playback',
path: '/api/spotify/playback',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSpotifyCaptureRoute = ApiSpotifyCaptureRouteImport.update({
id: '/api/spotify/capture',
path: '/api/spotify/capture',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSpotifyCallbackRoute = ApiSpotifyCallbackRouteImport.update({
id: '/api/spotify/callback',
path: '/api/spotify/callback',
getParentRoute: () => rootRouteImport,
} as any)
const ApiEventsSplatRoute = ApiEventsSplatRouteImport.update({
id: '/api/events/$',
path: '/api/events/$',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
id: '/api/auth/$',
path: '/api/auth/$',
getParentRoute: () => rootRouteImport,
} as any)
const AuthedTournamentsTournamentIdRoute = const AuthedTournamentsTournamentIdRoute =
AuthedTournamentsTournamentIdRouteImport.update({ AuthedTournamentsTournamentIdRouteImport.update({
id: '/tournaments/$tournamentId', id: '/tournaments/$tournamentId',
@@ -114,6 +163,16 @@ const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({
path: '/preview', path: '/preview',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const AuthedAdminBadgesRoute = AuthedAdminBadgesRouteImport.update({
id: '/badges',
path: '/badges',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminActivitiesRoute = AuthedAdminActivitiesRouteImport.update({
id: '/activities',
path: '/activities',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminTournamentsIndexRoute = const AuthedAdminTournamentsIndexRoute =
AuthedAdminTournamentsIndexRouteImport.update({ AuthedAdminTournamentsIndexRouteImport.update({
id: '/tournaments/', id: '/tournaments/',
@@ -132,6 +191,12 @@ const AuthedAdminTournamentsIdIndexRoute =
path: '/tournaments/$id/', path: '/tournaments/$id/',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const ApiFilesCollectionRecordIdFileRoute =
ApiFilesCollectionRecordIdFileRouteImport.update({
id: '/api/files/$collection/$recordId/$file',
path: '/api/files/$collection/$recordId/$file',
getParentRoute: () => rootRouteImport,
} as any)
const AuthedAdminTournamentsRunIdRoute = const AuthedAdminTournamentsRunIdRoute =
AuthedAdminTournamentsRunIdRouteImport.update({ AuthedAdminTournamentsRunIdRouteImport.update({
id: '/tournaments/run/$id', id: '/tournaments/run/$id',
@@ -144,66 +209,6 @@ const AuthedAdminTournamentsIdTeamsRoute =
path: '/tournaments/$id/teams', path: '/tournaments/$id/teams',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const ApiTournamentsUploadLogoServerRoute =
ApiTournamentsUploadLogoServerRouteImport.update({
id: '/api/tournaments/upload-logo',
path: '/api/tournaments/upload-logo',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiTeamsUploadLogoServerRoute =
ApiTeamsUploadLogoServerRouteImport.update({
id: '/api/teams/upload-logo',
path: '/api/teams/upload-logo',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiSpotifyTokenServerRoute = ApiSpotifyTokenServerRouteImport.update({
id: '/api/spotify/token',
path: '/api/spotify/token',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiSpotifySearchServerRoute = ApiSpotifySearchServerRouteImport.update({
id: '/api/spotify/search',
path: '/api/spotify/search',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiSpotifyResumeServerRoute = ApiSpotifyResumeServerRouteImport.update({
id: '/api/spotify/resume',
path: '/api/spotify/resume',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiSpotifyPlaybackServerRoute =
ApiSpotifyPlaybackServerRouteImport.update({
id: '/api/spotify/playback',
path: '/api/spotify/playback',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiSpotifyCaptureServerRoute = ApiSpotifyCaptureServerRouteImport.update({
id: '/api/spotify/capture',
path: '/api/spotify/capture',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiSpotifyCallbackServerRoute =
ApiSpotifyCallbackServerRouteImport.update({
id: '/api/spotify/callback',
path: '/api/spotify/callback',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiEventsSplatServerRoute = ApiEventsSplatServerRouteImport.update({
id: '/api/events/$',
path: '/api/events/$',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiAuthSplatServerRoute = ApiAuthSplatServerRouteImport.update({
id: '/api/auth/$',
path: '/api/auth/$',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiFilesCollectionRecordIdFileServerRoute =
ApiFilesCollectionRecordIdFileServerRouteImport.update({
id: '/api/files/$collection/$recordId/$file',
path: '/api/files/$collection/$recordId/$file',
getParentRoute: () => rootServerRouteImport,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/login': typeof LoginRoute '/login': typeof LoginRoute
@@ -213,16 +218,29 @@ export interface FileRoutesByFullPath {
'/settings': typeof AuthedSettingsRoute '/settings': typeof AuthedSettingsRoute
'/stats': typeof AuthedStatsRoute '/stats': typeof AuthedStatsRoute
'/': typeof AuthedIndexRoute '/': typeof AuthedIndexRoute
'/admin/activities': typeof AuthedAdminActivitiesRoute
'/admin/badges': typeof AuthedAdminBadgesRoute
'/admin/preview': typeof AuthedAdminPreviewRoute '/admin/preview': typeof AuthedAdminPreviewRoute
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute '/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute '/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
'/api/spotify/resume': typeof ApiSpotifyResumeRoute
'/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/admin/': typeof AuthedAdminIndexRoute '/admin/': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute '/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
@@ -232,16 +250,29 @@ export interface FileRoutesByTo {
'/settings': typeof AuthedSettingsRoute '/settings': typeof AuthedSettingsRoute
'/stats': typeof AuthedStatsRoute '/stats': typeof AuthedStatsRoute
'/': typeof AuthedIndexRoute '/': typeof AuthedIndexRoute
'/admin/activities': typeof AuthedAdminActivitiesRoute
'/admin/badges': typeof AuthedAdminBadgesRoute
'/admin/preview': typeof AuthedAdminPreviewRoute '/admin/preview': typeof AuthedAdminPreviewRoute
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute '/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute '/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
'/api/spotify/resume': typeof ApiSpotifyResumeRoute
'/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/admin': typeof AuthedAdminIndexRoute '/admin': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute '/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
@@ -254,16 +285,29 @@ export interface FileRoutesById {
'/_authed/settings': typeof AuthedSettingsRoute '/_authed/settings': typeof AuthedSettingsRoute
'/_authed/stats': typeof AuthedStatsRoute '/_authed/stats': typeof AuthedStatsRoute
'/_authed/': typeof AuthedIndexRoute '/_authed/': typeof AuthedIndexRoute
'/_authed/admin/activities': typeof AuthedAdminActivitiesRoute
'/_authed/admin/badges': typeof AuthedAdminBadgesRoute
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute '/_authed/admin/preview': typeof AuthedAdminPreviewRoute
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute '/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute '/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
'/api/spotify/resume': typeof ApiSpotifyResumeRoute
'/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/_authed/admin/': typeof AuthedAdminIndexRoute '/_authed/admin/': typeof AuthedAdminIndexRoute
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute '/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
@@ -276,16 +320,29 @@ export interface FileRouteTypes {
| '/settings' | '/settings'
| '/stats' | '/stats'
| '/' | '/'
| '/admin/activities'
| '/admin/badges'
| '/admin/preview' | '/admin/preview'
| '/profile/$playerId' | '/profile/$playerId'
| '/teams/$teamId' | '/teams/$teamId'
| '/tournaments/$tournamentId' | '/tournaments/$tournamentId'
| '/api/auth/$'
| '/api/events/$'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
| '/api/spotify/resume'
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/tournaments/upload-logo'
| '/admin/' | '/admin/'
| '/tournaments' | '/tournaments'
| '/tournaments/$id/bracket' | '/tournaments/$id/bracket'
| '/admin/tournaments' | '/admin/tournaments'
| '/admin/tournaments/$id/teams' | '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id' | '/admin/tournaments/$id'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
@@ -295,16 +352,29 @@ export interface FileRouteTypes {
| '/settings' | '/settings'
| '/stats' | '/stats'
| '/' | '/'
| '/admin/activities'
| '/admin/badges'
| '/admin/preview' | '/admin/preview'
| '/profile/$playerId' | '/profile/$playerId'
| '/teams/$teamId' | '/teams/$teamId'
| '/tournaments/$tournamentId' | '/tournaments/$tournamentId'
| '/api/auth/$'
| '/api/events/$'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
| '/api/spotify/resume'
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/tournaments/upload-logo'
| '/admin' | '/admin'
| '/tournaments' | '/tournaments'
| '/tournaments/$id/bracket' | '/tournaments/$id/bracket'
| '/admin/tournaments' | '/admin/tournaments'
| '/admin/tournaments/$id/teams' | '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id' | '/admin/tournaments/$id'
id: id:
| '__root__' | '__root__'
@@ -316,16 +386,29 @@ export interface FileRouteTypes {
| '/_authed/settings' | '/_authed/settings'
| '/_authed/stats' | '/_authed/stats'
| '/_authed/' | '/_authed/'
| '/_authed/admin/activities'
| '/_authed/admin/badges'
| '/_authed/admin/preview' | '/_authed/admin/preview'
| '/_authed/profile/$playerId' | '/_authed/profile/$playerId'
| '/_authed/teams/$teamId' | '/_authed/teams/$teamId'
| '/_authed/tournaments/$tournamentId' | '/_authed/tournaments/$tournamentId'
| '/api/auth/$'
| '/api/events/$'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
| '/api/spotify/resume'
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/tournaments/upload-logo'
| '/_authed/admin/' | '/_authed/admin/'
| '/_authed/tournaments/' | '/_authed/tournaments/'
| '/_authed/tournaments/$id/bracket' | '/_authed/tournaments/$id/bracket'
| '/_authed/admin/tournaments/' | '/_authed/admin/tournaments/'
| '/_authed/admin/tournaments/$id/teams' | '/_authed/admin/tournaments/$id/teams'
| '/_authed/admin/tournaments/run/$id' | '/_authed/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
| '/_authed/admin/tournaments/$id/' | '/_authed/admin/tournaments/$id/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
@@ -334,101 +417,17 @@ export interface RootRouteChildren {
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
LogoutRoute: typeof LogoutRoute LogoutRoute: typeof LogoutRoute
RefreshSessionRoute: typeof RefreshSessionRoute RefreshSessionRoute: typeof RefreshSessionRoute
} ApiAuthSplatRoute: typeof ApiAuthSplatRoute
export interface FileServerRoutesByFullPath { ApiEventsSplatRoute: typeof ApiEventsSplatRoute
'/api/auth/$': typeof ApiAuthSplatServerRoute ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
'/api/events/$': typeof ApiEventsSplatServerRoute ApiSpotifyCaptureRoute: typeof ApiSpotifyCaptureRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute ApiSpotifyPlaybackRoute: typeof ApiSpotifyPlaybackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute ApiSpotifyResumeRoute: typeof ApiSpotifyResumeRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute
'/api/spotify/search': typeof ApiSpotifySearchServerRoute ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
}
export interface FileServerRoutesByTo {
'/api/auth/$': typeof ApiAuthSplatServerRoute
'/api/events/$': typeof ApiEventsSplatServerRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
}
export interface FileServerRoutesById {
__root__: typeof rootServerRouteImport
'/api/auth/$': typeof ApiAuthSplatServerRoute
'/api/events/$': typeof ApiEventsSplatServerRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
}
export interface FileServerRouteTypes {
fileServerRoutesByFullPath: FileServerRoutesByFullPath
fullPaths:
| '/api/auth/$'
| '/api/events/$'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
| '/api/spotify/resume'
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/tournaments/upload-logo'
| '/api/files/$collection/$recordId/$file'
fileServerRoutesByTo: FileServerRoutesByTo
to:
| '/api/auth/$'
| '/api/events/$'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
| '/api/spotify/resume'
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/tournaments/upload-logo'
| '/api/files/$collection/$recordId/$file'
id:
| '__root__'
| '/api/auth/$'
| '/api/events/$'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
| '/api/spotify/resume'
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/tournaments/upload-logo'
| '/api/files/$collection/$recordId/$file'
fileServerRoutesById: FileServerRoutesById
}
export interface RootServerRouteChildren {
ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute
ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute
ApiSpotifyCallbackServerRoute: typeof ApiSpotifyCallbackServerRoute
ApiSpotifyCaptureServerRoute: typeof ApiSpotifyCaptureServerRoute
ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute
ApiSpotifyResumeServerRoute: typeof ApiSpotifyResumeServerRoute
ApiSpotifySearchServerRoute: typeof ApiSpotifySearchServerRoute
ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute
ApiTeamsUploadLogoServerRoute: typeof ApiTeamsUploadLogoServerRoute
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -503,6 +502,76 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminIndexRouteImport preLoaderRoute: typeof AuthedAdminIndexRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
'/api/tournaments/upload-logo': {
id: '/api/tournaments/upload-logo'
path: '/api/tournaments/upload-logo'
fullPath: '/api/tournaments/upload-logo'
preLoaderRoute: typeof ApiTournamentsUploadLogoRouteImport
parentRoute: typeof rootRouteImport
}
'/api/teams/upload-logo': {
id: '/api/teams/upload-logo'
path: '/api/teams/upload-logo'
fullPath: '/api/teams/upload-logo'
preLoaderRoute: typeof ApiTeamsUploadLogoRouteImport
parentRoute: typeof rootRouteImport
}
'/api/spotify/token': {
id: '/api/spotify/token'
path: '/api/spotify/token'
fullPath: '/api/spotify/token'
preLoaderRoute: typeof ApiSpotifyTokenRouteImport
parentRoute: typeof rootRouteImport
}
'/api/spotify/search': {
id: '/api/spotify/search'
path: '/api/spotify/search'
fullPath: '/api/spotify/search'
preLoaderRoute: typeof ApiSpotifySearchRouteImport
parentRoute: typeof rootRouteImport
}
'/api/spotify/resume': {
id: '/api/spotify/resume'
path: '/api/spotify/resume'
fullPath: '/api/spotify/resume'
preLoaderRoute: typeof ApiSpotifyResumeRouteImport
parentRoute: typeof rootRouteImport
}
'/api/spotify/playback': {
id: '/api/spotify/playback'
path: '/api/spotify/playback'
fullPath: '/api/spotify/playback'
preLoaderRoute: typeof ApiSpotifyPlaybackRouteImport
parentRoute: typeof rootRouteImport
}
'/api/spotify/capture': {
id: '/api/spotify/capture'
path: '/api/spotify/capture'
fullPath: '/api/spotify/capture'
preLoaderRoute: typeof ApiSpotifyCaptureRouteImport
parentRoute: typeof rootRouteImport
}
'/api/spotify/callback': {
id: '/api/spotify/callback'
path: '/api/spotify/callback'
fullPath: '/api/spotify/callback'
preLoaderRoute: typeof ApiSpotifyCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/api/events/$': {
id: '/api/events/$'
path: '/api/events/$'
fullPath: '/api/events/$'
preLoaderRoute: typeof ApiEventsSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/api/auth/$': {
id: '/api/auth/$'
path: '/api/auth/$'
fullPath: '/api/auth/$'
preLoaderRoute: typeof ApiAuthSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/_authed/tournaments/$tournamentId': { '/_authed/tournaments/$tournamentId': {
id: '/_authed/tournaments/$tournamentId' id: '/_authed/tournaments/$tournamentId'
path: '/tournaments/$tournamentId' path: '/tournaments/$tournamentId'
@@ -531,6 +600,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminPreviewRouteImport preLoaderRoute: typeof AuthedAdminPreviewRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
'/_authed/admin/badges': {
id: '/_authed/admin/badges'
path: '/badges'
fullPath: '/admin/badges'
preLoaderRoute: typeof AuthedAdminBadgesRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/admin/activities': {
id: '/_authed/admin/activities'
path: '/activities'
fullPath: '/admin/activities'
preLoaderRoute: typeof AuthedAdminActivitiesRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/admin/tournaments/': { '/_authed/admin/tournaments/': {
id: '/_authed/admin/tournaments/' id: '/_authed/admin/tournaments/'
path: '/tournaments' path: '/tournaments'
@@ -552,6 +635,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIdIndexRouteImport preLoaderRoute: typeof AuthedAdminTournamentsIdIndexRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
'/api/files/$collection/$recordId/$file': {
id: '/api/files/$collection/$recordId/$file'
path: '/api/files/$collection/$recordId/$file'
fullPath: '/api/files/$collection/$recordId/$file'
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport
parentRoute: typeof rootRouteImport
}
'/_authed/admin/tournaments/run/$id': { '/_authed/admin/tournaments/run/$id': {
id: '/_authed/admin/tournaments/run/$id' id: '/_authed/admin/tournaments/run/$id'
path: '/tournaments/run/$id' path: '/tournaments/run/$id'
@@ -568,89 +658,10 @@ declare module '@tanstack/react-router' {
} }
} }
} }
declare module '@tanstack/react-start/server' {
interface ServerFileRoutesByPath {
'/api/tournaments/upload-logo': {
id: '/api/tournaments/upload-logo'
path: '/api/tournaments/upload-logo'
fullPath: '/api/tournaments/upload-logo'
preLoaderRoute: typeof ApiTournamentsUploadLogoServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/teams/upload-logo': {
id: '/api/teams/upload-logo'
path: '/api/teams/upload-logo'
fullPath: '/api/teams/upload-logo'
preLoaderRoute: typeof ApiTeamsUploadLogoServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/spotify/token': {
id: '/api/spotify/token'
path: '/api/spotify/token'
fullPath: '/api/spotify/token'
preLoaderRoute: typeof ApiSpotifyTokenServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/spotify/search': {
id: '/api/spotify/search'
path: '/api/spotify/search'
fullPath: '/api/spotify/search'
preLoaderRoute: typeof ApiSpotifySearchServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/spotify/resume': {
id: '/api/spotify/resume'
path: '/api/spotify/resume'
fullPath: '/api/spotify/resume'
preLoaderRoute: typeof ApiSpotifyResumeServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/spotify/playback': {
id: '/api/spotify/playback'
path: '/api/spotify/playback'
fullPath: '/api/spotify/playback'
preLoaderRoute: typeof ApiSpotifyPlaybackServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/spotify/capture': {
id: '/api/spotify/capture'
path: '/api/spotify/capture'
fullPath: '/api/spotify/capture'
preLoaderRoute: typeof ApiSpotifyCaptureServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/spotify/callback': {
id: '/api/spotify/callback'
path: '/api/spotify/callback'
fullPath: '/api/spotify/callback'
preLoaderRoute: typeof ApiSpotifyCallbackServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/events/$': {
id: '/api/events/$'
path: '/api/events/$'
fullPath: '/api/events/$'
preLoaderRoute: typeof ApiEventsSplatServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/auth/$': {
id: '/api/auth/$'
path: '/api/auth/$'
fullPath: '/api/auth/$'
preLoaderRoute: typeof ApiAuthSplatServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/files/$collection/$recordId/$file': {
id: '/api/files/$collection/$recordId/$file'
path: '/api/files/$collection/$recordId/$file'
fullPath: '/api/files/$collection/$recordId/$file'
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileServerRouteImport
parentRoute: typeof rootServerRouteImport
}
}
}
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
@@ -660,6 +671,8 @@ interface AuthedAdminRouteChildren {
} }
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = { const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
AuthedAdminActivitiesRoute: AuthedAdminActivitiesRoute,
AuthedAdminBadgesRoute: AuthedAdminBadgesRoute,
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute, AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
AuthedAdminIndexRoute: AuthedAdminIndexRoute, AuthedAdminIndexRoute: AuthedAdminIndexRoute,
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute, AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
@@ -704,24 +717,27 @@ const rootRouteChildren: RootRouteChildren = {
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
LogoutRoute: LogoutRoute, LogoutRoute: LogoutRoute,
RefreshSessionRoute: RefreshSessionRoute, RefreshSessionRoute: RefreshSessionRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiEventsSplatRoute: ApiEventsSplatRoute,
ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute,
ApiSpotifyCaptureRoute: ApiSpotifyCaptureRoute,
ApiSpotifyPlaybackRoute: ApiSpotifyPlaybackRoute,
ApiSpotifyResumeRoute: ApiSpotifyResumeRoute,
ApiSpotifySearchRoute: ApiSpotifySearchRoute,
ApiSpotifyTokenRoute: ApiSpotifyTokenRoute,
ApiTeamsUploadLogoRoute: ApiTeamsUploadLogoRoute,
ApiTournamentsUploadLogoRoute: ApiTournamentsUploadLogoRoute,
ApiFilesCollectionRecordIdFileRoute: ApiFilesCollectionRecordIdFileRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>() ._addFileTypes<FileRouteTypes>()
const rootServerRouteChildren: RootServerRouteChildren = {
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute, import type { getRouter } from './router.tsx'
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute, import type { createStart } from '@tanstack/react-start'
ApiSpotifyCallbackServerRoute: ApiSpotifyCallbackServerRoute, declare module '@tanstack/react-start' {
ApiSpotifyCaptureServerRoute: ApiSpotifyCaptureServerRoute, interface Register {
ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute, ssr: true
ApiSpotifyResumeServerRoute: ApiSpotifyResumeServerRoute, router: Awaited<ReturnType<typeof getRouter>>
ApiSpotifySearchServerRoute: ApiSpotifySearchServerRoute, }
ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute,
ApiTeamsUploadLogoServerRoute: ApiTeamsUploadLogoServerRoute,
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
ApiFilesCollectionRecordIdFileServerRoute:
ApiFilesCollectionRecordIdFileServerRoute,
} }
export const serverRouteTree = rootServerRouteImport
._addFileChildren(rootServerRouteChildren)
._addFileTypes<FileServerRouteTypes>()

View File

@@ -1,11 +1,11 @@
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";
export function createRouter() { export function getRouter() {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
@@ -18,8 +18,7 @@ export function createRouter() {
}, },
}); });
return routerWithQueryClient( const router = createTanStackRouter({
createTanStackRouter({
routeTree, routeTree,
context: { context: {
queryClient, queryClient,
@@ -33,13 +32,18 @@ export function createRouter() {
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" {
interface Register { interface Register {
router: ReturnType<typeof createRouter>; router: ReturnType<typeof getRouter>;
} }
} }

View File

@@ -37,7 +37,7 @@ export const Route = createRootRouteWithContext<{
{ {
name: "viewport", name: "viewport",
content: content:
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=overlays-content", "width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content",
}, },
], ],
links: [ links: [
@@ -63,7 +63,17 @@ export const Route = createRootRouteWithContext<{
{ rel: 'stylesheet', href: mantineCssUrl }, { rel: 'stylesheet', href: mantineCssUrl },
{ rel: 'stylesheet', href: mantineCarouselCssUrl }, { rel: 'stylesheet', href: mantineCarouselCssUrl },
{ rel: 'stylesheet', href: mantineDatesCssUrl }, { rel: 'stylesheet', href: mantineDatesCssUrl },
{ rel: 'stylesheet', href: mantineTiptapCssUrl } { rel: 'stylesheet', href: mantineTiptapCssUrl },
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=League+Spartan:wght@100..900&display=swap",
}
], ],
}), }),
errorComponent: (props) => { errorComponent: (props) => {
@@ -83,12 +93,20 @@ export const Route = createRootRouteWithContext<{
return {}; return {};
} }
if (location.pathname === '/login' || location.pathname === '/logout') {
return {};
}
try {
// https://github.com/TanStack/router/discussions/3531 // https://github.com/TanStack/router/discussions/3531
const auth = await ensureServerQueryData( const auth = await ensureServerQueryData(
context.queryClient, context.queryClient,
playerQueries.auth() playerQueries.auth()
); );
return { auth }; return { auth };
} catch (error) {
return {};
}
}, },
pendingComponent: () => <Providers><FullScreenLoader /></Providers>, pendingComponent: () => <Providers><FullScreenLoader /></Providers>,
}); });
@@ -114,8 +132,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
{...mantineHtmlProps} {...mantineHtmlProps}
style={{ style={{
overflowX: "hidden", overflowX: "hidden",
overflowY: "hidden", height: "100%",
position: "fixed",
width: "100%", width: "100%",
}} }}
> >
@@ -127,9 +144,10 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<body <body
style={{ style={{
overflowX: "hidden", overflowX: "hidden",
overflowY: "hidden", height: "100%",
position: "fixed",
width: "100%", width: "100%",
margin: 0,
padding: 0,
}} }}
> >
<div className="app">{children}</div> <div className="app">{children}</div>

View File

@@ -0,0 +1,24 @@
import { createFileRoute } from "@tanstack/react-router";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { ActivitiesTable, activityQueries } from "@/features/activities";
export const Route = createFileRoute("/_authed/admin/activities")({
component: Stats,
beforeLoad: ({ context }) => {
const queryClient = context.queryClient;
prefetchServerQuery(queryClient, activityQueries.search());
},
loader: () => ({
withPadding: false,
fullWidth: true,
header: {
title: "Activities",
withBackButton: true,
},
refresh: [activityQueries.search().queryKey],
}),
});
function Stats() {
return <ActivitiesTable />;
}

View File

@@ -0,0 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import AwardBadges from "@/features/admin/components/award-badges";
export const Route = createFileRoute("/_authed/admin/badges")({
component: RouteComponent,
});
function RouteComponent() {
return <AwardBadges />;
}

View File

@@ -1,3 +1,4 @@
import { badgeKeys, badgeQueries } from "@/features/badges/queries";
import Profile from "@/features/players/components/profile"; import Profile from "@/features/players/components/profile";
import HeaderSkeleton from "@/features/players/components/profile/header-skeleton"; import HeaderSkeleton from "@/features/players/components/profile/header-skeleton";
import ProfileSkeleton from "@/features/players/components/profile/skeleton"; import ProfileSkeleton from "@/features/players/components/profile/skeleton";
@@ -24,6 +25,14 @@ export const Route = createFileRoute("/_authed/profile/$playerId")({
queryClient, queryClient,
playerQueries.matches(params.playerId) playerQueries.matches(params.playerId)
), ),
prefetchServerQuery(
queryClient,
playerQueries.stats(params.playerId)
),
prefetchServerQuery(
queryClient,
badgeQueries.playerBadges(params.playerId)
),
]); ]);
}, },
loader: ({ params, context }) => ({ loader: ({ params, context }) => ({
@@ -34,7 +43,7 @@ export const Route = createFileRoute("/_authed/profile/$playerId")({
context?.auth.user.id === params.playerId ? "/settings" : undefined, context?.auth.user.id === params.playerId ? "/settings" : undefined,
}, },
withPadding: false, withPadding: false,
refresh: [playerKeys.details(params.playerId), playerKeys.matches(params.playerId), playerKeys.stats(params.playerId)], refresh: [playerKeys.details(params.playerId), playerKeys.matches(params.playerId), playerKeys.stats(params.playerId), badgeKeys.playerBadges(params.playerId)],
}), }),
component: () => { component: () => {
const { playerId } = Route.useParams(); const { playerId } = Route.useParams();

View File

@@ -1,5 +1,5 @@
// API file that handles all supertokens auth routes // API file that handles all supertokens auth routes
import { createServerFileRoute } from '@tanstack/react-start/server'; import { createFileRoute } from '@tanstack/react-router';
import { handleAuthAPIRequest } from 'supertokens-node/custom' import { handleAuthAPIRequest } from 'supertokens-node/custom'
import { ensureSuperTokensBackend } from '@/lib/supertokens/server' import { ensureSuperTokensBackend } from '@/lib/supertokens/server'
@@ -12,7 +12,9 @@ const handleRequest = async ({ request }: {request: Request}) => {
console.log("Handling auth request:", request.method, request.url); console.log("Handling auth request:", request.method, request.url);
return superTokensHandler(request); return superTokensHandler(request);
}; };
export const ServerRoute = createServerFileRoute('/api/auth/$').methods({ export const Route = createFileRoute('/api/auth/$')({
server: {
handlers: {
GET: handleRequest, GET: handleRequest,
POST: handleRequest, POST: handleRequest,
PUT: handleRequest, PUT: handleRequest,
@@ -20,4 +22,6 @@ export const ServerRoute = createServerFileRoute('/api/auth/$').methods({
PATCH: handleRequest, PATCH: handleRequest,
OPTIONS: handleRequest, OPTIONS: handleRequest,
HEAD: handleRequest, HEAD: handleRequest,
}
}
}) })

View File

@@ -1,21 +1,31 @@
import { createServerFileRoute } from "@tanstack/react-start/server"; import { createFileRoute } from "@tanstack/react-router";
import { serverEvents, type ServerEvent } from "@/lib/events/emitter"; 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";
export const ServerRoute = createServerFileRoute("/api/events/$").middleware([superTokensRequestMiddleware]).methods({ let activeConnections = 0;
GET: ({ request, context }) => {
logger.info('ServerEvents | New connection', context?.userAuthId);
export const Route = createFileRoute("/api/events/$")({
server: {
middleware: [superTokensRequestMiddleware],
handlers: {
GET: ({ request }) => {
activeConnections++;
const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
logger.info(`ServerEvents | New connection ${connectionId}. Active: ${activeConnections}`);
const stream = new ReadableStream({ const stream = new ReadableStream({
start(controller) { start(controller) {
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`; const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
controller.enqueue(new TextEncoder().encode(connectMessage)); controller.enqueue(new TextEncoder().encode(connectMessage));
const handleEvent = (event: ServerEvent) => { const handleEvent = (event: ServerEvent) => {
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);
@@ -28,27 +38,37 @@ export const ServerRoute = createServerFileRoute("/api/events/$").middleware([su
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;
}, },
}); });
@@ -56,11 +76,17 @@ export const ServerRoute = createServerFileRoute("/api/events/$").middleware([su
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

@@ -1,84 +1,87 @@
import { createServerFileRoute } from "@tanstack/react-start/server"; import { createFileRoute } from "@tanstack/react-router";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
export const ServerRoute = createServerFileRoute("/api/files/$collection/$recordId/$file").methods({ export const Route = createFileRoute(
"/api/files/$collection/$recordId/$file"
)({
server: {
handlers: {
GET: async ({ params, request }) => { GET: async ({ params, request }) => {
try { try {
const { collection, recordId, file } = params; const { collection, recordId, file } = params;
const pocketbaseUrl = process.env.POCKETBASE_URL || 'http://127.0.0.1:8090'; const pocketbaseUrl =
process.env.POCKETBASE_URL || "http://127.0.0.1:8090";
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`; const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
logger.info('File proxy', {
collection,
recordId,
file,
targetUrl: fileUrl
});
const response = await fetch(fileUrl, { const response = await fetch(fileUrl, {
method: 'GET', method: "GET",
headers: { headers: {
...(request.headers.get('range') && { 'Range': request.headers.get('range')! }), ...(request.headers.get("range") && {
...(request.headers.get('if-none-match') && { 'If-None-Match': request.headers.get('if-none-match')! }), Range: request.headers.get("range")!,
...(request.headers.get('if-modified-since') && { 'If-Modified-Since': request.headers.get('if-modified-since')! }), }),
...(request.headers.get("if-none-match") && {
"If-None-Match": request.headers.get("if-none-match")!,
}),
...(request.headers.get("if-modified-since") && {
"If-Modified-Since": request.headers.get("if-modified-since")!,
}),
}, },
}); });
if (!response.ok) { if (!response.ok) {
logger.error('PocketBase file request failed', { logger.error("PocketBase file request failed", {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
url: fileUrl url: fileUrl,
}); });
if (response.status === 404) { if (response.status === 404) {
return new Response('File not found', { status: 404 }); return new Response("File not found", { status: 404 });
} }
return new Response(`PocketBase error: ${response.statusText}`, { return new Response(`PocketBase error: ${response.statusText}`, {
status: response.status status: response.status,
}); });
} }
const body = response.body; const body = response.body;
const responseHeaders = new Headers(); const responseHeaders = new Headers();
const headers = [ const headers = [
'content-type', "content-type",
'content-length', "content-length",
'content-disposition', "content-disposition",
'etag', "etag",
'last-modified', "last-modified",
'cache-control', "cache-control",
'accept-ranges', "accept-ranges",
'content-range' "content-range",
]; ];
headers.forEach(header => { headers.forEach((header) => {
const value = response.headers.get(header); const value = response.headers.get(header);
if (value) { if (value) {
responseHeaders.set(header, value); responseHeaders.set(header, value);
} }
}); });
responseHeaders.set('Access-Control-Allow-Origin', '*'); responseHeaders.set("Access-Control-Allow-Origin", "*");
responseHeaders.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS'); responseHeaders.set(
responseHeaders.set('Access-Control-Allow-Headers', 'Range, If-None-Match, If-Modified-Since'); "Access-Control-Allow-Methods",
"GET, HEAD, OPTIONS"
logger.info('File proxy response', { );
status: response.status, responseHeaders.set(
contentType: response.headers.get('content-type'), "Access-Control-Allow-Headers",
contentLength: response.headers.get('content-length') "Range, If-None-Match, If-Modified-Since"
}); );
return new Response(body, { return new Response(body, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
headers: responseHeaders headers: responseHeaders,
}); });
} catch (error) { } catch (error) {
logger.error('File proxy error', error); logger.error("File proxy error", error);
return new Response('Internal server error', { status: 500 }); return new Response("Internal server error", { status: 500 });
} }
}, },
@@ -86,10 +89,12 @@ export const ServerRoute = createServerFileRoute("/api/files/$collection/$record
return new Response(null, { return new Response(null, {
status: 200, status: 200,
headers: { headers: {
'Access-Control-Allow-Origin': '*', "Access-Control-Allow-Origin": "*",
'Access-Control-Allow-Methods': 'GET, OPTIONS', "Access-Control-Allow-Methods": "GET, OPTIONS",
'Access-Control-Max-Age': '86400', "Access-Control-Max-Age": "86400",
} },
}); });
} },
},
},
}); });

View File

@@ -1,127 +1,145 @@
import { createServerFileRoute } from '@tanstack/react-start/server' 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 = process.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 ServerRoute = createServerFileRoute('/api/spotify/callback').methods({ export const Route = createFileRoute("/api/spotify/callback")({
server: {
handlers: {
GET: async ({ request }: { request: Request }) => { GET: async ({ request }: { request: Request }) => {
const getReturnPath = (state: string | null): string => { const getReturnPath = (state: string | null): string => {
if (!state) return '/'; if (!state) return "/";
try { try {
const decodedState = JSON.parse(atob(state)); const decodedState = JSON.parse(atob(state));
return decodedState.returnPath || '/'; return decodedState.returnPath || "/";
} catch { } catch {
return '/'; return "/";
} }
}; };
try { try {
const url = new URL(request.url) const url = new URL(request.url);
const code = url.searchParams.get('code') const code = url.searchParams.get("code");
const state = url.searchParams.get('state') const state = url.searchParams.get("state");
const error = url.searchParams.get('error') const error = url.searchParams.get("error");
const returnPath = getReturnPath(state); const returnPath = getReturnPath(state);
if (error) { if (error) {
console.error('Spotify OAuth error:', error) console.error("Spotify OAuth error:", error);
return new Response(null, { return new Response(null, {
status: 302, status: 302,
headers: { headers: {
'Location': returnPath + '?spotify_error=' + encodeURIComponent(error), Location:
returnPath + "?spotify_error=" + encodeURIComponent(error),
}, },
}) });
} }
if (!code || !state) { if (!code || !state) {
console.error('Missing code or state:', { code: !!code, state: !!state }) console.error("Missing code or state:", {
code: !!code,
state: !!state,
});
return new Response(null, { return new Response(null, {
status: 302, status: 302,
headers: { headers: {
'Location': returnPath + '?spotify_error=missing_code_or_state', Location: returnPath + "?spotify_error=missing_code_or_state",
}, },
}) });
} }
console.log('Token exchange attempt:', { console.log("Token exchange attempt:", {
client_id: SPOTIFY_CLIENT_ID, client_id: SPOTIFY_CLIENT_ID,
redirect_uri: SPOTIFY_REDIRECT_URI, redirect_uri: SPOTIFY_REDIRECT_URI,
has_code: !!code, has_code: !!code,
has_state: !!state, has_state: !!state,
}) });
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', { const tokenResponse = await fetch(
method: 'POST', "https://accounts.spotify.com/api/token",
{
method: "POST",
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', "Content-Type": "application/x-www-form-urlencoded",
'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`, Authorization: `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString("base64")}`,
}, },
body: new URLSearchParams({ body: new URLSearchParams({
grant_type: 'authorization_code', grant_type: "authorization_code",
code, code,
redirect_uri: SPOTIFY_REDIRECT_URI, redirect_uri: SPOTIFY_REDIRECT_URI,
}), }),
}) }
);
if (!tokenResponse.ok) { if (!tokenResponse.ok) {
const errorText = await tokenResponse.text() const errorText = await tokenResponse.text();
console.error('Token exchange error:', { console.error("Token exchange error:", {
status: tokenResponse.status, status: tokenResponse.status,
statusText: tokenResponse.statusText, statusText: tokenResponse.statusText,
body: errorText, body: errorText,
redirect_uri: SPOTIFY_REDIRECT_URI, redirect_uri: SPOTIFY_REDIRECT_URI,
}) });
const errorParam = encodeURIComponent(`${tokenResponse.status}: ${errorText}`) const errorParam = encodeURIComponent(
`${tokenResponse.status}: ${errorText}`
);
return new Response(null, { return new Response(null, {
status: 302, status: 302,
headers: { headers: {
'Location': `${returnPath}?spotify_error=token_exchange_failed&details=${errorParam}`, Location: `${returnPath}?spotify_error=token_exchange_failed&details=${errorParam}`,
}, },
}) });
} }
const tokens = await tokenResponse.json() const tokens = await tokenResponse.json();
console.log('Token exchange successful:', { console.log("Token exchange successful:", {
has_access_token: !!tokens.access_token, has_access_token: !!tokens.access_token,
has_refresh_token: !!tokens.refresh_token, has_refresh_token: !!tokens.refresh_token,
expires_in: tokens.expires_in, expires_in: tokens.expires_in,
}) });
console.log('Decoded return path:', returnPath); console.log("Decoded return path:", returnPath);
const response = new Response(null, { const response = new Response(null, {
status: 302, status: 302,
headers: { headers: {
'Location': returnPath + '?spotify_auth=success', Location: returnPath + "?spotify_auth=success",
}, },
}) });
const isSecure = process.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('Set-Cookie', `spotify_access_token=${tokens.access_token}; ${cookieOptions}`) response.headers.append(
"Set-Cookie",
`spotify_access_token=${tokens.access_token}; ${cookieOptions}`
);
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('Set-Cookie', `spotify_refresh_token=${tokens.refresh_token}; ${refreshCookieOptions}`) response.headers.append(
"Set-Cookie",
`spotify_refresh_token=${tokens.refresh_token}; ${refreshCookieOptions}`
);
} }
return response return response;
} catch (error) { } catch (error) {
console.error('Spotify callback error:', error) console.error("Spotify callback error:", error);
const url = new URL(request.url); const url = new URL(request.url);
const state = url.searchParams.get('state'); const state = url.searchParams.get("state");
const returnPath = getReturnPath(state); const returnPath = getReturnPath(state);
return new Response(null, { return new Response(null, {
status: 302, status: 302,
headers: { headers: {
'Location': returnPath + '?spotify_error=callback_failed', Location: returnPath + "?spotify_error=callback_failed",
}, },
}) });
} }
}, },
}) },
},
});

View File

@@ -1,59 +1,60 @@
import { createServerFileRoute } from '@tanstack/react-start/server' import { createFileRoute } from "@tanstack/react-router";
import { SpotifyWebApiClient } from '@/lib/spotify/client' import { SpotifyWebApiClient } from "@/lib/spotify/client";
import type { SpotifyPlaybackSnapshot } from '@/lib/spotify/types' import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
export const ServerRoute = createServerFileRoute('/api/spotify/capture').methods({ export const Route = createFileRoute("/api/spotify/capture")({
server: {
handlers: {
POST: async ({ request }: { request: Request }) => { POST: async ({ request }: { request: Request }) => {
try { try {
// Get access token from cookies const cookies = request.headers.get("Cookie") || "";
const cookies = request.headers.get('Cookie') || '' const accessTokenMatch = cookies.match(
const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/) /spotify_access_token=([^;]+)/
);
if (!accessTokenMatch) { if (!accessTokenMatch) {
return new Response( return new Response(
JSON.stringify({ error: 'No access token found' }), JSON.stringify({ error: "No access token found" }),
{ {
status: 401, status: 401,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
} }
) );
} }
const accessToken = decodeURIComponent(accessTokenMatch[1]) const accessToken = decodeURIComponent(accessTokenMatch[1]);
const spotifyClient = new SpotifyWebApiClient(accessToken) const spotifyClient = new SpotifyWebApiClient(accessToken);
// Create a snapshot of the current playback state const snapshot = await spotifyClient.createPlaybackSnapshot();
const snapshot = await spotifyClient.createPlaybackSnapshot()
if (!snapshot) { if (!snapshot) {
return new Response( return new Response(
JSON.stringify({ error: 'No active playback to capture' }), JSON.stringify({ error: "No active playback to capture" }),
{ {
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
} }
) );
} }
return new Response( return new Response(JSON.stringify({ snapshot }), {
JSON.stringify({ snapshot }),
{
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
} });
)
} catch (error) { } catch (error) {
console.error('Spotify capture error:', error) console.error("Spotify capture error:", error);
const errorMessage = error instanceof Error ? error.message : 'Failed to capture playback state' const errorMessage =
error instanceof Error
? error.message
: "Failed to capture playback state";
return new Response( return new Response(JSON.stringify({ error: errorMessage }), {
JSON.stringify({ error: errorMessage }),
{
status: 500, status: 500,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
} });
)
} }
}, },
}) },
},
});

View File

@@ -1,202 +1,203 @@
import { createServerFileRoute } from '@tanstack/react-start/server' import { createFileRoute } from "@tanstack/react-router";
import { SpotifyWebApiClient } from '@/lib/spotify/client' import { SpotifyWebApiClient } from "@/lib/spotify/client";
function getAccessTokenFromCookies(request: Request): string | null { function getAccessTokenFromCookies(request: Request): string | null {
const cookieHeader = request.headers.get('cookie') const cookieHeader = request.headers.get("cookie");
if (!cookieHeader) return null if (!cookieHeader) return null;
const cookies = Object.fromEntries( const cookies = Object.fromEntries(
cookieHeader.split('; ').map(c => c.split('=')) cookieHeader.split("; ").map((c) => c.split("="))
) );
return cookies.spotify_access_token || null return cookies.spotify_access_token || null;
} }
export const ServerRoute = createServerFileRoute('/api/spotify/playback').methods({ export const Route = createFileRoute("/api/spotify/playback")({
server: {
handlers: {
POST: async ({ request }: { request: Request }) => { POST: async ({ request }: { request: Request }) => {
try { try {
const accessToken = getAccessTokenFromCookies(request) const accessToken = getAccessTokenFromCookies(request);
if (!accessToken) { if (!accessToken) {
return new Response( return new Response(
JSON.stringify({ error: 'No access token found' }), JSON.stringify({ error: "No access token found" }),
{ {
status: 401, status: 401,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} }
const body = await request.json() const body = await request.json();
const { action, deviceId, volumePercent, trackId, positionMs } = body const { action, deviceId, volumePercent, trackId, positionMs } = body;
const spotifyClient = new SpotifyWebApiClient(accessToken) const spotifyClient = new SpotifyWebApiClient(accessToken);
switch (action) { switch (action) {
case 'play': case "play":
await spotifyClient.play(deviceId) await spotifyClient.play(deviceId);
break break;
case 'playTrack': case "playTrack":
if (!trackId) { if (!trackId) {
return new Response( return new Response(
JSON.stringify({ error: 'trackId is required for playTrack action' }), JSON.stringify({
error: "trackId is required for playTrack action",
}),
{ {
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} }
await spotifyClient.playTrack(trackId, deviceId, positionMs) await spotifyClient.playTrack(trackId, deviceId, positionMs);
break break;
case 'pause': case "pause":
await spotifyClient.pause() await spotifyClient.pause();
break break;
case 'next': case "next":
await spotifyClient.skipToNext() await spotifyClient.skipToNext();
break break;
case 'previous': case "previous":
await spotifyClient.skipToPrevious() await spotifyClient.skipToPrevious();
break break;
case 'volume': case "volume":
if (typeof volumePercent !== 'number') { if (typeof volumePercent !== "number") {
return new Response( return new Response(
JSON.stringify({ error: 'volumePercent must be a number' }), JSON.stringify({ error: "volumePercent must be a number" }),
{ {
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} }
await spotifyClient.setVolume(volumePercent) await spotifyClient.setVolume(volumePercent);
break break;
case 'transfer': case "transfer":
if (!deviceId) { if (!deviceId) {
return new Response( return new Response(
JSON.stringify({ error: 'deviceId is required for transfer action' }), JSON.stringify({
error: "deviceId is required for transfer action",
}),
{ {
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} }
await spotifyClient.transferPlayback(deviceId) await spotifyClient.transferPlayback(deviceId);
break break;
default: default:
return new Response( return new Response(JSON.stringify({ error: "Invalid action" }), {
JSON.stringify({ error: 'Invalid action' }),
{
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} });
)
} }
return new Response( return new Response(JSON.stringify({ success: true }), {
JSON.stringify({ success: true }),
{
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} });
)
} catch (error) { } catch (error) {
console.error('Playback control error:', error) console.error("Playback control error:", error);
if (error instanceof Error) { if (error instanceof Error) {
if (error.message.includes('NO_ACTIVE_DEVICE')) { if (error.message.includes("NO_ACTIVE_DEVICE")) {
return new Response( return new Response(
JSON.stringify({ error: 'No active device found. Please select a device first.' }), JSON.stringify({
error:
"No active device found. Please select a device first.",
}),
{ {
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} }
if (error.message.includes('PREMIUM_REQUIRED')) { if (error.message.includes("PREMIUM_REQUIRED")) {
return new Response( return new Response(
JSON.stringify({ error: 'Spotify Premium is required for playback control.' }), JSON.stringify({
error: "Spotify Premium is required for playback control.",
}),
{ {
status: 403, status: 403,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} }
console.error('Full error details:', { console.error("Full error details:", {
message: error.message, message: error.message,
stack: error.stack, stack: error.stack,
name: error.name, name: error.name,
}) });
} }
return new Response( return new Response(
JSON.stringify({ error: 'Playback control failed', details: error instanceof Error ? error.message : 'Unknown error' }), JSON.stringify({
error: "Playback control failed",
details: error instanceof Error ? error.message : "Unknown error",
}),
{ {
status: 500, status: 500,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} }
}, },
GET: async ({ request }: { request: Request }) => { GET: async ({ request }: { request: Request }) => {
try { try {
const accessToken = getAccessTokenFromCookies(request) const accessToken = getAccessTokenFromCookies(request);
if (!accessToken) { if (!accessToken) {
return new Response( return new Response(
JSON.stringify({ error: 'No access token found' }), JSON.stringify({ error: "No access token found" }),
{ {
status: 401, status: 401,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} }
const url = new URL(request.url) const url = new URL(request.url);
const type = url.searchParams.get('type') const type = url.searchParams.get("type");
const spotifyClient = new SpotifyWebApiClient(accessToken) const spotifyClient = new SpotifyWebApiClient(accessToken);
if (type === 'devices') { if (type === "devices") {
const devices = await spotifyClient.getDevices() const devices = await spotifyClient.getDevices();
return new Response( return new Response(JSON.stringify({ devices }), {
JSON.stringify({ devices }),
{
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} });
) } else if (type === "state") {
} else if (type === 'state') { const playbackState = await spotifyClient.getPlaybackState();
const playbackState = await spotifyClient.getPlaybackState() return new Response(JSON.stringify({ playbackState }), {
return new Response(
JSON.stringify({ playbackState }),
{
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} });
)
} else { } else {
const [devices, playbackState] = await Promise.all([ const [devices, playbackState] = await Promise.all([
spotifyClient.getDevices(), spotifyClient.getDevices(),
spotifyClient.getPlaybackState(), spotifyClient.getPlaybackState(),
]) ]);
return new Response( return new Response(JSON.stringify({ devices, playbackState }), {
JSON.stringify({ devices, playbackState }),
{
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} });
)
} }
} catch (error) { } catch (error) {
console.error('Get playback data error:', error) console.error("Get playback data error:", error);
return new Response( return new Response(
JSON.stringify({ error: 'Failed to get playback data' }), JSON.stringify({ error: "Failed to get playback data" }),
{ {
status: 500, status: 500,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} }
}, },
}) },
},
});

View File

@@ -1,72 +1,71 @@
import { createServerFileRoute } from '@tanstack/react-start/server' import { createFileRoute } from "@tanstack/react-router";
import { SpotifyWebApiClient } from '@/lib/spotify/client' import { SpotifyWebApiClient } from "@/lib/spotify/client";
import type { SpotifyPlaybackSnapshot } from '@/lib/spotify/types' import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
export const ServerRoute = createServerFileRoute('/api/spotify/resume').methods({ export const Route = createFileRoute("/api/spotify/resume")({
server: {
handlers: {
POST: async ({ request }: { request: Request }) => { POST: async ({ request }: { request: Request }) => {
try { try {
// Get access token from cookies const cookies = request.headers.get("Cookie") || "";
const cookies = request.headers.get('Cookie') || '' const accessTokenMatch = cookies.match(
const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/) /spotify_access_token=([^;]+)/
);
if (!accessTokenMatch) { if (!accessTokenMatch) {
return new Response( return new Response(
JSON.stringify({ error: 'No access token found' }), JSON.stringify({ error: "No access token found" }),
{ {
status: 401, status: 401,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
} }
) );
} }
const accessToken = decodeURIComponent(accessTokenMatch[1]) const accessToken = decodeURIComponent(accessTokenMatch[1]);
const spotifyClient = new SpotifyWebApiClient(accessToken) const spotifyClient = new SpotifyWebApiClient(accessToken);
// Parse the request body to get the snapshot const body = await request.json();
const body = await request.json() const { snapshot } = body as { snapshot: SpotifyPlaybackSnapshot };
const { snapshot } = body as { snapshot: SpotifyPlaybackSnapshot }
if (!snapshot) { if (!snapshot) {
return new Response( return new Response(
JSON.stringify({ error: 'No snapshot provided' }), JSON.stringify({ error: "No snapshot provided" }),
{ {
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
} }
) );
} }
// Restore the playback state from the snapshot await spotifyClient.restorePlaybackSnapshot(snapshot);
await spotifyClient.restorePlaybackSnapshot(snapshot)
return new Response( return new Response(JSON.stringify({ success: true }), {
JSON.stringify({ success: true }),
{
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
} });
)
} catch (error) { } catch (error) {
console.error('Spotify resume error:', error) console.error("Spotify resume error:", error);
let errorMessage = 'Failed to resume playback state' let errorMessage = "Failed to resume playback state";
// Handle common Spotify Premium requirement error
if (error instanceof Error) { if (error instanceof Error) {
if (error.message.includes('Premium') || error.message.includes('403')) { if (
errorMessage = 'Spotify Premium required for playback control' error.message.includes("Premium") ||
error.message.includes("403")
) {
errorMessage = "Spotify premium required";
} else { } else {
errorMessage = error.message errorMessage = error.message;
} }
} }
return new Response( return new Response(JSON.stringify({ error: errorMessage }), {
JSON.stringify({ error: errorMessage }),
{
status: 500, status: 500,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
} });
)
} }
}, },
}) },
},
});

View File

@@ -1,81 +1,87 @@
import { createServerFileRoute } from '@tanstack/react-start/server' import { createFileRoute } from "@tanstack/react-router";
// Function to get Client Credentials access token
async function getClientCredentialsToken(): Promise<string> { async function getClientCredentialsToken(): Promise<string> {
const clientId = process.env.VITE_SPOTIFY_CLIENT_ID const clientId = process.env.VITE_SPOTIFY_CLIENT_ID;
const clientSecret = process.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");
} }
const response = await fetch('https://accounts.spotify.com/api/token', { const response = await fetch("https://accounts.spotify.com/api/token", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', "Content-Type": "application/x-www-form-urlencoded",
'Authorization': `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`, Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
}, },
body: 'grant_type=client_credentials', body: "grant_type=client_credentials",
}) });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to get Spotify access token') throw new Error("Failed to get Spotify access token");
} }
const data = await response.json() const data = await response.json();
return data.access_token return data.access_token;
} }
export const ServerRoute = createServerFileRoute('/api/spotify/search').methods({ export const Route = createFileRoute("/api/spotify/search")({
server: {
handlers: {
GET: async ({ request }: { request: Request }) => { GET: async ({ request }: { request: Request }) => {
try { try {
const url = new URL(request.url) const url = new URL(request.url);
const query = url.searchParams.get('q') const query = url.searchParams.get("q");
if (!query) { if (!query) {
return new Response( return new Response(
JSON.stringify({ error: 'Query parameter q is required' }), JSON.stringify({ error: "Query parameter q is required" }),
{ {
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} }
// Get client credentials access token // Get client credentials access token
const accessToken = await getClientCredentialsToken() const accessToken = await getClientCredentialsToken();
// Search using Spotify API directly // Search using Spotify API directly
const searchUrl = `https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=20` const searchUrl = `https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=20`;
const searchResponse = await fetch(searchUrl, { const searchResponse = await fetch(searchUrl, {
headers: { headers: {
'Authorization': `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
}, },
}) });
if (!searchResponse.ok) { if (!searchResponse.ok) {
throw new Error('Spotify search request failed') throw new Error("Spotify search request failed");
} }
const searchResult = await searchResponse.json() const searchResult = await searchResponse.json();
return new Response( return new Response(
JSON.stringify({ tracks: searchResult.tracks.items }), JSON.stringify({ tracks: searchResult.tracks.items }),
{ {
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} catch (error) { } catch (error) {
console.error('Search error:', error) console.error("Search error:", error);
return new Response( return new Response(
JSON.stringify({ error: 'Search failed', details: error instanceof Error ? error.message : 'Unknown error' }), JSON.stringify({
error: "Search failed",
details: error instanceof Error ? error.message : "Unknown error",
}),
{ {
status: 500, status: 500,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} }
}, },
}) },
},
});

View File

@@ -1,52 +1,58 @@
import { createServerFileRoute } from '@tanstack/react-start/server' 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 = process.env.SPOTIFY_CLIENT_SECRET! const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
export const ServerRoute = createServerFileRoute('/api/spotify/token').methods({ export const Route = createFileRoute("/api/spotify/token")({
server: {
handlers: {
POST: async ({ request }: { request: Request }) => { POST: async ({ request }: { request: Request }) => {
try { try {
const body = await request.json() const body = await request.json();
const { refresh_token } = body const { refresh_token } = body;
if (!refresh_token) { if (!refresh_token) {
return new Response( return new Response(
JSON.stringify({ error: 'refresh_token is required' }), JSON.stringify({ error: "refresh_token is required" }),
{ {
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} }
// Refresh access token const tokenResponse = await fetch(
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', { "https://accounts.spotify.com/api/token",
method: 'POST', {
method: "POST",
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', "Content-Type": "application/x-www-form-urlencoded",
'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`, Authorization: `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString("base64")}`,
}, },
body: new URLSearchParams({ body: new URLSearchParams({
grant_type: 'refresh_token', grant_type: "refresh_token",
refresh_token, refresh_token,
}), }),
}) }
);
if (!tokenResponse.ok) { if (!tokenResponse.ok) {
const error = await tokenResponse.json() const error = await tokenResponse.json();
console.error('Token refresh error:', error) console.error("Token refresh error:", error);
return new Response( return new Response(
JSON.stringify({ error: 'Failed to refresh token', details: error }), JSON.stringify({
error: "Failed to refresh token",
details: error,
}),
{ {
status: tokenResponse.status, status: tokenResponse.status,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} }
const tokens = await tokenResponse.json() const tokens = await tokenResponse.json();
// Return new tokens
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
access_token: tokens.access_token, access_token: tokens.access_token,
@@ -56,50 +62,46 @@ export const ServerRoute = createServerFileRoute('/api/spotify/token').methods({
}), }),
{ {
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} catch (error) { } catch (error) {
console.error('Token refresh endpoint error:', error) console.error("Token refresh endpoint error:", error);
return new Response( return new Response(
JSON.stringify({ error: 'Internal server error' }), JSON.stringify({ error: "Internal server error" }),
{ {
status: 500, status: 500,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} }
}, },
// GET endpoint to retrieve current tokens from cookies
GET: async ({ request }: { request: Request }) => { GET: async ({ request }: { request: Request }) => {
try { try {
const cookieHeader = request.headers.get('cookie') const cookieHeader = request.headers.get("cookie");
if (!cookieHeader) { if (!cookieHeader) {
return new Response( return new Response(JSON.stringify({ error: "No cookies found" }), {
JSON.stringify({ error: 'No cookies found' }),
{
status: 401, status: 401,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} });
)
} }
const cookies = Object.fromEntries( const cookies = Object.fromEntries(
cookieHeader.split('; ').map((c: string) => c.split('=')) cookieHeader.split("; ").map((c: string) => c.split("="))
) );
const accessToken = cookies.spotify_access_token const accessToken = cookies.spotify_access_token;
const refreshToken = cookies.spotify_refresh_token const refreshToken = cookies.spotify_refresh_token;
if (!accessToken && !refreshToken) { if (!accessToken && !refreshToken) {
return new Response( return new Response(
JSON.stringify({ error: 'No Spotify tokens found' }), JSON.stringify({ error: "No Spotify tokens found" }),
{ {
status: 401, status: 401,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} }
return new Response( return new Response(
@@ -110,18 +112,20 @@ export const ServerRoute = createServerFileRoute('/api/spotify/token').methods({
}), }),
{ {
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} catch (error) { } catch (error) {
console.error('Get tokens endpoint error:', error) console.error("Get tokens endpoint error:", error);
return new Response( return new Response(
JSON.stringify({ error: 'Internal server error' }), JSON.stringify({ error: "Internal server error" }),
{ {
status: 500, status: 500,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} }
}, },
}) },
},
});

View File

@@ -1,116 +1,147 @@
import { createServerFileRoute } from '@tanstack/react-start/server'; import { createFileRoute } from "@tanstack/react-router";
import { superTokensRequestMiddleware } from '@/utils/supertokens'; import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { pbAdmin } from '@/lib/pocketbase/client'; import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from '@/lib/logger'; import { logger } from "@/lib/logger";
import { z } from 'zod'; import { z } from "zod";
const uploadSchema = z.object({ const uploadSchema = z.object({
teamId: z.string().min(1, 'Team ID is required'), teamId: z.string().min(1, "Team ID is required"),
}); });
export const ServerRoute = createServerFileRoute('/api/teams/upload-logo') export const Route = createFileRoute("/api/teams/upload-logo")({
.middleware([superTokensRequestMiddleware]) server: {
.methods({ middleware: [superTokensRequestMiddleware],
handlers: {
POST: async ({ request, context }) => { POST: async ({ request, context }) => {
try { try {
const userId = context.userAuthId; const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin"); const isAdmin = context.roles.includes("Admin");
if (!userId) return new Response('Unauthenticated', { status: 401 }); if (!userId) return new Response("Unauthenticated", { status: 401 });
const formData = await request.formData(); const formData = await request.formData();
const teamId = formData.get('teamId') as string; const teamId = formData.get("teamId") as string;
const logoFile = formData.get('logo') as File; const logoFile = formData.get("logo") as File;
const validationResult = uploadSchema.safeParse({ teamId }); const validationResult = uploadSchema.safeParse({ teamId });
if (!validationResult.success) { if (!validationResult.success) {
return new Response(JSON.stringify({ return new Response(
error: 'Invalid input', JSON.stringify({
details: validationResult.error.issues error: "Invalid input",
}), { details: validationResult.error.issues,
}),
{
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
}); }
);
} }
if (!logoFile || logoFile.size === 0) { if (!logoFile || logoFile.size === 0) {
return new Response(JSON.stringify({ return new Response(
error: 'Logo file is required' JSON.stringify({
}), { error: "Logo file is required",
}),
{
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
}); }
);
} }
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif']; const allowedTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
];
if (!allowedTypes.includes(logoFile.type)) { if (!allowedTypes.includes(logoFile.type)) {
return new Response(JSON.stringify({ return new Response(
error: 'Invalid file type. Only JPEG, PNG and GIF are allowed.' JSON.stringify({
}), { error: "Invalid file type. Only JPEG, PNG and GIF are allowed.",
}),
{
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
}); }
);
} }
const maxSize = 10 * 1024 * 1024; const maxSize = 10 * 1024 * 1024;
if (logoFile.size > maxSize) { if (logoFile.size > maxSize) {
return new Response(JSON.stringify({ return new Response(
error: 'File too large. Maximum size is 10MB.' JSON.stringify({
}), { error: "File too large. Maximum size is 10MB.",
}),
{
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
}); }
);
} }
const team = await pbAdmin.getTeam(teamId); const team = await pbAdmin.getTeam(teamId);
if (!team) { if (!team) {
return new Response(JSON.stringify({ return new Response(
error: 'Team not found' JSON.stringify({
}), { error: "Team not found",
}),
{
status: 404, status: 404,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
}); }
);
} }
if (!team.players.map(p => p.id).includes(context.userId) && !isAdmin) const user = await pbAdmin.getPlayerByAuthId(userId);
return new Response('Unauthorized', { status: 403 }); if (!team.players.map((p) => p.id).includes(user?.id!) && !isAdmin)
return new Response("Unauthorized", { status: 403 });
logger.info('Uploading team logo', { logger.info("Uploading team logo", {
teamId, teamId,
fileName: logoFile.name, fileName: logoFile.name,
fileSize: logoFile.size, fileSize: logoFile.size,
userId userId,
}); });
const pbFormData = new FormData(); const pbFormData = new FormData();
pbFormData.append('logo', logoFile); pbFormData.append("logo", logoFile);
const updatedTeam= await pbAdmin.updateTeam(teamId, pbFormData as any); await pbAdmin.updateTeam(teamId, pbFormData as any);
const updatedTeam = await pbAdmin.getTeam(teamId);
if (!updatedTeam) throw new Error("Failed to fetch updated team");
logger.info('Team logo uploaded successfully', { logger.info("Team logo uploaded successfully", {
teamId, teamId,
logo: updatedTeam.logo logo: updatedTeam.logo,
}); });
return new Response(JSON.stringify({ return new Response(
JSON.stringify({
success: true, success: true,
team: updatedTeam, team: updatedTeam,
message: 'Logo uploaded successfully' message: "Logo uploaded successfully",
}), { }),
{
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
}); }
);
} catch (error: any) { } catch (error: any) {
logger.error('Error uploading team logo:', error); logger.error("Error uploading team logo:", error);
return new Response(JSON.stringify({ return new Response(
error: 'Failed to upload logo', JSON.stringify({
message: error.message || 'Unknown error occurred' error: "Failed to upload logo",
}), { message: error.message || "Unknown error occurred",
}),
{
status: 500, status: 500,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
});
} }
);
} }
},
},
},
}); });

View File

@@ -1,115 +1,145 @@
import { createServerFileRoute } from '@tanstack/react-start/server'; import { createFileRoute } from "@tanstack/react-router";
import { superTokensRequestMiddleware } from '@/utils/supertokens'; import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { pbAdmin } from '@/lib/pocketbase/client'; import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from '@/lib/logger'; import { logger } from "@/lib/logger";
import { z } from 'zod'; import { z } from "zod";
const uploadSchema = z.object({ const uploadSchema = z.object({
tournamentId: z.string().min(1, 'Tournament ID is required'), tournamentId: z.string().min(1, "Tournament ID is required"),
}); });
export const ServerRoute = createServerFileRoute('/api/tournaments/upload-logo') export const Route = createFileRoute("/api/tournaments/upload-logo")({
.middleware([superTokensRequestMiddleware]) server: {
.methods({ middleware: [superTokensRequestMiddleware],
handlers: {
POST: async ({ request, context }) => { POST: async ({ request, context }) => {
try { try {
const userId = context.userAuthId; const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin"); const isAdmin = context.roles.includes("Admin");
if (!userId) return new Response('Unauthenticated', { status: 401 }); if (!userId) return new Response("Unauthenticated", { status: 401 });
if (!isAdmin) return new Response('Unauthorized', { status: 403 }); if (!isAdmin) return new Response("Unauthorized", { status: 403 });
const formData = await request.formData(); const formData = await request.formData();
const tournamentId = formData.get('tournamentId') as string; const tournamentId = formData.get("tournamentId") as string;
const logoFile = formData.get('logo') as File; const logoFile = formData.get("logo") as File;
const validationResult = uploadSchema.safeParse({ tournamentId }); const validationResult = uploadSchema.safeParse({ tournamentId });
if (!validationResult.success) { if (!validationResult.success) {
return new Response(JSON.stringify({ return new Response(
error: 'Invalid input', JSON.stringify({
details: validationResult.error.issues error: "Invalid input",
}), { details: validationResult.error.issues,
}),
{
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
}); }
);
} }
if (!logoFile || logoFile.size === 0) { if (!logoFile || logoFile.size === 0) {
return new Response(JSON.stringify({ return new Response(
error: 'Logo file is required' JSON.stringify({
}), { error: "Logo file is required",
}),
{
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
}); }
);
} }
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif']; const allowedTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
];
if (!allowedTypes.includes(logoFile.type)) { if (!allowedTypes.includes(logoFile.type)) {
return new Response(JSON.stringify({ return new Response(
error: 'Invalid file type. Only JPEG, PNG and GIF are allowed.' JSON.stringify({
}), { error: "Invalid file type. Only JPEG, PNG and GIF are allowed.",
}),
{
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
}); }
);
} }
const maxSize = 10 * 1024 * 1024; const maxSize = 10 * 1024 * 1024;
if (logoFile.size > maxSize) { if (logoFile.size > maxSize) {
return new Response(JSON.stringify({ return new Response(
error: 'File too large. Maximum size is 10MB.' JSON.stringify({
}), { error: "File too large. Maximum size is 10MB.",
}),
{
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
}); }
);
} }
const tournament = await pbAdmin.getTournament(tournamentId); const tournament = await pbAdmin.getTournament(tournamentId);
if (!tournament) { if (!tournament) {
return new Response(JSON.stringify({ return new Response(
error: 'Tournament not found' JSON.stringify({
}), { error: "Tournament not found",
}),
{
status: 404, status: 404,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
}); }
);
} }
logger.info("Uploading tournament logo", {
logger.info('Uploading tournament logo', {
tournamentId, tournamentId,
fileName: logoFile.name, fileName: logoFile.name,
fileSize: logoFile.size, fileSize: logoFile.size,
userId userId,
}); });
const pbFormData = new FormData(); const pbFormData = new FormData();
pbFormData.append('logo', logoFile); pbFormData.append("logo", logoFile);
const updatedTournament = await pbAdmin.updateTournament(tournamentId, pbFormData as any); const updatedTournament = await pbAdmin.updateTournament(
logger.info('Tournament logo uploaded successfully', {
tournamentId, tournamentId,
logo: updatedTournament.logo pbFormData as any
);
logger.info("Tournament logo uploaded successfully", {
tournamentId,
logo: updatedTournament.logo,
}); });
return new Response(JSON.stringify({ return new Response(
JSON.stringify({
success: true, success: true,
tournament: updatedTournament, tournament: updatedTournament,
message: 'Logo uploaded successfully' message: "Logo uploaded successfully",
}), { }),
{
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
}); }
);
} catch (error: any) { } catch (error: any) {
logger.error('Error uploading tournament logo:', error); logger.error("Error uploading tournament logo:", error);
return new Response(JSON.stringify({ return new Response(
error: 'Failed to upload logo', JSON.stringify({
message: error.message || 'Unknown error occurred' error: "Failed to upload logo",
}), { message: error.message || "Unknown error occurred",
}),
{
status: 500, status: 500,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
});
} }
);
} }
},
},
},
}); });

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,7 +2,14 @@ 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"> {
@@ -10,6 +17,8 @@ interface AvatarProps
size?: number; size?: number;
radius?: string | number; radius?: string | number;
withBorder?: boolean; withBorder?: boolean;
disableFullscreen?: boolean;
contain?: boolean;
} }
const Avatar = ({ const Avatar = ({
@@ -17,10 +26,39 @@ 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}> <>
<Paper
p={size / 20}
radius={radius}
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 <MantineAvatar
alt={name} alt={name}
key={name} key={name}
@@ -31,12 +69,79 @@ const Avatar = ({
w={size} w={size}
styles={{ styles={{
image: { image: {
objectFit: "contain", objectFit: contain ? 'contain' : 'cover',
}, },
}} }}
{...props} {...props}
/> />
</Paper> </Paper>
<Modal
opened={isFullscreenOpen}
onClose={() => setIsFullscreenOpen(false)}
size="auto"
centered
withCloseButton={false}
overlayProps={{
backgroundOpacity: 0.9,
blur: 2,
}}
styles={{
content: {
background: 'transparent',
border: 'none',
},
body: {
padding: 0,
},
}}
>
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
<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

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

View File

@@ -62,6 +62,22 @@ const Drawer: React.FC<DrawerProps> = ({
useEffect(() => { useEffect(() => {
if (!opened || !contentRef.current) return; if (!opened || !contentRef.current) return;
const updateDrawerHeight = () => {
if (contentRef.current) {
const drawerContent = contentRef.current;
const visualViewport = window.visualViewport;
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';
}
}
};
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
if (contentRef.current) { if (contentRef.current) {
const drawerContent = contentRef.current.closest('[data-vaul-drawer-wrapper]'); const drawerContent = contentRef.current.closest('[data-vaul-drawer-wrapper]');
@@ -72,15 +88,24 @@ const Drawer: React.FC<DrawerProps> = ({
} }
}); });
updateDrawerHeight();
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', updateDrawerHeight);
}
resizeObserver.observe(contentRef.current); resizeObserver.observe(contentRef.current);
return () => { return () => {
resizeObserver.disconnect(); resizeObserver.disconnect();
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', updateDrawerHeight);
}
}; };
}, [opened, children]); }, [opened, children]);
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}>

View File

@@ -23,14 +23,14 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
onChange={onChange} onChange={onChange}
onClose={handleClose} onClose={handleClose}
> >
<ScrollArea <ScrollArea.Autosize
style={{ flex: 1 }} style={{ flex: 1, maxHeight: '75dvh' }}
scrollbarSize={8} scrollbarSize={8}
scrollbars="y" scrollbars="y"
type="scroll" type="scroll"
> >
{children} {children}
</ScrollArea> </ScrollArea.Autosize>
</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"
/> />
<ScrollArea.Autosize w="100%" p={0} offsetScrollbars>
<panelConfig.Component <panelConfig.Component
value={tempValue} value={tempValue}
onChange={setTempValue} onChange={setTempValue}
{...(panelConfig.componentProps || {})} {...(panelConfig.componentProps || {})}
/> />
</ScrollArea.Autosize>
<Stack mt="auto" w="100%" gap={2}>
<Button mt="md" onClick={handleConfirm}>Confirm</Button>
<Button variant="subtle" onClick={closePanel} mt="sm" color="red">Cancel</Button>
</Stack>
</> </>
)} )}
</Box> </Box>

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

@@ -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,386 @@
import { useState, useMemo, memo } from "react";
import {
Text,
TextInput,
Stack,
Group,
Box,
Container,
Divider,
UnstyledButton,
Select,
Pagination,
Code,
Alert,
} from "@mantine/core";
import {
MagnifyingGlassIcon,
CaretUpIcon,
CaretDownIcon,
CheckIcon,
XIcon,
ChecksIcon,
} from "@phosphor-icons/react";
import { Activity, ActivitySearchParams } from "../types";
import { useActivities } from "../queries";
import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet";
interface ActivityListItemProps {
activity: Activity;
onClick: () => void;
}
const ActivityListItem = memo(({ activity, onClick }: ActivityListItemProps) => {
const playerName = typeof activity.player === "object" && activity.player
? `${activity.player.first_name} ${activity.player.last_name}`
: "System";
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleString();
};
return (
<UnstyledButton
w="100%"
p="md"
onClick={onClick}
style={{
borderRadius: 0,
transition: "background-color 0.15s ease",
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'var(--mantine-color-gray-0)',
},
},
}}
>
<Group justify="space-between" align="flex-start" w="100%">
<Stack gap={4} flex={1}>
<Group gap="xs">
<Text size="sm" fw={600}>
{activity.name}
</Text>
{activity.success ? (
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
) : (
<XIcon size={16} color="var(--mantine-color-red-6)" />
)}
</Group>
<Group gap="md">
<Text size="xs" c="dimmed">
{playerName}
</Text>
<Text size="xs" c="dimmed">
{activity.duration}ms
</Text>
<Text size="xs" c="dimmed">
{formatDate(activity.created)}
</Text>
</Group>
{activity.error && (
<Text size="xs" c="red" lineClamp={1}>
{activity.error}
</Text>
)}
</Stack>
</Group>
</UnstyledButton>
);
});
ActivityListItem.displayName = "ActivityListItem";
interface ActivityDetailsSheetProps {
activity: Activity | null;
isOpen: boolean;
onClose: () => void;
}
const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetailsSheetProps) => {
if (!activity) return null;
const playerName = typeof activity.player === "object" && activity.player
? `${activity.player.first_name} ${activity.player.last_name}`
: "System";
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleString();
};
return (
<Sheet title="Activity Details" opened={isOpen} onChange={onClose}>
<Stack gap="md" p="md">
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
Function Name
</Text>
<Text size="sm">{activity.name}</Text>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
Status
</Text>
<Group gap="xs">
{activity.success ? (
<>
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
<Text size="sm" c="green">
Success
</Text>
</>
) : (
<>
<XIcon size={16} color="var(--mantine-color-red-6)" />
<Text size="sm" c="red">
Failed
</Text>
</>
)}
</Group>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
Player
</Text>
<Text size="sm">{playerName}</Text>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
Duration
</Text>
<Text size="sm">{activity.duration}ms</Text>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
Created
</Text>
<Text size="sm">{formatDate(activity.created)}</Text>
</Stack>
{activity.user_agent && (
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
User Agent
</Text>
<Text size="xs" style={{ wordBreak: "break-word" }}>
{activity.user_agent}
</Text>
</Stack>
)}
{activity.error && (
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
Error Message
</Text>
<Alert color="red" variant="light">
<Text size="sm" style={{ wordBreak: "break-word" }}>
{activity.error}
</Text>
</Alert>
</Stack>
)}
{activity.arguments && (
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
Arguments
</Text>
<Code block style={{ fontSize: "11px" }}>
{JSON.stringify(activity.arguments, null, 2)}
</Code>
</Stack>
)}
</Stack>
</Sheet>
);
});
ActivityDetailsSheet.displayName = "ActivityDetailsSheet";
const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any) => {
const { data: result } = useActivities(searchParams);
return (
<>
<Stack gap={0}>
{result.items.map((activity: Activity, index: number) => (
<Box key={activity.id}>
<ActivityListItem
activity={activity}
onClick={() => onActivityClick(activity)}
/>
{index < result.items.length - 1 && <Divider />}
</Box>
))}
</Stack>
{result.items.length === 0 && (
<Text ta="center" c="dimmed" py="xl">
No activities found
</Text>
)}
{result.totalPages > 1 && (
<Group justify="center" py="md">
<Pagination
total={result.totalPages}
value={page}
onChange={setPage}
size="sm"
/>
</Group>
)}
</>
);
};
export const ActivitiesTable = () => {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [successFilter, setSuccessFilter] = useState<string | null>(null);
const [sortBy, setSortBy] = useState("-created");
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
const {
isOpen: detailsOpened,
open: openDetails,
close: closeDetails,
} = useSheet();
const searchParams: ActivitySearchParams = useMemo(
() => ({
page,
perPage: 100,
name: search || undefined,
success: successFilter === "success" ? true : successFilter === "failure" ? false : undefined,
sortBy,
}),
[page, search, successFilter, sortBy]
);
const { data: result } = useActivities(searchParams);
const handleSort = (field: string) => {
setSortBy((prev) => {
if (prev === field) return `-${field}`;
if (prev === `-${field}`) return field;
return `-${field}`;
});
};
const getSortIcon = (field: string) => {
if (sortBy === field) return <CaretUpIcon size={14} />;
if (sortBy === `-${field}`) return <CaretDownIcon size={14} />;
return null;
};
const handleActivityClick = (activity: Activity) => {
setSelectedActivity(activity);
openDetails();
};
const handleCloseDetails = () => {
setSelectedActivity(null);
closeDetails();
};
return (
<Container size="100%" px={0}>
<Stack gap="xs">
<Stack gap="xs" px="md">
<TextInput
placeholder="serverFn name"
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
setPage(1);
}}
leftSection={<MagnifyingGlassIcon size={16} />}
size="md"
/>
<Group>
<Select
placeholder="Status"
value={successFilter}
onChange={(value) => {
setSuccessFilter(value);
setPage(1);
}}
data={[
{ value: "all", label: "All" },
{ value: "success", label: "Success" },
{ value: "failure", label: "Failure" },
]}
clearable
size="sm"
style={{ flex: 1 }}
/>
</Group>
</Stack>
<Group px="md" justify="space-between" align="center">
<Text size="10px" lh={0} c="dimmed">
{result.totalItems} total activities
</Text>
<Group gap="xs">
<Text size="xs" c="dimmed">
Sort:
</Text>
<UnstyledButton
onClick={() => handleSort("created")}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<Text
size="xs"
fw={sortBy.includes("created") ? 600 : 400}
c={sortBy.includes("created") ? "dark" : "dimmed"}
>
Date
</Text>
{getSortIcon("created")}
</UnstyledButton>
<Text size="xs" c="dimmed">
</Text>
<UnstyledButton
onClick={() => handleSort("duration")}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<Text
size="xs"
fw={sortBy.includes("duration") ? 600 : 400}
c={sortBy.includes("duration") ? "dark" : "dimmed"}
>
Duration
</Text>
{getSortIcon("duration")}
</UnstyledButton>
</Group>
</Group>
<ActivitiesResults
searchParams={searchParams}
page={page}
setPage={setPage}
onActivityClick={handleActivityClick}
/>
</Stack>
<ActivityDetailsSheet
activity={selectedActivity}
isOpen={detailsOpened}
onClose={handleCloseDetails}
/>
</Container>
);
};

View File

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

View File

@@ -0,0 +1,17 @@
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { searchActivities } from "./server";
import { ActivitySearchParams } from "./types";
export const activityKeys = {
search: (params: ActivitySearchParams) => ['activities', 'search', params] as const,
};
export const activityQueries = {
search: (params: ActivitySearchParams = {}) => ({
queryKey: activityKeys.search(params),
queryFn: () => searchActivities({ data: params }),
}),
};
export const useActivities = (params: ActivitySearchParams = {}) =>
useServerSuspenseQuery(activityQueries.search(params));

View File

@@ -0,0 +1,29 @@
import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { transformActivity } from "@/lib/pocketbase/util/transform-types";
import { Activity, ActivityListResult, ActivitySearchParams } from "./types";
const activitySearchParamsSchema = z.object({
page: z.number().optional(),
perPage: z.number().optional(),
name: z.string().optional(),
player: z.string().optional(),
success: z.boolean().optional(),
sortBy: z.string().optional(),
});
export const searchActivities = createServerFn()
.inputValidator(activitySearchParamsSchema)
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<ActivityListResult>(async () => {
const result = await pbAdmin.searchActivities(data);
return {
...result,
items: result.items.map(transformActivity),
};
})
);

View File

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

View File

@@ -4,17 +4,48 @@ 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}

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;

View File

@@ -0,0 +1,267 @@
import { Box, Text, Popover, Progress, Title } from "@mantine/core";
import { usePlayerBadges, useAllBadges } from "../queries";
import { useAuth } from "@/contexts/auth-context";
import { Badge, BadgeProgress } from "../types";
import { useMemo } from "react";
import { MedalIcon, LockKeyIcon } from "@phosphor-icons/react";
interface BadgeShowcaseProps {
playerId: string;
}
interface BadgeDisplay {
badge: Badge;
progress?: BadgeProgress;
earned: boolean;
progressText: string;
}
const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
const { user } = useAuth();
const { data: badgeProgress } = usePlayerBadges(playerId);
const { data: allBadges } = useAllBadges();
const isCurrentUser = user?.id === playerId;
const badgesToDisplay = useMemo(() => {
const displays: BadgeDisplay[] = [];
if (isCurrentUser) {
for (const badge of allBadges) {
const progress = badgeProgress.find(bp => bp.badge.id === badge.id);
const earned = progress?.earned || false;
if (badge.type === 'manual' && !earned) {
continue;
}
let progressText = "";
if (progress) {
const target = getTargetProgress(badge);
progressText = `${progress.progress} / ${target}`;
} else {
const target = getTargetProgress(badge);
progressText = `0 / ${target}`;
}
displays.push({
badge,
progress,
earned,
progressText,
});
}
displays.sort((a, b) => {
if (a.earned && !b.earned) return -1;
if (!a.earned && b.earned) return 1;
return a.badge.order - b.badge.order;
});
} else {
const earnedProgress = badgeProgress.filter(bp => bp.earned);
for (const progress of earnedProgress) {
const badge: Badge = {
...progress.badge,
criteria: {},
created: progress.created,
updated: progress.updated,
};
const target = getTargetProgress(badge);
displays.push({
badge,
progress,
earned: true,
progressText: `${progress.progress} / ${target}`,
});
}
displays.sort((a, b) => a.badge.order - b.badge.order);
}
return displays;
}, [allBadges, badgeProgress, isCurrentUser]);
if (badgesToDisplay.length === 0) {
return null;
}
return (
<Box mb="lg">
<Box
px="md"
style={{
maxHeight: '220px',
overflowY: 'auto',
overflowX: 'hidden',
width: '100%',
}}
>
<Box
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(85px, 1fr))',
gap: 'var(--mantine-spacing-md)',
paddingBottom: 'var(--mantine-spacing-sm)',
width: '100%',
}}
>
{badgesToDisplay.map((display) => {
const isStackableBadge = ['winner_badge', 'silver_medal_badge', 'bronze_medal_badge'].includes(display.badge.key);
const stackCount = display.earned && isStackableBadge
? (display.progress?.progress || 0)
: 1;
const showStack = stackCount > 1;
return (
<Popover key={display.badge.id} width={220} position="top" withArrow shadow="md">
<Popover.Target>
<Box
style={{
cursor: "pointer",
transition: 'all 0.2s ease',
position: 'relative',
}}
>
{showStack && (
<>
{[...Array(Math.min(stackCount - 1, 2))].map((_, i) => (
<Box
key={i}
style={{
aspectRatio: '1',
borderRadius: '12px',
background: 'transparent',
border: '2px solid var(--mantine-primary-color-5)',
position: 'absolute',
top: `${(i + 1) * 3}px`,
left: `${(i + 1) * 3}px`,
right: `-${(i + 1) * 3}px`,
bottom: `-${(i + 1) * 3}px`,
opacity: 0.4 - (i * 0.15),
zIndex: -(i + 1),
}}
/>
))}
</>
)}
<Box
style={{
aspectRatio: '1',
borderRadius: '12px',
background: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-7))',
border: display.earned
? '2px solid var(--mantine-primary-color-6)'
: '2px dashed var(--mantine-primary-color-4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: '4px',
padding: 'var(--mantine-spacing-xs)',
position: 'relative',
boxShadow: display.earned
? '0 0 0 1px color-mix(in srgb, var(--mantine-primary-color-6) 20%, transparent)'
: 'none',
opacity: display.earned ? 1 : 0.4,
zIndex: 1,
}}
>
{display.earned ? (
<MedalIcon
size={32}
weight="fill"
color="var(--mantine-primary-color-6)"
/>
) : (
<LockKeyIcon
size={28}
weight="regular"
color="var(--mantine-color-dimmed)"
/>
)}
{showStack && (
<Box
style={{
position: 'absolute',
top: '4px',
right: '4px',
color: 'var(--mantine-primary-color-6)',
width: '20px',
height: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '10px',
fontWeight: 700,
}}
>
x{stackCount}
</Box>
)}
<Title
order={6}
fw={display.earned ? 600 : 500}
ta="center"
c={display.earned ? undefined : 'dimmed'}
style={{ lineHeight: 1.1 }}
>
{display.badge.name}
</Title>
</Box>
</Box>
</Popover.Target>
<Popover.Dropdown>
<Box>
<Title order={5}>
{display.badge.name}
</Title>
<Text size="xs" c="dimmed" mb={isCurrentUser ? "sm" : undefined}>
{display.badge.description}
</Text>
{isCurrentUser && (
<Box>
<Box mb="xs" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text size="xs" fw={500} c="dimmed">
Progress
</Text>
<Text size="xs" fw={600} c="dimmed">
{display.progressText}
</Text>
</Box>
<Progress
value={(display.progress?.progress || 0) / getTargetProgress(display.badge) * 100}
size="sm"
radius="sm"
color={display.earned ? "green" : undefined}
/>
</Box>
)}
</Box>
</Popover.Dropdown>
</Popover>
);
})}
</Box>
</Box>
</Box>
);
};
function getTargetProgress(badge: Badge): number {
const criteria = badge.criteria;
return (
criteria.matches_played ||
criteria.tournament_wins ||
criteria.tournaments_attended ||
criteria.overtime_matches ||
criteria.overtime_wins ||
criteria.consecutive_wins ||
1
);
}
export default BadgeShowcase;

View File

@@ -0,0 +1,24 @@
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { getPlayerBadges, getAllBadges } from "./server";
export const badgeKeys = {
playerBadges: (playerId: string) => ['badges', 'player', playerId],
allBadges: () => ['badges', 'all'],
};
export const badgeQueries = {
playerBadges: (playerId: string) => ({
queryKey: badgeKeys.playerBadges(playerId),
queryFn: async () => await getPlayerBadges({ data: playerId })
}),
allBadges: () => ({
queryKey: badgeKeys.allBadges(),
queryFn: async () => await getAllBadges()
}),
};
export const usePlayerBadges = (playerId: string) =>
useServerSuspenseQuery(badgeQueries.playerBadges(playerId));
export const useAllBadges = () =>
useServerSuspenseQuery(badgeQueries.allBadges());

View File

@@ -0,0 +1,34 @@
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod";
export const getPlayerBadges = createServerFn()
.inputValidator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: playerId }) =>
toServerResult(() => pbAdmin.getPlayerBadgeProgress(playerId))
);
export const migrateBadgeProgress = createServerFn()
.middleware([superTokensAdminFunctionMiddleware])
.handler(async () =>
toServerResult(() => pbAdmin.migrateBadgeProgress())
);
export const getAllBadges = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () =>
toServerResult(() => pbAdmin.listBadges())
);
export const awardManualBadge = createServerFn()
.inputValidator(z.object({
playerId: z.string(),
badgeId: z.string(),
}))
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult(() => pbAdmin.awardManualBadge(data.playerId, data.badgeId))
);

View File

@@ -0,0 +1,25 @@
export interface BadgeInfo {
id: string;
name: string;
key: string;
description: string;
type: "manual" | "match" | "tournament";
progressive: boolean;
order: number;
}
export interface Badge extends BadgeInfo {
criteria: Record<string, any>;
created: string;
updated: string;
}
export interface BadgeProgress {
id: string;
badge: BadgeInfo;
player: string;
progress: number;
earned: boolean;
created: string;
updated: string;
}

View File

@@ -0,0 +1,8 @@
/*
pb.collection("team_stats_per_tournament").getFullList({
filter: `tournament_id = "${id}"`,
sort: "-wins,-total_cups_made"
})
*/

View File

@@ -1,6 +1,7 @@
import { Flex } from "@mantine/core"; import { Flex, Box } from "@mantine/core";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
import { MatchCard } from "./match-card"; import { MatchCard } from "./match-card";
import { useEffect, useRef } from "react";
interface BracketProps { interface BracketProps {
rounds: Match[][]; rounds: Match[][];
@@ -13,8 +14,105 @@ export const Bracket: React.FC<BracketProps> = ({
orders, orders,
showControls, showControls,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
useEffect(() => {
const updateConnectorLines = () => {
if (!containerRef.current || !svgRef.current) return;
const svg = svgRef.current;
const container = containerRef.current;
const flexContainer = container.querySelector('.bracket-flex-container') as HTMLElement;
if (!flexContainer) return;
svg.innerHTML = '';
const flexRect = flexContainer.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
svg.style.width = `${flexContainer.scrollWidth}px`;
svg.style.height = `${flexContainer.scrollHeight}px`;
rounds.forEach((round, roundIndex) => {
if (roundIndex === rounds.length - 1) return;
const nextRound = rounds[roundIndex + 1];
round.forEach((match, matchIndex) => {
if (match.bye) return;
const matchElement = container.querySelector(`[data-match-lid="${match.lid}"]`) as HTMLElement;
if (!matchElement) return;
const nextMatches = nextRound.filter(nextMatch =>
!nextMatch.bye && (
orders[nextMatch.home_from_lid] === match.order ||
orders[nextMatch.away_from_lid] === match.order
)
);
nextMatches.forEach(nextMatch => {
const nextMatchElement = container.querySelector(`[data-match-lid="${nextMatch.lid}"]`) as HTMLElement;
if (!nextMatchElement) return;
const matchRect = matchElement.getBoundingClientRect();
const nextMatchRect = nextMatchElement.getBoundingClientRect();
const startX = matchRect.right - flexRect.left;
const startY = matchRect.top + matchRect.height / 2 - flexRect.top;
const endX = nextMatchRect.left - flexRect.left;
const endY = nextMatchRect.top + nextMatchRect.height / 2 - flexRect.top;
const midX = startX + (endX - startX) * 0.5;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
const pathData = `M ${startX} ${startY} L ${midX} ${startY} L ${midX} ${endY} L ${endX} ${endY}`;
path.setAttribute('d', pathData);
path.setAttribute('stroke', 'var(--mantine-color-default-border)');
path.setAttribute('stroke-width', '2');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
svg.appendChild(path);
});
});
});
};
updateConnectorLines();
const handleUpdate = () => {
requestAnimationFrame(updateConnectorLines);
};
const scrollContainer = containerRef.current?.closest('.mantine-ScrollArea-viewport');
scrollContainer?.addEventListener('scroll', handleUpdate);
window.addEventListener('resize', handleUpdate);
return () => {
scrollContainer?.removeEventListener('scroll', handleUpdate);
window.removeEventListener('resize', handleUpdate);
};
}, [rounds, orders]);
return ( return (
<Flex direction="row" gap={24} justify="left"> <Box pos="relative" ref={containerRef}>
<svg
ref={svgRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 0,
}}
/>
<Flex direction="row" gap={24} justify="left" pos="relative" style={{ zIndex: 1 }} className="bracket-flex-container">
{rounds.map((round, roundIndex) => ( {rounds.map((round, roundIndex) => (
<Flex <Flex
key={roundIndex} key={roundIndex}
@@ -41,5 +139,6 @@ export const Bracket: React.FC<BracketProps> = ({
</Flex> </Flex>
))} ))}
</Flex> </Flex>
</Box>
); );
}; };

View File

@@ -199,7 +199,15 @@ export const MatchCard: React.FC<MatchCardProps> = ({
return ( return (
<Flex direction="row" align="center" justify="end" gap={8}> <Flex direction="row" align="center" justify="end" gap={8}>
<Text c="dimmed" fw="bolder"> <Text
c="dimmed"
fw="bolder"
px={6}
py={2}
style={{
backgroundColor: 'var(--mantine-color-body)'
}}
>
{match.order} {match.order}
</Text> </Text>
<Flex align="stretch"> <Flex align="stretch">
@@ -214,7 +222,12 @@ export const MatchCard: React.FC<MatchCardProps> = ({
w={showToolbar || showEditButton ? 200 : 220} w={showToolbar || showEditButton ? 200 : 220}
withBorder withBorder
pos="relative" pos="relative"
style={{ overflow: "visible" }} style={{
overflow: "visible",
backgroundColor: 'var(--mantine-color-body)',
borderColor: 'var(--mantine-color-default-border)',
boxShadow: 'var(--mantine-shadow-sm)',
}}
data-match-lid={match.lid} data-match-lid={match.lid}
> >
<Card.Section withBorder p={0}> <Card.Section withBorder p={0}>

View File

@@ -87,7 +87,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
{match.home?.name} Cups {match.home?.name} Cups
</Text> </Text>
{ {
match.home?.players.map(p => (<Text size='xs' c='dimmed'> match.home?.players?.map(p => (<Text key={p.id} size='xs' c='dimmed'>
{p.first_name} {p.last_name} {p.first_name} {p.last_name}
</Text>)) </Text>))
} }
@@ -110,7 +110,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
{match.away?.name} Cups {match.away?.name} Cups
</Text> </Text>
{ {
match.away?.players.map(p => (<Text size='xs' c='dimmed'> match.away?.players?.map(p => (<Text key={p.id} size='xs' c='dimmed'>
{p.first_name} {p.last_name} {p.first_name} {p.last_name}
</Text>)) </Text>))
} }

View File

@@ -21,16 +21,23 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
cups, cups,
isWinner isWinner
}) => ( }) => (
<Flex align="stretch"> <Flex
align="stretch"
style={{
backgroundColor: isWinner ? 'var(--mantine-color-green-light)' : 'transparent',
borderRadius: 'var(--mantine-radius-sm)',
transition: 'background-color 200ms ease',
}}
>
{(seed && seed > 0) ? <SeedBadge seed={seed} /> : undefined} {(seed && seed > 0) ? <SeedBadge seed={seed} /> : undefined}
<Flex p="4px 8px" w='100%' align="center"> <Flex p="6px 10px" w='100%' align="center">
<Flex align="center" gap={4} flex={1}> <Flex align="center" gap={4} flex={1}>
{team ? ( {team ? (
<> <>
<Text <Text
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'} size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
truncate truncate
style={{ minWidth: 0, flex: 1 }} style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
> >
{team.name} {team.name}
</Text> </Text>

View File

@@ -9,7 +9,7 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
const logger = new Logger("Bracket Generation"); const logger = new Logger("Bracket Generation");
export const previewBracket = createServerFn() export const previewBracket = createServerFn()
.validator(z.number()) .inputValidator(z.number())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teams }) => .handler(async ({ data: teams }) =>
toServerResult(async () => { toServerResult(async () => {

View File

@@ -6,10 +6,14 @@ interface HeaderProps extends HeaderConfig {}
const Header = ({ collapsed, title, withBackButton }: HeaderProps) => { const Header = ({ collapsed, title, withBackButton }: HeaderProps) => {
return ( return (
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}> <AppShell.Header
id='app-header'
display={collapsed ? 'none' : 'flex'}
style={{ alignItems: 'center', justifyContent: 'center' }}
>
{ withBackButton && <BackButton /> } { withBackButton && <BackButton /> }
<Flex justify='center' align='center' h='100%' px='md'> <Flex justify='center' px='md' mt={8}>
<Title order={2}>{title}</Title> <Title order={1}>{title?.toLocaleUpperCase()}</Title>
</Flex> </Flex>
</AppShell.Header> </AppShell.Header>
); );

View File

@@ -32,9 +32,9 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
h='100dvh' h='100dvh'
mah='100dvh' mah='100dvh'
style={{ style={{
top: 0, height: `${viewport.height}px`,
minHeight: '100dvh', minHeight: '100dvh',
maxHeight: '100dvh' // top: viewport.top
}} }}
> >
<Header {...header} /> <Header {...header} />
@@ -42,7 +42,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
pos='relative' pos='relative'
h='100%' h='100%'
mah='100%' mah='100%'
pb={{ base: 65, md: 0 }} pb={{ base: 65, sm: 0 }}
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }} px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
maw='100dvw' maw='100dvw'
style={{ transition: 'none', overflow: 'hidden' }} style={{ transition: 'none', overflow: 'hidden' }}

View File

@@ -1,4 +1,4 @@
import { AppShell, ScrollArea, Stack, Group, Paper } from "@mantine/core"; import { AppShell, ScrollArea, Stack, Group, Paper, useMantineColorScheme } from "@mantine/core";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { NavLink } from "./nav-link"; import { NavLink } from "./nav-link";
import { useIsMobile } from "@/hooks/use-is-mobile"; import { useIsMobile } from "@/hooks/use-is-mobile";
@@ -9,11 +9,17 @@ import { memo } from "react";
const Navbar = () => { const Navbar = () => {
const { user, roles } = useAuth() const { user, roles } = useAuth()
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { colorScheme } = useMantineColorScheme();
const links = useLinks(user?.id, roles); const links = useLinks(user?.id, roles);
const isDark = colorScheme === 'dark';
const borderColor = isDark ? 'var(--mantine-color-dimmed)' : 'black';
const boxShadowColor = isDark ? 'var(--mantine-color-dimmed)' : 'black';
// boxShadow: `5px 5px ${boxShadowColor}`, borderColor
if (isMobile) return ( if (isMobile) return (
<Paper component='nav' role='navigation' withBorder radius='lg' h='4rem' w='calc(100% - 1rem)' shadow='sm' pos='fixed' m='0.5rem' bottom='0' style={{ zIndex: 10 }}> <Paper component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1rem)' pos='fixed' m='0.5rem' bottom='0' style={{ zIndex: 10 }}>
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}> <Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
{links.map((link) => ( {links.map((link) => (
<NavLink key={link.href} {...link} /> <NavLink key={link.href} {...link} />
@@ -30,9 +36,6 @@ const Navbar = () => {
))} ))}
</Stack> </Stack>
</AppShell.Section> </AppShell.Section>
<AppShell.Section>
<Link to="/logout">Logout</Link>
</AppShell.Section>
</AppShell.Navbar> </AppShell.Navbar>
} }

View File

@@ -19,11 +19,17 @@ const useVisualViewportSize = () => {
useEffect(() => { useEffect(() => {
if (!windowExists) return; if (!windowExists) return;
setSize();
window.visualViewport?.addEventListener('resize', setSize, eventListerOptions); window.visualViewport?.addEventListener('resize', setSize, eventListerOptions);
window.visualViewport?.addEventListener('scroll', setSize, eventListerOptions);
return () => { return () => {
window.visualViewport?.removeEventListener('resize', setSize); window.visualViewport?.removeEventListener('resize', setSize);
window.visualViewport?.removeEventListener('scroll', setSize);
} }
}, []); }, [setSize]);
return windowSize; return windowSize;
} }

View File

@@ -1,4 +1,6 @@
import GlitchAvatar from '@/components/glitch-avatar';
import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size'; import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size';
import { useCurrentTournament } from '@/features/tournaments/queries';
import { AppShell, Flex, Paper, em, Title, Stack } from '@mantine/core'; import { AppShell, Flex, Paper, em, Title, Stack } from '@mantine/core';
import { useMediaQuery, useViewportSize } from '@mantine/hooks'; import { useMediaQuery, useViewportSize } from '@mantine/hooks';
import { TrophyIcon } from '@phosphor-icons/react'; import { TrophyIcon } from '@phosphor-icons/react';
@@ -8,6 +10,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const isMobile = useMediaQuery(`(max-width: ${em(450)})`); const isMobile = useMediaQuery(`(max-width: ${em(450)})`);
const visualViewport = useVisualViewportSize(); const visualViewport = useVisualViewportSize();
const viewport = useViewportSize(); const viewport = useViewportSize();
const { data: tournament } = useCurrentTournament();
return ( return (
<AppShell> <AppShell>
@@ -31,8 +34,27 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
radius='md' radius='md'
> >
<Stack align='center' gap='xs' mb='md'> <Stack align='center' gap='xs' mb='md'>
<TrophyIcon size={75} /> <GlitchAvatar
<Title order={4} ta='center'>Welcome to FLXN</Title> name={tournament.name}
contain
src={
tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
: undefined
}
glitchSrc={
tournament.glitch_logo
? `/api/files/tournaments/${tournament.id}/${tournament.glitch_logo}`
: undefined
}
radius="md"
size={250}
px="xs"
withBorder={false}
>
<TrophyIcon size={32} />
</GlitchAvatar>
<Title order={1} ta='center'>Welcome to FLXN</Title>
</Stack> </Stack>
{children} {children}
</Paper> </Paper>

View File

@@ -9,7 +9,7 @@ interface MatchListProps {
const MatchList = ({ matches }: MatchListProps) => { const MatchList = ({ matches }: MatchListProps) => {
const filteredMatches = matches?.filter(match => const filteredMatches = matches?.filter(match =>
match.home && match.away && !match.bye && match.status != "tbd" match.home && match.away && !match.bye && match.status != "tbd"
) || []; ).sort((a, b) => a.start_time < b.start_time ? 1 : -1) || [];
if (!filteredMatches.length) { if (!filteredMatches.length) {
return undefined; return undefined;

View File

@@ -9,6 +9,7 @@ import { MatchInput } from "@/features/matches/types";
import { serverEvents } from "@/lib/events/emitter"; import { serverEvents } from "@/lib/events/emitter";
import { superTokensFunctionMiddleware } from "@/utils/supertokens"; import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { PlayerInfo } from "../players/types"; import { PlayerInfo } from "../players/types";
import { serverFnLoggingMiddleware } from "@/utils/activities";
const orderedTeamsSchema = z.object({ const orderedTeamsSchema = z.object({
tournamentId: z.string(), tournamentId: z.string(),
@@ -16,8 +17,8 @@ const orderedTeamsSchema = z.object({
}); });
export const generateTournamentBracket = createServerFn() export const generateTournamentBracket = createServerFn()
.validator(orderedTeamsSchema) .inputValidator(orderedTeamsSchema)
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { tournamentId, orderedTeamIds } }) => .handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
toServerResult(async () => { toServerResult(async () => {
logger.info("Generating tournament bracket", { logger.info("Generating tournament bracket", {
@@ -137,8 +138,8 @@ export const generateTournamentBracket = createServerFn()
); );
export const startMatch = createServerFn() export const startMatch = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult(async () => { toServerResult(async () => {
logger.info("Starting match", data); logger.info("Starting match", data);
@@ -170,8 +171,8 @@ const endMatchSchema = z.object({
ot_count: z.number(), ot_count: z.number(),
}); });
export const endMatch = createServerFn() export const endMatch = createServerFn()
.validator(endMatchSchema) .inputValidator(endMatchSchema)
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) => .handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
toServerResult(async () => { toServerResult(async () => {
logger.info("Ending match", matchId); logger.info("Ending match", matchId);
@@ -252,7 +253,7 @@ const toggleReactionSchema = z.object({
}); });
export const toggleMatchReaction = createServerFn() export const toggleMatchReaction = createServerFn()
.validator(toggleReactionSchema) .inputValidator(toggleReactionSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: { matchId, emoji }, context }) => .handler(async ({ data: { matchId, emoji }, context }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -312,7 +313,7 @@ export interface Reaction {
players: PlayerInfo[]; players: PlayerInfo[];
} }
export const getMatchReactions = createServerFn() export const getMatchReactions = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: matchId, context }) => .handler(async ({ data: matchId, context }) =>
toServerResult(async () => { toServerResult(async () => {

View File

@@ -25,7 +25,7 @@ const PlayerList = ({ players, loading = false }: PlayerListProps) => {
))} ))}
</List> </List>
return <List> return <List p="0">
{players?.map((player) => ( {players?.map((player) => (
<ListItem key={player.id} <ListItem key={player.id}
py='xs' py='xs'

View File

@@ -20,8 +20,8 @@ const Header = ({ player }: HeaderProps) => {
const name = useMemo(() => `${player.first_name} ${player.last_name}`, [player.first_name, player.last_name]); const name = useMemo(() => `${player.first_name} ${player.last_name}`, [player.first_name, player.last_name]);
const fontSize = useMemo(() => { const fontSize = useMemo(() => {
const baseSize = 24; const baseSize = 28;
const maxLength = 20; const maxLength = 24;
if (name.length <= maxLength) { if (name.length <= maxLength) {
return `${baseSize}px`; return `${baseSize}px`;

View File

@@ -1,10 +1,13 @@
import { Box } from "@mantine/core"; import { Box, Stack, Text, Divider } from "@mantine/core";
import { Suspense } from "react";
import Header from "./header"; import Header from "./header";
import SwipeableTabs from "@/components/swipeable-tabs"; import SwipeableTabs from "@/components/swipeable-tabs";
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries"; import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
import TeamList from "@/features/teams/components/team-list"; import TeamList from "@/features/teams/components/team-list";
import StatsOverview from "@/components/stats-overview"; import StatsOverview from "@/components/stats-overview";
import MatchList from "@/features/matches/components/match-list"; import MatchList from "@/features/matches/components/match-list";
import BadgeShowcase from "@/features/badges/components/badge-showcase";
import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton";
interface ProfileProps { interface ProfileProps {
id: string; id: string;
@@ -18,7 +21,19 @@ const Profile = ({ id }: ProfileProps) => {
const tabs = [ const tabs = [
{ {
label: "Overview", label: "Overview",
content: <StatsOverview statsData={stats} isLoading={statsLoading} />, content: <>
<Stack px="md">
<Text size="md" fw={700}>Badges</Text>
<Suspense fallback={<BadgeShowcaseSkeleton />}>
<BadgeShowcase playerId={id} />
</Suspense>
</Stack>
<Divider my="md" />
<Stack>
<Text px="md" size="md" fw={700}>Statistics</Text>
<StatsOverview statsData={stats} isLoading={statsLoading} />
</Stack>
</>,
}, },
{ {
label: "Matches", label: "Matches",

View File

@@ -5,13 +5,14 @@ import { Match } from "@/features/matches/types";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod"; import { z } from "zod";
import { logger } from "."; import { logger } from ".";
import { getWebRequest } from "@tanstack/react-start/server"; import { getRequest } from "@tanstack/react-start/server";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { serverFnLoggingMiddleware } from "@/utils/activities";
export const fetchMe = createServerFn() export const fetchMe = createServerFn()
.handler(async () => .handler(async () =>
toServerResult(async () => { toServerResult(async () => {
const request = getWebRequest(); const request = getRequest();
try { try {
const context = await getSessionContext(request); const context = await getSessionContext(request);
@@ -25,7 +26,7 @@ export const fetchMe = createServerFn()
phone: context.phone phone: context.phone
}; };
} catch (error: any) { } catch (error: any) {
logger.info("FetchMe: Session error", error) // logger.info("FetchMe: Session error", error)
if (error?.response?.status === 401) { if (error?.response?.status === 401) {
const errorData = error?.response?.data; const errorData = error?.response?.data;
if (errorData?.error === "SESSION_REFRESH_REQUIRED") { if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
@@ -38,15 +39,15 @@ export const fetchMe = createServerFn()
); );
export const getPlayer = createServerFn() export const getPlayer = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult<Player>(async () => await pbAdmin.getPlayer(data)) toServerResult<Player>(async () => await pbAdmin.getPlayer(data))
); );
export const updatePlayer = createServerFn() export const updatePlayer = createServerFn()
.validator(playerUpdateSchema) .inputValidator(playerUpdateSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {
const userAuthId = context.userAuthId; const userAuthId = context.userAuthId;
@@ -72,7 +73,7 @@ export const updatePlayer = createServerFn()
); );
export const createPlayer = createServerFn() export const createPlayer = createServerFn()
.validator(playerInputSchema) .inputValidator(playerInputSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -97,8 +98,8 @@ export const createPlayer = createServerFn()
); );
export const associatePlayer = createServerFn() export const associatePlayer = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {
const userAuthId = context.userAuthId; const userAuthId = context.userAuthId;
@@ -129,7 +130,7 @@ export const getUnassociatedPlayers = createServerFn()
); );
export const getPlayerStats = createServerFn() export const getPlayerStats = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult<PlayerStats>(async () => await pbAdmin.getPlayerStats(data)) toServerResult<PlayerStats>(async () => await pbAdmin.getPlayerStats(data))
@@ -142,14 +143,14 @@ export const getAllPlayerStats = createServerFn()
); );
export const getPlayerMatches = createServerFn() export const getPlayerMatches = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult<Match[]>(async () => await pbAdmin.getPlayerMatches(data)) toServerResult<Match[]>(async () => await pbAdmin.getPlayerMatches(data))
); );
export const getUnenrolledPlayers = createServerFn() export const getUnenrolledPlayers = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: tournamentId }) => .handler(async ({ data: tournamentId }) =>
toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId)) toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId))

View File

@@ -4,7 +4,8 @@ import {
Group, Group,
Box, Box,
Stack, Stack,
Divider Divider,
Title
} from "@mantine/core"; } from "@mantine/core";
import { useTeam } from "../queries"; import { useTeam } from "../queries";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
@@ -56,9 +57,9 @@ const TeamCard = ({ teamId }: TeamCardProps) => {
}} }}
/> />
<Box style={{ flex: 1, minWidth: 0 }}> <Box style={{ flex: 1, minWidth: 0 }}>
<Text size="md" fw={600} lineClamp={1} mb={2}> <Title order={5} lineClamp={1}>
{team.name} {team.name}
</Text> </Title>
<Text size="sm" c="dimmed" lineClamp={1}> <Text size="sm" c="dimmed" lineClamp={1}>
{team.players?.map(p => `${p.first_name} ${p.last_name}`).join(', ')} {team.players?.map(p => `${p.first_name} ${p.last_name}`).join(', ')}
</Text> </Text>

View File

@@ -1,7 +1,7 @@
import { Badge, FileInput, Group, Stack, Text, TextInput } from "@mantine/core"; import { FileInput, Stack, TextInput } from "@mantine/core";
import { useForm, UseFormInput } from "@mantine/form"; import { useForm, UseFormInput } from "@mantine/form";
import { LinkIcon } from "@phosphor-icons/react"; import { LinkIcon } from "@phosphor-icons/react";
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel"; import SlidePanel from "@/components/sheet/slide-panel";
import { isNotEmpty } from "@mantine/form"; import { isNotEmpty } from "@mantine/form";
import useCreateTeam from "../../hooks/use-create-team"; import useCreateTeam from "../../hooks/use-create-team";
import useUpdateTeam from "../../hooks/use-update-team"; import useUpdateTeam from "../../hooks/use-update-team";
@@ -13,8 +13,8 @@ import { useCallback } from "react";
import { TeamInput } from "../../types"; import { TeamInput } from "../../types";
import { teamKeys } from "../../queries"; import { teamKeys } from "../../queries";
import SongPicker from "./song-picker"; import SongPicker from "./song-picker";
import TeamColorPicker from "./color-picker";
import PlayersPicker from "./players-picker"; import PlayersPicker from "./players-picker";
import imageCompression from "browser-image-compression";
interface TeamFormProps { interface TeamFormProps {
close: () => void; close: () => void;
@@ -106,16 +106,35 @@ const TeamForm = ({
mutation(teamData, { mutation(teamData, {
onSuccess: async (team: any) => { onSuccess: async (team: any) => {
queryClient.invalidateQueries({ queryKey: teamKeys.list }); close();
queryClient.invalidateQueries({
queryKey: teamKeys.details(team.id),
});
if (logo && team) { if (logo && team) {
try { try {
let processedLogo = logo;
if (logo.size > 500 * 1024) {
const compressionOptions = {
maxSizeMB: 0.5,
maxWidthOrHeight: 800,
useWebWorker: true,
fileType: logo.type,
};
try {
processedLogo = await imageCompression(logo, compressionOptions);
logger.info("image compressed", {
originalSize: logo.size,
compressedSize: processedLogo.size,
reduction: ((logo.size - processedLogo.size) / logo.size * 100).toFixed(1) + "%"
});
} catch (compressionError) {
logger.warn("compression failed, falling back", compressionError);
processedLogo = logo;
}
}
const formData = new FormData(); const formData = new FormData();
formData.append("teamId", team.id); formData.append("teamId", team.id);
formData.append("logo", logo); formData.append("logo", processedLogo);
const response = await fetch("/api/teams/upload-logo", { const response = await fetch("/api/teams/upload-logo", {
method: "POST", method: "POST",
@@ -129,7 +148,12 @@ const TeamForm = ({
const result = await response.json(); const result = await response.json();
console.log("Logo upload result:", result);
queryClient.invalidateQueries({ queryKey: teamKeys.list });
queryClient.invalidateQueries({
queryKey: teamKeys.details(team.id),
});
queryClient.setQueryData( queryClient.setQueryData(
tournamentKeys.details(result.team!.id), tournamentKeys.details(result.team!.id),
result.team result.team
@@ -141,12 +165,16 @@ const TeamForm = ({
toast.error(logoErrorMessage); toast.error(logoErrorMessage);
logger.error("Team logo upload error", error); logger.error("Team logo upload error", error);
} }
} else {
queryClient.invalidateQueries({ queryKey: teamKeys.list });
queryClient.invalidateQueries({
queryKey: teamKeys.details(team.id),
});
} }
if (team && team.id) { if (team && team.id) {
onSubmit?.(team.id) onSubmit?.(team.id)
} }
close();
}, },
onError: (error: any) => { onError: (error: any) => {
toast.error(`${errorMessage}: ${error.message}`); toast.error(`${errorMessage}: ${error.message}`);

View File

@@ -1,7 +1,6 @@
import { useState, useRef, useEffect } from "react"; import { Text, Group, Avatar, Box } from "@mantine/core";
import { Text, TextInput, Group, Avatar, Loader, Paper, Stack, Box } from "@mantine/core";
import { SpotifyTrack } from "@/lib/spotify/types"; import { SpotifyTrack } from "@/lib/spotify/types";
import { useDebouncedCallback } from "@mantine/hooks"; import Typeahead, { TypeaheadOption } from "@/components/typeahead";
interface SongSearchProps { interface SongSearchProps {
onChange: (track: SpotifyTrack) => void; onChange: (track: SpotifyTrack) => void;
@@ -9,146 +8,46 @@ interface SongSearchProps {
} }
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => { const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
const [searchQuery, setSearchQuery] = useState(""); const searchSpotifyTracks = async (query: string): Promise<TypeaheadOption<SpotifyTrack>[]> => {
const [searchResults, setSearchResults] = useState<SpotifyTrack[]>([]);
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 searchSpotifyTracks = async (query: string): Promise<SpotifyTrack[]> => {
if (!query.trim()) return [];
try { try {
const response = await fetch(`/api/spotify/search?q=${encodeURIComponent(query)}`); // Use a default search term when query is empty to show popular tracks
const searchTerm = query.trim() || 'top hits';
const response = await fetch(`/api/spotify/search?q=${encodeURIComponent(searchTerm)}`);
if (!response.ok) { if (!response.ok) {
throw new Error('Search failed'); throw new Error('Search failed');
} }
const data = await response.json(); const data = await response.json();
return data.tracks || []; const tracks = data.tracks || [];
return tracks.map((track: SpotifyTrack) => ({
id: track.id,
data: track
}));
} catch (error) { } catch (error) {
console.error('Failed to search tracks:', error); console.error('Failed to search tracks:', error);
return []; return [];
} }
}; };
const debouncedSearch = useDebouncedCallback(async (query: string) => { const handleSongSelect = (option: TypeaheadOption<SpotifyTrack>) => {
if (!query.trim()) { onChange(option.data);
setSearchResults([]);
setIsOpen(false);
return;
}
setIsLoading(true);
try {
const results = await searchSpotifyTracks(query);
setSearchResults(results);
setIsOpen(results.length > 0);
setSelectedIndex(-1);
} catch (error) {
console.error('Search failed:', error);
setSearchResults([]);
setIsOpen(false);
} finally {
setIsLoading(false);
}
}, 300);
const handleSearchChange = (value: string) => {
setSearchQuery(value);
debouncedSearch(value);
}; };
const handleSongSelect = (track: SpotifyTrack) => { const formatTrack = (option: TypeaheadOption<SpotifyTrack>) => {
onChange(track); const track = option.data;
setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`); return `${track.name} - ${track.artists.map(a => a.name).join(', ')}`;
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]) {
handleSongSelect(searchResults[selectedIndex]);
}
break;
case 'Escape':
setIsOpen(false);
setSelectedIndex(-1);
break;
}
}; };
const renderOption = (option: TypeaheadOption<SpotifyTrack>) => {
const track = option.data;
return ( return (
<Box ref={containerRef} pos="relative" w="100%">
<TextInput
ref={inputRef}
value={searchQuery}
onChange={(event) => handleSearchChange(event.currentTarget.value)}
onKeyDown={handleKeyDown}
onFocus={() => {
if (searchResults.length > 0) setIsOpen(true);
}}
placeholder={placeholder}
rightSection={isLoading ? <Loader size="xs" /> : null}
/>
{isOpen && (
<Paper
shadow="md"
p={0}
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 9999,
maxHeight: '160px',
overflowY: 'auto',
WebkitOverflowScrolling: 'touch',
touchAction: 'pan-y'
}}
onTouchMove={(e) => e.stopPropagation()}
>
{searchResults.length > 0 ? (
<Stack gap={0}>
{searchResults.map((track, index) => (
<Box <Box
key={track.id}
p="sm" p="sm"
style={{ style={{
cursor: 'pointer', borderBottom: '1px solid var(--mantine-color-dimmed)'
backgroundColor: selectedIndex === index ? 'var(--mantine-color-gray-1)' : 'transparent',
borderBottom: index < searchResults.length - 1 ? '1px solid var(--mantine-color-gray-3)' : 'none'
}} }}
onClick={() => handleSongSelect(track)}
onMouseEnter={() => setSelectedIndex(index)}
> >
<Group gap="sm"> <Group gap="sm">
{track.album.images[2] && ( {track.album.images[2] && (
@@ -164,19 +63,18 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
</div> </div>
</Group> </Group>
</Box> </Box>
))}
</Stack>
) : (
<Box p="md">
<Text size="sm" c="dimmed" ta="center">
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
</Text>
</Box>
)}
</Paper>
)}
</Box>
); );
}; };
return (
<Typeahead
onSelect={handleSongSelect}
searchFn={searchSpotifyTracks}
renderOption={renderOption}
format={formatTrack}
placeholder={placeholder}
/>
)
};
export default SongSearch; export default SongSearch;

View File

@@ -58,7 +58,7 @@ const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
if (loading) if (loading)
return ( return (
<List> <List p="0">
{Array.from({ length: 10 }).map((_, i) => ( {Array.from({ length: 10 }).map((_, i) => (
<ListItem <ListItem
key={`skeleton-${i}`} key={`skeleton-${i}`}
@@ -72,7 +72,7 @@ const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
); );
return ( return (
<List> <List p="0">
{teams?.map((team) => ( {teams?.map((team) => (
<div key={team.id}> <div key={team.id}>
<ListItem <ListItem

View File

@@ -19,7 +19,7 @@ const Header = ({ name, logo, id }: HeaderProps) => {
src={logo && id ? `/api/files/teams/${id}/${logo}` : undefined} src={logo && id ? `/api/files/teams/${id}/${logo}` : undefined}
/> />
<Flex align="center" justify="center" gap={4} pb={20} w="100%"> <Flex align="center" justify="center" gap={4} pb={20} w="100%">
<Title ta="center" order={2}> <Title ta="center" order={1}>
{name} {name}
</Title> </Title>
</Flex> </Flex>

View File

@@ -6,6 +6,7 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { teamInputSchema, teamUpdateSchema } from "./types"; import { teamInputSchema, teamUpdateSchema } from "./types";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { Match } from "../matches/types"; import { Match } from "../matches/types";
import { serverFnLoggingMiddleware } from "@/utils/activities";
export const listTeamInfos = createServerFn() export const listTeamInfos = createServerFn()
@@ -15,22 +16,22 @@ export const listTeamInfos = createServerFn()
); );
export const getTeam = createServerFn() export const getTeam = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teamId }) => .handler(async ({ data: teamId }) =>
toServerResult(() => pbAdmin.getTeam(teamId)) toServerResult(() => pbAdmin.getTeam(teamId))
); );
export const getTeamInfo = createServerFn() export const getTeamInfo = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teamId }) => .handler(async ({ data: teamId }) =>
toServerResult(() => pbAdmin.getTeamInfo(teamId)) toServerResult(() => pbAdmin.getTeamInfo(teamId))
); );
export const createTeam = createServerFn() export const createTeam = createServerFn()
.validator(teamInputSchema) .inputValidator(teamInputSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data, context }) => .handler(async ({ data, context }) =>
toServerResult(async () => { toServerResult(async () => {
const userId = context.userAuthId; const userId = context.userAuthId;
@@ -46,11 +47,11 @@ export const createTeam = createServerFn()
); );
export const updateTeam = createServerFn() export const updateTeam = createServerFn()
.validator(z.object({ .inputValidator(z.object({
id: z.string(), id: z.string(),
updates: teamUpdateSchema updates: teamUpdateSchema
})) }))
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { id, updates }, context }) => .handler(async ({ data: { id, updates }, context }) =>
toServerResult(async () => { toServerResult(async () => {
const userId = context.userAuthId; const userId = context.userAuthId;
@@ -61,10 +62,10 @@ export const updateTeam = createServerFn()
throw new Error("Team not found"); throw new Error("Team not found");
} }
const isPlayerOnTeam = team.players.some(player => player.id === userId); //const isPlayerOnTeam = team.players.some(player => player.id === userId);
if (!isAdmin && !isPlayerOnTeam) { //if (!isAdmin && !isPlayerOnTeam) {
throw new Error("You can only update teams that you are a member of"); // throw new Error("You can only update teams that you are a member of");
} // }
logger.info("Updating team", { teamId: id, userId, isAdmin }); logger.info("Updating team", { teamId: id, userId, isAdmin });
return pbAdmin.updateTeam(id, updates); return pbAdmin.updateTeam(id, updates);
@@ -72,14 +73,14 @@ export const updateTeam = createServerFn()
); );
export const getTeamStats = createServerFn() export const getTeamStats = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teamId }) => .handler(async ({ data: teamId }) =>
toServerResult(() => pbAdmin.getTeamStats(teamId)) toServerResult(() => pbAdmin.getTeamStats(teamId))
); );
export const getTeamMatches = createServerFn() export const getTeamMatches = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult<Match[]>(async () => await pbAdmin.getTeamMatches(data)) toServerResult<Match[]>(async () => await pbAdmin.getTeamMatches(data))

View File

@@ -1,11 +1,11 @@
import { import {
Autocomplete,
Stack, Stack,
ActionIcon, ActionIcon,
Text, Text,
Group, Group,
Loader, Loader,
} from "@mantine/core"; } from "@mantine/core";
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
import { TrashIcon } from "@phosphor-icons/react"; import { TrashIcon } from "@phosphor-icons/react";
import { useState, useCallback, useMemo, memo } from "react"; import { useState, useCallback, useMemo, memo } from "react";
import { useTournament, useUnenrolledTeams } from "../queries"; import { useTournament, useUnenrolledTeams } from "../queries";
@@ -68,8 +68,6 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => {
}); });
const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => { const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
const [search, setSearch] = useState("");
const { data: tournament, isLoading: tournamentLoading } = const { data: tournament, isLoading: tournamentLoading } =
useTournament(tournamentId); useTournament(tournamentId);
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } = const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
@@ -78,27 +76,24 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam(); const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam(); const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
const autocompleteData = useMemo( const searchTeams = async (query: string): Promise<TypeaheadOption<Team>[]> => {
() => if (!query.trim()) return [];
unenrolledTeams.map((team: Team) => ({
value: team.id, const filtered = unenrolledTeams.filter((team: Team) =>
label: team.name, team.name.toLowerCase().includes(query.toLowerCase())
})),
[unenrolledTeams]
); );
return filtered.map((team: Team) => ({
id: team.id,
data: team
}));
};
const handleEnrollTeam = useCallback( const handleEnrollTeam = useCallback(
(teamId: string) => { (option: TypeaheadOption<Team>) => {
enrollTeam( enrollTeam({ tournamentId, teamId: option.data.id });
{ tournamentId, teamId },
{
onSuccess: () => {
setSearch("");
}, },
} [enrollTeam, tournamentId]
);
},
[enrollTeam, tournamentId, setSearch]
); );
const handleUnenrollTeam = useCallback( const handleUnenrollTeam = useCallback(
@@ -108,6 +103,31 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
[unenrollTeam, tournamentId] [unenrollTeam, tournamentId]
); );
const renderTeamOption = (option: TypeaheadOption<Team>) => {
const team = option.data;
return (
<Group py="xs" px="sm" gap="sm" align="center">
<Avatar
size={32}
radius="sm"
name={team.name}
src={
team.logo
? `/api/files/teams/${team.id}/${team.logo}`
: undefined
}
/>
<Text fw={500} truncate>
{team.name}
</Text>
</Group>
);
};
const formatTeam = (option: TypeaheadOption<Team>) => {
return option.data.name;
};
const isLoading = tournamentLoading || unenrolledLoading; const isLoading = tournamentLoading || unenrolledLoading;
const enrolledTeams = tournament?.teams || []; const enrolledTeams = tournament?.teams || [];
const hasEnrolledTeams = enrolledTeams.length > 0; const hasEnrolledTeams = enrolledTeams.length > 0;
@@ -118,16 +138,13 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
<Text fw={600} size="sm"> <Text fw={600} size="sm">
Add Team Add Team
</Text> </Text>
<Autocomplete <Typeahead
placeholder="Search for teams to enroll..." placeholder="Search for teams to enroll..."
data={autocompleteData} onSelect={handleEnrollTeam}
value={search} searchFn={searchTeams}
onChange={setSearch} renderOption={renderTeamOption}
onOptionSubmit={handleEnrollTeam} format={formatTeam}
disabled={isEnrolling || unenrolledLoading} disabled={isEnrolling || unenrolledLoading}
rightSection={isEnrolling ? <Loader size="xs" /> : null}
maxDropdownHeight={200}
limit={10}
/> />
</Stack> </Stack>

View File

@@ -45,7 +45,7 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
return ( return (
<> <>
<List> <List p="0">
<ListButton <ListButton
label="Edit Tournament" label="Edit Tournament"
Icon={HardDrivesIcon} Icon={HardDrivesIcon}

View File

@@ -0,0 +1,102 @@
import { Stack, Group, Text, ThemeIcon, Box, Center } from "@mantine/core";
import { CrownIcon, MedalIcon } from "@phosphor-icons/react";
import { Tournament } from "../types";
interface PodiumProps {
tournament: Tournament;
}
export const Podium = ({ tournament }: PodiumProps) => {
if (!tournament.first_place) return;
return (
<Stack gap="xs" px="md">
{tournament.first_place && (
<Group
gap="md"
p="md"
style={{
backgroundColor: 'var(--mantine-color-yellow-light)',
borderRadius: 'var(--mantine-radius-md)',
border: '3px solid var(--mantine-color-yellow-outline)',
boxShadow: 'var(--mantine-shadow-md)',
}}
>
<ThemeIcon size="xl" color="yellow" variant="light" radius="xl">
<CrownIcon size={24} />
</ThemeIcon>
<Stack gap={4} style={{ flex: 1 }}>
<Text size="md" fw={600}>
{tournament.first_place.name}
</Text>
<Group gap="xs">
{tournament.first_place.players?.map((player) => (
<Text key={player.id} size="sm" c="dimmed">
{player.first_name} {player.last_name}
</Text>
))}
</Group>
</Stack>
</Group>
)}
{tournament.second_place && (
<Group
gap="md"
p="xs"
style={{
backgroundColor: 'var(--mantine-color-default)',
borderRadius: 'var(--mantine-radius-md)',
border: '2px solid var(--mantine-color-default-border)',
boxShadow: 'var(--mantine-shadow-sm)',
}}
>
<ThemeIcon size="lg" color="gray" variant="light" radius="xl">
<MedalIcon size={20} />
</ThemeIcon>
<Stack gap={4} style={{ flex: 1 }}>
<Text size="sm" fw={600}>
{tournament.second_place.name}
</Text>
<Group gap="xs">
{tournament.second_place.players?.map((player) => (
<Text key={player.id} size="xs" c="dimmed">
{player.first_name} {player.last_name}
</Text>
))}
</Group>
</Stack>
</Group>
)}
{tournament.third_place && (
<Group
gap="md"
p="xs"
style={{
backgroundColor: 'var(--mantine-color-orange-light)',
borderRadius: 'var(--mantine-radius-md)',
border: '2px solid var(--mantine-color-orange-outline)',
boxShadow: 'var(--mantine-shadow-sm)',
}}
>
<ThemeIcon size="lg" color="orange" variant="light" radius="xl">
<MedalIcon size={18} />
</ThemeIcon>
<Stack gap={4} style={{ flex: 1 }}>
<Text size="sm" fw={600}>
{tournament.third_place.name}
</Text>
<Group gap="xs">
{tournament.third_place.players?.map((player) => (
<Text key={player.id} size="xs" c="dimmed">
{player.first_name} {player.last_name}
</Text>
))}
</Group>
</Stack>
</Group>
)}
</Stack>
);
};

View File

@@ -11,9 +11,9 @@ const Header = ({ tournament }: HeaderProps) => {
return ( return (
<> <>
<Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'> <Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'>
<Avatar name={tournament.name} radius={0} withBorder={false} size={125} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} /> <Avatar contain name={tournament.name} radius="sm" size={150} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />
<Flex align='center' justify='center' gap={4} pb={20} w='100%'> <Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Title ta='center' order={2}>{tournament.name}</Title> <Title ta='center' order={1}>{tournament.name}</Title>
</Flex> </Flex>
</Flex> </Flex>
</> </>

View File

@@ -1,12 +1,8 @@
import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core"; import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
import { Tournament } from "../../types"; import { Tournament } from "../../types";
import Avatar from "@/components/avatar"; import { CalendarIcon, MapPinIcon, TrophyIcon } from "@phosphor-icons/react";
import {
CalendarIcon,
MapPinIcon,
TrophyIcon,
} from "@phosphor-icons/react";
import { useMemo } from "react"; import { useMemo } from "react";
import GlitchAvatar from "@/components/glitch-avatar";
const Header = ({ tournament }: { tournament: Tournament }) => { const Header = ({ tournament }: { tournament: Tournament }) => {
const tournamentStart = useMemo( const tournamentStart = useMemo(
@@ -16,20 +12,26 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
return ( return (
<Stack px="sm" align="center" gap={0}> <Stack px="sm" align="center" gap={0}>
<Avatar <GlitchAvatar
name={tournament.name} name={tournament.name}
contain
src={ src={
tournament.logo tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}` ? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
: undefined : undefined
} }
glitchSrc={
tournament.glitch_logo
? `/api/files/tournaments/${tournament.id}/${tournament.glitch_logo}`
: undefined
}
radius="md" radius="md"
size={200} size={250}
px="xs" px="xs"
withBorder={false} withBorder={false}
> >
<TrophyIcon size={24} /> <TrophyIcon size={32} />
</Avatar> </GlitchAvatar>
<Flex gap="xs" direction="row" wrap="wrap" justify="space-around"> <Flex gap="xs" direction="row" wrap="wrap" justify="space-around">
{tournament.location && ( {tournament.location && (
<Group gap="xs"> <Group gap="xs">

View File

@@ -4,11 +4,12 @@ import { useAuth } from "@/contexts/auth-context";
import { Box, Divider, Stack, Text, Card, Center } from "@mantine/core"; import { Box, Divider, Stack, Text, Card, Center } from "@mantine/core";
import { Carousel } from "@mantine/carousel"; import { Carousel } from "@mantine/carousel";
import ListLink from "@/components/list-link"; import ListLink from "@/components/list-link";
import { TreeStructureIcon, UsersIcon, ClockIcon } from "@phosphor-icons/react"; import { TreeStructureIcon, UsersIcon, ClockIcon, TrophyIcon } from "@phosphor-icons/react";
import TeamListButton from "../upcoming-tournament/team-list-button"; import TeamListButton from "../upcoming-tournament/team-list-button";
import RulesListButton from "../upcoming-tournament/rules-list-button"; import RulesListButton from "../upcoming-tournament/rules-list-button";
import MatchCard from "@/features/matches/components/match-card"; import MatchCard from "@/features/matches/components/match-card";
import Header from "./header"; import Header from "./header";
import { Podium } from "../podium";
const StartedTournament: React.FC<{ tournament: Tournament }> = ({ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
tournament, tournament,
@@ -22,6 +23,20 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
[tournament.matches] [tournament.matches]
); );
const isTournamentOver = useMemo(() => {
const matches = tournament.matches || [];
if (matches.length === 0) return false;
const nonByeMatches = matches.filter((match) => !(match.status === 'tbd' && match.bye === true));
if (nonByeMatches.length === 0) return false;
const finalsMatch = nonByeMatches.reduce((highest, current) =>
(!highest || current.lid > highest.lid) ? current : highest
);
return finalsMatch?.status === 'ended';
}, [tournament.matches]);
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<Header tournament={tournament} /> <Header tournament={tournament} />
@@ -42,6 +57,10 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
))} ))}
</Carousel> </Carousel>
</Box> </Box>
) : isTournamentOver ? (
<Box px="lg" w="100%">
<Podium tournament={tournament} />
</Box>
) : ( ) : (
<Card withBorder radius="lg" p="xl" mx="md"> <Card withBorder radius="lg" p="xl" mx="md">
<Center> <Center>

View File

@@ -5,6 +5,7 @@ import {
Group, Group,
UnstyledButton, UnstyledButton,
Badge, Badge,
Title,
} from "@mantine/core"; } from "@mantine/core";
import { TournamentInfo } from "@/features/tournaments/types"; import { TournamentInfo } from "@/features/tournaments/types";
import { import {
@@ -27,14 +28,6 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
w="100%" w="100%"
onClick={() => navigate({ to: `/tournaments/${tournament.id}` })} onClick={() => navigate({ to: `/tournaments/${tournament.id}` })}
style={{ borderRadius: "var(--mantine-radius-md)" }} style={{ borderRadius: "var(--mantine-radius-md)" }}
styles={{
root: {
"&:hover": {
transform: "translateY(-2px)",
transition: "transform 0.15s ease",
},
},
}}
> >
<Card <Card
withBorder withBorder
@@ -45,21 +38,14 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
transition: "all 0.15s ease", transition: "all 0.15s ease",
border: "1px solid var(--mantine-color-default-border)", border: "1px solid var(--mantine-color-default-border)",
}} }}
styles={{
root: {
"&:hover": {
borderColor: "var(--mantine-primary-color-filled)",
boxShadow: "var(--mantine-shadow-sm)",
},
},
}}
> >
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Group gap="md" align="center"> <Group gap="md" align="center">
<Avatar <Avatar
size={90} size={75}
radius="sm" radius="sm"
name={tournament.name} name={tournament.name}
contain
src={ src={
tournament.logo tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}` ? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
@@ -69,14 +55,14 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
<TrophyIcon size={20} /> <TrophyIcon size={20} />
</Avatar> </Avatar>
<Stack gap="xs"> <Stack gap="xs">
<Text fw={600} size="lg" lineClamp={2}> <Title mb={-6} order={3} lineClamp={2}>
{tournament.name} {tournament.name}
</Text> </Title>
{(tournament.first_place || tournament.second_place || tournament.third_place) && ( {(tournament.first_place || tournament.second_place || tournament.third_place) && (
<Stack gap={6} > <Stack gap={6} >
{tournament.first_place && ( {tournament.first_place && (
<Badge <Badge
size="md" size="sm"
radius="md" radius="md"
variant="filled" variant="filled"
color="yellow" color="yellow"
@@ -94,7 +80,7 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
)} )}
{tournament.second_place && ( {tournament.second_place && (
<Badge <Badge
size="md" size="sm"
radius="md" radius="md"
color="gray" color="gray"
variant="filled" variant="filled"
@@ -111,7 +97,7 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
)} )}
{tournament.third_place && ( {tournament.third_place && (
<Badge <Badge
size="md" size="sm"
radius="md" radius="md"
color="orange" color="orange"
variant="filled" variant="filled"

View File

@@ -63,7 +63,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
if (loading) { if (loading) {
return ( return (
<List> <List p="0">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<ListItem <ListItem
key={`skeleton-${i}`} key={`skeleton-${i}`}
@@ -97,7 +97,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
} }
return ( return (
<List> <List p="0">
{tournaments.map((tournament) => ( {tournaments.map((tournament) => (
<> <>
<ListItem <ListItem
@@ -108,6 +108,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
radius="sm" radius="sm"
size={40} size={40}
name={tournament.name} name={tournament.name}
contain
src={ src={
tournament.logo tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}` ? `/api/files/tournaments/${tournament.id}/${tournament.logo}`

View File

@@ -14,6 +14,7 @@ import { Tournament } from "@/features/tournaments/types";
import { CrownIcon, MedalIcon, TreeStructureIcon } from "@phosphor-icons/react"; import { CrownIcon, MedalIcon, TreeStructureIcon } from "@phosphor-icons/react";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import ListLink from "@/components/list-link"; import ListLink from "@/components/list-link";
import { Podium } from "./podium";
interface TournamentStatsProps { interface TournamentStatsProps {
tournament: Tournament; tournament: Tournament;
@@ -40,118 +41,12 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
}); });
}, [tournament.team_stats]); }, [tournament.team_stats]);
const renderPodium = () => {
if (!isComplete || !tournament.first_place) {
return (
<Box p="md">
<Center>
<Text c="dimmed" size="sm">
Podium will appear here when the tournament is over
</Text>
</Center>
</Box>
);
}
return (
<Stack gap="xs" px="md">
{tournament.first_place && (
<Group
gap="md"
p="md"
style={{
backgroundColor: 'var(--mantine-color-yellow-light)',
borderRadius: 'var(--mantine-radius-md)',
border: '3px solid var(--mantine-color-yellow-outline)',
boxShadow: 'var(--mantine-shadow-md)',
}}
>
<ThemeIcon size="xl" color="yellow" variant="light" radius="xl">
<CrownIcon size={24} />
</ThemeIcon>
<Stack gap={4} style={{ flex: 1 }}>
<Text size="md" fw={600}>
{tournament.first_place.name}
</Text>
<Group gap="xs">
{tournament.first_place.players?.map((player) => (
<Text key={player.id} size="sm" c="dimmed">
{player.first_name} {player.last_name}
</Text>
))}
</Group>
</Stack>
</Group>
)}
{tournament.second_place && (
<Group
gap="md"
p="xs"
style={{
backgroundColor: 'var(--mantine-color-default)',
borderRadius: 'var(--mantine-radius-md)',
border: '2px solid var(--mantine-color-default-border)',
boxShadow: 'var(--mantine-shadow-sm)',
}}
>
<ThemeIcon size="lg" color="gray" variant="light" radius="xl">
<MedalIcon size={20} />
</ThemeIcon>
<Stack gap={4} style={{ flex: 1 }}>
<Text size="sm" fw={600}>
{tournament.second_place.name}
</Text>
<Group gap="xs">
{tournament.second_place.players?.map((player) => (
<Text key={player.id} size="xs" c="dimmed">
{player.first_name} {player.last_name}
</Text>
))}
</Group>
</Stack>
</Group>
)}
{tournament.third_place && (
<Group
gap="md"
p="xs"
style={{
backgroundColor: 'var(--mantine-color-orange-light)',
borderRadius: 'var(--mantine-radius-md)',
border: '2px solid var(--mantine-color-orange-outline)',
boxShadow: 'var(--mantine-shadow-sm)',
}}
>
<ThemeIcon size="lg" color="orange" variant="light" radius="xl">
<MedalIcon size={18} />
</ThemeIcon>
<Stack gap={4} style={{ flex: 1 }}>
<Text size="sm" fw={600}>
{tournament.third_place.name}
</Text>
<Group gap="xs">
{tournament.third_place.players?.map((player) => (
<Text key={player.id} size="xs" c="dimmed">
{player.first_name} {player.last_name}
</Text>
))}
</Group>
</Stack>
</Group>
)}
</Stack>
);
};
const teamStatsWithCalculations = useMemo(() => { const teamStatsWithCalculations = useMemo(() => {
return sortedTeamStats.map((stat, index) => ({ return sortedTeamStats.map((stat) => ({
...stat, ...stat,
index,
winPercentage: stat.matches > 0 ? (stat.wins / stat.matches) * 100 : 0, winPercentage: stat.matches > 0 ? (stat.wins / stat.matches) * 100 : 0,
avgCupsPerMatch: stat.matches > 0 ? stat.total_cups_made / stat.matches : 0, avgCupsPerMatch: stat.matches > 0 ? stat.total_cups_made / stat.matches : 0,
})); })).sort((a, b) => b.winPercentage - a.winPercentage);;
}, [sortedTeamStats]); }, [sortedTeamStats]);
const renderTeamStatsTable = () => { const renderTeamStatsTable = () => {
@@ -170,23 +65,14 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
return ( return (
<Stack gap={0}> <Stack gap={0}>
<Text px="md" size="lg" fw={600}>Results</Text> <Text px="md" size="lg" fw={600}>Results</Text>
{teamStatsWithCalculations.map((stat) => { <Text px="md" c="dimmed" size="xs" fw={500}>Sorted by win percentage</Text>
{teamStatsWithCalculations.map((stat, index) => {
return ( return (
<Box key={stat.id}> <Box key={stat.id}>
<UnstyledButton <UnstyledButton
w="100%" w="100%"
p="md" p="md"
style={{ style={{ borderRadius: 0 }}
borderRadius: 0,
transition: "background-color 0.15s ease",
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'var(--mantine-color-gray-0)',
},
},
}}
> >
<Group justify="space-between" align="center" w="100%"> <Group justify="space-between" align="center" w="100%">
<Group gap="sm" align="center"> <Group gap="sm" align="center">
@@ -194,12 +80,12 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
<Stack gap={2}> <Stack gap={2}>
<Group gap='xs'> <Group gap='xs'>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
#{stat.index + 1} #{index + 1}
</Text> </Text>
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
{stat.team_name} {stat.team_name}
</Text> </Text>
{stat.index === 0 && isComplete && ( {index === 0 && isComplete && (
<ThemeIcon size="xs" color="yellow" variant="light" radius="xl"> <ThemeIcon size="xs" color="yellow" variant="light" radius="xl">
<CrownIcon size={12} /> <CrownIcon size={12} />
</ThemeIcon> </ThemeIcon>
@@ -259,7 +145,7 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
</Group> </Group>
</Group> </Group>
</UnstyledButton> </UnstyledButton>
{stat.index < teamStatsWithCalculations.length - 1 && <Divider />} {index < teamStatsWithCalculations.length - 1 && <Divider />}
</Box> </Box>
); );
})} })}
@@ -270,7 +156,7 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
return ( return (
<Container size="100%" px={0}> <Container size="100%" px={0}>
<Stack gap="md"> <Stack gap="md">
{renderPodium()} <Podium tournament={tournament} />
<ListLink <ListLink
label={`View Bracket`} label={`View Bracket`}
to={`/tournaments/${tournament.id}/bracket`} to={`/tournaments/${tournament.id}/bracket`}
@@ -281,5 +167,3 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
</Container> </Container>
); );
}); });
TournamentStats.displayName = 'TournamentStats';

View File

@@ -28,13 +28,13 @@ const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
<Sheet title="Free Agent Enrollment" opened={isOpen} onChange={toggle}> <Sheet title="Free Agent Enrollment" opened={isOpen} onChange={toggle}>
<Stack gap="xs"> <Stack gap="xs">
<Text size="md"> <Text size="md">
Enrolling as a free agent will enter you in a pool of players wanting to play but don't have a teammate yet. Enrolling as a free agent adds you to a pool of players looking for teammates.
</Text> </Text>
<Text size="sm" c='dimmed'> <Text size="sm" c='dimmed'>
You will be able to see a list of other enrolled free agents, as well as their contact information for organizing your team and walkout song. By enrolling, your phone number will be visible to other free agents. Once enrolled, you can view other free agents and their phone number in order to coordinate teams and walkout songs.
</Text> </Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
Note: this does not guarantee you a spot in the tournament. One person from your team must enroll in the app and choose a walkout song in order to secure a spot. Important: Enrolling as a free agent does not guarantee a tournament spot. To secure a spot, one team member must register through the app and select a walkout song.
</Text> </Text>
<Button onClick={handleEnroll}>Confirm</Button> <Button onClick={handleEnroll}>Confirm</Button>
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button> <Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>

View File

@@ -27,7 +27,7 @@ const EnrollTeam = ({ tournamentId, onSubmit }: EnrollTeamProps) => {
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null); const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
const { data: teamData } = useServerQuery({ const { data: teamData } = useServerQuery({
...teamQueries.details(selectedTeamId!), ...teamQueries.details(selectedTeamId || ''),
options: { enabled: !!selectedTeamId } options: { enabled: !!selectedTeamId }
}); });

View File

@@ -1,6 +1,7 @@
import { Stack, Button, Divider, Autocomplete, Group, ComboboxItem } from '@mantine/core'; import { Stack, Button, Divider, Group, ComboboxItem, Text } from '@mantine/core';
import { PlusIcon } from '@phosphor-icons/react'; import { PlusIcon } from '@phosphor-icons/react';
import React, { useMemo, useState } from 'react'; import React, { useMemo } from 'react';
import Typeahead, { TypeaheadOption } from '@/components/typeahead';
interface TeamSelectionViewProps { interface TeamSelectionViewProps {
options: ComboboxItem[]; options: ComboboxItem[];
@@ -11,11 +12,37 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
options, options,
onSelect onSelect
}) => { }) => {
const [value, setValue] = useState<string>(''); const [selectedTeam, setSelectedTeam] = React.useState<ComboboxItem | null>(null);
const selectedOption = useMemo(() => options.find(option => option.label === value), [value, options])
const searchTeams = async (query: string): Promise<TypeaheadOption<ComboboxItem>[]> => {
const filtered = options.filter(option =>
option.label.toLowerCase().includes(query.toLowerCase())
);
return filtered.map(option => ({
id: String(option.value),
data: option
}));
};
const handleTeamSelect = (option: TypeaheadOption<ComboboxItem>) => {
setSelectedTeam(option.data);
};
const renderTeamOption = (option: TypeaheadOption<ComboboxItem>) => {
return (
<Group py="xs" px="sm" gap="sm">
<Text fw={500}>{option.data.label}</Text>
</Group>
);
};
const formatTeam = (option: TypeaheadOption<ComboboxItem>) => {
return option.data.label;
};
const handleCreateNewTeamClicked = () => onSelect(undefined); const handleCreateNewTeamClicked = () => onSelect(undefined);
const handleSelectExistingTeam = () => onSelect(selectedOption?.value) const handleSelectExistingTeam = () => onSelect(selectedTeam?.value);
return ( return (
<Stack gap="md"> <Stack gap="md">
@@ -31,17 +58,18 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
<Divider my="sm" label="or" /> <Divider my="sm" label="or" />
<Stack gap="sm"> <Stack gap="sm">
<Autocomplete <Typeahead
placeholder="Select one of your existing teams" placeholder="Select one of your existing teams"
value={value} onSelect={handleTeamSelect}
onChange={setValue} searchFn={searchTeams}
data={options.map(option => option.label)} renderOption={renderTeamOption}
comboboxProps={{ withinPortal: false }} format={formatTeam}
maxHeight={80}
/> />
<Button <Button
onClick={handleSelectExistingTeam} onClick={handleSelectExistingTeam}
disabled={!selectedOption} disabled={!selectedTeam}
fullWidth fullWidth
> >
Enroll Selected Team Enroll Selected Team

View File

@@ -1,6 +1,6 @@
import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core"; import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
import { Tournament } from "../../types"; import { Tournament } from "../../types";
import Avatar from "@/components/avatar"; import GlitchAvatar from "@/components/glitch-avatar";
import { import {
CalendarIcon, CalendarIcon,
MapPinIcon, MapPinIcon,
@@ -16,21 +16,27 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
); );
return ( return (
<Stack align="center" gap={0}> <Stack align="center" gap={16}>
<Avatar <GlitchAvatar
name={tournament.name} name={tournament.name}
contain
src={ src={
tournament.logo tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}` ? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
: undefined : undefined
} }
glitchSrc={
tournament.glitch_logo
? `/api/files/tournaments/${tournament.id}/${tournament.glitch_logo}`
: undefined
}
radius="md" radius="md"
size={300} size={300}
px="xs" px="xs"
withBorder={false} withBorder={false}
> >
<TrophyIcon size={32} /> <TrophyIcon size={32} />
</Avatar> </GlitchAvatar>
<Flex gap="xs" direction="column" justify="space-around"> <Flex gap="xs" direction="column" justify="space-around">
{tournament.location && ( {tournament.location && (
<Group gap="xs"> <Group gap="xs">

View File

@@ -1,7 +1,7 @@
import { Suspense, useCallback, useMemo } from "react"; import { Suspense, useCallback, useMemo } from "react";
import { Tournament } from "../../types"; import { Tournament } from "../../types";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
import { Box, Button, Card, Divider, Group, Stack, Text } from "@mantine/core"; import { Box, Button, Card, Divider, Group, Stack, Text, Title } from "@mantine/core";
import Countdown from "@/components/countdown"; import Countdown from "@/components/countdown";
import ListLink from "@/components/list-link"; import ListLink from "@/components/list-link";
import ListButton from "@/components/list-button"; import ListButton from "@/components/list-button";
@@ -56,11 +56,11 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
<Card withBorder radius="lg" p="lg"> <Card withBorder radius="lg" p="lg">
<Stack gap="xs"> <Stack gap="xs">
<Group mb="sm" gap="xs" align="center"> <Group gap="xs" align="center">
<UsersIcon size={16} /> <UsersIcon size={16} />
<Text size="sm" fw={500}> <Title mt={4} order={5}>
Enrollment Enrollment
</Text> </Title>
{isEnrollmentOpen && ( {isEnrollmentOpen && (
<Box ml="auto"> <Box ml="auto">
<Countdown <Countdown

View File

@@ -18,7 +18,6 @@ interface UpdateTeamProps {
const UpdateTeam = ({ tournamentId, teamId }: UpdateTeamProps) => { const UpdateTeam = ({ tournamentId, teamId }: UpdateTeamProps) => {
const { open, isOpen, toggle } = useSheet(); const { open, isOpen, toggle } = useSheet();
const { data: team } = useTeam(teamId); const { data: team } = useTeam(teamId);
const initialValues = useMemo(() => { const initialValues = useMemo(() => {

View File

@@ -5,6 +5,8 @@ import { tournamentInputSchema } from "@/features/tournaments/types";
import { logger } from "."; import { logger } from ".";
import { z } from "zod"; import { z } from "zod";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { serverFnLoggingMiddleware } from "@/utils/activities";
import { fa } from "zod/v4/locales";
export const listTournaments = createServerFn() export const listTournaments = createServerFn()
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
@@ -13,24 +15,24 @@ export const listTournaments = createServerFn()
); );
export const createTournament = createServerFn() export const createTournament = createServerFn()
.validator(tournamentInputSchema) .inputValidator(tournamentInputSchema)
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult(() => pbAdmin.createTournament(data)) toServerResult(() => pbAdmin.createTournament(data))
); );
export const updateTournament = createServerFn() export const updateTournament = createServerFn()
.validator(z.object({ .inputValidator(z.object({
id: z.string(), id: z.string(),
updates: tournamentInputSchema.partial() updates: tournamentInputSchema.partial()
})) }))
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult(() => pbAdmin.updateTournament(data.id, data.updates)) toServerResult(() => pbAdmin.updateTournament(data.id, data.updates))
); );
export const getTournament = createServerFn() export const getTournament = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: tournamentId, context }) => { .handler(async ({ data: tournamentId, context }) => {
const isAdmin = context.roles.includes("Admin"); const isAdmin = context.roles.includes("Admin");
@@ -44,11 +46,11 @@ export const getCurrentTournament = createServerFn()
); );
export const enrollTeam = createServerFn() export const enrollTeam = createServerFn()
.validator(z.object({ .inputValidator(z.object({
tournamentId: z.string(), tournamentId: z.string(),
teamId: z.string() teamId: z.string()
})) }))
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { tournamentId, teamId }, context }) => .handler(async ({ data: { tournamentId, teamId }, context }) =>
toServerResult(async () => { toServerResult(async () => {
const userId = context.userAuthId; const userId = context.userAuthId;
@@ -57,10 +59,18 @@ export const enrollTeam = createServerFn()
const team = await pbAdmin.getTeam(teamId); const team = await pbAdmin.getTeam(teamId);
if (!team) { throw new Error('Team not found'); } if (!team) { throw new Error('Team not found'); }
const isPlayerOnTeam = team.players?.some(player => player.id === userId); //const isPlayerOnTeam = team.players?.some(player => player.id === userId);
if (!isPlayerOnTeam && !isAdmin) { //if (!isPlayerOnTeam && !isAdmin) {
throw new Error('You do not have permission to enroll this team'); // throw new Error('You do not have permission to enroll this team');
//}
const freeAgents = await pbAdmin.getFreeAgents(tournamentId);
for (const player of team.players || []) {
const isFreeAgent = freeAgents.some(fa => fa.player?.id === player.id);
if (isFreeAgent) {
await pbAdmin.unenrollFreeAgent(player.id, tournamentId);
}
} }
logger.info('Enrolling team in tournament', { tournamentId, teamId, userId }); logger.info('Enrolling team in tournament', { tournamentId, teamId, userId });
@@ -70,32 +80,32 @@ export const enrollTeam = createServerFn()
); );
export const unenrollTeam = createServerFn() export const unenrollTeam = createServerFn()
.validator(z.object({ .inputValidator(z.object({
tournamentId: z.string(), tournamentId: z.string(),
teamId: z.string() teamId: z.string()
})) }))
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { tournamentId, teamId }, context }) => .handler(async ({ data: { tournamentId, teamId }, context }) =>
toServerResult(() => pbAdmin.unenrollTeam(tournamentId, teamId)) toServerResult(() => pbAdmin.unenrollTeam(tournamentId, teamId))
); );
export const getUnenrolledTeams = createServerFn() export const getUnenrolledTeams = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: tournamentId }) => .handler(async ({ data: tournamentId }) =>
toServerResult(() => pbAdmin.getUnenrolledTeams(tournamentId)) toServerResult(() => pbAdmin.getUnenrolledTeams(tournamentId))
); );
export const getFreeAgents = createServerFn() export const getFreeAgents = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: tournamentId }) => .handler(async ({ data: tournamentId }) =>
toServerResult(() => pbAdmin.getFreeAgents(tournamentId)) toServerResult(() => pbAdmin.getFreeAgents(tournamentId))
); );
export const enrollFreeAgent = createServerFn() export const enrollFreeAgent = createServerFn()
.validator(z.object({ phone: z.string(), tournamentId: z.string() })) .inputValidator(z.object({ phone: z.string(), tournamentId: z.string() }))
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {
const userAuthId = context.userAuthId; const userAuthId = context.userAuthId;
@@ -108,8 +118,8 @@ export const enrollFreeAgent = createServerFn()
); );
export const unenrollFreeAgent = createServerFn() export const unenrollFreeAgent = createServerFn()
.validator(z.object({ tournamentId: z.string() })) .inputValidator(z.object({ tournamentId: z.string() }))
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {
const userAuthId = context.userAuthId; const userAuthId = context.userAuthId;

View File

@@ -25,6 +25,7 @@ export interface TournamentInfo {
start_time?: string; start_time?: string;
end_time?: string; end_time?: string;
logo?: string; logo?: string;
glitch_logo?: string;
first_place?: TeamInfo; first_place?: TeamInfo;
second_place?: TeamInfo; second_place?: TeamInfo;
third_place?: TeamInfo; third_place?: TeamInfo;
@@ -37,6 +38,7 @@ export interface Tournament {
desc?: string; desc?: string;
rules?: string; rules?: string;
logo?: string; logo?: string;
glitch_logo?: string;
enroll_time?: string; enroll_time?: string;
start_time: string; start_time: string;
end_time?: string; end_time?: string;

View File

@@ -19,6 +19,7 @@ const eventHandlers: Record<string, EventHandler> = {
logger.info("New Connection"); logger.info("New Connection");
}, },
"ping": () => {}, "ping": () => {},
"heartbeat": () => {},
"match": (event, queryClient) => { "match": (event, queryClient) => {
queryClient.invalidateQueries(tournamentQueries.details(event.tournamentId)) queryClient.invalidateQueries(tournamentQueries.details(event.tournamentId))
queryClient.invalidateQueries(tournamentQueries.current()) queryClient.invalidateQueries(tournamentQueries.current())
@@ -37,6 +38,7 @@ export function useServerEvents() {
const timeoutRef = useRef<NodeJS.Timeout | null>(null); const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return;
if (!user?.id) return; if (!user?.id) return;
shouldConnectRef.current = true; shouldConnectRef.current = true;
@@ -72,15 +74,15 @@ export function useServerEvents() {
logger.error("SSE connection error", error); logger.error("SSE connection error", error);
eventSource.close(); eventSource.close();
if (shouldConnectRef.current && retryCountRef.current < 5) { if (shouldConnectRef.current && retryCountRef.current < 10) {
retryCountRef.current += 1; retryCountRef.current += 1;
const delay = Math.min( const delay = Math.min(
1000 * Math.pow(2, retryCountRef.current - 1), 1000 * Math.pow(1.5, retryCountRef.current - 1),
30000 15000
); );
logger.info( logger.info(
`SSE reconnection attempt ${retryCountRef.current}/5 in ${delay}ms` `SSE reconnection attempt ${retryCountRef.current}/10 in ${delay}ms`
); );
timeoutRef.current = setTimeout(() => { timeoutRef.current = setTimeout(() => {
@@ -88,7 +90,7 @@ export function useServerEvents() {
connectEventSource(); connectEventSource();
} }
}, delay); }, delay);
} else if (retryCountRef.current >= 5) { } else if (retryCountRef.current >= 10) {
logger.error("SSE max reconnection attempts reached"); logger.error("SSE max reconnection attempts reached");
} }
}; };
@@ -111,5 +113,5 @@ export function useServerEvents() {
eventSource.close(); eventSource.close();
} }
}; };
}, [user?.id, queryClient]); }, [user?.id]);
} }

View File

@@ -2,6 +2,23 @@ import { EventEmitter } from "events";
export const serverEvents = new EventEmitter(); export const serverEvents = new EventEmitter();
serverEvents.setMaxListeners(50);
// Debug logging for listener count
if (process.env.NODE_ENV === 'development') {
setInterval(() => {
const listenerCounts = {
test: serverEvents.listenerCount('test'),
match: serverEvents.listenerCount('match'),
reaction: serverEvents.listenerCount('reaction'),
};
if (listenerCounts.test > 0 || listenerCounts.match > 0 || listenerCounts.reaction > 0) {
console.log('ServerEvents listener count:', listenerCounts);
}
}, 30000); // Log every 30 seconds in development
}
export type TestEvent = { export type TestEvent = {
type: "test"; type: "test";
playerId: string; playerId: string;

View File

@@ -46,7 +46,7 @@ class Logger {
constructor(context?: string, options: LoggerOptions = {}) { constructor(context?: string, options: LoggerOptions = {}) {
this.context = context; this.context = context;
this.options = { this.options = {
enabled: process.env.NODE_ENV !== "production", enabled: import.meta.env.NODE_ENV !== "production",
showTimestamp: true, showTimestamp: true,
collapsed: true, collapsed: true,
colors: true, colors: true,

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