1 Commits

Author SHA1 Message Date
yohlo
6ed77dd471 idk 2025-09-24 07:59:13 -05:00
117 changed files with 1984 additions and 7223 deletions

1
.gitignore vendored
View File

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

1245
bun.lock

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -37,7 +37,7 @@ export const Route = createRootRouteWithContext<{
{ {
name: "viewport", name: "viewport",
content: content:
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import ListLink from "@/components/list-link";
const ManageTournaments = () => { const ManageTournaments = () => {
const { data: tournaments } = useTournaments(); const { data: tournaments } = useTournaments();
return ( return (
<List 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}`} />
))} ))}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -87,7 +87,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
{match.home?.name} Cups {match.home?.name} Cups
</Text> </Text>
{ {
match.home?.players?.map(p => (<Text 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>))
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,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 () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
import { import {
Autocomplete,
Stack, Stack,
ActionIcon, ActionIcon,
Text, Text,
Group, Group,
Loader, Loader,
} from "@mantine/core"; } from "@mantine/core";
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
import { TrashIcon } from "@phosphor-icons/react"; import { TrashIcon } from "@phosphor-icons/react";
import { useState, useCallback, useMemo, memo } from "react"; import { useState, useCallback, useMemo, memo } from "react";
import { useTournament, useUnenrolledTeams } from "../queries"; import { useTournament, useUnenrolledTeams } from "../queries";
@@ -68,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>

View File

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

View File

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

View File

@@ -11,9 +11,9 @@ const Header = ({ tournament }: HeaderProps) => {
return ( return (
<> <>
<Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'> <Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'>
<Avatar contain name={tournament.name} radius="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>
</> </>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,13 +28,13 @@ const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
<Sheet title="Free Agent Enrollment" opened={isOpen} onChange={toggle}> <Sheet title="Free Agent Enrollment" opened={isOpen} onChange={toggle}>
<Stack gap="xs"> <Stack gap="xs">
<Text size="md"> <Text size="md">
Enrolling as a free agent 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>

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core"; import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
import { Tournament } from "../../types"; import { Tournament } from "../../types";
import 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">

View File

@@ -1,7 +1,7 @@
import { Suspense, useCallback, useMemo } from "react"; import { Suspense, useCallback, useMemo } from "react";
import { Tournament } from "../../types"; import { Tournament } from "../../types";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
import { Box, Button, Card, Divider, Group, Stack, Text, 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

View File

@@ -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(() => {

View File

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

View File

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

View File

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