Compare commits
1 Commits
af0ec85811
...
main_old
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ed77dd471 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,4 +20,3 @@ yarn.lock
|
|||||||
/scripts/
|
/scripts/
|
||||||
/pb_data/
|
/pb_data/
|
||||||
/.tanstack/
|
/.tanstack/
|
||||||
/dist/
|
|
||||||
18
package.json
18
package.json
@@ -6,8 +6,7 @@
|
|||||||
"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": "bun run .output/server/index.mjs",
|
"start": "node .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",
|
||||||
@@ -22,17 +21,14 @@
|
|||||||
"@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.132.2",
|
"@tanstack/react-start": "1.130.15",
|
||||||
"@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",
|
||||||
@@ -55,8 +51,6 @@
|
|||||||
"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",
|
||||||
@@ -69,7 +63,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": "^7.1.7",
|
"vite": "^6.3.5",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
/// <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);
|
|
||||||
})
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
/// <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)
|
|
||||||
})
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
/// <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)
|
|
||||||
})
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
/// <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)
|
|
||||||
})
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
/// <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)
|
|
||||||
})
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
/// <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);
|
|
||||||
})
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
/// <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);
|
|
||||||
})
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
/// <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);
|
|
||||||
})
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
/// <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);
|
|
||||||
})
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
/// <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);
|
|
||||||
})
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
/// <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)
|
|
||||||
})
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
/// <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)
|
|
||||||
})
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
/// <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
366
server.ts
@@ -1,366 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,6 +8,8 @@
|
|||||||
// 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'
|
||||||
@@ -19,28 +21,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',
|
||||||
@@ -91,57 +93,6 @@ 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',
|
||||||
@@ -163,16 +114,6 @@ 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/',
|
||||||
@@ -191,12 +132,6 @@ 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',
|
||||||
@@ -209,6 +144,66 @@ 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
|
||||||
@@ -218,29 +213,16 @@ 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 {
|
||||||
@@ -250,29 +232,16 @@ 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 {
|
||||||
@@ -285,29 +254,16 @@ 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 {
|
||||||
@@ -320,29 +276,16 @@ 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:
|
||||||
@@ -352,29 +295,16 @@ 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__'
|
||||||
@@ -386,12 +316,68 @@ 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'
|
||||||
|
| '/_authed/admin/'
|
||||||
|
| '/_authed/tournaments/'
|
||||||
|
| '/_authed/tournaments/$id/bracket'
|
||||||
|
| '/_authed/admin/tournaments/'
|
||||||
|
| '/_authed/admin/tournaments/$id/teams'
|
||||||
|
| '/_authed/admin/tournaments/run/$id'
|
||||||
|
| '/_authed/admin/tournaments/$id/'
|
||||||
|
fileRoutesById: FileRoutesById
|
||||||
|
}
|
||||||
|
export interface RootRouteChildren {
|
||||||
|
AuthedRoute: typeof AuthedRouteWithChildren
|
||||||
|
LoginRoute: typeof LoginRoute
|
||||||
|
LogoutRoute: typeof LogoutRoute
|
||||||
|
RefreshSessionRoute: typeof RefreshSessionRoute
|
||||||
|
}
|
||||||
|
export interface FileServerRoutesByFullPath {
|
||||||
|
'/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 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/auth/$'
|
||||||
| '/api/events/$'
|
| '/api/events/$'
|
||||||
| '/api/spotify/callback'
|
| '/api/spotify/callback'
|
||||||
@@ -402,32 +388,47 @@ export interface FileRouteTypes {
|
|||||||
| '/api/spotify/token'
|
| '/api/spotify/token'
|
||||||
| '/api/teams/upload-logo'
|
| '/api/teams/upload-logo'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
| '/_authed/admin/'
|
|
||||||
| '/_authed/tournaments/'
|
|
||||||
| '/_authed/tournaments/$id/bracket'
|
|
||||||
| '/_authed/admin/tournaments/'
|
|
||||||
| '/_authed/admin/tournaments/$id/teams'
|
|
||||||
| '/_authed/admin/tournaments/run/$id'
|
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
| '/_authed/admin/tournaments/$id/'
|
fileServerRoutesByTo: FileServerRoutesByTo
|
||||||
fileRoutesById: FileRoutesById
|
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 RootRouteChildren {
|
export interface RootServerRouteChildren {
|
||||||
AuthedRoute: typeof AuthedRouteWithChildren
|
ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute
|
||||||
LoginRoute: typeof LoginRoute
|
ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute
|
||||||
LogoutRoute: typeof LogoutRoute
|
ApiSpotifyCallbackServerRoute: typeof ApiSpotifyCallbackServerRoute
|
||||||
RefreshSessionRoute: typeof RefreshSessionRoute
|
ApiSpotifyCaptureServerRoute: typeof ApiSpotifyCaptureServerRoute
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute
|
||||||
ApiEventsSplatRoute: typeof ApiEventsSplatRoute
|
ApiSpotifyResumeServerRoute: typeof ApiSpotifyResumeServerRoute
|
||||||
ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
|
ApiSpotifySearchServerRoute: typeof ApiSpotifySearchServerRoute
|
||||||
ApiSpotifyCaptureRoute: typeof ApiSpotifyCaptureRoute
|
ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute
|
||||||
ApiSpotifyPlaybackRoute: typeof ApiSpotifyPlaybackRoute
|
ApiTeamsUploadLogoServerRoute: typeof ApiTeamsUploadLogoServerRoute
|
||||||
ApiSpotifyResumeRoute: typeof ApiSpotifyResumeRoute
|
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
|
||||||
ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute
|
ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||||
ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute
|
|
||||||
ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute
|
|
||||||
ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute
|
|
||||||
ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
@@ -502,76 +503,6 @@ 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'
|
||||||
@@ -600,20 +531,6 @@ 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'
|
||||||
@@ -635,13 +552,6 @@ 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'
|
||||||
@@ -658,10 +568,89 @@ 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
|
||||||
@@ -671,8 +660,6 @@ interface AuthedAdminRouteChildren {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
||||||
AuthedAdminActivitiesRoute: AuthedAdminActivitiesRoute,
|
|
||||||
AuthedAdminBadgesRoute: AuthedAdminBadgesRoute,
|
|
||||||
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
||||||
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
||||||
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
||||||
@@ -717,27 +704,24 @@ 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 = {
|
||||||
import type { getRouter } from './router.tsx'
|
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute,
|
||||||
import type { createStart } from '@tanstack/react-start'
|
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute,
|
||||||
declare module '@tanstack/react-start' {
|
ApiSpotifyCallbackServerRoute: ApiSpotifyCallbackServerRoute,
|
||||||
interface Register {
|
ApiSpotifyCaptureServerRoute: ApiSpotifyCaptureServerRoute,
|
||||||
ssr: true
|
ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute,
|
||||||
router: Awaited<ReturnType<typeof getRouter>>
|
ApiSpotifyResumeServerRoute: ApiSpotifyResumeServerRoute,
|
||||||
}
|
ApiSpotifySearchServerRoute: ApiSpotifySearchServerRoute,
|
||||||
|
ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute,
|
||||||
|
ApiTeamsUploadLogoServerRoute: ApiTeamsUploadLogoServerRoute,
|
||||||
|
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
|
||||||
|
ApiFilesCollectionRecordIdFileServerRoute:
|
||||||
|
ApiFilesCollectionRecordIdFileServerRoute,
|
||||||
}
|
}
|
||||||
|
export const serverRouteTree = rootServerRouteImport
|
||||||
|
._addFileChildren(rootServerRouteChildren)
|
||||||
|
._addFileTypes<FileServerRouteTypes>()
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
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 { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
|
import { routerWithQueryClient } from "@tanstack/react-router-with-query";
|
||||||
import { routeTree } from "./routeTree.gen";
|
import { routeTree } from "./routeTree.gen";
|
||||||
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
|
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
|
||||||
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
|
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
|
||||||
|
|
||||||
export function getRouter() {
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
|
||||||
|
export function createRouter() {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
@@ -18,7 +22,8 @@ export function getRouter() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const router = createTanStackRouter({
|
return routerWithQueryClient(
|
||||||
|
createTanStackRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
context: {
|
context: {
|
||||||
queryClient,
|
queryClient,
|
||||||
@@ -32,18 +37,13 @@ export function getRouter() {
|
|||||||
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 getRouter>;
|
router: ReturnType<typeof createRouter>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=resizes-content",
|
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=overlays-content",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
@@ -63,17 +63,7 @@ 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) => {
|
||||||
@@ -93,20 +83,12 @@ export const Route = createRootRouteWithContext<{
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (location.pathname === '/login' || location.pathname === '/logout') {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// https://github.com/TanStack/router/discussions/3531
|
// https://github.com/TanStack/router/discussions/3531
|
||||||
const auth = await ensureServerQueryData(
|
const auth = await ensureServerQueryData(
|
||||||
context.queryClient,
|
context.queryClient,
|
||||||
playerQueries.auth()
|
playerQueries.auth()
|
||||||
);
|
);
|
||||||
return { auth };
|
return { auth };
|
||||||
} catch (error) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
pendingComponent: () => <Providers><FullScreenLoader /></Providers>,
|
pendingComponent: () => <Providers><FullScreenLoader /></Providers>,
|
||||||
});
|
});
|
||||||
@@ -132,7 +114,8 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||||||
{...mantineHtmlProps}
|
{...mantineHtmlProps}
|
||||||
style={{
|
style={{
|
||||||
overflowX: "hidden",
|
overflowX: "hidden",
|
||||||
height: "100%",
|
overflowY: "hidden",
|
||||||
|
position: "fixed",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -144,10 +127,9 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||||||
<body
|
<body
|
||||||
style={{
|
style={{
|
||||||
overflowX: "hidden",
|
overflowX: "hidden",
|
||||||
height: "100%",
|
overflowY: "hidden",
|
||||||
|
position: "fixed",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="app">{children}</div>
|
<div className="app">{children}</div>
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
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 />;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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,4 +1,3 @@
|
|||||||
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";
|
||||||
@@ -25,14 +24,6 @@ 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 }) => ({
|
||||||
@@ -43,7 +34,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), badgeKeys.playerBadges(params.playerId)],
|
refresh: [playerKeys.details(params.playerId), playerKeys.matches(params.playerId), playerKeys.stats(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 { createFileRoute } from '@tanstack/react-router';
|
import { createServerFileRoute } from '@tanstack/react-start/server';
|
||||||
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,9 +12,7 @@ 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 Route = createFileRoute('/api/auth/$')({
|
export const ServerRoute = createServerFileRoute('/api/auth/$').methods({
|
||||||
server: {
|
|
||||||
handlers: {
|
|
||||||
GET: handleRequest,
|
GET: handleRequest,
|
||||||
POST: handleRequest,
|
POST: handleRequest,
|
||||||
PUT: handleRequest,
|
PUT: handleRequest,
|
||||||
@@ -22,6 +20,4 @@ export const Route = createFileRoute('/api/auth/$')({
|
|||||||
PATCH: handleRequest,
|
PATCH: handleRequest,
|
||||||
OPTIONS: handleRequest,
|
OPTIONS: handleRequest,
|
||||||
HEAD: handleRequest,
|
HEAD: handleRequest,
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,31 +1,21 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createServerFileRoute } from "@tanstack/react-start/server";
|
||||||
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";
|
||||||
|
|
||||||
let activeConnections = 0;
|
export const ServerRoute = createServerFileRoute("/api/events/$").middleware([superTokensRequestMiddleware]).methods({
|
||||||
|
GET: ({ request, context }) => {
|
||||||
|
logger.info('ServerEvents | New connection', context?.userAuthId);
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/events/$")({
|
|
||||||
server: {
|
|
||||||
middleware: [superTokensRequestMiddleware],
|
|
||||||
handlers: {
|
|
||||||
GET: ({ request }) => {
|
|
||||||
activeConnections++;
|
|
||||||
const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
||||||
logger.info(`ServerEvents | New connection ${connectionId}. Active: ${activeConnections}`);
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
|
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
|
||||||
controller.enqueue(new TextEncoder().encode(connectMessage));
|
controller.enqueue(new TextEncoder().encode(connectMessage));
|
||||||
|
|
||||||
const handleEvent = (event: ServerEvent) => {
|
const handleEvent = (event: ServerEvent) => {
|
||||||
logger.info("ServerEvents | Event received", event);
|
logger.info('ServerEvents | Event received', event);
|
||||||
const message = `data: ${JSON.stringify(event)}\n\n`;
|
const message = `data: ${JSON.stringify(event)}\n\n`;
|
||||||
try {
|
try {
|
||||||
if (!controller.desiredSize || controller.desiredSize <= 0) {
|
|
||||||
logger.warn("ServerEvents | Stream closed, skipping event");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
controller.enqueue(new TextEncoder().encode(message));
|
controller.enqueue(new TextEncoder().encode(message));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("ServerEvents | Error sending SSE message", error);
|
logger.error("ServerEvents | Error sending SSE message", error);
|
||||||
@@ -38,37 +28,27 @@ export const Route = createFileRoute("/api/events/$")({
|
|||||||
|
|
||||||
const pingInterval = setInterval(() => {
|
const pingInterval = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
if (!controller.desiredSize || controller.desiredSize <= 0) {
|
const pingMessage = `data: ${JSON.stringify({ type: "ping" })}\n\n`;
|
||||||
clearInterval(pingInterval);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pingMessage = `data: ${JSON.stringify({ type: "ping", timestamp: Date.now() })}\n\n`;
|
|
||||||
controller.enqueue(new TextEncoder().encode(pingMessage));
|
controller.enqueue(new TextEncoder().encode(pingMessage));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("ServerEvents | Ping interval error", e);
|
|
||||||
clearInterval(pingInterval);
|
clearInterval(pingInterval);
|
||||||
|
controller.close();
|
||||||
}
|
}
|
||||||
}, 15000);
|
}, 30000);
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
const heartbeatMessage = `data: ${JSON.stringify({ type: "heartbeat", timestamp: Date.now() })}\n\n`;
|
|
||||||
controller.enqueue(new TextEncoder().encode(heartbeatMessage));
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("ServerEvents | Heartbeat error", e);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
activeConnections--;
|
|
||||||
serverEvents.off("test", handleEvent);
|
serverEvents.off("test", handleEvent);
|
||||||
serverEvents.off("match", handleEvent);
|
|
||||||
serverEvents.off("reaction", handleEvent);
|
|
||||||
clearInterval(pingInterval);
|
clearInterval(pingInterval);
|
||||||
logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${activeConnections}`);
|
try {
|
||||||
|
logger.info('ServerEvents | Closing connection', context?.userAuthId);
|
||||||
|
controller.close();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('ServerEvents | Error closing controller', e);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
request.signal?.addEventListener("abort", cleanup);
|
request.signal?.addEventListener("abort", cleanup);
|
||||||
|
|
||||||
return cleanup;
|
return cleanup;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -76,17 +56,11 @@ export const Route = createFileRoute("/api/events/$")({
|
|||||||
return new Response(stream, {
|
return new Response(stream, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
"Cache-Control": "no-cache",
|
||||||
"Connection": "keep-alive",
|
Connection: "keep-alive",
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Access-Control-Allow-Headers": "Cache-Control",
|
"Access-Control-Allow-Headers": "Cache-Control",
|
||||||
"X-Accel-Buffering": "no",
|
|
||||||
"X-Proxy-Buffering": "no",
|
|
||||||
"Proxy-Buffering": "off",
|
|
||||||
"Transfer-Encoding": "chunked",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
@@ -1,87 +1,84 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createServerFileRoute } from "@tanstack/react-start/server";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const ServerRoute = createServerFileRoute("/api/files/$collection/$recordId/$file").methods({
|
||||||
"/api/files/$collection/$recordId/$file"
|
|
||||||
)({
|
|
||||||
server: {
|
|
||||||
handlers: {
|
|
||||||
GET: async ({ params, request }) => {
|
GET: async ({ params, request }) => {
|
||||||
try {
|
try {
|
||||||
const { collection, recordId, file } = params;
|
const { collection, recordId, file } = params;
|
||||||
const pocketbaseUrl =
|
const pocketbaseUrl = process.env.POCKETBASE_URL || 'http://127.0.0.1:8090';
|
||||||
process.env.POCKETBASE_URL || "http://127.0.0.1:8090";
|
|
||||||
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
|
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
|
||||||
|
|
||||||
|
logger.info('File proxy', {
|
||||||
|
collection,
|
||||||
|
recordId,
|
||||||
|
file,
|
||||||
|
targetUrl: fileUrl
|
||||||
|
});
|
||||||
|
|
||||||
const response = await fetch(fileUrl, {
|
const response = await fetch(fileUrl, {
|
||||||
method: "GET",
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
...(request.headers.get("range") && {
|
...(request.headers.get('range') && { 'Range': request.headers.get('range')! }),
|
||||||
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')! }),
|
||||||
...(request.headers.get("if-none-match") && {
|
|
||||||
"If-None-Match": request.headers.get("if-none-match")!,
|
|
||||||
}),
|
|
||||||
...(request.headers.get("if-modified-since") && {
|
|
||||||
"If-Modified-Since": request.headers.get("if-modified-since")!,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
logger.error("PocketBase file request failed", {
|
logger.error('PocketBase file request failed', {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
url: fileUrl,
|
url: fileUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
return new Response("File not found", { status: 404 });
|
return new Response('File not found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(`PocketBase error: ${response.statusText}`, {
|
return new Response(`PocketBase error: ${response.statusText}`, {
|
||||||
status: response.status,
|
status: response.status
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = response.body;
|
const body = response.body;
|
||||||
const responseHeaders = new Headers();
|
const responseHeaders = new Headers();
|
||||||
const headers = [
|
const headers = [
|
||||||
"content-type",
|
'content-type',
|
||||||
"content-length",
|
'content-length',
|
||||||
"content-disposition",
|
'content-disposition',
|
||||||
"etag",
|
'etag',
|
||||||
"last-modified",
|
'last-modified',
|
||||||
"cache-control",
|
'cache-control',
|
||||||
"accept-ranges",
|
'accept-ranges',
|
||||||
"content-range",
|
'content-range'
|
||||||
];
|
];
|
||||||
|
|
||||||
headers.forEach((header) => {
|
headers.forEach(header => {
|
||||||
const value = response.headers.get(header);
|
const value = response.headers.get(header);
|
||||||
if (value) {
|
if (value) {
|
||||||
responseHeaders.set(header, value);
|
responseHeaders.set(header, value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
responseHeaders.set("Access-Control-Allow-Origin", "*");
|
responseHeaders.set('Access-Control-Allow-Origin', '*');
|
||||||
responseHeaders.set(
|
responseHeaders.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
||||||
"Access-Control-Allow-Methods",
|
responseHeaders.set('Access-Control-Allow-Headers', 'Range, If-None-Match, If-Modified-Since');
|
||||||
"GET, HEAD, OPTIONS"
|
|
||||||
);
|
logger.info('File proxy response', {
|
||||||
responseHeaders.set(
|
status: response.status,
|
||||||
"Access-Control-Allow-Headers",
|
contentType: response.headers.get('content-type'),
|
||||||
"Range, If-None-Match, If-Modified-Since"
|
contentLength: response.headers.get('content-length')
|
||||||
);
|
});
|
||||||
|
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
headers: responseHeaders,
|
headers: responseHeaders
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("File proxy error", error);
|
logger.error('File proxy error', error);
|
||||||
return new Response("Internal server error", { status: 500 });
|
return new Response('Internal server error', { status: 500 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -89,12 +86,10 @@ export const Route = createFileRoute(
|
|||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Access-Control-Allow-Origin": "*",
|
'Access-Control-Allow-Origin': '*',
|
||||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
"Access-Control-Max-Age": "86400",
|
'Access-Control-Max-Age': '86400',
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
@@ -1,145 +1,127 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||||
|
import { SpotifyAuth } from '@/lib/spotify/auth'
|
||||||
|
|
||||||
const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!;
|
const SPOTIFY_CLIENT_ID = import.meta.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 = process.env.VITE_SPOTIFY_REDIRECT_URI!;
|
const SPOTIFY_REDIRECT_URI = import.meta.env.VITE_SPOTIFY_REDIRECT_URI!
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/spotify/callback")({
|
export const ServerRoute = createServerFileRoute('/api/spotify/callback').methods({
|
||||||
server: {
|
|
||||||
handlers: {
|
|
||||||
GET: async ({ request }: { request: Request }) => {
|
GET: async ({ request }: { request: Request }) => {
|
||||||
const getReturnPath = (state: string | null): string => {
|
const getReturnPath = (state: string | null): string => {
|
||||||
if (!state) return "/";
|
if (!state) return '/';
|
||||||
try {
|
try {
|
||||||
const decodedState = JSON.parse(atob(state));
|
const decodedState = JSON.parse(atob(state));
|
||||||
return decodedState.returnPath || "/";
|
return decodedState.returnPath || '/';
|
||||||
} catch {
|
} catch {
|
||||||
return "/";
|
return '/';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url)
|
||||||
const code = url.searchParams.get("code");
|
const code = url.searchParams.get('code')
|
||||||
const state = url.searchParams.get("state");
|
const state = url.searchParams.get('state')
|
||||||
const error = url.searchParams.get("error");
|
const error = url.searchParams.get('error')
|
||||||
|
|
||||||
const returnPath = getReturnPath(state);
|
const returnPath = getReturnPath(state);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Spotify OAuth error:", error);
|
console.error('Spotify OAuth error:', error)
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
Location:
|
'Location': returnPath + '?spotify_error=' + encodeURIComponent(error),
|
||||||
returnPath + "?spotify_error=" + encodeURIComponent(error),
|
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code || !state) {
|
if (!code || !state) {
|
||||||
console.error("Missing code or state:", {
|
console.error('Missing code or state:', { code: !!code, state: !!state })
|
||||||
code: !!code,
|
|
||||||
state: !!state,
|
|
||||||
});
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
Location: returnPath + "?spotify_error=missing_code_or_state",
|
'Location': returnPath + '?spotify_error=missing_code_or_state',
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Token exchange attempt:", {
|
console.log('Token exchange attempt:', {
|
||||||
client_id: SPOTIFY_CLIENT_ID,
|
client_id: SPOTIFY_CLIENT_ID,
|
||||||
redirect_uri: SPOTIFY_REDIRECT_URI,
|
redirect_uri: SPOTIFY_REDIRECT_URI,
|
||||||
has_code: !!code,
|
has_code: !!code,
|
||||||
has_state: !!state,
|
has_state: !!state,
|
||||||
});
|
})
|
||||||
|
|
||||||
const tokenResponse = await fetch(
|
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
|
||||||
"https://accounts.spotify.com/api/token",
|
method: 'POST',
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
Authorization: `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString("base64")}`,
|
'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`,
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
grant_type: "authorization_code",
|
grant_type: 'authorization_code',
|
||||||
code,
|
code,
|
||||||
redirect_uri: SPOTIFY_REDIRECT_URI,
|
redirect_uri: SPOTIFY_REDIRECT_URI,
|
||||||
}),
|
}),
|
||||||
}
|
})
|
||||||
);
|
|
||||||
|
|
||||||
if (!tokenResponse.ok) {
|
if (!tokenResponse.ok) {
|
||||||
const errorText = await tokenResponse.text();
|
const errorText = await tokenResponse.text()
|
||||||
console.error("Token exchange error:", {
|
console.error('Token exchange error:', {
|
||||||
status: tokenResponse.status,
|
status: tokenResponse.status,
|
||||||
statusText: tokenResponse.statusText,
|
statusText: tokenResponse.statusText,
|
||||||
body: errorText,
|
body: errorText,
|
||||||
redirect_uri: SPOTIFY_REDIRECT_URI,
|
redirect_uri: SPOTIFY_REDIRECT_URI,
|
||||||
});
|
})
|
||||||
|
|
||||||
const errorParam = encodeURIComponent(
|
const errorParam = encodeURIComponent(`${tokenResponse.status}: ${errorText}`)
|
||||||
`${tokenResponse.status}: ${errorText}`
|
|
||||||
);
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
Location: `${returnPath}?spotify_error=token_exchange_failed&details=${errorParam}`,
|
'Location': `${returnPath}?spotify_error=token_exchange_failed&details=${errorParam}`,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await tokenResponse.json();
|
const tokens = await tokenResponse.json()
|
||||||
|
|
||||||
console.log("Token exchange successful:", {
|
console.log('Token exchange successful:', {
|
||||||
has_access_token: !!tokens.access_token,
|
has_access_token: !!tokens.access_token,
|
||||||
has_refresh_token: !!tokens.refresh_token,
|
has_refresh_token: !!tokens.refresh_token,
|
||||||
expires_in: tokens.expires_in,
|
expires_in: tokens.expires_in,
|
||||||
});
|
})
|
||||||
|
|
||||||
console.log("Decoded return path:", returnPath);
|
console.log('Decoded return path:', returnPath);
|
||||||
|
|
||||||
const response = new Response(null, {
|
const response = new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
Location: returnPath + "?spotify_auth=success",
|
'Location': returnPath + '?spotify_auth=success',
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const isSecure = import.meta.env.NODE_ENV === "production";
|
const isSecure = process.env.NODE_ENV === 'production'
|
||||||
const cookieOptions = `HttpOnly; ${isSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${tokens.expires_in}`;
|
const cookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${tokens.expires_in}`
|
||||||
|
|
||||||
response.headers.append(
|
response.headers.append('Set-Cookie', `spotify_access_token=${tokens.access_token}; ${cookieOptions}`)
|
||||||
"Set-Cookie",
|
|
||||||
`spotify_access_token=${tokens.access_token}; ${cookieOptions}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
const refreshCookieOptions = `HttpOnly; ${isSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${60 * 60 * 24 * 30}`; // 30 days
|
const refreshCookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${60 * 60 * 24 * 30}` // 30 days
|
||||||
response.headers.append(
|
response.headers.append('Set-Cookie', `spotify_refresh_token=${tokens.refresh_token}; ${refreshCookieOptions}`)
|
||||||
"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,60 +1,59 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||||
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 Route = createFileRoute("/api/spotify/capture")({
|
export const ServerRoute = createServerFileRoute('/api/spotify/capture').methods({
|
||||||
server: {
|
|
||||||
handlers: {
|
|
||||||
POST: async ({ request }: { request: Request }) => {
|
POST: async ({ request }: { request: Request }) => {
|
||||||
try {
|
try {
|
||||||
const cookies = request.headers.get("Cookie") || "";
|
// Get access token from cookies
|
||||||
const accessTokenMatch = cookies.match(
|
const cookies = request.headers.get('Cookie') || ''
|
||||||
/spotify_access_token=([^;]+)/
|
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)
|
||||||
|
|
||||||
const snapshot = await spotifyClient.createPlaybackSnapshot();
|
// Create a snapshot of the current playback state
|
||||||
|
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(JSON.stringify({ snapshot }), {
|
return new Response(
|
||||||
|
JSON.stringify({ snapshot }),
|
||||||
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
});
|
}
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Spotify capture error:", error);
|
console.error('Spotify capture error:', error)
|
||||||
|
|
||||||
const errorMessage =
|
const errorMessage = error instanceof Error ? error.message : 'Failed to capture playback state'
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to capture playback state";
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ error: errorMessage }), {
|
return new Response(
|
||||||
|
JSON.stringify({ error: errorMessage }),
|
||||||
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
});
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,203 +1,202 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||||
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 Route = createFileRoute("/api/spotify/playback")({
|
export const ServerRoute = createServerFileRoute('/api/spotify/playback').methods({
|
||||||
server: {
|
|
||||||
handlers: {
|
|
||||||
POST: async ({ request }: { request: Request }) => {
|
POST: async ({ request }: { request: Request }) => {
|
||||||
try {
|
try {
|
||||||
const accessToken = getAccessTokenFromCookies(request);
|
const accessToken = getAccessTokenFromCookies(request)
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "No access token found" }),
|
JSON.stringify({ error: 'No access token found' }),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json()
|
||||||
const { action, deviceId, volumePercent, trackId, positionMs } = body;
|
const { action, deviceId, volumePercent, trackId, positionMs } = body
|
||||||
|
|
||||||
const spotifyClient = new SpotifyWebApiClient(accessToken);
|
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "play":
|
case 'play':
|
||||||
await spotifyClient.play(deviceId);
|
await spotifyClient.play(deviceId)
|
||||||
break;
|
break
|
||||||
case "playTrack":
|
case 'playTrack':
|
||||||
if (!trackId) {
|
if (!trackId) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({ error: 'trackId is required for playTrack action' }),
|
||||||
error: "trackId is required for playTrack action",
|
|
||||||
}),
|
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
await spotifyClient.playTrack(trackId, deviceId, positionMs);
|
await spotifyClient.playTrack(trackId, deviceId, positionMs)
|
||||||
break;
|
break
|
||||||
case "pause":
|
case 'pause':
|
||||||
await spotifyClient.pause();
|
await spotifyClient.pause()
|
||||||
break;
|
break
|
||||||
case "next":
|
case 'next':
|
||||||
await spotifyClient.skipToNext();
|
await spotifyClient.skipToNext()
|
||||||
break;
|
break
|
||||||
case "previous":
|
case 'previous':
|
||||||
await spotifyClient.skipToPrevious();
|
await spotifyClient.skipToPrevious()
|
||||||
break;
|
break
|
||||||
case "volume":
|
case 'volume':
|
||||||
if (typeof volumePercent !== "number") {
|
if (typeof volumePercent !== 'number') {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "volumePercent must be a number" }),
|
JSON.stringify({ error: 'volumePercent must be a number' }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
await spotifyClient.setVolume(volumePercent);
|
await spotifyClient.setVolume(volumePercent)
|
||||||
break;
|
break
|
||||||
case "transfer":
|
case 'transfer':
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({ error: 'deviceId is required for transfer action' }),
|
||||||
error: "deviceId is required for transfer action",
|
|
||||||
}),
|
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
await spotifyClient.transferPlayback(deviceId);
|
await spotifyClient.transferPlayback(deviceId)
|
||||||
break;
|
break
|
||||||
default:
|
default:
|
||||||
return new Response(JSON.stringify({ error: "Invalid action" }), {
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Invalid action' }),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify({ success: true }), {
|
return new Response(
|
||||||
|
JSON.stringify({ success: true }),
|
||||||
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
}
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Playback control error:", error);
|
console.error('Playback control error:', error)
|
||||||
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
if (error.message.includes("NO_ACTIVE_DEVICE")) {
|
if (error.message.includes('NO_ACTIVE_DEVICE')) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({ error: 'No active device found. Please select a device first.' }),
|
||||||
error:
|
|
||||||
"No active device found. Please select a device first.",
|
|
||||||
}),
|
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.message.includes("PREMIUM_REQUIRED")) {
|
if (error.message.includes('PREMIUM_REQUIRED')) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({ error: 'Spotify Premium is required for playback control.' }),
|
||||||
error: "Spotify Premium is required for playback control.",
|
|
||||||
}),
|
|
||||||
{
|
{
|
||||||
status: 403,
|
status: 403,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("Full error details:", {
|
console.error('Full error details:', {
|
||||||
message: error.message,
|
message: error.message,
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
name: error.name,
|
name: error.name,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({ error: 'Playback control failed', details: error instanceof Error ? error.message : 'Unknown error' }),
|
||||||
error: "Playback control failed",
|
|
||||||
details: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
}),
|
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
GET: async ({ request }: { request: Request }) => {
|
GET: async ({ request }: { request: Request }) => {
|
||||||
try {
|
try {
|
||||||
const accessToken = getAccessTokenFromCookies(request);
|
const accessToken = getAccessTokenFromCookies(request)
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "No access token found" }),
|
JSON.stringify({ error: 'No access token found' }),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url)
|
||||||
const type = url.searchParams.get("type");
|
const type = url.searchParams.get('type')
|
||||||
|
|
||||||
const spotifyClient = new SpotifyWebApiClient(accessToken);
|
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
||||||
|
|
||||||
if (type === "devices") {
|
if (type === 'devices') {
|
||||||
const devices = await spotifyClient.getDevices();
|
const devices = await spotifyClient.getDevices()
|
||||||
return new Response(JSON.stringify({ devices }), {
|
return new Response(
|
||||||
|
JSON.stringify({ devices }),
|
||||||
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
}
|
||||||
} else if (type === "state") {
|
)
|
||||||
const playbackState = await spotifyClient.getPlaybackState();
|
} else if (type === 'state') {
|
||||||
return new Response(JSON.stringify({ playbackState }), {
|
const playbackState = await spotifyClient.getPlaybackState()
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ playbackState }),
|
||||||
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
}
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
const [devices, playbackState] = await Promise.all([
|
const [devices, playbackState] = await Promise.all([
|
||||||
spotifyClient.getDevices(),
|
spotifyClient.getDevices(),
|
||||||
spotifyClient.getPlaybackState(),
|
spotifyClient.getPlaybackState(),
|
||||||
]);
|
])
|
||||||
|
|
||||||
return new Response(JSON.stringify({ devices, playbackState }), {
|
return new Response(
|
||||||
|
JSON.stringify({ devices, playbackState }),
|
||||||
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Get playback data error:", error);
|
console.error('Get playback data error:', error)
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "Failed to get playback data" }),
|
JSON.stringify({ error: 'Failed to get playback data' }),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,71 +1,72 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||||
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 Route = createFileRoute("/api/spotify/resume")({
|
export const ServerRoute = createServerFileRoute('/api/spotify/resume').methods({
|
||||||
server: {
|
|
||||||
handlers: {
|
|
||||||
POST: async ({ request }: { request: Request }) => {
|
POST: async ({ request }: { request: Request }) => {
|
||||||
try {
|
try {
|
||||||
const cookies = request.headers.get("Cookie") || "";
|
// Get access token from cookies
|
||||||
const accessTokenMatch = cookies.match(
|
const cookies = request.headers.get('Cookie') || ''
|
||||||
/spotify_access_token=([^;]+)/
|
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)
|
||||||
|
|
||||||
const body = await request.json();
|
// Parse the request body to get the snapshot
|
||||||
const { snapshot } = body as { snapshot: SpotifyPlaybackSnapshot };
|
const body = await request.json()
|
||||||
|
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' }
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
await spotifyClient.restorePlaybackSnapshot(snapshot);
|
// Restore the playback state from the snapshot
|
||||||
|
await spotifyClient.restorePlaybackSnapshot(snapshot)
|
||||||
|
|
||||||
return new Response(JSON.stringify({ success: true }), {
|
return new Response(
|
||||||
|
JSON.stringify({ success: true }),
|
||||||
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
});
|
}
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Spotify resume error:", error);
|
console.error('Spotify resume error:', error)
|
||||||
|
|
||||||
let errorMessage = "Failed to resume playback state";
|
let errorMessage = 'Failed to resume playback state'
|
||||||
|
|
||||||
|
// Handle common Spotify Premium requirement error
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
if (
|
if (error.message.includes('Premium') || error.message.includes('403')) {
|
||||||
error.message.includes("Premium") ||
|
errorMessage = 'Spotify Premium required for playback control'
|
||||||
error.message.includes("403")
|
|
||||||
) {
|
|
||||||
errorMessage = "Spotify premium required";
|
|
||||||
} else {
|
} else {
|
||||||
errorMessage = error.message;
|
errorMessage = error.message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify({ error: errorMessage }), {
|
return new Response(
|
||||||
|
JSON.stringify({ error: errorMessage }),
|
||||||
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
});
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,87 +1,81 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||||
|
|
||||||
|
// 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 Route = createFileRoute("/api/spotify/search")({
|
export const ServerRoute = createServerFileRoute('/api/spotify/search').methods({
|
||||||
server: {
|
|
||||||
handlers: {
|
|
||||||
GET: async ({ request }: { request: Request }) => {
|
GET: async ({ request }: { request: Request }) => {
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url)
|
||||||
const query = url.searchParams.get("q");
|
const query = url.searchParams.get('q')
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "Query parameter q is required" }),
|
JSON.stringify({ error: 'Query parameter q is required' }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get client credentials access token
|
// Get client credentials access token
|
||||||
const accessToken = await getClientCredentialsToken();
|
const accessToken = await getClientCredentialsToken()
|
||||||
|
|
||||||
// Search using Spotify API directly
|
// Search using Spotify API directly
|
||||||
const searchUrl = `https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=20`;
|
const searchUrl = `https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=20`
|
||||||
|
|
||||||
const searchResponse = await fetch(searchUrl, {
|
const searchResponse = await fetch(searchUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!searchResponse.ok) {
|
if (!searchResponse.ok) {
|
||||||
throw new Error("Spotify search request failed");
|
throw new Error('Spotify search request failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchResult = await searchResponse.json();
|
const searchResult = await searchResponse.json()
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ tracks: searchResult.tracks.items }),
|
JSON.stringify({ tracks: searchResult.tracks.items }),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Search error:", error);
|
console.error('Search error:', error)
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({ error: 'Search failed', details: error instanceof Error ? error.message : 'Unknown error' }),
|
||||||
error: "Search failed",
|
|
||||||
details: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
}),
|
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,57 +1,49 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||||
|
|
||||||
const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!;
|
const SPOTIFY_CLIENT_ID = import.meta.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 Route = createFileRoute("/api/spotify/token")({
|
export const ServerRoute = createServerFileRoute('/api/spotify/token').methods({
|
||||||
server: {
|
|
||||||
handlers: {
|
|
||||||
POST: async ({ request }: { request: Request }) => {
|
POST: async ({ request }: { request: Request }) => {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json()
|
||||||
const { refresh_token } = body;
|
const { refresh_token } = body
|
||||||
|
|
||||||
if (!refresh_token) {
|
if (!refresh_token) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "refresh_token is required" }),
|
JSON.stringify({ error: 'refresh_token is required' }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenResponse = await fetch(
|
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
|
||||||
"https://accounts.spotify.com/api/token",
|
method: 'POST',
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
Authorization: `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString("base64")}`,
|
'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`,
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
grant_type: "refresh_token",
|
grant_type: 'refresh_token',
|
||||||
refresh_token,
|
refresh_token,
|
||||||
}),
|
}),
|
||||||
}
|
})
|
||||||
);
|
|
||||||
|
|
||||||
if (!tokenResponse.ok) {
|
if (!tokenResponse.ok) {
|
||||||
const error = await tokenResponse.json();
|
const error = await tokenResponse.json()
|
||||||
console.error("Token refresh error:", error);
|
console.error('Token refresh error:', error)
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({ error: 'Failed to refresh token', details: error }),
|
||||||
error: "Failed to refresh token",
|
|
||||||
details: error,
|
|
||||||
}),
|
|
||||||
{
|
{
|
||||||
status: tokenResponse.status,
|
status: tokenResponse.status,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await tokenResponse.json();
|
const tokens = await tokenResponse.json()
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -62,46 +54,50 @@ export const Route = createFileRoute("/api/spotify/token")({
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Token refresh endpoint error:", error);
|
console.error('Token refresh endpoint error:', error)
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "Internal server error" }),
|
JSON.stringify({ error: 'Internal server error' }),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// GET endpoint to retrieve current tokens from cookies
|
||||||
GET: async ({ request }: { request: Request }) => {
|
GET: async ({ request }: { request: Request }) => {
|
||||||
try {
|
try {
|
||||||
const cookieHeader = request.headers.get("cookie");
|
const cookieHeader = request.headers.get('cookie')
|
||||||
if (!cookieHeader) {
|
if (!cookieHeader) {
|
||||||
return new Response(JSON.stringify({ error: "No cookies found" }), {
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'No cookies found' }),
|
||||||
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookies = Object.fromEntries(
|
const cookies = Object.fromEntries(
|
||||||
cookieHeader.split("; ").map((c: string) => c.split("="))
|
cookieHeader.split('; ').map((c: string) => c.split('='))
|
||||||
);
|
)
|
||||||
|
|
||||||
const accessToken = cookies.spotify_access_token;
|
const accessToken = cookies.spotify_access_token
|
||||||
const refreshToken = cookies.spotify_refresh_token;
|
const refreshToken = cookies.spotify_refresh_token
|
||||||
|
|
||||||
if (!accessToken && !refreshToken) {
|
if (!accessToken && !refreshToken) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "No Spotify tokens found" }),
|
JSON.stringify({ error: 'No Spotify tokens found' }),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
@@ -112,20 +108,18 @@ export const Route = createFileRoute("/api/spotify/token")({
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Get tokens endpoint error:", error);
|
console.error('Get tokens endpoint error:', error)
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "Internal server error" }),
|
JSON.stringify({ error: 'Internal server error' }),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,147 +1,117 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createServerFileRoute } from '@tanstack/react-start/server';
|
||||||
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 Route = createFileRoute("/api/teams/upload-logo")({
|
export const ServerRoute = createServerFileRoute('/api/teams/upload-logo')
|
||||||
server: {
|
.middleware([superTokensRequestMiddleware])
|
||||||
middleware: [superTokensRequestMiddleware],
|
.methods({
|
||||||
handlers: {
|
|
||||||
POST: async ({ request, context }) => {
|
POST: async ({ request, context }) => {
|
||||||
try {
|
try {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const isAdmin = context.roles.includes("Admin");
|
||||||
|
|
||||||
if (!userId) return new Response("Unauthenticated", { status: 401 });
|
if (!userId) return new Response('Unauthenticated', { status: 401 });
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const teamId = formData.get("teamId") as string;
|
const teamId = formData.get('teamId') as string;
|
||||||
const logoFile = formData.get("logo") as File;
|
const logoFile = formData.get('logo') as File;
|
||||||
|
|
||||||
const validationResult = uploadSchema.safeParse({ teamId });
|
const validationResult = uploadSchema.safeParse({ teamId });
|
||||||
if (!validationResult.success) {
|
if (!validationResult.success) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({
|
||||||
JSON.stringify({
|
error: 'Invalid input',
|
||||||
error: "Invalid input",
|
details: validationResult.error.issues
|
||||||
details: validationResult.error.issues,
|
}), {
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!logoFile || logoFile.size === 0) {
|
if (!logoFile || logoFile.size === 0) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({
|
||||||
JSON.stringify({
|
error: 'Logo file is required'
|
||||||
error: "Logo file is required",
|
}), {
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedTypes = [
|
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
|
||||||
"image/jpeg",
|
|
||||||
"image/jpg",
|
|
||||||
"image/png",
|
|
||||||
"image/gif",
|
|
||||||
];
|
|
||||||
if (!allowedTypes.includes(logoFile.type)) {
|
if (!allowedTypes.includes(logoFile.type)) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({
|
||||||
JSON.stringify({
|
error: 'Invalid file type. Only JPEG, PNG and GIF are allowed.'
|
||||||
error: "Invalid file type. Only JPEG, PNG and GIF are allowed.",
|
}), {
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxSize = 10 * 1024 * 1024;
|
const maxSize = 10 * 1024 * 1024;
|
||||||
if (logoFile.size > maxSize) {
|
if (logoFile.size > maxSize) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({
|
||||||
JSON.stringify({
|
error: 'File too large. Maximum size is 10MB.'
|
||||||
error: "File too large. Maximum size is 10MB.",
|
}), {
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const team = await pbAdmin.getTeam(teamId);
|
const team = await pbAdmin.getTeam(teamId);
|
||||||
if (!team) {
|
if (!team) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({
|
||||||
JSON.stringify({
|
error: 'Team not found'
|
||||||
error: "Team not found",
|
}), {
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 404,
|
status: 404,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await pbAdmin.getPlayerByAuthId(userId);
|
const user = await pbAdmin.getPlayerByAuthId(context.userAuthId)
|
||||||
if (!team.players.map((p) => p.id).includes(user?.id!) && !isAdmin)
|
if (!team.players.map(p => p.id).includes(user!.id) && !isAdmin)
|
||||||
return new Response("Unauthorized", { status: 403 });
|
return new Response('Unauthorized', { status: 403 });
|
||||||
|
|
||||||
logger.info("Uploading team logo", {
|
logger.info('Uploading team logo', {
|
||||||
teamId,
|
teamId,
|
||||||
fileName: logoFile.name,
|
fileName: logoFile.name,
|
||||||
fileSize: logoFile.size,
|
fileSize: logoFile.size,
|
||||||
userId,
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
const pbFormData = new FormData();
|
const pbFormData = new FormData();
|
||||||
pbFormData.append("logo", logoFile);
|
pbFormData.append('logo', logoFile);
|
||||||
|
|
||||||
await pbAdmin.updateTeam(teamId, pbFormData as any);
|
const updatedTeam= await pbAdmin.updateTeam(teamId, pbFormData as any);
|
||||||
const updatedTeam = await pbAdmin.getTeam(teamId);
|
|
||||||
if (!updatedTeam) throw new Error("Failed to fetch updated team");
|
|
||||||
|
|
||||||
logger.info("Team logo uploaded successfully", {
|
logger.info('Team logo uploaded successfully', {
|
||||||
teamId,
|
teamId,
|
||||||
logo: updatedTeam.logo,
|
logo: updatedTeam.logo
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(
|
return new Response(JSON.stringify({
|
||||||
JSON.stringify({
|
|
||||||
success: true,
|
success: true,
|
||||||
team: updatedTeam,
|
team: updatedTeam,
|
||||||
message: "Logo uploaded successfully",
|
message: 'Logo uploaded successfully'
|
||||||
}),
|
}), {
|
||||||
{
|
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
}
|
});
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error("Error uploading team logo:", error);
|
|
||||||
|
|
||||||
return new Response(
|
} catch (error: any) {
|
||||||
JSON.stringify({
|
logger.error('Error uploading team logo:', error);
|
||||||
error: "Failed to upload logo",
|
|
||||||
message: error.message || "Unknown error occurred",
|
return new Response(JSON.stringify({
|
||||||
}),
|
error: 'Failed to upload logo',
|
||||||
{
|
message: error.message || 'Unknown error occurred'
|
||||||
|
}), {
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,145 +1,115 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createServerFileRoute } from '@tanstack/react-start/server';
|
||||||
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 Route = createFileRoute("/api/tournaments/upload-logo")({
|
export const ServerRoute = createServerFileRoute('/api/tournaments/upload-logo')
|
||||||
server: {
|
.middleware([superTokensRequestMiddleware])
|
||||||
middleware: [superTokensRequestMiddleware],
|
.methods({
|
||||||
handlers: {
|
|
||||||
POST: async ({ request, context }) => {
|
POST: async ({ request, context }) => {
|
||||||
try {
|
try {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const isAdmin = context.roles.includes("Admin");
|
||||||
|
|
||||||
if (!userId) return new Response("Unauthenticated", { status: 401 });
|
if (!userId) return new Response('Unauthenticated', { status: 401 });
|
||||||
if (!isAdmin) return new Response("Unauthorized", { status: 403 });
|
if (!isAdmin) return new Response('Unauthorized', { status: 403 });
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const tournamentId = formData.get("tournamentId") as string;
|
const tournamentId = formData.get('tournamentId') as string;
|
||||||
const logoFile = formData.get("logo") as File;
|
const logoFile = formData.get('logo') as File;
|
||||||
|
|
||||||
const validationResult = uploadSchema.safeParse({ tournamentId });
|
const validationResult = uploadSchema.safeParse({ tournamentId });
|
||||||
if (!validationResult.success) {
|
if (!validationResult.success) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({
|
||||||
JSON.stringify({
|
error: 'Invalid input',
|
||||||
error: "Invalid input",
|
details: validationResult.error.issues
|
||||||
details: validationResult.error.issues,
|
}), {
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!logoFile || logoFile.size === 0) {
|
if (!logoFile || logoFile.size === 0) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({
|
||||||
JSON.stringify({
|
error: 'Logo file is required'
|
||||||
error: "Logo file is required",
|
}), {
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedTypes = [
|
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
|
||||||
"image/jpeg",
|
|
||||||
"image/jpg",
|
|
||||||
"image/png",
|
|
||||||
"image/gif",
|
|
||||||
];
|
|
||||||
if (!allowedTypes.includes(logoFile.type)) {
|
if (!allowedTypes.includes(logoFile.type)) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({
|
||||||
JSON.stringify({
|
error: 'Invalid file type. Only JPEG, PNG and GIF are allowed.'
|
||||||
error: "Invalid file type. Only JPEG, PNG and GIF are allowed.",
|
}), {
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxSize = 10 * 1024 * 1024;
|
const maxSize = 10 * 1024 * 1024;
|
||||||
if (logoFile.size > maxSize) {
|
if (logoFile.size > maxSize) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({
|
||||||
JSON.stringify({
|
error: 'File too large. Maximum size is 10MB.'
|
||||||
error: "File too large. Maximum size is 10MB.",
|
}), {
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tournament = await pbAdmin.getTournament(tournamentId);
|
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||||
if (!tournament) {
|
if (!tournament) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({
|
||||||
JSON.stringify({
|
error: 'Tournament not found'
|
||||||
error: "Tournament not found",
|
}), {
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 404,
|
status: 404,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Uploading tournament logo", {
|
|
||||||
|
logger.info('Uploading tournament logo', {
|
||||||
tournamentId,
|
tournamentId,
|
||||||
fileName: logoFile.name,
|
fileName: logoFile.name,
|
||||||
fileSize: logoFile.size,
|
fileSize: logoFile.size,
|
||||||
userId,
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
const pbFormData = new FormData();
|
const pbFormData = new FormData();
|
||||||
pbFormData.append("logo", logoFile);
|
pbFormData.append('logo', logoFile);
|
||||||
|
|
||||||
const updatedTournament = await pbAdmin.updateTournament(
|
const updatedTournament = await pbAdmin.updateTournament(tournamentId, pbFormData as any);
|
||||||
tournamentId,
|
|
||||||
pbFormData as any
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info("Tournament logo uploaded successfully", {
|
logger.info('Tournament logo uploaded successfully', {
|
||||||
tournamentId,
|
tournamentId,
|
||||||
logo: updatedTournament.logo,
|
logo: updatedTournament.logo
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(
|
return new Response(JSON.stringify({
|
||||||
JSON.stringify({
|
|
||||||
success: true,
|
success: true,
|
||||||
tournament: updatedTournament,
|
tournament: updatedTournament,
|
||||||
message: "Logo uploaded successfully",
|
message: 'Logo uploaded successfully'
|
||||||
}),
|
}), {
|
||||||
{
|
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
}
|
});
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error("Error uploading tournament logo:", error);
|
|
||||||
|
|
||||||
return new Response(
|
} catch (error: any) {
|
||||||
JSON.stringify({
|
logger.error('Error uploading tournament logo:', error);
|
||||||
error: "Failed to upload logo",
|
|
||||||
message: error.message || "Unknown error occurred",
|
return new Response(JSON.stringify({
|
||||||
}),
|
error: 'Failed to upload logo',
|
||||||
{
|
message: error.message || 'Unknown error occurred'
|
||||||
|
}), {
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,33 +1,38 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect } 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')
|
||||||
|
|
||||||
if (redirect && !redirect.includes('_serverFn') && !redirect.includes('/api/')) {
|
const isServerFunction = redirect && (
|
||||||
|
redirect.startsWith('_serverFn') ||
|
||||||
|
redirect.startsWith('api/') ||
|
||||||
|
redirect.includes('_serverFn')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (redirect && !isServerFunction) {
|
||||||
window.location.href = decodeURIComponent(redirect)
|
window.location.href = decodeURIComponent(redirect)
|
||||||
} else {
|
} else {
|
||||||
window.location.href = '/';
|
const referrer = document.referrer;
|
||||||
|
const referrerUrl = referrer && !referrer.includes('/_serverFn') && !referrer.includes('/api/')
|
||||||
|
? referrer
|
||||||
|
: '/';
|
||||||
|
|
||||||
|
window.location.href = referrerUrl;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
@@ -37,7 +42,8 @@ function RouteComponent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(handleRefresh, 100)
|
const timeout = setTimeout(handleRefresh, 100)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return <FullScreenLoader />
|
return <FullScreenLoader />
|
||||||
|
|||||||
@@ -2,14 +2,7 @@ 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"> {
|
||||||
@@ -17,8 +10,6 @@ interface AvatarProps
|
|||||||
size?: number;
|
size?: number;
|
||||||
radius?: string | number;
|
radius?: string | number;
|
||||||
withBorder?: boolean;
|
withBorder?: boolean;
|
||||||
disableFullscreen?: boolean;
|
|
||||||
contain?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Avatar = ({
|
const Avatar = ({
|
||||||
@@ -26,39 +17,10 @@ const Avatar = ({
|
|||||||
size = 35,
|
size = 35,
|
||||||
radius = "100%",
|
radius = "100%",
|
||||||
withBorder = true,
|
withBorder = true,
|
||||||
disableFullscreen = false,
|
|
||||||
contain = false,
|
|
||||||
...props
|
...props
|
||||||
}: AvatarProps) => {
|
}: AvatarProps) => {
|
||||||
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
|
||||||
const hasImage = Boolean(props.src);
|
|
||||||
|
|
||||||
const handleAvatarClick = () => {
|
|
||||||
if (hasImage && !disableFullscreen) {
|
|
||||||
setIsFullscreenOpen(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Paper p={size / 20} radius={radius} withBorder={withBorder}>
|
||||||
<Paper
|
|
||||||
p={size / 20}
|
|
||||||
radius={radius}
|
|
||||||
withBorder={withBorder}
|
|
||||||
style={{
|
|
||||||
cursor: hasImage && !disableFullscreen ? 'pointer' : 'default',
|
|
||||||
transition: 'transform 0.15s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (hasImage && !disableFullscreen) {
|
|
||||||
e.currentTarget.style.transform = 'scale(1.02)';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'scale(1)';
|
|
||||||
}}
|
|
||||||
onClick={handleAvatarClick}
|
|
||||||
>
|
|
||||||
<MantineAvatar
|
<MantineAvatar
|
||||||
alt={name}
|
alt={name}
|
||||||
key={name}
|
key={name}
|
||||||
@@ -69,79 +31,12 @@ const Avatar = ({
|
|||||||
w={size}
|
w={size}
|
||||||
styles={{
|
styles={{
|
||||||
image: {
|
image: {
|
||||||
objectFit: contain ? 'contain' : 'cover',
|
objectFit: "contain",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Modal
|
|
||||||
opened={isFullscreenOpen}
|
|
||||||
onClose={() => setIsFullscreenOpen(false)}
|
|
||||||
size="auto"
|
|
||||||
centered
|
|
||||||
withCloseButton={false}
|
|
||||||
overlayProps={{
|
|
||||||
backgroundOpacity: 0.9,
|
|
||||||
blur: 2,
|
|
||||||
}}
|
|
||||||
styles={{
|
|
||||||
content: {
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
padding: 0,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="filled"
|
|
||||||
color="dark"
|
|
||||||
size="lg"
|
|
||||||
radius="xl"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: -10,
|
|
||||||
right: -10,
|
|
||||||
zIndex: 1000,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
|
||||||
}}
|
|
||||||
onClick={() => setIsFullscreenOpen(false)}
|
|
||||||
>
|
|
||||||
<XIcon size={18} color="white" />
|
|
||||||
</ActionIcon>
|
|
||||||
|
|
||||||
<Image
|
|
||||||
src={props.src}
|
|
||||||
alt={name}
|
|
||||||
fit="contain"
|
|
||||||
style={{
|
|
||||||
borderRadius: 8,
|
|
||||||
maxWidth: '90vw',
|
|
||||||
maxHeight: '90vh',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group
|
|
||||||
justify="center"
|
|
||||||
mt="md"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: -50,
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text c="white" size="sm" fw={500}>
|
|
||||||
{name}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
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,33 +1,22 @@
|
|||||||
import { Divider, Group, Loader, Text, UnstyledButton } from "@mantine/core";
|
import { Divider, Group, 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, loading }: ListButtonProps) => {
|
const ListButton = ({ label, onClick, Icon }: ListButtonProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<UnstyledButton
|
<UnstyledButton w="100%" p="md" component={"button"} onClick={onClick}>
|
||||||
w="100%"
|
|
||||||
p="md"
|
|
||||||
component={"button"}
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<Group>
|
<Group>
|
||||||
<Icon weight="bold" size={20} />
|
<Icon weight="bold" size={20} />
|
||||||
<Text fw={500} size="md">
|
<Text fw={500} size="md">
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
{loading ? (
|
|
||||||
<Loader size="sm" style={{ marginLeft: "auto" }} />
|
|
||||||
) : (
|
|
||||||
<CaretRightIcon style={{ marginLeft: "auto" }} size={20} />
|
<CaretRightIcon style={{ marginLeft: "auto" }} size={20} />
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|||||||
@@ -62,22 +62,6 @@ 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]');
|
||||||
@@ -88,24 +72,15 @@ 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 repositionInputs={false} open={opened} onOpenChange={onChange}>
|
<VaulDrawer.Root 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.Autosize
|
<ScrollArea
|
||||||
style={{ flex: 1, maxHeight: '75dvh' }}
|
style={{ flex: 1 }}
|
||||||
scrollbarSize={8}
|
scrollbarSize={8}
|
||||||
scrollbars="y"
|
scrollbars="y"
|
||||||
type="scroll"
|
type="scroll"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollArea.Autosize>
|
</ScrollArea>
|
||||||
</SheetComponent>
|
</SheetComponent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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";
|
||||||
@@ -70,7 +69,6 @@ const SlidePanel = ({
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
width: "100%",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
@@ -169,17 +167,11 @@ const SlidePanel = ({
|
|||||||
bg="var(--mantine-color-dimmed)"
|
bg="var(--mantine-color-dimmed)"
|
||||||
my="xs"
|
my="xs"
|
||||||
/>
|
/>
|
||||||
<ScrollArea.Autosize w="100%" p={0} offsetScrollbars>
|
|
||||||
<panelConfig.Component
|
<panelConfig.Component
|
||||||
value={tempValue}
|
value={tempValue}
|
||||||
onChange={setTempValue}
|
onChange={setTempValue}
|
||||||
{...(panelConfig.componentProps || {})}
|
{...(panelConfig.componentProps || {})}
|
||||||
/>
|
/>
|
||||||
</ScrollArea.Autosize>
|
|
||||||
<Stack mt="auto" w="100%" gap={2}>
|
|
||||||
<Button mt="md" onClick={handleConfirm}>Confirm</Button>
|
|
||||||
<Button variant="subtle" onClick={closePanel} mt="sm" color="red">Cancel</Button>
|
|
||||||
</Stack>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -13,10 +13,11 @@
|
|||||||
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, max-height 0.2s ease-out;
|
transition: height 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
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,
|
user: data?.user || defaultAuthData.user,
|
||||||
metadata: data?.metadata || { accentColor: "blue" as MantineColor, colorScheme: "dark" as MantineColorScheme },
|
metadata: data?.metadata || defaultAuthData.metadata,
|
||||||
roles: data?.roles || [],
|
roles: data?.roles || defaultAuthData.roles,
|
||||||
phone: data?.phone || "",
|
phone: data?.phone || "",
|
||||||
set,
|
set,
|
||||||
}),
|
}),
|
||||||
[data, set]
|
[data, defaultAuthData]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AuthContext value={value}>{children}</AuthContext>;
|
return <AuthContext value={value}>{children}</AuthContext>;
|
||||||
|
|||||||
@@ -438,7 +438,6 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
activeDevice,
|
activeDevice,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
// Capture/Resume state
|
|
||||||
capturedState,
|
capturedState,
|
||||||
isCaptureLoading,
|
isCaptureLoading,
|
||||||
isResumeLoading,
|
isResumeLoading,
|
||||||
@@ -453,11 +452,9 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,386 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from "./types";
|
|
||||||
export * from "./queries";
|
|
||||||
export { ActivitiesTable } from "./components/activities-table";
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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));
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export type { Activity, ActivityListResult, ActivitySearchParams } from "@/lib/pocketbase/services/activities";
|
|
||||||
@@ -4,48 +4,17 @@ import {
|
|||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
TreeStructureIcon,
|
TreeStructureIcon,
|
||||||
TrophyIcon,
|
TrophyIcon,
|
||||||
MedalIcon,
|
|
||||||
CrownIcon,
|
|
||||||
ListIcon,
|
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import ListButton from "@/components/list-button";
|
import ListButton from "@/components/list-button";
|
||||||
import { migrateBadgeProgress } from "@/features/badges/server";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
const AdminPage = () => {
|
const AdminPage = () => {
|
||||||
const [isMigrating, setIsMigrating] = useState(false);
|
|
||||||
|
|
||||||
const handleMigrateBadges = async () => {
|
|
||||||
if (isMigrating) return;
|
|
||||||
|
|
||||||
setIsMigrating(true);
|
|
||||||
await migrateBadgeProgress();
|
|
||||||
setIsMigrating(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List p="0">
|
<List>
|
||||||
<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}
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
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 p="0">
|
<List>
|
||||||
{tournaments.map((t) => (
|
{tournaments.map((t) => (
|
||||||
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} />
|
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
|
||||||
import { getPlayerBadges, getAllBadges } from "./server";
|
|
||||||
|
|
||||||
export const badgeKeys = {
|
|
||||||
playerBadges: (playerId: string) => ['badges', 'player', playerId],
|
|
||||||
allBadges: () => ['badges', 'all'],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const badgeQueries = {
|
|
||||||
playerBadges: (playerId: string) => ({
|
|
||||||
queryKey: badgeKeys.playerBadges(playerId),
|
|
||||||
queryFn: async () => await getPlayerBadges({ data: playerId })
|
|
||||||
}),
|
|
||||||
allBadges: () => ({
|
|
||||||
queryKey: badgeKeys.allBadges(),
|
|
||||||
queryFn: async () => await getAllBadges()
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const usePlayerBadges = (playerId: string) =>
|
|
||||||
useServerSuspenseQuery(badgeQueries.playerBadges(playerId));
|
|
||||||
|
|
||||||
export const useAllBadges = () =>
|
|
||||||
useServerSuspenseQuery(badgeQueries.allBadges());
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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))
|
|
||||||
);
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/*
|
|
||||||
|
|
||||||
pb.collection("team_stats_per_tournament").getFullList({
|
|
||||||
filter: `tournament_id = "${id}"`,
|
|
||||||
sort: "-wins,-total_cups_made"
|
|
||||||
})
|
|
||||||
|
|
||||||
*/
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Flex, Box } from "@mantine/core";
|
import { Flex } 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[][];
|
||||||
@@ -14,105 +13,8 @@ export const Bracket: React.FC<BracketProps> = ({
|
|||||||
orders,
|
orders,
|
||||||
showControls,
|
showControls,
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updateConnectorLines = () => {
|
|
||||||
if (!containerRef.current || !svgRef.current) return;
|
|
||||||
|
|
||||||
const svg = svgRef.current;
|
|
||||||
const container = containerRef.current;
|
|
||||||
const flexContainer = container.querySelector('.bracket-flex-container') as HTMLElement;
|
|
||||||
if (!flexContainer) return;
|
|
||||||
|
|
||||||
svg.innerHTML = '';
|
|
||||||
|
|
||||||
const flexRect = flexContainer.getBoundingClientRect();
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
|
||||||
|
|
||||||
svg.style.width = `${flexContainer.scrollWidth}px`;
|
|
||||||
svg.style.height = `${flexContainer.scrollHeight}px`;
|
|
||||||
|
|
||||||
rounds.forEach((round, roundIndex) => {
|
|
||||||
if (roundIndex === rounds.length - 1) return;
|
|
||||||
|
|
||||||
const nextRound = rounds[roundIndex + 1];
|
|
||||||
|
|
||||||
round.forEach((match, matchIndex) => {
|
|
||||||
if (match.bye) return;
|
|
||||||
|
|
||||||
const matchElement = container.querySelector(`[data-match-lid="${match.lid}"]`) as HTMLElement;
|
|
||||||
if (!matchElement) return;
|
|
||||||
|
|
||||||
const nextMatches = nextRound.filter(nextMatch =>
|
|
||||||
!nextMatch.bye && (
|
|
||||||
orders[nextMatch.home_from_lid] === match.order ||
|
|
||||||
orders[nextMatch.away_from_lid] === match.order
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
nextMatches.forEach(nextMatch => {
|
|
||||||
const nextMatchElement = container.querySelector(`[data-match-lid="${nextMatch.lid}"]`) as HTMLElement;
|
|
||||||
if (!nextMatchElement) return;
|
|
||||||
|
|
||||||
const matchRect = matchElement.getBoundingClientRect();
|
|
||||||
const nextMatchRect = nextMatchElement.getBoundingClientRect();
|
|
||||||
|
|
||||||
const startX = matchRect.right - flexRect.left;
|
|
||||||
const startY = matchRect.top + matchRect.height / 2 - flexRect.top;
|
|
||||||
const endX = nextMatchRect.left - flexRect.left;
|
|
||||||
const endY = nextMatchRect.top + nextMatchRect.height / 2 - flexRect.top;
|
|
||||||
|
|
||||||
const midX = startX + (endX - startX) * 0.5;
|
|
||||||
|
|
||||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
||||||
const pathData = `M ${startX} ${startY} L ${midX} ${startY} L ${midX} ${endY} L ${endX} ${endY}`;
|
|
||||||
|
|
||||||
path.setAttribute('d', pathData);
|
|
||||||
path.setAttribute('stroke', 'var(--mantine-color-default-border)');
|
|
||||||
path.setAttribute('stroke-width', '2');
|
|
||||||
path.setAttribute('fill', 'none');
|
|
||||||
path.setAttribute('stroke-linecap', 'round');
|
|
||||||
path.setAttribute('stroke-linejoin', 'round');
|
|
||||||
|
|
||||||
svg.appendChild(path);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
updateConnectorLines();
|
|
||||||
|
|
||||||
const handleUpdate = () => {
|
|
||||||
requestAnimationFrame(updateConnectorLines);
|
|
||||||
};
|
|
||||||
|
|
||||||
const scrollContainer = containerRef.current?.closest('.mantine-ScrollArea-viewport');
|
|
||||||
scrollContainer?.addEventListener('scroll', handleUpdate);
|
|
||||||
window.addEventListener('resize', handleUpdate);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
scrollContainer?.removeEventListener('scroll', handleUpdate);
|
|
||||||
window.removeEventListener('resize', handleUpdate);
|
|
||||||
};
|
|
||||||
}, [rounds, orders]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box pos="relative" ref={containerRef}>
|
<Flex direction="row" gap={24} justify="left">
|
||||||
<svg
|
|
||||||
ref={svgRef}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
zIndex: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Flex direction="row" gap={24} justify="left" pos="relative" style={{ zIndex: 1 }} className="bracket-flex-container">
|
|
||||||
{rounds.map((round, roundIndex) => (
|
{rounds.map((round, roundIndex) => (
|
||||||
<Flex
|
<Flex
|
||||||
key={roundIndex}
|
key={roundIndex}
|
||||||
@@ -139,6 +41,5 @@ export const Bracket: React.FC<BracketProps> = ({
|
|||||||
</Flex>
|
</Flex>
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -199,15 +199,7 @@ 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
|
<Text c="dimmed" fw="bolder">
|
||||||
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">
|
||||||
@@ -222,12 +214,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
w={showToolbar || showEditButton ? 200 : 220}
|
w={showToolbar || showEditButton ? 200 : 220}
|
||||||
withBorder
|
withBorder
|
||||||
pos="relative"
|
pos="relative"
|
||||||
style={{
|
style={{ overflow: "visible" }}
|
||||||
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 key={p.id} size='xs' c='dimmed'>
|
match.home?.players.map(p => (<Text 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 key={p.id} size='xs' c='dimmed'>
|
match.away?.players.map(p => (<Text size='xs' c='dimmed'>
|
||||||
{p.first_name} {p.last_name}
|
{p.first_name} {p.last_name}
|
||||||
</Text>))
|
</Text>))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,23 +21,16 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
|||||||
cups,
|
cups,
|
||||||
isWinner
|
isWinner
|
||||||
}) => (
|
}) => (
|
||||||
<Flex
|
<Flex align="stretch">
|
||||||
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="6px 10px" w='100%' align="center">
|
<Flex p="4px 8px" 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, lineHeight: "12px" }}
|
style={{ minWidth: 0, flex: 1 }}
|
||||||
>
|
>
|
||||||
{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()
|
||||||
.inputValidator(z.number())
|
.validator(z.number())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: teams }) =>
|
.handler(async ({ data: teams }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
|
|||||||
@@ -6,14 +6,10 @@ interface HeaderProps extends HeaderConfig {}
|
|||||||
|
|
||||||
const Header = ({ collapsed, title, withBackButton }: HeaderProps) => {
|
const Header = ({ collapsed, title, withBackButton }: HeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<AppShell.Header
|
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
|
||||||
id='app-header'
|
|
||||||
display={collapsed ? 'none' : 'flex'}
|
|
||||||
style={{ alignItems: 'center', justifyContent: 'center' }}
|
|
||||||
>
|
|
||||||
{ withBackButton && <BackButton /> }
|
{ withBackButton && <BackButton /> }
|
||||||
<Flex justify='center' px='md' mt={8}>
|
<Flex justify='center' align='center' h='100%' px='md'>
|
||||||
<Title order={1}>{title?.toLocaleUpperCase()}</Title>
|
<Title order={2}>{title}</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={{
|
||||||
height: `${viewport.height}px`,
|
top: 0,
|
||||||
minHeight: '100dvh',
|
minHeight: '100dvh',
|
||||||
// top: viewport.top
|
maxHeight: '100dvh'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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, sm: 0 }}
|
pb={{ base: 65, md: 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, useMantineColorScheme } from "@mantine/core";
|
import { AppShell, ScrollArea, Stack, Group, Paper } 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,17 +9,11 @@ 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 shadow="sm" radius='lg' h='4rem' w='calc(100% - 1rem)' pos='fixed' m='0.5rem' bottom='0' style={{ zIndex: 10 }}>
|
<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 }}>
|
||||||
<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} />
|
||||||
@@ -36,6 +30,9 @@ const Navbar = () => {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</AppShell.Section>
|
</AppShell.Section>
|
||||||
|
<AppShell.Section>
|
||||||
|
<Link to="/logout">Logout</Link>
|
||||||
|
</AppShell.Section>
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,17 +19,11 @@ 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,6 +1,4 @@
|
|||||||
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';
|
||||||
@@ -10,7 +8,6 @@ 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>
|
||||||
@@ -34,27 +31,8 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
radius='md'
|
radius='md'
|
||||||
>
|
>
|
||||||
<Stack align='center' gap='xs' mb='md'>
|
<Stack align='center' gap='xs' mb='md'>
|
||||||
<GlitchAvatar
|
<TrophyIcon size={75} />
|
||||||
name={tournament.name}
|
<Title order={4} ta='center'>Welcome to FLXN</Title>
|
||||||
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,7 +9,6 @@ import { MatchInput } from "@/features/matches/types";
|
|||||||
import { serverEvents } from "@/lib/events/emitter";
|
import { serverEvents } from "@/lib/events/emitter";
|
||||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { PlayerInfo } from "../players/types";
|
import { PlayerInfo } from "../players/types";
|
||||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
|
||||||
|
|
||||||
const orderedTeamsSchema = z.object({
|
const orderedTeamsSchema = z.object({
|
||||||
tournamentId: z.string(),
|
tournamentId: z.string(),
|
||||||
@@ -17,8 +16,8 @@ const orderedTeamsSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const generateTournamentBracket = createServerFn()
|
export const generateTournamentBracket = createServerFn()
|
||||||
.inputValidator(orderedTeamsSchema)
|
.validator(orderedTeamsSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
logger.info("Generating tournament bracket", {
|
logger.info("Generating tournament bracket", {
|
||||||
@@ -138,8 +137,8 @@ export const generateTournamentBracket = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const startMatch = createServerFn()
|
export const startMatch = createServerFn()
|
||||||
.inputValidator(z.string())
|
.validator(z.string())
|
||||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
logger.info("Starting match", data);
|
logger.info("Starting match", data);
|
||||||
@@ -171,8 +170,8 @@ const endMatchSchema = z.object({
|
|||||||
ot_count: z.number(),
|
ot_count: z.number(),
|
||||||
});
|
});
|
||||||
export const endMatch = createServerFn()
|
export const endMatch = createServerFn()
|
||||||
.inputValidator(endMatchSchema)
|
.validator(endMatchSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.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);
|
||||||
@@ -253,7 +252,7 @@ const toggleReactionSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const toggleMatchReaction = createServerFn()
|
export const toggleMatchReaction = createServerFn()
|
||||||
.inputValidator(toggleReactionSchema)
|
.validator(toggleReactionSchema)
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: { matchId, emoji }, context }) =>
|
.handler(async ({ data: { matchId, emoji }, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
@@ -313,7 +312,7 @@ export interface Reaction {
|
|||||||
players: PlayerInfo[];
|
players: PlayerInfo[];
|
||||||
}
|
}
|
||||||
export const getMatchReactions = createServerFn()
|
export const getMatchReactions = createServerFn()
|
||||||
.inputValidator(z.string())
|
.validator(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 p="0">
|
return <List>
|
||||||
{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 = 28;
|
const baseSize = 24;
|
||||||
const maxLength = 24;
|
const maxLength = 20;
|
||||||
|
|
||||||
if (name.length <= maxLength) {
|
if (name.length <= maxLength) {
|
||||||
return `${baseSize}px`;
|
return `${baseSize}px`;
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { Box, Stack, Text, Divider } from "@mantine/core";
|
import { Box } 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;
|
||||||
@@ -21,19 +18,7 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
content: <>
|
content: <StatsOverview statsData={stats} isLoading={statsLoading} />,
|
||||||
<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,6 +60,7 @@ 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,14 +5,13 @@ 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 { getRequest } from "@tanstack/react-start/server";
|
import { getWebRequest } 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 = getRequest();
|
const request = getWebRequest();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const context = await getSessionContext(request);
|
const context = await getSessionContext(request);
|
||||||
@@ -26,7 +25,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") {
|
||||||
@@ -39,15 +38,15 @@ export const fetchMe = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getPlayer = createServerFn()
|
export const getPlayer = createServerFn()
|
||||||
.inputValidator(z.string())
|
.validator(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()
|
||||||
.inputValidator(playerUpdateSchema)
|
.validator(playerUpdateSchema)
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userAuthId = context.userAuthId;
|
const userAuthId = context.userAuthId;
|
||||||
@@ -73,7 +72,7 @@ export const updatePlayer = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const createPlayer = createServerFn()
|
export const createPlayer = createServerFn()
|
||||||
.inputValidator(playerInputSchema)
|
.validator(playerInputSchema)
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
@@ -98,8 +97,8 @@ export const createPlayer = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const associatePlayer = createServerFn()
|
export const associatePlayer = createServerFn()
|
||||||
.inputValidator(z.string())
|
.validator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userAuthId = context.userAuthId;
|
const userAuthId = context.userAuthId;
|
||||||
@@ -130,7 +129,7 @@ export const getUnassociatedPlayers = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getPlayerStats = createServerFn()
|
export const getPlayerStats = createServerFn()
|
||||||
.inputValidator(z.string())
|
.validator(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))
|
||||||
@@ -143,14 +142,14 @@ export const getAllPlayerStats = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getPlayerMatches = createServerFn()
|
export const getPlayerMatches = createServerFn()
|
||||||
.inputValidator(z.string())
|
.validator(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()
|
||||||
.inputValidator(z.string())
|
.validator(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,8 +4,7 @@ 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";
|
||||||
@@ -57,9 +56,9 @@ const TeamCard = ({ teamId }: TeamCardProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Title order={5} lineClamp={1}>
|
<Text size="md" fw={600} lineClamp={1} mb={2}>
|
||||||
{team.name}
|
{team.name}
|
||||||
</Title>
|
</Text>
|
||||||
<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 { FileInput, Stack, TextInput } from "@mantine/core";
|
import { Badge, FileInput, Group, Stack, Text, 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 from "@/components/sheet/slide-panel";
|
import SlidePanel, { SlidePanelField } 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,35 +106,16 @@ const TeamForm = ({
|
|||||||
|
|
||||||
mutation(teamData, {
|
mutation(teamData, {
|
||||||
onSuccess: async (team: any) => {
|
onSuccess: async (team: any) => {
|
||||||
close();
|
queryClient.invalidateQueries({ queryKey: teamKeys.list });
|
||||||
|
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", processedLogo);
|
formData.append("logo", logo);
|
||||||
|
|
||||||
const response = await fetch("/api/teams/upload-logo", {
|
const response = await fetch("/api/teams/upload-logo", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -148,12 +129,7 @@ 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
|
||||||
@@ -165,16 +141,12 @@ 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,6 +1,7 @@
|
|||||||
import { Text, Group, Avatar, Box } from "@mantine/core";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
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 Typeahead, { TypeaheadOption } from "@/components/typeahead";
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
|
|
||||||
interface SongSearchProps {
|
interface SongSearchProps {
|
||||||
onChange: (track: SpotifyTrack) => void;
|
onChange: (track: SpotifyTrack) => void;
|
||||||
@@ -8,46 +9,146 @@ interface SongSearchProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
|
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
|
||||||
const searchSpotifyTracks = async (query: string): Promise<TypeaheadOption<SpotifyTrack>[]> => {
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
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 {
|
||||||
// Use a default search term when query is empty to show popular tracks
|
const response = await fetch(`/api/spotify/search?q=${encodeURIComponent(query)}`);
|
||||||
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();
|
||||||
const tracks = data.tracks || [];
|
return 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 handleSongSelect = (option: TypeaheadOption<SpotifyTrack>) => {
|
const debouncedSearch = useDebouncedCallback(async (query: string) => {
|
||||||
onChange(option.data);
|
if (!query.trim()) {
|
||||||
|
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 formatTrack = (option: TypeaheadOption<SpotifyTrack>) => {
|
const handleSongSelect = (track: SpotifyTrack) => {
|
||||||
const track = option.data;
|
onChange(track);
|
||||||
return `${track.name} - ${track.artists.map(a => a.name).join(', ')}`;
|
setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`);
|
||||||
|
setIsOpen(false);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!isOpen || searchResults.length === 0) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => (prev < searchResults.length - 1 ? prev + 1 : prev));
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev));
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedIndex >= 0 && searchResults[selectedIndex]) {
|
||||||
|
handleSongSelect(searchResults[selectedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
setIsOpen(false);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderOption = (option: TypeaheadOption<SpotifyTrack>) => {
|
|
||||||
const track = option.data;
|
|
||||||
return (
|
return (
|
||||||
|
<Box ref={containerRef} pos="relative" w="100%">
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => handleSearchChange(event.currentTarget.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => {
|
||||||
|
if (searchResults.length > 0) setIsOpen(true);
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rightSection={isLoading ? <Loader size="xs" /> : null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<Paper
|
||||||
|
shadow="md"
|
||||||
|
p={0}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
maxHeight: '160px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
touchAction: 'pan-y'
|
||||||
|
}}
|
||||||
|
onTouchMove={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{searchResults.length > 0 ? (
|
||||||
|
<Stack gap={0}>
|
||||||
|
{searchResults.map((track, index) => (
|
||||||
<Box
|
<Box
|
||||||
|
key={track.id}
|
||||||
p="sm"
|
p="sm"
|
||||||
style={{
|
style={{
|
||||||
borderBottom: '1px solid var(--mantine-color-dimmed)'
|
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">
|
<Group gap="sm">
|
||||||
{track.album.images[2] && (
|
{track.album.images[2] && (
|
||||||
@@ -63,18 +164,19 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
|
|||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Box p="md">
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Typeahead
|
|
||||||
onSelect={handleSongSelect}
|
|
||||||
searchFn={searchSpotifyTracks}
|
|
||||||
renderOption={renderOption}
|
|
||||||
format={formatTrack}
|
|
||||||
placeholder={placeholder}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SongSearch;
|
export default SongSearch;
|
||||||
@@ -58,7 +58,7 @@ const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
|
|||||||
|
|
||||||
if (loading)
|
if (loading)
|
||||||
return (
|
return (
|
||||||
<List p="0">
|
<List>
|
||||||
{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 p="0">
|
<List>
|
||||||
{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={1}>
|
<Title ta="center" order={2}>
|
||||||
{name}
|
{name}
|
||||||
</Title>
|
</Title>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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()
|
||||||
@@ -16,22 +15,22 @@ export const listTeamInfos = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getTeam = createServerFn()
|
export const getTeam = createServerFn()
|
||||||
.inputValidator(z.string())
|
.validator(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()
|
||||||
.inputValidator(z.string())
|
.validator(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()
|
||||||
.inputValidator(teamInputSchema)
|
.validator(teamInputSchema)
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data, context }) =>
|
.handler(async ({ data, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
@@ -47,11 +46,11 @@ export const createTeam = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const updateTeam = createServerFn()
|
export const updateTeam = createServerFn()
|
||||||
.inputValidator(z.object({
|
.validator(z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
updates: teamUpdateSchema
|
updates: teamUpdateSchema
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: { id, updates }, context }) =>
|
.handler(async ({ data: { id, updates }, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
@@ -62,10 +61,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);
|
||||||
@@ -73,14 +72,14 @@ export const updateTeam = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getTeamStats = createServerFn()
|
export const getTeamStats = createServerFn()
|
||||||
.inputValidator(z.string())
|
.validator(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()
|
||||||
.inputValidator(z.string())
|
.validator(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,6 +68,8 @@ 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 } =
|
||||||
@@ -76,24 +78,27 @@ 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 searchTeams = async (query: string): Promise<TypeaheadOption<Team>[]> => {
|
const autocompleteData = useMemo(
|
||||||
if (!query.trim()) return [];
|
() =>
|
||||||
|
unenrolledTeams.map((team: Team) => ({
|
||||||
const filtered = unenrolledTeams.filter((team: Team) =>
|
value: team.id,
|
||||||
team.name.toLowerCase().includes(query.toLowerCase())
|
label: team.name,
|
||||||
|
})),
|
||||||
|
[unenrolledTeams]
|
||||||
);
|
);
|
||||||
|
|
||||||
return filtered.map((team: Team) => ({
|
|
||||||
id: team.id,
|
|
||||||
data: team
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEnrollTeam = useCallback(
|
const handleEnrollTeam = useCallback(
|
||||||
(option: TypeaheadOption<Team>) => {
|
(teamId: string) => {
|
||||||
enrollTeam({ tournamentId, teamId: option.data.id });
|
enrollTeam(
|
||||||
|
{ tournamentId, teamId },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSearch("");
|
||||||
},
|
},
|
||||||
[enrollTeam, tournamentId]
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[enrollTeam, tournamentId, setSearch]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUnenrollTeam = useCallback(
|
const handleUnenrollTeam = useCallback(
|
||||||
@@ -103,31 +108,6 @@ 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;
|
||||||
@@ -138,13 +118,16 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
|||||||
<Text fw={600} size="sm">
|
<Text fw={600} size="sm">
|
||||||
Add Team
|
Add Team
|
||||||
</Text>
|
</Text>
|
||||||
<Typeahead
|
<Autocomplete
|
||||||
placeholder="Search for teams to enroll..."
|
placeholder="Search for teams to enroll..."
|
||||||
onSelect={handleEnrollTeam}
|
data={autocompleteData}
|
||||||
searchFn={searchTeams}
|
value={search}
|
||||||
renderOption={renderTeamOption}
|
onChange={setSearch}
|
||||||
format={formatTeam}
|
onOptionSubmit={handleEnrollTeam}
|
||||||
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 p="0">
|
<List>
|
||||||
<ListButton
|
<ListButton
|
||||||
label="Edit Tournament"
|
label="Edit Tournament"
|
||||||
Icon={HardDrivesIcon}
|
Icon={HardDrivesIcon}
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import { Stack, Group, Text, ThemeIcon, Box, Center } from "@mantine/core";
|
|
||||||
import { CrownIcon, MedalIcon } from "@phosphor-icons/react";
|
|
||||||
import { Tournament } from "../types";
|
|
||||||
|
|
||||||
interface PodiumProps {
|
|
||||||
tournament: Tournament;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Podium = ({ tournament }: PodiumProps) => {
|
|
||||||
if (!tournament.first_place) return;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="xs" px="md">
|
|
||||||
{tournament.first_place && (
|
|
||||||
<Group
|
|
||||||
gap="md"
|
|
||||||
p="md"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--mantine-color-yellow-light)',
|
|
||||||
borderRadius: 'var(--mantine-radius-md)',
|
|
||||||
border: '3px solid var(--mantine-color-yellow-outline)',
|
|
||||||
boxShadow: 'var(--mantine-shadow-md)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ThemeIcon size="xl" color="yellow" variant="light" radius="xl">
|
|
||||||
<CrownIcon size={24} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Stack gap={4} style={{ flex: 1 }}>
|
|
||||||
<Text size="md" fw={600}>
|
|
||||||
{tournament.first_place.name}
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs">
|
|
||||||
{tournament.first_place.players?.map((player) => (
|
|
||||||
<Text key={player.id} size="sm" c="dimmed">
|
|
||||||
{player.first_name} {player.last_name}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tournament.second_place && (
|
|
||||||
<Group
|
|
||||||
gap="md"
|
|
||||||
p="xs"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--mantine-color-default)',
|
|
||||||
borderRadius: 'var(--mantine-radius-md)',
|
|
||||||
border: '2px solid var(--mantine-color-default-border)',
|
|
||||||
boxShadow: 'var(--mantine-shadow-sm)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ThemeIcon size="lg" color="gray" variant="light" radius="xl">
|
|
||||||
<MedalIcon size={20} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Stack gap={4} style={{ flex: 1 }}>
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{tournament.second_place.name}
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs">
|
|
||||||
{tournament.second_place.players?.map((player) => (
|
|
||||||
<Text key={player.id} size="xs" c="dimmed">
|
|
||||||
{player.first_name} {player.last_name}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tournament.third_place && (
|
|
||||||
<Group
|
|
||||||
gap="md"
|
|
||||||
p="xs"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--mantine-color-orange-light)',
|
|
||||||
borderRadius: 'var(--mantine-radius-md)',
|
|
||||||
border: '2px solid var(--mantine-color-orange-outline)',
|
|
||||||
boxShadow: 'var(--mantine-shadow-sm)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ThemeIcon size="lg" color="orange" variant="light" radius="xl">
|
|
||||||
<MedalIcon size={18} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Stack gap={4} style={{ flex: 1 }}>
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{tournament.third_place.name}
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs">
|
|
||||||
{tournament.third_place.players?.map((player) => (
|
|
||||||
<Text key={player.id} size="xs" c="dimmed">
|
|
||||||
{player.first_name} {player.last_name}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -11,9 +11,9 @@ const Header = ({ tournament }: HeaderProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'>
|
<Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'>
|
||||||
<Avatar contain name={tournament.name} radius="sm" size={150} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />
|
<Avatar name={tournament.name} radius={0} withBorder={false} size={125} 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={1}>{tournament.name}</Title>
|
<Title ta='center' order={2}>{tournament.name}</Title>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
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 { CalendarIcon, MapPinIcon, TrophyIcon } from "@phosphor-icons/react";
|
import Avatar from "@/components/avatar";
|
||||||
|
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(
|
||||||
@@ -12,26 +16,20 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack px="sm" align="center" gap={0}>
|
<Stack px="sm" align="center" gap={0}>
|
||||||
<GlitchAvatar
|
<Avatar
|
||||||
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={250}
|
size={200}
|
||||||
px="xs"
|
px="xs"
|
||||||
withBorder={false}
|
withBorder={false}
|
||||||
>
|
>
|
||||||
<TrophyIcon size={32} />
|
<TrophyIcon size={24} />
|
||||||
</GlitchAvatar>
|
</Avatar>
|
||||||
<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,12 +4,11 @@ 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, TrophyIcon } from "@phosphor-icons/react";
|
import { TreeStructureIcon, UsersIcon, ClockIcon } 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,
|
||||||
@@ -23,20 +22,6 @@ 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} />
|
||||||
@@ -57,10 +42,6 @@ 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,7 +5,6 @@ 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 {
|
||||||
@@ -28,6 +27,14 @@ 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
|
||||||
@@ -38,14 +45,21 @@ 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={75}
|
size={90}
|
||||||
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}`
|
||||||
@@ -55,14 +69,14 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
|||||||
<TrophyIcon size={20} />
|
<TrophyIcon size={20} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Title mb={-6} order={3} lineClamp={2}>
|
<Text fw={600} size="lg" lineClamp={2}>
|
||||||
{tournament.name}
|
{tournament.name}
|
||||||
</Title>
|
</Text>
|
||||||
{(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="sm"
|
size="md"
|
||||||
radius="md"
|
radius="md"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color="yellow"
|
color="yellow"
|
||||||
@@ -80,7 +94,7 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
|||||||
)}
|
)}
|
||||||
{tournament.second_place && (
|
{tournament.second_place && (
|
||||||
<Badge
|
<Badge
|
||||||
size="sm"
|
size="md"
|
||||||
radius="md"
|
radius="md"
|
||||||
color="gray"
|
color="gray"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
@@ -97,7 +111,7 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
|||||||
)}
|
)}
|
||||||
{tournament.third_place && (
|
{tournament.third_place && (
|
||||||
<Badge
|
<Badge
|
||||||
size="sm"
|
size="md"
|
||||||
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 p="0">
|
<List>
|
||||||
{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 p="0">
|
<List>
|
||||||
{tournaments.map((tournament) => (
|
{tournaments.map((tournament) => (
|
||||||
<>
|
<>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -108,7 +108,6 @@ 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,7 +14,6 @@ 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;
|
||||||
@@ -41,12 +40,118 @@ 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) => ({
|
return sortedTeamStats.map((stat, index) => ({
|
||||||
...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 = () => {
|
||||||
@@ -65,14 +170,23 @@ 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>
|
||||||
<Text px="md" c="dimmed" size="xs" fw={500}>Sorted by win percentage</Text>
|
{teamStatsWithCalculations.map((stat) => {
|
||||||
{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={{ borderRadius: 0 }}
|
style={{
|
||||||
|
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">
|
||||||
@@ -80,12 +194,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">
|
||||||
#{index + 1}
|
#{stat.index + 1}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
{stat.team_name}
|
{stat.team_name}
|
||||||
</Text>
|
</Text>
|
||||||
{index === 0 && isComplete && (
|
{stat.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>
|
||||||
@@ -145,7 +259,7 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
{index < teamStatsWithCalculations.length - 1 && <Divider />}
|
{stat.index < teamStatsWithCalculations.length - 1 && <Divider />}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -156,7 +270,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">
|
||||||
<Podium tournament={tournament} />
|
{renderPodium()}
|
||||||
<ListLink
|
<ListLink
|
||||||
label={`View Bracket`}
|
label={`View Bracket`}
|
||||||
to={`/tournaments/${tournament.id}/bracket`}
|
to={`/tournaments/${tournament.id}/bracket`}
|
||||||
@@ -167,3 +281,5 @@ 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 adds you to a pool of players looking for teammates.
|
Enrolling as a free agent will enter you in a pool of players wanting to play but don't have a teammate yet.
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c='dimmed'>
|
<Text size="sm" c='dimmed'>
|
||||||
Once enrolled, you can view other free agents and their phone number in order to coordinate teams and walkout songs.
|
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.
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
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.
|
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.
|
||||||
</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,7 +1,6 @@
|
|||||||
import { Stack, Button, Divider, Group, ComboboxItem, Text } from '@mantine/core';
|
import { Stack, Button, Divider, Autocomplete, Group, ComboboxItem } from '@mantine/core';
|
||||||
import { PlusIcon } from '@phosphor-icons/react';
|
import { PlusIcon } from '@phosphor-icons/react';
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import Typeahead, { TypeaheadOption } from '@/components/typeahead';
|
|
||||||
|
|
||||||
interface TeamSelectionViewProps {
|
interface TeamSelectionViewProps {
|
||||||
options: ComboboxItem[];
|
options: ComboboxItem[];
|
||||||
@@ -12,37 +11,11 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
|
|||||||
options,
|
options,
|
||||||
onSelect
|
onSelect
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedTeam, setSelectedTeam] = React.useState<ComboboxItem | null>(null);
|
const [value, setValue] = useState<string>('');
|
||||||
|
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(selectedTeam?.value);
|
const handleSelectExistingTeam = () => onSelect(selectedOption?.value)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
@@ -58,18 +31,17 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
|
|||||||
<Divider my="sm" label="or" />
|
<Divider my="sm" label="or" />
|
||||||
|
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Typeahead
|
<Autocomplete
|
||||||
placeholder="Select one of your existing teams"
|
placeholder="Select one of your existing teams"
|
||||||
onSelect={handleTeamSelect}
|
value={value}
|
||||||
searchFn={searchTeams}
|
onChange={setValue}
|
||||||
renderOption={renderTeamOption}
|
data={options.map(option => option.label)}
|
||||||
format={formatTeam}
|
comboboxProps={{ withinPortal: false }}
|
||||||
maxHeight={80}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSelectExistingTeam}
|
onClick={handleSelectExistingTeam}
|
||||||
disabled={!selectedTeam}
|
disabled={!selectedOption}
|
||||||
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 GlitchAvatar from "@/components/glitch-avatar";
|
import Avatar from "@/components/avatar";
|
||||||
import {
|
import {
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
@@ -16,27 +16,21 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack align="center" gap={16}>
|
<Stack align="center" gap={0}>
|
||||||
<GlitchAvatar
|
<Avatar
|
||||||
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} />
|
||||||
</GlitchAvatar>
|
</Avatar>
|
||||||
<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, Title } from "@mantine/core";
|
import { Box, Button, Card, Divider, Group, Stack, Text } 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 gap="xs" align="center">
|
<Group mb="sm" gap="xs" align="center">
|
||||||
<UsersIcon size={16} />
|
<UsersIcon size={16} />
|
||||||
<Title mt={4} order={5}>
|
<Text size="sm" fw={500}>
|
||||||
Enrollment
|
Enrollment
|
||||||
</Title>
|
</Text>
|
||||||
{isEnrollmentOpen && (
|
{isEnrollmentOpen && (
|
||||||
<Box ml="auto">
|
<Box ml="auto">
|
||||||
<Countdown
|
<Countdown
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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,8 +5,6 @@ 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])
|
||||||
@@ -15,24 +13,24 @@ export const listTournaments = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const createTournament = createServerFn()
|
export const createTournament = createServerFn()
|
||||||
.inputValidator(tournamentInputSchema)
|
.validator(tournamentInputSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult(() => pbAdmin.createTournament(data))
|
toServerResult(() => pbAdmin.createTournament(data))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const updateTournament = createServerFn()
|
export const updateTournament = createServerFn()
|
||||||
.inputValidator(z.object({
|
.validator(z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
updates: tournamentInputSchema.partial()
|
updates: tournamentInputSchema.partial()
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.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()
|
||||||
.inputValidator(z.string())
|
.validator(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");
|
||||||
@@ -46,11 +44,11 @@ export const getCurrentTournament = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const enrollTeam = createServerFn()
|
export const enrollTeam = createServerFn()
|
||||||
.inputValidator(z.object({
|
.validator(z.object({
|
||||||
tournamentId: z.string(),
|
tournamentId: z.string(),
|
||||||
teamId: z.string()
|
teamId: z.string()
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
@@ -59,18 +57,10 @@ 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 });
|
||||||
@@ -80,32 +70,32 @@ export const enrollTeam = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const unenrollTeam = createServerFn()
|
export const unenrollTeam = createServerFn()
|
||||||
.inputValidator(z.object({
|
.validator(z.object({
|
||||||
tournamentId: z.string(),
|
tournamentId: z.string(),
|
||||||
teamId: z.string()
|
teamId: z.string()
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.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()
|
||||||
.inputValidator(z.string())
|
.validator(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()
|
||||||
.inputValidator(z.string())
|
.validator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data: tournamentId }) =>
|
.handler(async ({ data: tournamentId }) =>
|
||||||
toServerResult(() => pbAdmin.getFreeAgents(tournamentId))
|
toServerResult(() => pbAdmin.getFreeAgents(tournamentId))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const enrollFreeAgent = createServerFn()
|
export const enrollFreeAgent = createServerFn()
|
||||||
.inputValidator(z.object({ phone: z.string(), tournamentId: z.string() }))
|
.validator(z.object({ phone: z.string(), tournamentId: z.string() }))
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userAuthId = context.userAuthId;
|
const userAuthId = context.userAuthId;
|
||||||
@@ -118,8 +108,8 @@ export const enrollFreeAgent = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const unenrollFreeAgent = createServerFn()
|
export const unenrollFreeAgent = createServerFn()
|
||||||
.inputValidator(z.object({ tournamentId: z.string() }))
|
.validator(z.object({ tournamentId: z.string() }))
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userAuthId = context.userAuthId;
|
const userAuthId = context.userAuthId;
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ 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;
|
||||||
@@ -38,7 +37,6 @@ 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,7 +19,6 @@ 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())
|
||||||
@@ -38,7 +37,6 @@ 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;
|
||||||
@@ -74,15 +72,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 < 10) {
|
if (shouldConnectRef.current && retryCountRef.current < 5) {
|
||||||
retryCountRef.current += 1;
|
retryCountRef.current += 1;
|
||||||
const delay = Math.min(
|
const delay = Math.min(
|
||||||
1000 * Math.pow(1.5, retryCountRef.current - 1),
|
1000 * Math.pow(2, retryCountRef.current - 1),
|
||||||
15000
|
30000
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`SSE reconnection attempt ${retryCountRef.current}/10 in ${delay}ms`
|
`SSE reconnection attempt ${retryCountRef.current}/5 in ${delay}ms`
|
||||||
);
|
);
|
||||||
|
|
||||||
timeoutRef.current = setTimeout(() => {
|
timeoutRef.current = setTimeout(() => {
|
||||||
@@ -90,7 +88,7 @@ export function useServerEvents() {
|
|||||||
connectEventSource();
|
connectEventSource();
|
||||||
}
|
}
|
||||||
}, delay);
|
}, delay);
|
||||||
} else if (retryCountRef.current >= 10) {
|
} else if (retryCountRef.current >= 5) {
|
||||||
logger.error("SSE max reconnection attempts reached");
|
logger.error("SSE max reconnection attempts reached");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -113,5 +111,5 @@ export function useServerEvents() {
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [user?.id]);
|
}, [user?.id, queryClient]);
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user