Compare commits
47 Commits
main_old
...
af0ec85811
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af0ec85811 | ||
|
|
d18d148d32 | ||
|
|
95a50ee7a7 | ||
|
|
1ef786ea79 | ||
|
|
47962a8681 | ||
|
|
2e6d3366e4 | ||
|
|
fafe5ca3ec | ||
|
|
b52c79772f | ||
|
|
8579ec36ca | ||
|
|
2dfb7c63d3 | ||
|
|
03b2b54c1f | ||
|
|
0910f11228 | ||
|
|
a376f98fe7 | ||
|
|
1f4f66f8c5 | ||
|
|
5729dab35f | ||
|
|
c05fd5dc6d | ||
|
|
b9a42b4743 | ||
|
|
74e28cc2ac | ||
|
|
adf304b1e0 | ||
|
|
d18cdce15f | ||
|
|
aa87a9da5b | ||
|
|
6224404aa9 | ||
|
|
654041b6b6 | ||
|
|
ce29c41bf3 | ||
|
|
63ea515a31 | ||
|
|
8b1bbe213d | ||
|
|
ed538b7373 | ||
|
|
03e3bbcbc0 | ||
|
|
baf75eddba | ||
|
|
5094933302 | ||
|
|
9564b46d45 | ||
|
|
ece5094f13 | ||
|
|
cfe1ee7171 | ||
|
|
3a41609a91 | ||
|
|
732afaf623 | ||
|
|
48aeaabeea | ||
|
|
a4b9fe9065 | ||
|
|
31e50af593 | ||
|
|
39053cadaa | ||
|
|
ea6656aa33 | ||
|
|
92c4987372 | ||
|
|
b3ebf46afa | ||
|
|
c0ef535001 | ||
|
|
81329e4354 | ||
|
|
36f3bb77d4 | ||
|
|
6760ea46f9 | ||
|
|
e4164cbc71 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,3 +20,4 @@ yarn.lock
|
|||||||
/scripts/
|
/scripts/
|
||||||
/pb_data/
|
/pb_data/
|
||||||
/.tanstack/
|
/.tanstack/
|
||||||
|
/dist/
|
||||||
18
package.json
18
package.json
@@ -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": "node .output/server/index.mjs"
|
"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",
|
||||||
@@ -21,14 +22,17 @@
|
|||||||
"@svgmoji/noto": "^3.2.0",
|
"@svgmoji/noto": "^3.2.0",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
"@tanstack/react-query-devtools": "^5.66.0",
|
"@tanstack/react-query-devtools": "^5.66.0",
|
||||||
"@tanstack/react-router": "1.130.12",
|
"@tanstack/react-router": "^1.130.12",
|
||||||
"@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",
|
||||||
"@tiptap/pm": "^3.4.3",
|
"@tiptap/pm": "^3.4.3",
|
||||||
"@tiptap/react": "^3.4.3",
|
"@tiptap/react": "^3.4.3",
|
||||||
"@tiptap/starter-kit": "^3.4.3",
|
"@tiptap/starter-kit": "^3.4.3",
|
||||||
|
"@types/bun": "^1.2.22",
|
||||||
"@types/ioredis": "^4.28.10",
|
"@types/ioredis": "^4.28.10",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
"dotenv": "^17.2.2",
|
"dotenv": "^17.2.2",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.23.12",
|
"framer-motion": "^12.23.12",
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
108
pb_migrations/1759244692_created_activities.js
Normal file
108
pb_migrations/1759244692_created_activities.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1579384326",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json4225120046",
|
||||||
|
"maxSize": 0,
|
||||||
|
"name": "arguments",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2551806565",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "player",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3293145029",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "user_agent",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_1262591861",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "activities",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1262591861");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
27
pb_migrations/1759245857_updated_activities.js
Normal file
27
pb_migrations/1759245857_updated_activities.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1262591861")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(5, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number2254405824",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "duration",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1262591861")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("number2254405824")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
43
pb_migrations/1759246171_updated_activities.js
Normal file
43
pb_migrations/1759246171_updated_activities.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1262591861")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(6, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool1862328242",
|
||||||
|
"name": "success",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(7, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1574812785",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "error",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1262591861")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("bool1862328242")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("text1574812785")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
27
pb_migrations/1759340868_updated_badges.js
Normal file
27
pb_migrations/1759340868_updated_badges.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1340419796")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(7, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number4113142680",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "order",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1340419796")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("number4113142680")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
46
pb_migrations/1759344923_updated_players.js
Normal file
46
pb_migrations/1759344923_updated_players.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_3072146508")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("relation2029409178")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("relation2813965191")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_3072146508")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(5, new Field({
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1340419796",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2029409178",
|
||||||
|
"maxSelect": 999,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "badges",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(6, new Field({
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1340419796",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2813965191",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "featured_badge",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
187
pb_migrations/1759344931_deleted_player_badges_view.js
Normal file
187
pb_migrations/1759344931_deleted_player_badges_view.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_5062686152");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2582050271",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "player_id",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1340419796",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation4154639100",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "badge_id",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_GhrR",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "badge_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_DEaW",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "badge_description",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_MHmw",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "badge_type",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"tournament_participation",
|
||||||
|
"tournament_placement",
|
||||||
|
"performance",
|
||||||
|
"overtime",
|
||||||
|
"match_milestone"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_11YE",
|
||||||
|
"max": 50,
|
||||||
|
"min": 0,
|
||||||
|
"name": "badge_icon",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_qAJu",
|
||||||
|
"max": 50,
|
||||||
|
"min": 0,
|
||||||
|
"name": "badge_color",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_giOf",
|
||||||
|
"name": "is_progressive",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json3212413036",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "current_progress",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json4171899439",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "target_progress",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json3435813110",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "is_earned",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_Q7lC",
|
||||||
|
"max": "",
|
||||||
|
"min": "",
|
||||||
|
"name": "earned_at",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "date"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_5062686152",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "player_badges_view",
|
||||||
|
"system": false,
|
||||||
|
"type": "view",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewQuery": "\n SELECT\n (p.id || '_' || b.id) as id,\n p.id as player_id,\n b.id as badge_id,\n b.name as badge_name,\n b.description as badge_description,\n b.type as badge_type,\n b.icon as badge_icon,\n b.color as badge_color,\n b.is_progressive,\n COALESCE(pbp.current_progress, 0) as current_progress,\n COALESCE(pbp.target_progress, b.progress_target, 1) as target_progress,\n COALESCE(pbp.is_earned, false) as is_earned,\n pbp.earned_at\n FROM players p\n CROSS JOIN badges b\n LEFT JOIN player_badge_progress pbp ON pbp.player_id = p.id AND pbp.badge_id = b.id\n ",
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
})
|
||||||
129
pb_migrations/1759344938_deleted_player_badge_progress.js
Normal file
129
pb_migrations/1759344938_deleted_player_badge_progress.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_4251874343");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2847519201",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 1,
|
||||||
|
"name": "player_id",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "pbc_1340419796",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation3948571039",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 1,
|
||||||
|
"name": "badge_id",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number1847293057",
|
||||||
|
"max": null,
|
||||||
|
"min": 0,
|
||||||
|
"name": "current_progress",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number2948571040",
|
||||||
|
"max": null,
|
||||||
|
"min": 1,
|
||||||
|
"name": "target_progress",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool3049672141",
|
||||||
|
"name": "is_earned",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "date1150773242",
|
||||||
|
"max": "",
|
||||||
|
"min": "",
|
||||||
|
"name": "earned_at",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_4251874343",
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX `idx_unique_player_badge` ON `player_badge_progress` (`player_id`, `badge_id`)"
|
||||||
|
],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "player_badge_progress",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
})
|
||||||
173
pb_migrations/1759344944_deleted_badges.js
Normal file
173
pb_migrations/1759344944_deleted_badges.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1340419796");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1579384326",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1843675174",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "description",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "select4029814376",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "type",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"tournament_participation",
|
||||||
|
"tournament_placement",
|
||||||
|
"performance",
|
||||||
|
"overtime",
|
||||||
|
"match_milestone"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json1578432567",
|
||||||
|
"maxSize": 2000000,
|
||||||
|
"name": "criteria",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3928475610",
|
||||||
|
"max": 50,
|
||||||
|
"min": 0,
|
||||||
|
"name": "icon",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1847293056",
|
||||||
|
"max": 50,
|
||||||
|
"min": 0,
|
||||||
|
"name": "color",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number4113142680",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "order",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool2847519203",
|
||||||
|
"name": "is_progressive",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number2948571038",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "progress_target",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_1340419796",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "badges",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
})
|
||||||
145
pb_migrations/1759345060_created_badges.js
Normal file
145
pb_migrations/1759345060_created_badges.js
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1579384326",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text2324736937",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "key",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1843675174",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "description",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json3055524737",
|
||||||
|
"maxSize": 0,
|
||||||
|
"name": "criteria",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "select2363381545",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "type",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"manual",
|
||||||
|
"match",
|
||||||
|
"tournament"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool3646955747",
|
||||||
|
"name": "progressive",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number4113142680",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "order",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_1340419796",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "badges",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1340419796");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
104
pb_migrations/1759345122_created_player_badge_progress.js
Normal file
104
pb_migrations/1759345122_created_player_badge_progress.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1340419796",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation4277159965",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "badge",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2551806565",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "player",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number570552902",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "progress",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool2625885481",
|
||||||
|
"name": "earned",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_1063824264",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "player_badge_progress",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1063824264");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
20
pb_migrations/1759345318_updated_player_badge_progress.js
Normal file
20
pb_migrations/1759345318_updated_player_badge_progress.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1063824264")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"name": "badge_progress"
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1063824264")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"name": "player_badge_progress"
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
29
pb_migrations/1759594431_updated_tournaments.js
Normal file
29
pb_migrations/1759594431_updated_tournaments.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(10, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "file538556518",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"maxSize": 0,
|
||||||
|
"mimeTypes": [],
|
||||||
|
"name": "glitch_logo",
|
||||||
|
"presentable": false,
|
||||||
|
"protected": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"thumbs": [],
|
||||||
|
"type": "file"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("file538556518")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
42
pb_migrations/1759594880_updated_tournaments.js
Normal file
42
pb_migrations/1759594880_updated_tournaments.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(10, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "file538556518",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"maxSize": 6000000,
|
||||||
|
"mimeTypes": [],
|
||||||
|
"name": "glitch_logo",
|
||||||
|
"presentable": false,
|
||||||
|
"protected": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"thumbs": [],
|
||||||
|
"type": "file"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(10, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "file538556518",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"maxSize": 0,
|
||||||
|
"mimeTypes": [],
|
||||||
|
"name": "glitch_logo",
|
||||||
|
"presentable": false,
|
||||||
|
"protected": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"thumbs": [],
|
||||||
|
"type": "file"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
366
server.ts
Normal file
366
server.ts
Normal 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)
|
||||||
|
})
|
||||||
@@ -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>()
|
|
||||||
|
|||||||
@@ -1,15 +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";
|
||||||
|
|
||||||
import dotenv from 'dotenv';
|
export function getRouter() {
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
|
|
||||||
export function createRouter() {
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
@@ -22,28 +18,32 @@ export function createRouter() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return routerWithQueryClient(
|
const router = createTanStackRouter({
|
||||||
createTanStackRouter({
|
routeTree,
|
||||||
routeTree,
|
context: {
|
||||||
context: {
|
queryClient,
|
||||||
queryClient,
|
auth: undefined!,
|
||||||
auth: undefined!,
|
header: defaultHeaderConfig,
|
||||||
header: defaultHeaderConfig,
|
refresh: [],
|
||||||
refresh: [],
|
withPadding: true,
|
||||||
withPadding: true,
|
fullWidth: false,
|
||||||
fullWidth: false,
|
},
|
||||||
},
|
defaultPreload: "intent",
|
||||||
defaultPreload: "intent",
|
defaultErrorComponent: DefaultCatchBoundary,
|
||||||
defaultErrorComponent: DefaultCatchBoundary,
|
scrollRestoration: true,
|
||||||
scrollRestoration: true,
|
defaultViewTransition: false,
|
||||||
defaultViewTransition: false,
|
});
|
||||||
}),
|
|
||||||
|
setupRouterSsrQueryIntegration({
|
||||||
|
router,
|
||||||
queryClient
|
queryClient
|
||||||
);
|
})
|
||||||
|
|
||||||
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "@tanstack/react-router" {
|
declare module "@tanstack/react-router" {
|
||||||
interface Register {
|
interface Register {
|
||||||
router: ReturnType<typeof createRouter>;
|
router: ReturnType<typeof getRouter>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/TanStack/router/discussions/3531
|
if (location.pathname === '/login' || location.pathname === '/logout') {
|
||||||
const auth = await ensureServerQueryData(
|
return {};
|
||||||
context.queryClient,
|
}
|
||||||
playerQueries.auth()
|
|
||||||
);
|
try {
|
||||||
return { auth };
|
// https://github.com/TanStack/router/discussions/3531
|
||||||
|
const auth = await ensureServerQueryData(
|
||||||
|
context.queryClient,
|
||||||
|
playerQueries.auth()
|
||||||
|
);
|
||||||
|
return { auth };
|
||||||
|
} catch (error) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
pendingComponent: () => <Providers><FullScreenLoader /></Providers>,
|
pendingComponent: () => <Providers><FullScreenLoader /></Providers>,
|
||||||
});
|
});
|
||||||
@@ -114,8 +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>
|
||||||
|
|||||||
24
src/app/routes/_authed/admin/activities.tsx
Normal file
24
src/app/routes/_authed/admin/activities.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||||
|
import { ActivitiesTable, activityQueries } from "@/features/activities";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authed/admin/activities")({
|
||||||
|
component: Stats,
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
const queryClient = context.queryClient;
|
||||||
|
prefetchServerQuery(queryClient, activityQueries.search());
|
||||||
|
},
|
||||||
|
loader: () => ({
|
||||||
|
withPadding: false,
|
||||||
|
fullWidth: true,
|
||||||
|
header: {
|
||||||
|
title: "Activities",
|
||||||
|
withBackButton: true,
|
||||||
|
},
|
||||||
|
refresh: [activityQueries.search().queryKey],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function Stats() {
|
||||||
|
return <ActivitiesTable />;
|
||||||
|
}
|
||||||
10
src/app/routes/_authed/admin/badges.tsx
Normal file
10
src/app/routes/_authed/admin/badges.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import AwardBadges from "@/features/admin/components/award-badges";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authed/admin/badges")({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <AwardBadges />;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { badgeKeys, badgeQueries } from "@/features/badges/queries";
|
||||||
import Profile from "@/features/players/components/profile";
|
import Profile from "@/features/players/components/profile";
|
||||||
import HeaderSkeleton from "@/features/players/components/profile/header-skeleton";
|
import HeaderSkeleton from "@/features/players/components/profile/header-skeleton";
|
||||||
import ProfileSkeleton from "@/features/players/components/profile/skeleton";
|
import ProfileSkeleton from "@/features/players/components/profile/skeleton";
|
||||||
@@ -24,6 +25,14 @@ export const Route = createFileRoute("/_authed/profile/$playerId")({
|
|||||||
queryClient,
|
queryClient,
|
||||||
playerQueries.matches(params.playerId)
|
playerQueries.matches(params.playerId)
|
||||||
),
|
),
|
||||||
|
prefetchServerQuery(
|
||||||
|
queryClient,
|
||||||
|
playerQueries.stats(params.playerId)
|
||||||
|
),
|
||||||
|
prefetchServerQuery(
|
||||||
|
queryClient,
|
||||||
|
badgeQueries.playerBadges(params.playerId)
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
loader: ({ params, context }) => ({
|
loader: ({ params, context }) => ({
|
||||||
@@ -34,7 +43,7 @@ export const Route = createFileRoute("/_authed/profile/$playerId")({
|
|||||||
context?.auth.user.id === params.playerId ? "/settings" : undefined,
|
context?.auth.user.id === params.playerId ? "/settings" : undefined,
|
||||||
},
|
},
|
||||||
withPadding: false,
|
withPadding: false,
|
||||||
refresh: [playerKeys.details(params.playerId), playerKeys.matches(params.playerId), playerKeys.stats(params.playerId)],
|
refresh: [playerKeys.details(params.playerId), playerKeys.matches(params.playerId), playerKeys.stats(params.playerId), badgeKeys.playerBadges(params.playerId)],
|
||||||
}),
|
}),
|
||||||
component: () => {
|
component: () => {
|
||||||
const { playerId } = Route.useParams();
|
const { playerId } = Route.useParams();
|
||||||
|
|||||||
@@ -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,12 +12,16 @@ 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/$')({
|
||||||
GET: handleRequest,
|
server: {
|
||||||
POST: handleRequest,
|
handlers: {
|
||||||
PUT: handleRequest,
|
GET: handleRequest,
|
||||||
DELETE: handleRequest,
|
POST: handleRequest,
|
||||||
PATCH: handleRequest,
|
PUT: handleRequest,
|
||||||
OPTIONS: handleRequest,
|
DELETE: handleRequest,
|
||||||
HEAD: handleRequest,
|
PATCH: handleRequest,
|
||||||
|
OPTIONS: handleRequest,
|
||||||
|
HEAD: handleRequest,
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,66 +1,92 @@
|
|||||||
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);
|
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
export const Route = createFileRoute("/api/events/$")({
|
||||||
start(controller) {
|
server: {
|
||||||
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
|
middleware: [superTokensRequestMiddleware],
|
||||||
controller.enqueue(new TextEncoder().encode(connectMessage));
|
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({
|
||||||
|
start(controller) {
|
||||||
|
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
|
||||||
|
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 {
|
||||||
controller.enqueue(new TextEncoder().encode(message));
|
if (!controller.desiredSize || controller.desiredSize <= 0) {
|
||||||
} catch (error) {
|
logger.warn("ServerEvents | Stream closed, skipping event");
|
||||||
logger.error("ServerEvents | Error sending SSE message", error);
|
return;
|
||||||
}
|
}
|
||||||
};
|
controller.enqueue(new TextEncoder().encode(message));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("ServerEvents | Error sending SSE message", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
serverEvents.on("test", handleEvent);
|
serverEvents.on("test", handleEvent);
|
||||||
serverEvents.on("match", handleEvent);
|
serverEvents.on("match", handleEvent);
|
||||||
serverEvents.on("reaction", handleEvent);
|
serverEvents.on("reaction", handleEvent);
|
||||||
|
|
||||||
const pingInterval = setInterval(() => {
|
const pingInterval = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
const pingMessage = `data: ${JSON.stringify({ type: "ping" })}\n\n`;
|
if (!controller.desiredSize || controller.desiredSize <= 0) {
|
||||||
controller.enqueue(new TextEncoder().encode(pingMessage));
|
clearInterval(pingInterval);
|
||||||
} catch (e) {
|
return;
|
||||||
clearInterval(pingInterval);
|
}
|
||||||
controller.close();
|
const pingMessage = `data: ${JSON.stringify({ type: "ping", timestamp: Date.now() })}\n\n`;
|
||||||
}
|
controller.enqueue(new TextEncoder().encode(pingMessage));
|
||||||
}, 30000);
|
} catch (e) {
|
||||||
|
logger.error("ServerEvents | Ping interval error", e);
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
const cleanup = () => {
|
setTimeout(() => {
|
||||||
serverEvents.off("test", handleEvent);
|
try {
|
||||||
clearInterval(pingInterval);
|
const heartbeatMessage = `data: ${JSON.stringify({ type: "heartbeat", timestamp: Date.now() })}\n\n`;
|
||||||
try {
|
controller.enqueue(new TextEncoder().encode(heartbeatMessage));
|
||||||
logger.info('ServerEvents | Closing connection', context?.userAuthId);
|
} catch (e) {
|
||||||
controller.close();
|
logger.error("ServerEvents | Heartbeat error", e);
|
||||||
} catch (e) {
|
}
|
||||||
logger.error('ServerEvents | Error closing controller', e);
|
}, 1000);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
request.signal?.addEventListener("abort", cleanup);
|
const cleanup = () => {
|
||||||
|
activeConnections--;
|
||||||
|
serverEvents.off("test", handleEvent);
|
||||||
|
serverEvents.off("match", handleEvent);
|
||||||
|
serverEvents.off("reaction", handleEvent);
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${activeConnections}`);
|
||||||
|
};
|
||||||
|
|
||||||
return cleanup;
|
request.signal?.addEventListener("abort", cleanup);
|
||||||
|
return cleanup;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers": "Cache-Control",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
"X-Proxy-Buffering": "no",
|
||||||
|
"Proxy-Buffering": "off",
|
||||||
|
"Transfer-Encoding": "chunked",
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
|
||||||
return new Response(stream, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/event-stream",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
Connection: "keep-alive",
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Headers": "Cache-Control",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,95 +1,100 @@
|
|||||||
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(
|
||||||
GET: async ({ params, request }) => {
|
"/api/files/$collection/$recordId/$file"
|
||||||
try {
|
)({
|
||||||
const { collection, recordId, file } = params;
|
server: {
|
||||||
const pocketbaseUrl = process.env.POCKETBASE_URL || 'http://127.0.0.1:8090';
|
handlers: {
|
||||||
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
|
GET: async ({ params, request }) => {
|
||||||
|
try {
|
||||||
|
const { collection, recordId, file } = params;
|
||||||
|
const pocketbaseUrl =
|
||||||
|
process.env.POCKETBASE_URL || "http://127.0.0.1:8090";
|
||||||
|
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
|
||||||
|
|
||||||
logger.info('File proxy', {
|
const response = await fetch(fileUrl, {
|
||||||
collection,
|
method: "GET",
|
||||||
recordId,
|
headers: {
|
||||||
file,
|
...(request.headers.get("range") && {
|
||||||
targetUrl: fileUrl
|
Range: request.headers.get("range")!,
|
||||||
});
|
}),
|
||||||
|
...(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")!,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const response = await fetch(fileUrl, {
|
if (!response.ok) {
|
||||||
method: 'GET',
|
logger.error("PocketBase file request failed", {
|
||||||
headers: {
|
status: response.status,
|
||||||
...(request.headers.get('range') && { 'Range': request.headers.get('range')! }),
|
statusText: response.statusText,
|
||||||
...(request.headers.get('if-none-match') && { 'If-None-Match': request.headers.get('if-none-match')! }),
|
url: fileUrl,
|
||||||
...(request.headers.get('if-modified-since') && { 'If-Modified-Since': request.headers.get('if-modified-since')! }),
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (response.status === 404) {
|
||||||
logger.error('PocketBase file request failed', {
|
return new Response("File not found", { status: 404 });
|
||||||
status: response.status,
|
}
|
||||||
statusText: response.statusText,
|
|
||||||
url: fileUrl
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status === 404) {
|
return new Response(`PocketBase error: ${response.statusText}`, {
|
||||||
return new Response('File not found', { status: 404 });
|
status: response.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = response.body;
|
||||||
|
const responseHeaders = new Headers();
|
||||||
|
const headers = [
|
||||||
|
"content-type",
|
||||||
|
"content-length",
|
||||||
|
"content-disposition",
|
||||||
|
"etag",
|
||||||
|
"last-modified",
|
||||||
|
"cache-control",
|
||||||
|
"accept-ranges",
|
||||||
|
"content-range",
|
||||||
|
];
|
||||||
|
|
||||||
|
headers.forEach((header) => {
|
||||||
|
const value = response.headers.get(header);
|
||||||
|
if (value) {
|
||||||
|
responseHeaders.set(header, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
responseHeaders.set("Access-Control-Allow-Origin", "*");
|
||||||
|
responseHeaders.set(
|
||||||
|
"Access-Control-Allow-Methods",
|
||||||
|
"GET, HEAD, OPTIONS"
|
||||||
|
);
|
||||||
|
responseHeaders.set(
|
||||||
|
"Access-Control-Allow-Headers",
|
||||||
|
"Range, If-None-Match, If-Modified-Since"
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: responseHeaders,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("File proxy error", error);
|
||||||
|
return new Response("Internal server error", { status: 500 });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
return new Response(`PocketBase error: ${response.statusText}`, {
|
OPTIONS: () => {
|
||||||
status: response.status
|
return new Response(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
|
"Access-Control-Max-Age": "86400",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
},
|
||||||
const body = response.body;
|
|
||||||
const responseHeaders = new Headers();
|
|
||||||
const headers = [
|
|
||||||
'content-type',
|
|
||||||
'content-length',
|
|
||||||
'content-disposition',
|
|
||||||
'etag',
|
|
||||||
'last-modified',
|
|
||||||
'cache-control',
|
|
||||||
'accept-ranges',
|
|
||||||
'content-range'
|
|
||||||
];
|
|
||||||
|
|
||||||
headers.forEach(header => {
|
|
||||||
const value = response.headers.get(header);
|
|
||||||
if (value) {
|
|
||||||
responseHeaders.set(header, value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
responseHeaders.set('Access-Control-Allow-Origin', '*');
|
|
||||||
responseHeaders.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
|
||||||
responseHeaders.set('Access-Control-Allow-Headers', 'Range, If-None-Match, If-Modified-Since');
|
|
||||||
|
|
||||||
logger.info('File proxy response', {
|
|
||||||
status: response.status,
|
|
||||||
contentType: response.headers.get('content-type'),
|
|
||||||
contentLength: response.headers.get('content-length')
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(body, {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
headers: responseHeaders
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('File proxy error', error);
|
|
||||||
return new Response('Internal server error', { status: 500 });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
OPTIONS: () => {
|
|
||||||
return new Response(null, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
||||||
'Access-Control-Max-Age': '86400',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
@@ -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")({
|
||||||
GET: async ({ request }: { request: Request }) => {
|
server: {
|
||||||
const getReturnPath = (state: string | null): string => {
|
handlers: {
|
||||||
if (!state) return '/';
|
GET: async ({ request }: { request: Request }) => {
|
||||||
try {
|
const getReturnPath = (state: string | null): string => {
|
||||||
const decodedState = JSON.parse(atob(state));
|
if (!state) return "/";
|
||||||
return decodedState.returnPath || '/';
|
try {
|
||||||
} catch {
|
const decodedState = JSON.parse(atob(state));
|
||||||
return '/';
|
return decodedState.returnPath || "/";
|
||||||
}
|
} catch {
|
||||||
};
|
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:", {
|
||||||
return new Response(null, {
|
code: !!code,
|
||||||
status: 302,
|
state: !!state,
|
||||||
headers: {
|
});
|
||||||
'Location': returnPath + '?spotify_error=missing_code_or_state',
|
return new Response(null, {
|
||||||
},
|
status: 302,
|
||||||
})
|
headers: {
|
||||||
}
|
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",
|
||||||
headers: {
|
{
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
method: "POST",
|
||||||
'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`,
|
headers: {
|
||||||
},
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
body: new URLSearchParams({
|
Authorization: `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString("base64")}`,
|
||||||
grant_type: 'authorization_code',
|
},
|
||||||
code,
|
body: new URLSearchParams({
|
||||||
redirect_uri: SPOTIFY_REDIRECT_URI,
|
grant_type: "authorization_code",
|
||||||
}),
|
code,
|
||||||
})
|
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(
|
||||||
return new Response(null, {
|
`${tokenResponse.status}: ${errorText}`
|
||||||
status: 302,
|
);
|
||||||
headers: {
|
return new Response(null, {
|
||||||
'Location': `${returnPath}?spotify_error=token_exchange_failed&details=${errorParam}`,
|
status: 302,
|
||||||
},
|
headers: {
|
||||||
})
|
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",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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")({
|
||||||
POST: async ({ request }: { request: Request }) => {
|
server: {
|
||||||
try {
|
handlers: {
|
||||||
// Get access token from cookies
|
POST: async ({ request }: { request: Request }) => {
|
||||||
const cookies = request.headers.get('Cookie') || ''
|
try {
|
||||||
const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/)
|
const cookies = request.headers.get("Cookie") || "";
|
||||||
|
const accessTokenMatch = cookies.match(
|
||||||
|
/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,
|
||||||
{
|
headers: { "Content-Type": "application/json" },
|
||||||
status: 200,
|
});
|
||||||
headers: { 'Content-Type': 'application/json' }
|
} catch (error) {
|
||||||
|
console.error("Spotify capture error:", error);
|
||||||
|
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to capture playback state";
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ error: errorMessage }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
)
|
},
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Spotify capture error:', error)
|
|
||||||
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Failed to capture playback state'
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: errorMessage }),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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")({
|
||||||
POST: async ({ request }: { request: Request }) => {
|
server: {
|
||||||
try {
|
handlers: {
|
||||||
const accessToken = getAccessTokenFromCookies(request)
|
POST: async ({ request }: { request: Request }) => {
|
||||||
if (!accessToken) {
|
try {
|
||||||
return new Response(
|
const accessToken = getAccessTokenFromCookies(request);
|
||||||
JSON.stringify({ error: 'No access token found' }),
|
if (!accessToken) {
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json()
|
|
||||||
const { action, deviceId, volumePercent, trackId, positionMs } = body
|
|
||||||
|
|
||||||
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'play':
|
|
||||||
await spotifyClient.play(deviceId)
|
|
||||||
break
|
|
||||||
case 'playTrack':
|
|
||||||
if (!trackId) {
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'trackId is required for playTrack action' }),
|
JSON.stringify({ error: "No access token found" }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 401,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
await spotifyClient.playTrack(trackId, deviceId, positionMs)
|
|
||||||
break
|
const body = await request.json();
|
||||||
case 'pause':
|
const { action, deviceId, volumePercent, trackId, positionMs } = body;
|
||||||
await spotifyClient.pause()
|
|
||||||
break
|
const spotifyClient = new SpotifyWebApiClient(accessToken);
|
||||||
case 'next':
|
|
||||||
await spotifyClient.skipToNext()
|
switch (action) {
|
||||||
break
|
case "play":
|
||||||
case 'previous':
|
await spotifyClient.play(deviceId);
|
||||||
await spotifyClient.skipToPrevious()
|
break;
|
||||||
break
|
case "playTrack":
|
||||||
case 'volume':
|
if (!trackId) {
|
||||||
if (typeof volumePercent !== 'number') {
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "trackId is required for playTrack action",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await spotifyClient.playTrack(trackId, deviceId, positionMs);
|
||||||
|
break;
|
||||||
|
case "pause":
|
||||||
|
await spotifyClient.pause();
|
||||||
|
break;
|
||||||
|
case "next":
|
||||||
|
await spotifyClient.skipToNext();
|
||||||
|
break;
|
||||||
|
case "previous":
|
||||||
|
await spotifyClient.skipToPrevious();
|
||||||
|
break;
|
||||||
|
case "volume":
|
||||||
|
if (typeof volumePercent !== "number") {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "volumePercent must be a number" }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await spotifyClient.setVolume(volumePercent);
|
||||||
|
break;
|
||||||
|
case "transfer":
|
||||||
|
if (!deviceId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "deviceId is required for transfer action",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await spotifyClient.transferPlayback(deviceId);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return new Response(JSON.stringify({ error: "Invalid action" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Playback control error:", error);
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message.includes("NO_ACTIVE_DEVICE")) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error:
|
||||||
|
"No active device found. Please select a device first.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message.includes("PREMIUM_REQUIRED")) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Spotify Premium is required for playback control.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("Full error details:", {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
name: error.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Playback control failed",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
GET: async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
const accessToken = getAccessTokenFromCookies(request);
|
||||||
|
if (!accessToken) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'volumePercent must be a number' }),
|
JSON.stringify({ error: "No access token found" }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 401,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
await spotifyClient.setVolume(volumePercent)
|
|
||||||
break
|
const url = new URL(request.url);
|
||||||
case 'transfer':
|
const type = url.searchParams.get("type");
|
||||||
if (!deviceId) {
|
|
||||||
return new Response(
|
const spotifyClient = new SpotifyWebApiClient(accessToken);
|
||||||
JSON.stringify({ error: 'deviceId is required for transfer action' }),
|
|
||||||
{
|
if (type === "devices") {
|
||||||
status: 400,
|
const devices = await spotifyClient.getDevices();
|
||||||
headers: { 'Content-Type': 'application/json' },
|
return new Response(JSON.stringify({ devices }), {
|
||||||
}
|
status: 200,
|
||||||
)
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} else if (type === "state") {
|
||||||
|
const playbackState = await spotifyClient.getPlaybackState();
|
||||||
|
return new Response(JSON.stringify({ playbackState }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const [devices, playbackState] = await Promise.all([
|
||||||
|
spotifyClient.getDevices(),
|
||||||
|
spotifyClient.getPlaybackState(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ devices, playbackState }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await spotifyClient.transferPlayback(deviceId)
|
} catch (error) {
|
||||||
break
|
console.error("Get playback data error:", error);
|
||||||
default:
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Invalid action' }),
|
JSON.stringify({ error: "Failed to get playback data" }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ success: true }),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
}
|
||||||
)
|
},
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Playback control error:', error)
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
if (error.message.includes('NO_ACTIVE_DEVICE')) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'No active device found. Please select a device first.' }),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error.message.includes('PREMIUM_REQUIRED')) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Spotify Premium is required for playback control.' }),
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('Full error details:', {
|
|
||||||
message: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
name: error.name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Playback control failed', details: error instanceof Error ? error.message : 'Unknown error' }),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
});
|
||||||
GET: async ({ request }: { request: Request }) => {
|
|
||||||
try {
|
|
||||||
const accessToken = getAccessTokenFromCookies(request)
|
|
||||||
if (!accessToken) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'No access token found' }),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(request.url)
|
|
||||||
const type = url.searchParams.get('type')
|
|
||||||
|
|
||||||
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
|
||||||
|
|
||||||
if (type === 'devices') {
|
|
||||||
const devices = await spotifyClient.getDevices()
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ devices }),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else if (type === 'state') {
|
|
||||||
const playbackState = await spotifyClient.getPlaybackState()
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ playbackState }),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
const [devices, playbackState] = await Promise.all([
|
|
||||||
spotifyClient.getDevices(),
|
|
||||||
spotifyClient.getPlaybackState(),
|
|
||||||
])
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ devices, playbackState }),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Get playback data error:', error)
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Failed to get playback data' }),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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")({
|
||||||
POST: async ({ request }: { request: Request }) => {
|
server: {
|
||||||
try {
|
handlers: {
|
||||||
// Get access token from cookies
|
POST: async ({ request }: { request: Request }) => {
|
||||||
const cookies = request.headers.get('Cookie') || ''
|
try {
|
||||||
const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/)
|
const cookies = request.headers.get("Cookie") || "";
|
||||||
|
const accessTokenMatch = cookies.match(
|
||||||
|
/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,
|
||||||
{
|
headers: { "Content-Type": "application/json" },
|
||||||
status: 200,
|
});
|
||||||
headers: { 'Content-Type': 'application/json' }
|
} catch (error) {
|
||||||
|
console.error("Spotify resume error:", error);
|
||||||
|
|
||||||
|
let errorMessage = "Failed to resume playback state";
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (
|
||||||
|
error.message.includes("Premium") ||
|
||||||
|
error.message.includes("403")
|
||||||
|
) {
|
||||||
|
errorMessage = "Spotify premium required";
|
||||||
|
} else {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ error: errorMessage }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
)
|
},
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Spotify resume error:', error)
|
|
||||||
|
|
||||||
let errorMessage = 'Failed to resume playback state'
|
|
||||||
|
|
||||||
// Handle common Spotify Premium requirement error
|
|
||||||
if (error instanceof Error) {
|
|
||||||
if (error.message.includes('Premium') || error.message.includes('403')) {
|
|
||||||
errorMessage = 'Spotify Premium required for playback control'
|
|
||||||
} else {
|
|
||||||
errorMessage = error.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: errorMessage }),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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")({
|
||||||
GET: async ({ request }: { request: Request }) => {
|
server: {
|
||||||
try {
|
handlers: {
|
||||||
const url = new URL(request.url)
|
GET: async ({ request }: { request: Request }) => {
|
||||||
const query = url.searchParams.get('q')
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
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) {
|
||||||
|
console.error("Search error:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Search failed",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
)
|
},
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Search error:', error)
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Search failed', details: error instanceof Error ? error.message : 'Unknown error' }),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,125 +1,131 @@
|
|||||||
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")({
|
||||||
POST: async ({ request }: { request: Request }) => {
|
server: {
|
||||||
try {
|
handlers: {
|
||||||
const body = await request.json()
|
POST: async ({ request }: { request: Request }) => {
|
||||||
const { refresh_token } = body
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
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" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
|
const tokenResponse = await fetch(
|
||||||
method: 'POST',
|
"https://accounts.spotify.com/api/token",
|
||||||
headers: {
|
{
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
method: "POST",
|
||||||
'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`,
|
headers: {
|
||||||
},
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
body: new URLSearchParams({
|
Authorization: `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString("base64")}`,
|
||||||
grant_type: 'refresh_token',
|
},
|
||||||
refresh_token,
|
body: new URLSearchParams({
|
||||||
}),
|
grant_type: "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",
|
||||||
status: tokenResponse.status,
|
details: error,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
}),
|
||||||
|
{
|
||||||
|
status: tokenResponse.status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = await tokenResponse.json()
|
const tokens = await tokenResponse.json();
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
expires_in: tokens.expires_in,
|
expires_in: tokens.expires_in,
|
||||||
scope: tokens.scope,
|
scope: tokens.scope,
|
||||||
token_type: tokens.token_type,
|
token_type: tokens.token_type,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Token refresh endpoint error:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Internal server error" }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
)
|
},
|
||||||
} catch (error) {
|
|
||||||
console.error('Token refresh endpoint error:', error)
|
GET: async ({ request }: { request: Request }) => {
|
||||||
return new Response(
|
try {
|
||||||
JSON.stringify({ error: 'Internal server error' }),
|
const cookieHeader = request.headers.get("cookie");
|
||||||
{
|
if (!cookieHeader) {
|
||||||
status: 500,
|
return new Response(JSON.stringify({ error: "No cookies found" }), {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookies = Object.fromEntries(
|
||||||
|
cookieHeader.split("; ").map((c: string) => c.split("="))
|
||||||
|
);
|
||||||
|
|
||||||
|
const accessToken = cookies.spotify_access_token;
|
||||||
|
const refreshToken = cookies.spotify_refresh_token;
|
||||||
|
|
||||||
|
if (!accessToken && !refreshToken) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "No Spotify tokens found" }),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
access_token: accessToken || null,
|
||||||
|
refresh_token: refreshToken || null,
|
||||||
|
has_tokens: !!(accessToken || refreshToken),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get tokens endpoint error:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Internal server error" }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
)
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
});
|
||||||
// GET endpoint to retrieve current tokens from cookies
|
|
||||||
GET: async ({ request }: { request: Request }) => {
|
|
||||||
try {
|
|
||||||
const cookieHeader = request.headers.get('cookie')
|
|
||||||
if (!cookieHeader) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'No cookies found' }),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cookies = Object.fromEntries(
|
|
||||||
cookieHeader.split('; ').map((c: string) => c.split('='))
|
|
||||||
)
|
|
||||||
|
|
||||||
const accessToken = cookies.spotify_access_token
|
|
||||||
const refreshToken = cookies.spotify_refresh_token
|
|
||||||
|
|
||||||
if (!accessToken && !refreshToken) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'No Spotify tokens found' }),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
access_token: accessToken || null,
|
|
||||||
refresh_token: refreshToken || null,
|
|
||||||
has_tokens: !!(accessToken || refreshToken),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Get tokens endpoint error:', error)
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Internal server error' }),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,117 +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],
|
||||||
POST: async ({ request, context }) => {
|
handlers: {
|
||||||
try {
|
POST: async ({ request, context }) => {
|
||||||
const userId = context.userAuthId;
|
try {
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const userId = context.userAuthId;
|
||||||
|
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,
|
}),
|
||||||
headers: { 'Content-Type': 'application/json' }
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!logoFile || logoFile.size === 0) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Logo file is required",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedTypes = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
];
|
||||||
|
if (!allowedTypes.includes(logoFile.type)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Invalid file type. Only JPEG, PNG and GIF are allowed.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSize = 10 * 1024 * 1024;
|
||||||
|
if (logoFile.size > maxSize) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "File too large. Maximum size is 10MB.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = await pbAdmin.getTeam(teamId);
|
||||||
|
if (!team) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Team not found",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await pbAdmin.getPlayerByAuthId(userId);
|
||||||
|
if (!team.players.map((p) => p.id).includes(user?.id!) && !isAdmin)
|
||||||
|
return new Response("Unauthorized", { status: 403 });
|
||||||
|
|
||||||
|
logger.info("Uploading team logo", {
|
||||||
|
teamId,
|
||||||
|
fileName: logoFile.name,
|
||||||
|
fileSize: logoFile.size,
|
||||||
|
userId,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (!logoFile || logoFile.size === 0) {
|
const pbFormData = new FormData();
|
||||||
return new Response(JSON.stringify({
|
pbFormData.append("logo", logoFile);
|
||||||
error: 'Logo file is required'
|
|
||||||
}), {
|
await pbAdmin.updateTeam(teamId, pbFormData as any);
|
||||||
status: 400,
|
const updatedTeam = await pbAdmin.getTeam(teamId);
|
||||||
headers: { 'Content-Type': 'application/json' }
|
if (!updatedTeam) throw new Error("Failed to fetch updated team");
|
||||||
|
|
||||||
|
logger.info("Team logo uploaded successfully", {
|
||||||
|
teamId,
|
||||||
|
logo: updatedTeam.logo,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
team: updatedTeam,
|
||||||
|
message: "Logo uploaded successfully",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("Error uploading team logo:", error);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Failed to upload logo",
|
||||||
|
message: error.message || "Unknown error occurred",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
|
},
|
||||||
if (!allowedTypes.includes(logoFile.type)) {
|
},
|
||||||
return new Response(JSON.stringify({
|
});
|
||||||
error: 'Invalid file type. Only JPEG, PNG and GIF are allowed.'
|
|
||||||
}), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxSize = 10 * 1024 * 1024;
|
|
||||||
if (logoFile.size > maxSize) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'File too large. Maximum size is 10MB.'
|
|
||||||
}), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const team = await pbAdmin.getTeam(teamId);
|
|
||||||
if (!team) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'Team not found'
|
|
||||||
}), {
|
|
||||||
status: 404,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await pbAdmin.getPlayerByAuthId(context.userAuthId)
|
|
||||||
if (!team.players.map(p => p.id).includes(user!.id) && !isAdmin)
|
|
||||||
return new Response('Unauthorized', { status: 403 });
|
|
||||||
|
|
||||||
logger.info('Uploading team logo', {
|
|
||||||
teamId,
|
|
||||||
fileName: logoFile.name,
|
|
||||||
fileSize: logoFile.size,
|
|
||||||
userId
|
|
||||||
});
|
|
||||||
|
|
||||||
const pbFormData = new FormData();
|
|
||||||
pbFormData.append('logo', logoFile);
|
|
||||||
|
|
||||||
const updatedTeam= await pbAdmin.updateTeam(teamId, pbFormData as any);
|
|
||||||
|
|
||||||
logger.info('Team logo uploaded successfully', {
|
|
||||||
teamId,
|
|
||||||
logo: updatedTeam.logo
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
team: updatedTeam,
|
|
||||||
message: 'Logo uploaded successfully'
|
|
||||||
}), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error('Error uploading team logo:', error);
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'Failed to upload logo',
|
|
||||||
message: error.message || 'Unknown error occurred'
|
|
||||||
}), {
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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],
|
||||||
POST: async ({ request, context }) => {
|
handlers: {
|
||||||
try {
|
POST: async ({ request, context }) => {
|
||||||
const userId = context.userAuthId;
|
try {
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const userId = context.userAuthId;
|
||||||
|
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,
|
}),
|
||||||
headers: { 'Content-Type': 'application/json' }
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!logoFile || logoFile.size === 0) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Logo file is required",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedTypes = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
];
|
||||||
|
if (!allowedTypes.includes(logoFile.type)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Invalid file type. Only JPEG, PNG and GIF are allowed.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSize = 10 * 1024 * 1024;
|
||||||
|
if (logoFile.size > maxSize) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "File too large. Maximum size is 10MB.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||||
|
if (!tournament) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Tournament not found",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Uploading tournament logo", {
|
||||||
|
tournamentId,
|
||||||
|
fileName: logoFile.name,
|
||||||
|
fileSize: logoFile.size,
|
||||||
|
userId,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (!logoFile || logoFile.size === 0) {
|
const pbFormData = new FormData();
|
||||||
return new Response(JSON.stringify({
|
pbFormData.append("logo", logoFile);
|
||||||
error: 'Logo file is required'
|
|
||||||
}), {
|
const updatedTournament = await pbAdmin.updateTournament(
|
||||||
status: 400,
|
tournamentId,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
pbFormData as any
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("Tournament logo uploaded successfully", {
|
||||||
|
tournamentId,
|
||||||
|
logo: updatedTournament.logo,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
tournament: updatedTournament,
|
||||||
|
message: "Logo uploaded successfully",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("Error uploading tournament logo:", error);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Failed to upload logo",
|
||||||
|
message: error.message || "Unknown error occurred",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
|
},
|
||||||
if (!allowedTypes.includes(logoFile.type)) {
|
},
|
||||||
return new Response(JSON.stringify({
|
});
|
||||||
error: 'Invalid file type. Only JPEG, PNG and GIF are allowed.'
|
|
||||||
}), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxSize = 10 * 1024 * 1024;
|
|
||||||
if (logoFile.size > maxSize) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'File too large. Maximum size is 10MB.'
|
|
||||||
}), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const tournament = await pbAdmin.getTournament(tournamentId);
|
|
||||||
if (!tournament) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'Tournament not found'
|
|
||||||
}), {
|
|
||||||
status: 404,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
logger.info('Uploading tournament logo', {
|
|
||||||
tournamentId,
|
|
||||||
fileName: logoFile.name,
|
|
||||||
fileSize: logoFile.size,
|
|
||||||
userId
|
|
||||||
});
|
|
||||||
|
|
||||||
const pbFormData = new FormData();
|
|
||||||
pbFormData.append('logo', logoFile);
|
|
||||||
|
|
||||||
const updatedTournament = await pbAdmin.updateTournament(tournamentId, pbFormData as any);
|
|
||||||
|
|
||||||
logger.info('Tournament logo uploaded successfully', {
|
|
||||||
tournamentId,
|
|
||||||
logo: updatedTournament.logo
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
tournament: updatedTournament,
|
|
||||||
message: 'Logo uploaded successfully'
|
|
||||||
}), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error('Error uploading tournament logo:', error);
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'Failed to upload logo',
|
|
||||||
message: error.message || 'Unknown error occurred'
|
|
||||||
}), {
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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,26 +26,122 @@ const Avatar = ({
|
|||||||
size = 35,
|
size = 35,
|
||||||
radius = "100%",
|
radius = "100%",
|
||||||
withBorder = true,
|
withBorder = true,
|
||||||
|
disableFullscreen = false,
|
||||||
|
contain = false,
|
||||||
...props
|
...props
|
||||||
}: AvatarProps) => {
|
}: AvatarProps) => {
|
||||||
|
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
||||||
|
const hasImage = Boolean(props.src);
|
||||||
|
|
||||||
|
const handleAvatarClick = () => {
|
||||||
|
if (hasImage && !disableFullscreen) {
|
||||||
|
setIsFullscreenOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper p={size / 20} radius={radius} withBorder={withBorder}>
|
<>
|
||||||
<MantineAvatar
|
<Paper
|
||||||
alt={name}
|
p={size / 20}
|
||||||
key={name}
|
|
||||||
name={name}
|
|
||||||
color="initials"
|
|
||||||
size={size}
|
|
||||||
radius={radius}
|
radius={radius}
|
||||||
w={size}
|
withBorder={withBorder}
|
||||||
|
style={{
|
||||||
|
cursor: hasImage && !disableFullscreen ? 'pointer' : 'default',
|
||||||
|
transition: 'transform 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (hasImage && !disableFullscreen) {
|
||||||
|
e.currentTarget.style.transform = 'scale(1.02)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'scale(1)';
|
||||||
|
}}
|
||||||
|
onClick={handleAvatarClick}
|
||||||
|
>
|
||||||
|
<MantineAvatar
|
||||||
|
alt={name}
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
color="initials"
|
||||||
|
size={size}
|
||||||
|
radius={radius}
|
||||||
|
w={size}
|
||||||
|
styles={{
|
||||||
|
image: {
|
||||||
|
objectFit: contain ? 'contain' : 'cover',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={isFullscreenOpen}
|
||||||
|
onClose={() => setIsFullscreenOpen(false)}
|
||||||
|
size="auto"
|
||||||
|
centered
|
||||||
|
withCloseButton={false}
|
||||||
|
overlayProps={{
|
||||||
|
backgroundOpacity: 0.9,
|
||||||
|
blur: 2,
|
||||||
|
}}
|
||||||
styles={{
|
styles={{
|
||||||
image: {
|
content: {
|
||||||
objectFit: "contain",
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
padding: 0,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
{...props}
|
>
|
||||||
/>
|
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
|
||||||
</Paper>
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="dark"
|
||||||
|
size="lg"
|
||||||
|
radius="xl"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -10,
|
||||||
|
right: -10,
|
||||||
|
zIndex: 1000,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
}}
|
||||||
|
onClick={() => setIsFullscreenOpen(false)}
|
||||||
|
>
|
||||||
|
<XIcon size={18} color="white" />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<Image
|
||||||
|
src={props.src}
|
||||||
|
alt={name}
|
||||||
|
fit="contain"
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
maxWidth: '90vw',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group
|
||||||
|
justify="center"
|
||||||
|
mt="md"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -50,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text c="white" size="sm" fw={500}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
179
src/components/glitch-avatar.tsx
Normal file
179
src/components/glitch-avatar.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Paper, Box } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
Avatar as MantineAvatar,
|
||||||
|
AvatarProps as MantineAvatarProps,
|
||||||
|
} from "@mantine/core";
|
||||||
|
|
||||||
|
interface GlitchAvatarProps
|
||||||
|
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
||||||
|
name: string;
|
||||||
|
src?: string;
|
||||||
|
glitchSrc?: string;
|
||||||
|
size?: number;
|
||||||
|
radius?: string | number;
|
||||||
|
withBorder?: boolean;
|
||||||
|
contain?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
px?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GlitchAvatar = ({
|
||||||
|
name,
|
||||||
|
src,
|
||||||
|
glitchSrc,
|
||||||
|
size = 35,
|
||||||
|
radius = "100%",
|
||||||
|
withBorder = true,
|
||||||
|
contain = false,
|
||||||
|
children,
|
||||||
|
px,
|
||||||
|
...props
|
||||||
|
}: GlitchAvatarProps) => {
|
||||||
|
const [showGlitch, setShowGlitch] = useState(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!glitchSrc) return;
|
||||||
|
|
||||||
|
const scheduleNextGlitch = () => {
|
||||||
|
const delay = Math.random() * 10000 + 5000;
|
||||||
|
return setTimeout(() => {
|
||||||
|
setShowGlitch(true);
|
||||||
|
setIsPlaying(true);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowGlitch(false);
|
||||||
|
setIsPlaying(false);
|
||||||
|
scheduleNextGlitch();
|
||||||
|
}, 4000);
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutId = scheduleNextGlitch();
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [glitchSrc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
const handleEnded = () => {
|
||||||
|
setShowGlitch(false);
|
||||||
|
setIsPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener("ended", handleEnded);
|
||||||
|
return () => video.removeEventListener("ended", handleEnded);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
video.load();
|
||||||
|
}, [glitchSrc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !showGlitch || !isPlaying) return;
|
||||||
|
|
||||||
|
video.currentTime = 0;
|
||||||
|
video.play().catch((err) => {
|
||||||
|
console.error("Failed to play glitch", err);
|
||||||
|
});
|
||||||
|
}, [showGlitch, isPlaying]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius:
|
||||||
|
typeof radius === "number"
|
||||||
|
? `${radius + 8}px`
|
||||||
|
: "calc(var(--mantine-radius-md) + 8px)",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
opacity: showGlitch ? 0 : 1,
|
||||||
|
transition: "opacity 0.05s ease-in-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
py={size / 12.5}
|
||||||
|
px={size / 20}
|
||||||
|
bg="var(--mantine-color-default-border)"
|
||||||
|
radius={radius}
|
||||||
|
withBorder={false}
|
||||||
|
style={{
|
||||||
|
cursor: "default",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MantineAvatar
|
||||||
|
alt={name}
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
color="initials"
|
||||||
|
size={size}
|
||||||
|
radius={radius}
|
||||||
|
w={size}
|
||||||
|
styles={{
|
||||||
|
image: {
|
||||||
|
objectFit: contain ? "contain" : "cover",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
src={src}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MantineAvatar>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{glitchSrc && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "8px",
|
||||||
|
left: "8px",
|
||||||
|
opacity: showGlitch ? 1 : 0,
|
||||||
|
visibility: showGlitch ? "visible" : "hidden",
|
||||||
|
transition: "opacity 0.05s ease-in-out",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
py={size / 12.5}
|
||||||
|
px={size / 20}
|
||||||
|
bg="var(--mantine-color-default-border)"
|
||||||
|
radius={radius}
|
||||||
|
withBorder={false}
|
||||||
|
style={{
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={glitchSrc}
|
||||||
|
style={{
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
objectFit: contain ? "contain" : "cover",
|
||||||
|
borderRadius: typeof radius === "number" ? `${radius}px` : radius,
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="auto"
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlitchAvatar;
|
||||||
@@ -1,22 +1,33 @@
|
|||||||
import { Divider, Group, Text, UnstyledButton } from "@mantine/core";
|
import { Divider, Group, Loader, Text, UnstyledButton } from "@mantine/core";
|
||||||
import { CaretRightIcon, Icon } from "@phosphor-icons/react";
|
import { CaretRightIcon, Icon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
interface ListButtonProps {
|
interface ListButtonProps {
|
||||||
label: string;
|
label: string;
|
||||||
Icon: Icon;
|
Icon: Icon;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ListButton = ({ label, onClick, Icon }: ListButtonProps) => {
|
const ListButton = ({ label, onClick, Icon, loading }: ListButtonProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<UnstyledButton w="100%" p="md" component={"button"} onClick={onClick}>
|
<UnstyledButton
|
||||||
|
w="100%"
|
||||||
|
p="md"
|
||||||
|
component={"button"}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
<Group>
|
<Group>
|
||||||
<Icon weight="bold" size={20} />
|
<Icon weight="bold" size={20} />
|
||||||
<Text fw={500} size="md">
|
<Text fw={500} size="md">
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<CaretRightIcon style={{ marginLeft: "auto" }} size={20} />
|
{loading ? (
|
||||||
|
<Loader size="sm" style={{ marginLeft: "auto" }} />
|
||||||
|
) : (
|
||||||
|
<CaretRightIcon style={{ marginLeft: "auto" }} size={20} />
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ActionIcon,
|
ActionIcon,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Divider,
|
Divider,
|
||||||
|
Stack,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
||||||
import { useState, ReactNode } from "react";
|
import { useState, ReactNode } from "react";
|
||||||
@@ -69,6 +70,7 @@ const SlidePanel = ({
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
@@ -167,11 +169,17 @@ const SlidePanel = ({
|
|||||||
bg="var(--mantine-color-dimmed)"
|
bg="var(--mantine-color-dimmed)"
|
||||||
my="xs"
|
my="xs"
|
||||||
/>
|
/>
|
||||||
<panelConfig.Component
|
<ScrollArea.Autosize w="100%" p={0} offsetScrollbars>
|
||||||
value={tempValue}
|
<panelConfig.Component
|
||||||
onChange={setTempValue}
|
value={tempValue}
|
||||||
{...(panelConfig.componentProps || {})}
|
onChange={setTempValue}
|
||||||
/>
|
{...(panelConfig.componentProps || {})}
|
||||||
|
/>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
<Stack mt="auto" w="100%" gap={2}>
|
||||||
|
<Button mt="md" onClick={handleConfirm}>Confirm</Button>
|
||||||
|
<Button variant="subtle" onClick={closePanel} mt="sm" color="red">Cancel</Button>
|
||||||
|
</Stack>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
177
src/components/typeahead.tsx
Normal file
177
src/components/typeahead.tsx
Normal 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;
|
||||||
@@ -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>;
|
||||||
|
|||||||
@@ -438,6 +438,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
activeDevice,
|
activeDevice,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
// Capture/Resume state
|
||||||
capturedState,
|
capturedState,
|
||||||
isCaptureLoading,
|
isCaptureLoading,
|
||||||
isResumeLoading,
|
isResumeLoading,
|
||||||
@@ -452,9 +453,11 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
getDevices,
|
getDevices,
|
||||||
setActiveDevice,
|
setActiveDevice,
|
||||||
refreshPlaybackState,
|
refreshPlaybackState,
|
||||||
|
// Capture/Resume methods
|
||||||
capturePlaybackState,
|
capturePlaybackState,
|
||||||
resumePlaybackState,
|
resumePlaybackState,
|
||||||
clearCapturedState,
|
clearCapturedState,
|
||||||
|
// Search
|
||||||
searchTracks,
|
searchTracks,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
386
src/features/activities/components/activities-table.tsx
Normal file
386
src/features/activities/components/activities-table.tsx
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
import { useState, useMemo, memo } from "react";
|
||||||
|
import {
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Stack,
|
||||||
|
Group,
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
UnstyledButton,
|
||||||
|
Select,
|
||||||
|
Pagination,
|
||||||
|
Code,
|
||||||
|
Alert,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
CaretUpIcon,
|
||||||
|
CaretDownIcon,
|
||||||
|
CheckIcon,
|
||||||
|
XIcon,
|
||||||
|
ChecksIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import { Activity, ActivitySearchParams } from "../types";
|
||||||
|
import { useActivities } from "../queries";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
|
||||||
|
interface ActivityListItemProps {
|
||||||
|
activity: Activity;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActivityListItem = memo(({ activity, onClick }: ActivityListItemProps) => {
|
||||||
|
const playerName = typeof activity.player === "object" && activity.player
|
||||||
|
? `${activity.player.first_name} ${activity.player.last_name}`
|
||||||
|
: "System";
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
w="100%"
|
||||||
|
p="md"
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
borderRadius: 0,
|
||||||
|
transition: "background-color 0.15s ease",
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" w="100%">
|
||||||
|
<Stack gap={4} flex={1}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{activity.name}
|
||||||
|
</Text>
|
||||||
|
{activity.success ? (
|
||||||
|
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
||||||
|
) : (
|
||||||
|
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Group gap="md">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{playerName}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{activity.duration}ms
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatDate(activity.created)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{activity.error && (
|
||||||
|
<Text size="xs" c="red" lineClamp={1}>
|
||||||
|
{activity.error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ActivityListItem.displayName = "ActivityListItem";
|
||||||
|
|
||||||
|
interface ActivityDetailsSheetProps {
|
||||||
|
activity: Activity | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetailsSheetProps) => {
|
||||||
|
if (!activity) return null;
|
||||||
|
|
||||||
|
const playerName = typeof activity.player === "object" && activity.player
|
||||||
|
? `${activity.player.first_name} ${activity.player.last_name}`
|
||||||
|
: "System";
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet title="Activity Details" opened={isOpen} onChange={onClose}>
|
||||||
|
<Stack gap="md" p="md">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Function Name
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{activity.name}</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Status
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
{activity.success ? (
|
||||||
|
<>
|
||||||
|
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
||||||
|
<Text size="sm" c="green">
|
||||||
|
Success
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
||||||
|
<Text size="sm" c="red">
|
||||||
|
Failed
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Player
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{playerName}</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Duration
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{activity.duration}ms</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Created
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{formatDate(activity.created)}</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{activity.user_agent && (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
User Agent
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" style={{ wordBreak: "break-word" }}>
|
||||||
|
{activity.user_agent}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activity.error && (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Error Message
|
||||||
|
</Text>
|
||||||
|
<Alert color="red" variant="light">
|
||||||
|
<Text size="sm" style={{ wordBreak: "break-word" }}>
|
||||||
|
{activity.error}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activity.arguments && (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Arguments
|
||||||
|
</Text>
|
||||||
|
<Code block style={{ fontSize: "11px" }}>
|
||||||
|
{JSON.stringify(activity.arguments, null, 2)}
|
||||||
|
</Code>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ActivityDetailsSheet.displayName = "ActivityDetailsSheet";
|
||||||
|
|
||||||
|
const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any) => {
|
||||||
|
const { data: result } = useActivities(searchParams);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack gap={0}>
|
||||||
|
{result.items.map((activity: Activity, index: number) => (
|
||||||
|
<Box key={activity.id}>
|
||||||
|
<ActivityListItem
|
||||||
|
activity={activity}
|
||||||
|
onClick={() => onActivityClick(activity)}
|
||||||
|
/>
|
||||||
|
{index < result.items.length - 1 && <Divider />}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{result.items.length === 0 && (
|
||||||
|
<Text ta="center" c="dimmed" py="xl">
|
||||||
|
No activities found
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.totalPages > 1 && (
|
||||||
|
<Group justify="center" py="md">
|
||||||
|
<Pagination
|
||||||
|
total={result.totalPages}
|
||||||
|
value={page}
|
||||||
|
onChange={setPage}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ActivitiesTable = () => {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [successFilter, setSuccessFilter] = useState<string | null>(null);
|
||||||
|
const [sortBy, setSortBy] = useState("-created");
|
||||||
|
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isOpen: detailsOpened,
|
||||||
|
open: openDetails,
|
||||||
|
close: closeDetails,
|
||||||
|
} = useSheet();
|
||||||
|
|
||||||
|
const searchParams: ActivitySearchParams = useMemo(
|
||||||
|
() => ({
|
||||||
|
page,
|
||||||
|
perPage: 100,
|
||||||
|
name: search || undefined,
|
||||||
|
success: successFilter === "success" ? true : successFilter === "failure" ? false : undefined,
|
||||||
|
sortBy,
|
||||||
|
}),
|
||||||
|
[page, search, successFilter, sortBy]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: result } = useActivities(searchParams);
|
||||||
|
|
||||||
|
const handleSort = (field: string) => {
|
||||||
|
setSortBy((prev) => {
|
||||||
|
if (prev === field) return `-${field}`;
|
||||||
|
if (prev === `-${field}`) return field;
|
||||||
|
return `-${field}`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortIcon = (field: string) => {
|
||||||
|
if (sortBy === field) return <CaretUpIcon size={14} />;
|
||||||
|
if (sortBy === `-${field}`) return <CaretDownIcon size={14} />;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActivityClick = (activity: Activity) => {
|
||||||
|
setSelectedActivity(activity);
|
||||||
|
openDetails();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDetails = () => {
|
||||||
|
setSelectedActivity(null);
|
||||||
|
closeDetails();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="100%" px={0}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Stack gap="xs" px="md">
|
||||||
|
<TextInput
|
||||||
|
placeholder="serverFn name"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.currentTarget.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
leftSection={<MagnifyingGlassIcon size={16} />}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Select
|
||||||
|
placeholder="Status"
|
||||||
|
value={successFilter}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSuccessFilter(value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
data={[
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
{ value: "success", label: "Success" },
|
||||||
|
{ value: "failure", label: "Failure" },
|
||||||
|
]}
|
||||||
|
clearable
|
||||||
|
size="sm"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Group px="md" justify="space-between" align="center">
|
||||||
|
<Text size="10px" lh={0} c="dimmed">
|
||||||
|
{result.totalItems} total activities
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Sort:
|
||||||
|
</Text>
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => handleSort("created")}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={sortBy.includes("created") ? 600 : 400}
|
||||||
|
c={sortBy.includes("created") ? "dark" : "dimmed"}
|
||||||
|
>
|
||||||
|
Date
|
||||||
|
</Text>
|
||||||
|
{getSortIcon("created")}
|
||||||
|
</UnstyledButton>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
•
|
||||||
|
</Text>
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => handleSort("duration")}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={sortBy.includes("duration") ? 600 : 400}
|
||||||
|
c={sortBy.includes("duration") ? "dark" : "dimmed"}
|
||||||
|
>
|
||||||
|
Duration
|
||||||
|
</Text>
|
||||||
|
{getSortIcon("duration")}
|
||||||
|
</UnstyledButton>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ActivitiesResults
|
||||||
|
searchParams={searchParams}
|
||||||
|
page={page}
|
||||||
|
setPage={setPage}
|
||||||
|
onActivityClick={handleActivityClick}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<ActivityDetailsSheet
|
||||||
|
activity={selectedActivity}
|
||||||
|
isOpen={detailsOpened}
|
||||||
|
onClose={handleCloseDetails}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
3
src/features/activities/index.ts
Normal file
3
src/features/activities/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./types";
|
||||||
|
export * from "./queries";
|
||||||
|
export { ActivitiesTable } from "./components/activities-table";
|
||||||
17
src/features/activities/queries.ts
Normal file
17
src/features/activities/queries.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
|
import { searchActivities } from "./server";
|
||||||
|
import { ActivitySearchParams } from "./types";
|
||||||
|
|
||||||
|
export const activityKeys = {
|
||||||
|
search: (params: ActivitySearchParams) => ['activities', 'search', params] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const activityQueries = {
|
||||||
|
search: (params: ActivitySearchParams = {}) => ({
|
||||||
|
queryKey: activityKeys.search(params),
|
||||||
|
queryFn: () => searchActivities({ data: params }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useActivities = (params: ActivitySearchParams = {}) =>
|
||||||
|
useServerSuspenseQuery(activityQueries.search(params));
|
||||||
29
src/features/activities/server.ts
Normal file
29
src/features/activities/server.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
|
import { transformActivity } from "@/lib/pocketbase/util/transform-types";
|
||||||
|
import { Activity, ActivityListResult, ActivitySearchParams } from "./types";
|
||||||
|
|
||||||
|
const activitySearchParamsSchema = z.object({
|
||||||
|
page: z.number().optional(),
|
||||||
|
perPage: z.number().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
player: z.string().optional(),
|
||||||
|
success: z.boolean().optional(),
|
||||||
|
sortBy: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const searchActivities = createServerFn()
|
||||||
|
.inputValidator(activitySearchParamsSchema)
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
|
.handler(async ({ data }) =>
|
||||||
|
toServerResult<ActivityListResult>(async () => {
|
||||||
|
const result = await pbAdmin.searchActivities(data);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
items: result.items.map(transformActivity),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
1
src/features/activities/types.ts
Normal file
1
src/features/activities/types.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type { Activity, ActivityListResult, ActivitySearchParams } from "@/lib/pocketbase/services/activities";
|
||||||
@@ -4,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}
|
||||||
|
|||||||
114
src/features/admin/components/award-badges.tsx
Normal file
114
src/features/admin/components/award-badges.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Box, Card, Text, Select, Button, Group, Stack, Badge, Divider } from "@mantine/core";
|
||||||
|
import { awardManualBadge } from "@/features/badges/server";
|
||||||
|
import { useAllBadges } from "@/features/badges/queries";
|
||||||
|
import toast from "@/lib/sonner";
|
||||||
|
import { usePlayers } from "@/features/players/queries";
|
||||||
|
|
||||||
|
const AwardBadges = () => {
|
||||||
|
const { data: players } = usePlayers();
|
||||||
|
const { data: allBadges } = useAllBadges();
|
||||||
|
|
||||||
|
const [selectedPlayerId, setSelectedPlayerId] = useState<string | null>(null);
|
||||||
|
const [selectedBadgeId, setSelectedBadgeId] = useState<string | null>(null);
|
||||||
|
const [isAwarding, setIsAwarding] = useState(false);
|
||||||
|
|
||||||
|
const manualBadges = allBadges.filter((badge) => badge.type === "manual");
|
||||||
|
|
||||||
|
const handleAwardBadge = async () => {
|
||||||
|
if (!selectedPlayerId || !selectedBadgeId) return;
|
||||||
|
|
||||||
|
setIsAwarding(true);
|
||||||
|
try {
|
||||||
|
await awardManualBadge({
|
||||||
|
data: {
|
||||||
|
playerId: selectedPlayerId,
|
||||||
|
badgeId: selectedBadgeId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedPlayer = players.find((p) => p.id === selectedPlayerId);
|
||||||
|
const playerName = selectedPlayer
|
||||||
|
? `${selectedPlayer.first_name} ${selectedPlayer.last_name}`
|
||||||
|
: "Player";
|
||||||
|
|
||||||
|
toast.success(`Badge awarded to ${playerName}`);
|
||||||
|
|
||||||
|
setSelectedPlayerId(null);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to award badge");
|
||||||
|
} finally {
|
||||||
|
setIsAwarding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const playerOptions = players.map((player) => ({
|
||||||
|
value: player.id,
|
||||||
|
label: `${player.first_name} ${player.last_name}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const badgeOptions = manualBadges.map((badge) => ({
|
||||||
|
value: badge.id,
|
||||||
|
label: badge.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const selectedBadge = manualBadges.find((b) => b.id === selectedBadgeId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box p="md">
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Box>
|
||||||
|
<Text size="lg" fw={600} mb="xs">
|
||||||
|
Award Manual Badge
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Badge Type"
|
||||||
|
placeholder="Select a badge"
|
||||||
|
data={badgeOptions}
|
||||||
|
value={selectedBadgeId}
|
||||||
|
onChange={setSelectedBadgeId}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedBadgeId && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label="Select Player"
|
||||||
|
placeholder="Choose a player"
|
||||||
|
data={playerOptions}
|
||||||
|
value={selectedPlayerId}
|
||||||
|
onChange={setSelectedPlayerId}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
size="md"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleAwardBadge}
|
||||||
|
disabled={!selectedPlayerId}
|
||||||
|
loading={isAwarding}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
Award Badge
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AwardBadges;
|
||||||
@@ -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}`} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
47
src/features/badges/components/badge-showcase-skeleton.tsx
Normal file
47
src/features/badges/components/badge-showcase-skeleton.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Box, Skeleton, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
const BadgeShowcaseSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<Box mb="lg">
|
||||||
|
<Box
|
||||||
|
px="md"
|
||||||
|
style={{
|
||||||
|
maxHeight: '220px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(85px, 1fr))',
|
||||||
|
gap: 'var(--mantine-spacing-md)',
|
||||||
|
paddingBottom: 'var(--mantine-spacing-sm)',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
aspectRatio: '1',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton
|
||||||
|
height="100%"
|
||||||
|
radius="12px"
|
||||||
|
style={{
|
||||||
|
aspectRatio: '1',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BadgeShowcaseSkeleton;
|
||||||
267
src/features/badges/components/badge-showcase.tsx
Normal file
267
src/features/badges/components/badge-showcase.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { Box, Text, Popover, Progress, Title } from "@mantine/core";
|
||||||
|
import { usePlayerBadges, useAllBadges } from "../queries";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { Badge, BadgeProgress } from "../types";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { MedalIcon, LockKeyIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
interface BadgeShowcaseProps {
|
||||||
|
playerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BadgeDisplay {
|
||||||
|
badge: Badge;
|
||||||
|
progress?: BadgeProgress;
|
||||||
|
earned: boolean;
|
||||||
|
progressText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { data: badgeProgress } = usePlayerBadges(playerId);
|
||||||
|
const { data: allBadges } = useAllBadges();
|
||||||
|
|
||||||
|
const isCurrentUser = user?.id === playerId;
|
||||||
|
|
||||||
|
const badgesToDisplay = useMemo(() => {
|
||||||
|
const displays: BadgeDisplay[] = [];
|
||||||
|
|
||||||
|
if (isCurrentUser) {
|
||||||
|
for (const badge of allBadges) {
|
||||||
|
const progress = badgeProgress.find(bp => bp.badge.id === badge.id);
|
||||||
|
const earned = progress?.earned || false;
|
||||||
|
|
||||||
|
if (badge.type === 'manual' && !earned) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let progressText = "";
|
||||||
|
if (progress) {
|
||||||
|
const target = getTargetProgress(badge);
|
||||||
|
progressText = `${progress.progress} / ${target}`;
|
||||||
|
} else {
|
||||||
|
const target = getTargetProgress(badge);
|
||||||
|
progressText = `0 / ${target}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
displays.push({
|
||||||
|
badge,
|
||||||
|
progress,
|
||||||
|
earned,
|
||||||
|
progressText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
displays.sort((a, b) => {
|
||||||
|
if (a.earned && !b.earned) return -1;
|
||||||
|
if (!a.earned && b.earned) return 1;
|
||||||
|
return a.badge.order - b.badge.order;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const earnedProgress = badgeProgress.filter(bp => bp.earned);
|
||||||
|
for (const progress of earnedProgress) {
|
||||||
|
const badge: Badge = {
|
||||||
|
...progress.badge,
|
||||||
|
criteria: {},
|
||||||
|
created: progress.created,
|
||||||
|
updated: progress.updated,
|
||||||
|
};
|
||||||
|
|
||||||
|
const target = getTargetProgress(badge);
|
||||||
|
displays.push({
|
||||||
|
badge,
|
||||||
|
progress,
|
||||||
|
earned: true,
|
||||||
|
progressText: `${progress.progress} / ${target}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
displays.sort((a, b) => a.badge.order - b.badge.order);
|
||||||
|
}
|
||||||
|
|
||||||
|
return displays;
|
||||||
|
}, [allBadges, badgeProgress, isCurrentUser]);
|
||||||
|
|
||||||
|
if (badgesToDisplay.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box mb="lg">
|
||||||
|
<Box
|
||||||
|
px="md"
|
||||||
|
style={{
|
||||||
|
maxHeight: '220px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(85px, 1fr))',
|
||||||
|
gap: 'var(--mantine-spacing-md)',
|
||||||
|
paddingBottom: 'var(--mantine-spacing-sm)',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{badgesToDisplay.map((display) => {
|
||||||
|
const isStackableBadge = ['winner_badge', 'silver_medal_badge', 'bronze_medal_badge'].includes(display.badge.key);
|
||||||
|
const stackCount = display.earned && isStackableBadge
|
||||||
|
? (display.progress?.progress || 0)
|
||||||
|
: 1;
|
||||||
|
const showStack = stackCount > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover key={display.badge.id} width={220} position="top" withArrow shadow="md">
|
||||||
|
<Popover.Target>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showStack && (
|
||||||
|
<>
|
||||||
|
{[...Array(Math.min(stackCount - 1, 2))].map((_, i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
aspectRatio: '1',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '2px solid var(--mantine-primary-color-5)',
|
||||||
|
position: 'absolute',
|
||||||
|
top: `${(i + 1) * 3}px`,
|
||||||
|
left: `${(i + 1) * 3}px`,
|
||||||
|
right: `-${(i + 1) * 3}px`,
|
||||||
|
bottom: `-${(i + 1) * 3}px`,
|
||||||
|
opacity: 0.4 - (i * 0.15),
|
||||||
|
zIndex: -(i + 1),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
aspectRatio: '1',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-7))',
|
||||||
|
border: display.earned
|
||||||
|
? '2px solid var(--mantine-primary-color-6)'
|
||||||
|
: '2px dashed var(--mantine-primary-color-4)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '4px',
|
||||||
|
padding: 'var(--mantine-spacing-xs)',
|
||||||
|
position: 'relative',
|
||||||
|
boxShadow: display.earned
|
||||||
|
? '0 0 0 1px color-mix(in srgb, var(--mantine-primary-color-6) 20%, transparent)'
|
||||||
|
: 'none',
|
||||||
|
opacity: display.earned ? 1 : 0.4,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{display.earned ? (
|
||||||
|
<MedalIcon
|
||||||
|
size={32}
|
||||||
|
weight="fill"
|
||||||
|
color="var(--mantine-primary-color-6)"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LockKeyIcon
|
||||||
|
size={28}
|
||||||
|
weight="regular"
|
||||||
|
color="var(--mantine-color-dimmed)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showStack && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '4px',
|
||||||
|
right: '4px',
|
||||||
|
color: 'var(--mantine-primary-color-6)',
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
x{stackCount}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Title
|
||||||
|
order={6}
|
||||||
|
fw={display.earned ? 600 : 500}
|
||||||
|
ta="center"
|
||||||
|
c={display.earned ? undefined : 'dimmed'}
|
||||||
|
style={{ lineHeight: 1.1 }}
|
||||||
|
>
|
||||||
|
{display.badge.name}
|
||||||
|
</Title>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<Box>
|
||||||
|
<Title order={5}>
|
||||||
|
{display.badge.name}
|
||||||
|
</Title>
|
||||||
|
<Text size="xs" c="dimmed" mb={isCurrentUser ? "sm" : undefined}>
|
||||||
|
{display.badge.description}
|
||||||
|
</Text>
|
||||||
|
{isCurrentUser && (
|
||||||
|
<Box>
|
||||||
|
<Box mb="xs" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Text size="xs" fw={500} c="dimmed">
|
||||||
|
Progress
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" fw={600} c="dimmed">
|
||||||
|
{display.progressText}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Progress
|
||||||
|
value={(display.progress?.progress || 0) / getTargetProgress(display.badge) * 100}
|
||||||
|
size="sm"
|
||||||
|
radius="sm"
|
||||||
|
color={display.earned ? "green" : undefined}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTargetProgress(badge: Badge): number {
|
||||||
|
const criteria = badge.criteria;
|
||||||
|
return (
|
||||||
|
criteria.matches_played ||
|
||||||
|
criteria.tournament_wins ||
|
||||||
|
criteria.tournaments_attended ||
|
||||||
|
criteria.overtime_matches ||
|
||||||
|
criteria.overtime_wins ||
|
||||||
|
criteria.consecutive_wins ||
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BadgeShowcase;
|
||||||
24
src/features/badges/queries.ts
Normal file
24
src/features/badges/queries.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
|
import { getPlayerBadges, getAllBadges } from "./server";
|
||||||
|
|
||||||
|
export const badgeKeys = {
|
||||||
|
playerBadges: (playerId: string) => ['badges', 'player', playerId],
|
||||||
|
allBadges: () => ['badges', 'all'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const badgeQueries = {
|
||||||
|
playerBadges: (playerId: string) => ({
|
||||||
|
queryKey: badgeKeys.playerBadges(playerId),
|
||||||
|
queryFn: async () => await getPlayerBadges({ data: playerId })
|
||||||
|
}),
|
||||||
|
allBadges: () => ({
|
||||||
|
queryKey: badgeKeys.allBadges(),
|
||||||
|
queryFn: async () => await getAllBadges()
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePlayerBadges = (playerId: string) =>
|
||||||
|
useServerSuspenseQuery(badgeQueries.playerBadges(playerId));
|
||||||
|
|
||||||
|
export const useAllBadges = () =>
|
||||||
|
useServerSuspenseQuery(badgeQueries.allBadges());
|
||||||
34
src/features/badges/server.ts
Normal file
34
src/features/badges/server.ts
Normal 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))
|
||||||
|
);
|
||||||
25
src/features/badges/types.ts
Normal file
25
src/features/badges/types.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export interface BadgeInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
type: "manual" | "match" | "tournament";
|
||||||
|
progressive: boolean;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Badge extends BadgeInfo {
|
||||||
|
criteria: Record<string, any>;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BadgeProgress {
|
||||||
|
id: string;
|
||||||
|
badge: BadgeInfo;
|
||||||
|
player: string;
|
||||||
|
progress: number;
|
||||||
|
earned: boolean;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
8
src/features/badges/util.ts
Normal file
8
src/features/badges/util.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
|
||||||
|
pb.collection("team_stats_per_tournament").getFullList({
|
||||||
|
filter: `tournament_id = "${id}"`,
|
||||||
|
sort: "-wins,-total_cups_made"
|
||||||
|
})
|
||||||
|
|
||||||
|
*/
|
||||||
@@ -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,33 +14,131 @@ export const Bracket: React.FC<BracketProps> = ({
|
|||||||
orders,
|
orders,
|
||||||
showControls,
|
showControls,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
<Flex direction="row" gap={24} justify="left">
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
{rounds.map((round, roundIndex) => (
|
|
||||||
<Flex
|
useEffect(() => {
|
||||||
key={roundIndex}
|
const updateConnectorLines = () => {
|
||||||
direction="column"
|
if (!containerRef.current || !svgRef.current) return;
|
||||||
align="center"
|
|
||||||
pos="relative"
|
const svg = svgRef.current;
|
||||||
gap={24}
|
const container = containerRef.current;
|
||||||
justify="space-around"
|
const flexContainer = container.querySelector('.bracket-flex-container') as HTMLElement;
|
||||||
p={24}
|
if (!flexContainer) return;
|
||||||
>
|
|
||||||
{round.map((match) =>
|
svg.innerHTML = '';
|
||||||
match.bye ? (
|
|
||||||
<div key={match.lid}></div>
|
const flexRect = flexContainer.getBoundingClientRect();
|
||||||
) : (
|
const containerRect = container.getBoundingClientRect();
|
||||||
<div key={match.lid}>
|
|
||||||
<MatchCard
|
svg.style.width = `${flexContainer.scrollWidth}px`;
|
||||||
match={match}
|
svg.style.height = `${flexContainer.scrollHeight}px`;
|
||||||
orders={orders}
|
|
||||||
showControls={showControls}
|
rounds.forEach((round, roundIndex) => {
|
||||||
/>
|
if (roundIndex === rounds.length - 1) return;
|
||||||
</div>
|
|
||||||
|
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
|
||||||
)
|
)
|
||||||
)}
|
);
|
||||||
</Flex>
|
|
||||||
))}
|
nextMatches.forEach(nextMatch => {
|
||||||
</Flex>
|
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 (
|
||||||
|
<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) => (
|
||||||
|
<Flex
|
||||||
|
key={roundIndex}
|
||||||
|
direction="column"
|
||||||
|
align="center"
|
||||||
|
pos="relative"
|
||||||
|
gap={24}
|
||||||
|
justify="space-around"
|
||||||
|
p={24}
|
||||||
|
>
|
||||||
|
{round.map((match) =>
|
||||||
|
match.bye ? (
|
||||||
|
<div key={match.lid}></div>
|
||||||
|
) : (
|
||||||
|
<div key={match.lid}>
|
||||||
|
<MatchCard
|
||||||
|
match={match}
|
||||||
|
orders={orders}
|
||||||
|
showControls={showControls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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' }}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,17 @@ const useVisualViewportSize = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!windowExists) return;
|
if (!windowExists) return;
|
||||||
|
|
||||||
|
setSize();
|
||||||
|
|
||||||
window.visualViewport?.addEventListener('resize', setSize, eventListerOptions);
|
window.visualViewport?.addEventListener('resize', setSize, eventListerOptions);
|
||||||
|
window.visualViewport?.addEventListener('scroll', setSize, eventListerOptions);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.visualViewport?.removeEventListener('resize', setSize);
|
window.visualViewport?.removeEventListener('resize', setSize);
|
||||||
|
window.visualViewport?.removeEventListener('scroll', setSize);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setSize]);
|
||||||
|
|
||||||
return windowSize;
|
return windowSize;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import GlitchAvatar from '@/components/glitch-avatar';
|
||||||
import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size';
|
import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size';
|
||||||
|
import { useCurrentTournament } from '@/features/tournaments/queries';
|
||||||
import { AppShell, Flex, Paper, em, Title, Stack } from '@mantine/core';
|
import { AppShell, Flex, Paper, em, Title, Stack } from '@mantine/core';
|
||||||
import { useMediaQuery, useViewportSize } from '@mantine/hooks';
|
import { useMediaQuery, useViewportSize } from '@mantine/hooks';
|
||||||
import { TrophyIcon } from '@phosphor-icons/react';
|
import { TrophyIcon } from '@phosphor-icons/react';
|
||||||
@@ -8,6 +10,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
const isMobile = useMediaQuery(`(max-width: ${em(450)})`);
|
const isMobile = useMediaQuery(`(max-width: ${em(450)})`);
|
||||||
const visualViewport = useVisualViewportSize();
|
const visualViewport = useVisualViewportSize();
|
||||||
const viewport = useViewportSize();
|
const viewport = useViewportSize();
|
||||||
|
const { data: tournament } = useCurrentTournament();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
@@ -31,8 +34,27 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
radius='md'
|
radius='md'
|
||||||
>
|
>
|
||||||
<Stack align='center' gap='xs' mb='md'>
|
<Stack align='center' gap='xs' mb='md'>
|
||||||
<TrophyIcon size={75} />
|
<GlitchAvatar
|
||||||
<Title order={4} ta='center'>Welcome to FLXN</Title>
|
name={tournament.name}
|
||||||
|
contain
|
||||||
|
src={
|
||||||
|
tournament.logo
|
||||||
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
glitchSrc={
|
||||||
|
tournament.glitch_logo
|
||||||
|
? `/api/files/tournaments/${tournament.id}/${tournament.glitch_logo}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
radius="md"
|
||||||
|
size={250}
|
||||||
|
px="xs"
|
||||||
|
withBorder={false}
|
||||||
|
>
|
||||||
|
<TrophyIcon size={32} />
|
||||||
|
</GlitchAvatar>
|
||||||
|
<Title order={1} ta='center'>Welcome to FLXN</Title>
|
||||||
</Stack>
|
</Stack>
|
||||||
{children}
|
{children}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface MatchListProps {
|
|||||||
const MatchList = ({ matches }: MatchListProps) => {
|
const MatchList = ({ matches }: MatchListProps) => {
|
||||||
const filteredMatches = matches?.filter(match =>
|
const filteredMatches = matches?.filter(match =>
|
||||||
match.home && match.away && !match.bye && match.status != "tbd"
|
match.home && match.away && !match.bye && match.status != "tbd"
|
||||||
) || [];
|
).sort((a, b) => a.start_time < b.start_time ? 1 : -1) || [];
|
||||||
|
|
||||||
if (!filteredMatches.length) {
|
if (!filteredMatches.length) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { MatchInput } from "@/features/matches/types";
|
|||||||
import { serverEvents } from "@/lib/events/emitter";
|
import { serverEvents } from "@/lib/events/emitter";
|
||||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { PlayerInfo } from "../players/types";
|
import { PlayerInfo } from "../players/types";
|
||||||
|
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||||
|
|
||||||
const orderedTeamsSchema = z.object({
|
const orderedTeamsSchema = z.object({
|
||||||
tournamentId: z.string(),
|
tournamentId: z.string(),
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ const Header = ({ player }: HeaderProps) => {
|
|||||||
const name = useMemo(() => `${player.first_name} ${player.last_name}`, [player.first_name, player.last_name]);
|
const name = useMemo(() => `${player.first_name} ${player.last_name}`, [player.first_name, player.last_name]);
|
||||||
|
|
||||||
const fontSize = useMemo(() => {
|
const fontSize = useMemo(() => {
|
||||||
const baseSize = 24;
|
const baseSize = 28;
|
||||||
const maxLength = 20;
|
const maxLength = 24;
|
||||||
|
|
||||||
if (name.length <= maxLength) {
|
if (name.length <= maxLength) {
|
||||||
return `${baseSize}px`;
|
return `${baseSize}px`;
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Box } from "@mantine/core";
|
import { Box, Stack, Text, Divider } from "@mantine/core";
|
||||||
|
import { Suspense } from "react";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
|
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
|
||||||
import TeamList from "@/features/teams/components/team-list";
|
import TeamList from "@/features/teams/components/team-list";
|
||||||
import StatsOverview from "@/components/stats-overview";
|
import StatsOverview from "@/components/stats-overview";
|
||||||
import MatchList from "@/features/matches/components/match-list";
|
import MatchList from "@/features/matches/components/match-list";
|
||||||
|
import BadgeShowcase from "@/features/badges/components/badge-showcase";
|
||||||
|
import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton";
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,7 +21,19 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
content: <StatsOverview statsData={stats} isLoading={statsLoading} />,
|
content: <>
|
||||||
|
<Stack px="md">
|
||||||
|
<Text size="md" fw={700}>Badges</Text>
|
||||||
|
<Suspense fallback={<BadgeShowcaseSkeleton />}>
|
||||||
|
<BadgeShowcase playerId={id} />
|
||||||
|
</Suspense>
|
||||||
|
</Stack>
|
||||||
|
<Divider my="md" />
|
||||||
|
<Stack>
|
||||||
|
<Text px="md" size="md" fw={700}>Statistics</Text>
|
||||||
|
<StatsOverview statsData={stats} isLoading={statsLoading} />
|
||||||
|
</Stack>
|
||||||
|
</>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Matches",
|
label: "Matches",
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export const useMe = () => {
|
|||||||
const errorData = error?.response?.data;
|
const errorData = error?.response?.data;
|
||||||
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
|
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
|
||||||
const currentUrl = window.location.pathname + window.location.search;
|
const currentUrl = window.location.pathname + window.location.search;
|
||||||
console.log('redirecting 3')
|
|
||||||
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
|
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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,174 +8,73 @@ 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 renderOption = (option: TypeaheadOption<SpotifyTrack>) => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const track = option.data;
|
||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
return (
|
||||||
setIsOpen(false);
|
<Box
|
||||||
}
|
p="sm"
|
||||||
};
|
style={{
|
||||||
|
borderBottom: '1px solid var(--mantine-color-dimmed)'
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
}}
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
>
|
||||||
}, []);
|
<Group gap="sm">
|
||||||
|
{track.album.images[2] && (
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
|
||||||
if (!isOpen || searchResults.length === 0) return;
|
)}
|
||||||
|
<div>
|
||||||
switch (e.key) {
|
<Text size="sm" fw={500}>
|
||||||
case 'ArrowDown':
|
{track.name}
|
||||||
e.preventDefault();
|
</Text>
|
||||||
setSelectedIndex(prev => (prev < searchResults.length - 1 ? prev + 1 : prev));
|
<Text size="xs" c="dimmed">
|
||||||
break;
|
{track.artists.map(a => a.name).join(', ')} • {track.album.name}
|
||||||
case 'ArrowUp':
|
</Text>
|
||||||
e.preventDefault();
|
</div>
|
||||||
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev));
|
</Group>
|
||||||
break;
|
</Box>
|
||||||
case 'Enter':
|
);
|
||||||
e.preventDefault();
|
|
||||||
if (selectedIndex >= 0 && searchResults[selectedIndex]) {
|
|
||||||
handleSongSelect(searchResults[selectedIndex]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'Escape':
|
|
||||||
setIsOpen(false);
|
|
||||||
setSelectedIndex(-1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box ref={containerRef} pos="relative" w="100%">
|
<Typeahead
|
||||||
<TextInput
|
onSelect={handleSongSelect}
|
||||||
ref={inputRef}
|
searchFn={searchSpotifyTracks}
|
||||||
value={searchQuery}
|
renderOption={renderOption}
|
||||||
onChange={(event) => handleSearchChange(event.currentTarget.value)}
|
format={formatTrack}
|
||||||
onKeyDown={handleKeyDown}
|
placeholder={placeholder}
|
||||||
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
|
|
||||||
key={track.id}
|
|
||||||
p="sm"
|
|
||||||
style={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
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">
|
|
||||||
{track.album.images[2] && (
|
|
||||||
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{track.name}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{track.artists.map(a => a.name).join(', ')} • {track.album.name}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SongSearch;
|
export default SongSearch;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const Header = ({ name, logo, id }: HeaderProps) => {
|
|||||||
src={logo && id ? `/api/files/teams/${id}/${logo}` : undefined}
|
src={logo && id ? `/api/files/teams/${id}/${logo}` : undefined}
|
||||||
/>
|
/>
|
||||||
<Flex align="center" justify="center" gap={4} pb={20} w="100%">
|
<Flex align="center" justify="center" gap={4} pb={20} w="100%">
|
||||||
<Title ta="center" order={2}>
|
<Title ta="center" order={1}>
|
||||||
{name}
|
{name}
|
||||||
</Title>
|
</Title>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
|||||||
import { teamInputSchema, teamUpdateSchema } from "./types";
|
import { teamInputSchema, teamUpdateSchema } from "./types";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { Match } from "../matches/types";
|
import { Match } from "../matches/types";
|
||||||
|
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||||
|
|
||||||
|
|
||||||
export const listTeamInfos = createServerFn()
|
export const listTeamInfos = createServerFn()
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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, setSearch]
|
[enrollTeam, tournamentId]
|
||||||
);
|
);
|
||||||
|
|
||||||
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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
102
src/features/tournaments/components/podium.tsx
Normal file
102
src/features/tournaments/components/podium.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { Stack, Group, Text, ThemeIcon, Box, Center } from "@mantine/core";
|
||||||
|
import { CrownIcon, MedalIcon } from "@phosphor-icons/react";
|
||||||
|
import { Tournament } from "../types";
|
||||||
|
|
||||||
|
interface PodiumProps {
|
||||||
|
tournament: Tournament;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Podium = ({ tournament }: PodiumProps) => {
|
||||||
|
if (!tournament.first_place) return;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" px="md">
|
||||||
|
{tournament.first_place && (
|
||||||
|
<Group
|
||||||
|
gap="md"
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--mantine-color-yellow-light)',
|
||||||
|
borderRadius: 'var(--mantine-radius-md)',
|
||||||
|
border: '3px solid var(--mantine-color-yellow-outline)',
|
||||||
|
boxShadow: 'var(--mantine-shadow-md)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeIcon size="xl" color="yellow" variant="light" radius="xl">
|
||||||
|
<CrownIcon size={24} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={4} style={{ flex: 1 }}>
|
||||||
|
<Text size="md" fw={600}>
|
||||||
|
{tournament.first_place.name}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
{tournament.first_place.players?.map((player) => (
|
||||||
|
<Text key={player.id} size="sm" c="dimmed">
|
||||||
|
{player.first_name} {player.last_name}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tournament.second_place && (
|
||||||
|
<Group
|
||||||
|
gap="md"
|
||||||
|
p="xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--mantine-color-default)',
|
||||||
|
borderRadius: 'var(--mantine-radius-md)',
|
||||||
|
border: '2px solid var(--mantine-color-default-border)',
|
||||||
|
boxShadow: 'var(--mantine-shadow-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeIcon size="lg" color="gray" variant="light" radius="xl">
|
||||||
|
<MedalIcon size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={4} style={{ flex: 1 }}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{tournament.second_place.name}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
{tournament.second_place.players?.map((player) => (
|
||||||
|
<Text key={player.id} size="xs" c="dimmed">
|
||||||
|
{player.first_name} {player.last_name}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tournament.third_place && (
|
||||||
|
<Group
|
||||||
|
gap="md"
|
||||||
|
p="xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--mantine-color-orange-light)',
|
||||||
|
borderRadius: 'var(--mantine-radius-md)',
|
||||||
|
border: '2px solid var(--mantine-color-orange-outline)',
|
||||||
|
boxShadow: 'var(--mantine-shadow-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeIcon size="lg" color="orange" variant="light" radius="xl">
|
||||||
|
<MedalIcon size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={4} style={{ flex: 1 }}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{tournament.third_place.name}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
{tournament.third_place.players?.map((player) => (
|
||||||
|
<Text key={player.id} size="xs" c="dimmed">
|
||||||
|
{player.first_name} {player.last_name}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,9 +11,9 @@ const Header = ({ tournament }: HeaderProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'>
|
<Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'>
|
||||||
<Avatar 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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { useAuth } from "@/contexts/auth-context";
|
|||||||
import { Box, Divider, Stack, Text, Card, Center } from "@mantine/core";
|
import { Box, Divider, Stack, Text, Card, Center } from "@mantine/core";
|
||||||
import { Carousel } from "@mantine/carousel";
|
import { Carousel } from "@mantine/carousel";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
import { TreeStructureIcon, UsersIcon, ClockIcon } from "@phosphor-icons/react";
|
import { TreeStructureIcon, UsersIcon, ClockIcon, TrophyIcon } from "@phosphor-icons/react";
|
||||||
import TeamListButton from "../upcoming-tournament/team-list-button";
|
import TeamListButton from "../upcoming-tournament/team-list-button";
|
||||||
import RulesListButton from "../upcoming-tournament/rules-list-button";
|
import RulesListButton from "../upcoming-tournament/rules-list-button";
|
||||||
import MatchCard from "@/features/matches/components/match-card";
|
import MatchCard from "@/features/matches/components/match-card";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
|
import { Podium } from "../podium";
|
||||||
|
|
||||||
const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||||
tournament,
|
tournament,
|
||||||
@@ -22,6 +23,20 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
[tournament.matches]
|
[tournament.matches]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isTournamentOver = useMemo(() => {
|
||||||
|
const matches = tournament.matches || [];
|
||||||
|
if (matches.length === 0) return false;
|
||||||
|
|
||||||
|
const nonByeMatches = matches.filter((match) => !(match.status === 'tbd' && match.bye === true));
|
||||||
|
if (nonByeMatches.length === 0) return false;
|
||||||
|
|
||||||
|
const finalsMatch = nonByeMatches.reduce((highest, current) =>
|
||||||
|
(!highest || current.lid > highest.lid) ? current : highest
|
||||||
|
);
|
||||||
|
|
||||||
|
return finalsMatch?.status === 'ended';
|
||||||
|
}, [tournament.matches]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Header tournament={tournament} />
|
<Header tournament={tournament} />
|
||||||
@@ -42,6 +57,10 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
))}
|
))}
|
||||||
</Carousel>
|
</Carousel>
|
||||||
</Box>
|
</Box>
|
||||||
|
) : isTournamentOver ? (
|
||||||
|
<Box px="lg" w="100%">
|
||||||
|
<Podium tournament={tournament} />
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Card withBorder radius="lg" p="xl" mx="md">
|
<Card withBorder radius="lg" p="xl" mx="md">
|
||||||
<Center>
|
<Center>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
Badge,
|
Badge,
|
||||||
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { TournamentInfo } from "@/features/tournaments/types";
|
import { TournamentInfo } from "@/features/tournaments/types";
|
||||||
import {
|
import {
|
||||||
@@ -27,14 +28,6 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
|||||||
w="100%"
|
w="100%"
|
||||||
onClick={() => navigate({ to: `/tournaments/${tournament.id}` })}
|
onClick={() => navigate({ to: `/tournaments/${tournament.id}` })}
|
||||||
style={{ borderRadius: "var(--mantine-radius-md)" }}
|
style={{ borderRadius: "var(--mantine-radius-md)" }}
|
||||||
styles={{
|
|
||||||
root: {
|
|
||||||
"&:hover": {
|
|
||||||
transform: "translateY(-2px)",
|
|
||||||
transition: "transform 0.15s ease",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
withBorder
|
withBorder
|
||||||
@@ -45,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"
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
|||||||
@@ -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';
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface TournamentInfo {
|
|||||||
start_time?: string;
|
start_time?: string;
|
||||||
end_time?: string;
|
end_time?: string;
|
||||||
logo?: string;
|
logo?: string;
|
||||||
|
glitch_logo?: string;
|
||||||
first_place?: TeamInfo;
|
first_place?: TeamInfo;
|
||||||
second_place?: TeamInfo;
|
second_place?: TeamInfo;
|
||||||
third_place?: TeamInfo;
|
third_place?: TeamInfo;
|
||||||
@@ -37,6 +38,7 @@ export interface Tournament {
|
|||||||
desc?: string;
|
desc?: string;
|
||||||
rules?: string;
|
rules?: string;
|
||||||
logo?: string;
|
logo?: string;
|
||||||
|
glitch_logo?: string;
|
||||||
enroll_time?: string;
|
enroll_time?: string;
|
||||||
start_time: string;
|
start_time: string;
|
||||||
end_time?: string;
|
end_time?: string;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const eventHandlers: Record<string, EventHandler> = {
|
|||||||
logger.info("New Connection");
|
logger.info("New Connection");
|
||||||
},
|
},
|
||||||
"ping": () => {},
|
"ping": () => {},
|
||||||
|
"heartbeat": () => {},
|
||||||
"match": (event, queryClient) => {
|
"match": (event, queryClient) => {
|
||||||
queryClient.invalidateQueries(tournamentQueries.details(event.tournamentId))
|
queryClient.invalidateQueries(tournamentQueries.details(event.tournamentId))
|
||||||
queryClient.invalidateQueries(tournamentQueries.current())
|
queryClient.invalidateQueries(tournamentQueries.current())
|
||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user