49 Commits

Author SHA1 Message Date
yohlo
b458872ac1 images in badge popover 2025-10-06 02:23:25 -05:00
yohlo
afd0b692fa Badge images! 2025-10-06 02:13:51 -05:00
yohlo
af0ec85811 remove file proxy logs 2025-10-04 22:42:36 -05:00
yohlo
d18d148d32 fix tournament card size 2025-10-04 22:42:00 -05:00
yohlo
95a50ee7a7 glitch effect avatar 2025-10-04 18:41:46 -05:00
yohlo
1ef786ea79 slide panel button margins 2025-10-03 02:51:27 -05:00
yohlo
47962a8681 fix slide panel 2025-10-03 02:49:06 -05:00
yohlo
2e6d3366e4 fix slide panel 2025-10-03 02:47:52 -05:00
yohlo
fafe5ca3ec improvements 2025-10-03 02:34:45 -05:00
yohlo
b52c79772f activities 2025-10-02 21:58:20 -05:00
yohlo
8579ec36ca bug fixes, new fonts, etc 2025-10-02 14:49:29 -05:00
yohlo
2dfb7c63d3 smoother team form close 2025-10-01 22:29:40 -05:00
yohlo
03b2b54c1f fix logo not updating 2025-10-01 22:27:32 -05:00
yohlo
0910f11228 pb refresh, profile refresh update 2025-10-01 21:34:59 -05:00
yohlo
a376f98fe7 badge redesign again 2025-10-01 21:28:27 -05:00
yohlo
1f4f66f8c5 badge redesign 2025-10-01 17:54:40 -05:00
yohlo
5729dab35f use popover over tooltip for badges 2025-10-01 14:14:03 -05:00
yohlo
c05fd5dc6d pb 2025-10-01 14:02:13 -05:00
yohlo
b9a42b4743 pb 2025-10-01 13:58:51 -05:00
yohlo
74e28cc2ac pb 2025-10-01 13:55:53 -05:00
yohlo
adf304b1e0 pb 2025-10-01 13:46:50 -05:00
yohlo
d18cdce15f pb 2025-10-01 13:46:41 -05:00
yohlo
aa87a9da5b pb 2025-10-01 13:45:33 -05:00
yohlo
6224404aa9 award badges 2025-10-01 13:42:09 -05:00
yohlo
654041b6b6 badges 2025-10-01 13:26:42 -05:00
yohlo
ce29c41bf3 remove bad badge collections 2025-09-30 18:10:51 -05:00
yohlo
63ea515a31 activity logging middleware 2025-09-30 10:47:02 -05:00
yohlo
8b1bbe213d test sse fixes 2025-09-29 21:35:38 -05:00
yohlo
ed538b7373 test sse fixes 2025-09-29 21:35:12 -05:00
yohlo
03e3bbcbc0 test sse fixes 2025-09-29 21:31:00 -05:00
yohlo
baf75eddba test sse fixes 2025-09-29 21:28:22 -05:00
yohlo
5094933302 update admin 2025-09-29 15:49:18 -05:00
yohlo
9564b46d45 quick fix 2025-09-29 15:42:00 -05:00
yohlo
ece5094f13 quick fix 2025-09-29 15:40:41 -05:00
yohlo
cfe1ee7171 passwordless fix 2025-09-29 15:14:41 -05:00
yohlo
3a41609a91 bug fixes, layout fixes 2025-09-29 15:13:41 -05:00
yohlo
732afaf623 changes to twilio 2025-09-29 12:51:33 -05:00
yohlo
48aeaabeea improvements 2025-09-29 11:43:48 -05:00
yohlo
a4b9fe9065 updated bracket 2025-09-29 10:50:18 -05:00
yohlo
31e50af593 team logo compression, play around with style 2025-09-29 10:20:54 -05:00
yohlo
39053cadaa avatr contain 2025-09-26 12:55:04 -05:00
yohlo
ea6656aa33 avatar modal 2025-09-25 21:24:57 -05:00
yohlo
92c4987372 bun 2025-09-25 16:42:55 -05:00
yohlo
b3ebf46afa new typeahead 2025-09-25 16:11:54 -05:00
yohlo
c0ef535001 bug fixes 2025-09-25 15:49:09 -05:00
yohlo
81329e4354 fix refresh issue 2025-09-24 12:20:36 -05:00
yohlo
36f3bb77d4 updates 2025-09-24 11:02:56 -05:00
yohlo
6760ea46f9 update query integration 2025-09-24 08:04:09 -05:00
yohlo
e4164cbc71 attempted upgrade 2025-09-24 00:13:41 -05:00
142 changed files with 7296 additions and 1976 deletions

1
.gitignore vendored
View File

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

1245
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite dev --host 0.0.0.0",
"build": "vite build && tsc --noEmit",
"start": "node .output/server/index.mjs"
"start": "bun run .output/server/index.mjs",
"start:node": "node .output/server/index.mjs"
},
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
@@ -21,14 +22,17 @@
"@svgmoji/noto": "^3.2.0",
"@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-devtools": "^5.66.0",
"@tanstack/react-router": "1.130.12",
"@tanstack/react-router-devtools": "1.130.13",
"@tanstack/react-router-with-query": "1.130.12",
"@tanstack/react-start": "1.130.15",
"@tanstack/react-router": "^1.130.12",
"@tanstack/react-router-devtools": "^1.130.13",
"@tanstack/react-router-with-query": "^1.130.12",
"@tanstack/react-start": "^1.132.2",
"@tanstack/react-virtual": "^3.13.12",
"@tiptap/pm": "^3.4.3",
"@tiptap/react": "^3.4.3",
"@tiptap/starter-kit": "^3.4.3",
"@types/bun": "^1.2.22",
"@types/ioredis": "^4.28.10",
"browser-image-compression": "^2.0.2",
"dotenv": "^17.2.2",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.12",
@@ -51,6 +55,8 @@
"zustand": "^5.0.7"
},
"devDependencies": {
"@tanstack/react-router-ssr-query": "^1.132.2",
"@tanstack/router-plugin": "^1.132.2",
"@types/node": "^22.5.4",
"@types/pg": "^8.15.5",
"@types/react": "^19.0.8",
@@ -63,7 +69,7 @@
"postcss-simple-vars": "^7.0.1",
"tsx": "^4.20.3",
"typescript": "^5.7.2",
"vite": "^6.3.5",
"vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

366
server.ts Normal file
View File

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

View File

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

View File

@@ -1,15 +1,11 @@
import { QueryClient } from "@tanstack/react-query";
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routerWithQueryClient } from "@tanstack/react-router-with-query";
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
import { routeTree } from "./routeTree.gen";
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
import dotenv from 'dotenv';
dotenv.config();
export function createRouter() {
export function getRouter() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -22,28 +18,32 @@ export function createRouter() {
},
});
return routerWithQueryClient(
createTanStackRouter({
routeTree,
context: {
queryClient,
auth: undefined!,
header: defaultHeaderConfig,
refresh: [],
withPadding: true,
fullWidth: false,
},
defaultPreload: "intent",
defaultErrorComponent: DefaultCatchBoundary,
scrollRestoration: true,
defaultViewTransition: false,
}),
const router = createTanStackRouter({
routeTree,
context: {
queryClient,
auth: undefined!,
header: defaultHeaderConfig,
refresh: [],
withPadding: true,
fullWidth: false,
},
defaultPreload: "intent",
defaultErrorComponent: DefaultCatchBoundary,
scrollRestoration: true,
defaultViewTransition: false,
});
setupRouterSsrQueryIntegration({
router,
queryClient
);
})
return router;
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof createRouter>;
router: ReturnType<typeof getRouter>;
}
}

View File

@@ -37,7 +37,7 @@ export const Route = createRootRouteWithContext<{
{
name: "viewport",
content:
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=overlays-content",
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content",
},
],
links: [
@@ -63,7 +63,17 @@ export const Route = createRootRouteWithContext<{
{ rel: 'stylesheet', href: mantineCssUrl },
{ rel: 'stylesheet', href: mantineCarouselCssUrl },
{ 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) => {
@@ -83,12 +93,20 @@ export const Route = createRootRouteWithContext<{
return {};
}
// https://github.com/TanStack/router/discussions/3531
const auth = await ensureServerQueryData(
context.queryClient,
playerQueries.auth()
);
return { auth };
if (location.pathname === '/login' || location.pathname === '/logout') {
return {};
}
try {
// https://github.com/TanStack/router/discussions/3531
const auth = await ensureServerQueryData(
context.queryClient,
playerQueries.auth()
);
return { auth };
} catch (error) {
return {};
}
},
pendingComponent: () => <Providers><FullScreenLoader /></Providers>,
});
@@ -114,8 +132,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
{...mantineHtmlProps}
style={{
overflowX: "hidden",
overflowY: "hidden",
position: "fixed",
height: "100%",
width: "100%",
}}
>
@@ -127,9 +144,10 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<body
style={{
overflowX: "hidden",
overflowY: "hidden",
position: "fixed",
height: "100%",
width: "100%",
margin: 0,
padding: 0,
}}
>
<div className="app">{children}</div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,66 +1,92 @@
import { createServerFileRoute } from "@tanstack/react-start/server";
import { createFileRoute } from "@tanstack/react-router";
import { serverEvents, type ServerEvent } from "@/lib/events/emitter";
import { logger } from "@/lib/logger";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
export const ServerRoute = createServerFileRoute("/api/events/$").middleware([superTokensRequestMiddleware]).methods({
GET: ({ request, context }) => {
logger.info('ServerEvents | New connection', context?.userAuthId);
let activeConnections = 0;
const stream = new ReadableStream({
start(controller) {
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
controller.enqueue(new TextEncoder().encode(connectMessage));
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({
start(controller) {
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
controller.enqueue(new TextEncoder().encode(connectMessage));
const handleEvent = (event: ServerEvent) => {
logger.info('ServerEvents | Event received', event);
const message = `data: ${JSON.stringify(event)}\n\n`;
try {
controller.enqueue(new TextEncoder().encode(message));
} catch (error) {
logger.error("ServerEvents | Error sending SSE message", error);
}
};
const handleEvent = (event: ServerEvent) => {
logger.info("ServerEvents | Event received", event);
const message = `data: ${JSON.stringify(event)}\n\n`;
try {
if (!controller.desiredSize || controller.desiredSize <= 0) {
logger.warn("ServerEvents | Stream closed, skipping event");
return;
}
controller.enqueue(new TextEncoder().encode(message));
} catch (error) {
logger.error("ServerEvents | Error sending SSE message", error);
}
};
serverEvents.on("test", handleEvent);
serverEvents.on("match", handleEvent);
serverEvents.on("reaction", handleEvent);
serverEvents.on("test", handleEvent);
serverEvents.on("match", handleEvent);
serverEvents.on("reaction", handleEvent);
const pingInterval = setInterval(() => {
try {
const pingMessage = `data: ${JSON.stringify({ type: "ping" })}\n\n`;
controller.enqueue(new TextEncoder().encode(pingMessage));
} catch (e) {
clearInterval(pingInterval);
controller.close();
}
}, 30000);
const pingInterval = setInterval(() => {
try {
if (!controller.desiredSize || controller.desiredSize <= 0) {
clearInterval(pingInterval);
return;
}
const pingMessage = `data: ${JSON.stringify({ type: "ping", timestamp: Date.now() })}\n\n`;
controller.enqueue(new TextEncoder().encode(pingMessage));
} catch (e) {
logger.error("ServerEvents | Ping interval error", e);
clearInterval(pingInterval);
}
}, 15000);
const cleanup = () => {
serverEvents.off("test", handleEvent);
clearInterval(pingInterval);
try {
logger.info('ServerEvents | Closing connection', context?.userAuthId);
controller.close();
} catch (e) {
logger.error('ServerEvents | Error closing controller', e);
}
};
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);
request.signal?.addEventListener("abort", cleanup);
const cleanup = () => {
activeConnections--;
serverEvents.off("test", handleEvent);
serverEvents.off("match", handleEvent);
serverEvents.off("reaction", handleEvent);
clearInterval(pingInterval);
logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${activeConnections}`);
};
return cleanup;
request.signal?.addEventListener("abort", cleanup);
return cleanup;
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Cache-Control",
"X-Accel-Buffering": "no",
"X-Proxy-Buffering": "no",
"Proxy-Buffering": "off",
"Transfer-Encoding": "chunked",
},
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Cache-Control",
},
});
},
},
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,179 @@
import { useState, useEffect, useRef } from "react";
import { Paper, Box } from "@mantine/core";
import {
Avatar as MantineAvatar,
AvatarProps as MantineAvatarProps,
} from "@mantine/core";
interface GlitchAvatarProps
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
name: string;
src?: string;
glitchSrc?: string;
size?: number;
radius?: string | number;
withBorder?: boolean;
contain?: boolean;
children?: React.ReactNode;
px?: string | number;
}
const GlitchAvatar = ({
name,
src,
glitchSrc,
size = 35,
radius = "100%",
withBorder = true,
contain = false,
children,
px,
...props
}: GlitchAvatarProps) => {
const [showGlitch, setShowGlitch] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (!glitchSrc) return;
const scheduleNextGlitch = () => {
const delay = Math.random() * 10000 + 5000;
return setTimeout(() => {
setShowGlitch(true);
setIsPlaying(true);
setTimeout(() => {
setShowGlitch(false);
setIsPlaying(false);
scheduleNextGlitch();
}, 4000);
}, delay);
};
const timeoutId = scheduleNextGlitch();
return () => clearTimeout(timeoutId);
}, [glitchSrc]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleEnded = () => {
setShowGlitch(false);
setIsPlaying(false);
};
video.addEventListener("ended", handleEnded);
return () => video.removeEventListener("ended", handleEnded);
}, []);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.load();
}, [glitchSrc]);
useEffect(() => {
const video = videoRef.current;
if (!video || !showGlitch || !isPlaying) return;
video.currentTime = 0;
video.play().catch((err) => {
console.error("Failed to play glitch", err);
});
}, [showGlitch, isPlaying]);
return (
<Box
style={{
padding: "8px",
borderRadius:
typeof radius === "number"
? `${radius + 8}px`
: "calc(var(--mantine-radius-md) + 8px)",
position: "relative",
}}
>
<Box
style={{
opacity: showGlitch ? 0 : 1,
transition: "opacity 0.05s ease-in-out",
}}
>
<Paper
py={size / 12.5}
px={size / 20}
bg="var(--mantine-color-default-border)"
radius={radius}
withBorder={false}
style={{
cursor: "default",
}}
>
<MantineAvatar
alt={name}
key={name}
name={name}
color="initials"
size={size}
radius={radius}
w={size}
styles={{
image: {
objectFit: contain ? "contain" : "cover",
},
}}
src={src}
{...props}
>
{children}
</MantineAvatar>
</Paper>
</Box>
{glitchSrc && (
<Box
style={{
position: "absolute",
top: "8px",
left: "8px",
opacity: showGlitch ? 1 : 0,
visibility: showGlitch ? "visible" : "hidden",
transition: "opacity 0.05s ease-in-out",
pointerEvents: "none",
}}
>
<Paper
py={size / 12.5}
px={size / 20}
bg="var(--mantine-color-default-border)"
radius={radius}
withBorder={false}
style={{
overflow: "hidden",
}}
>
<video
ref={videoRef}
src={glitchSrc}
style={{
width: `${size}px`,
height: `${size}px`,
objectFit: contain ? "contain" : "cover",
borderRadius: typeof radius === "number" ? `${radius}px` : radius,
display: "block",
}}
muted
playsInline
preload="auto"
/>
</Paper>
</Box>
)}
</Box>
);
};
export default GlitchAvatar;

View File

@@ -1,22 +1,33 @@
import { Divider, Group, Text, UnstyledButton } from "@mantine/core";
import { Divider, Group, Loader, Text, UnstyledButton } from "@mantine/core";
import { CaretRightIcon, Icon } from "@phosphor-icons/react";
interface ListButtonProps {
label: string;
Icon: Icon;
onClick: () => void;
loading?: boolean;
}
const ListButton = ({ label, onClick, Icon }: ListButtonProps) => {
const ListButton = ({ label, onClick, Icon, loading }: ListButtonProps) => {
return (
<>
<UnstyledButton w="100%" p="md" component={"button"} onClick={onClick}>
<UnstyledButton
w="100%"
p="md"
component={"button"}
onClick={onClick}
disabled={loading}
>
<Group>
<Icon weight="bold" size={20} />
<Text fw={500} size="md">
{label}
</Text>
<CaretRightIcon style={{ marginLeft: "auto" }} size={20} />
{loading ? (
<Loader size="sm" style={{ marginLeft: "auto" }} />
) : (
<CaretRightIcon style={{ marginLeft: "auto" }} size={20} />
)}
</Group>
</UnstyledButton>
<Divider />

View File

@@ -1,4 +1,4 @@
import { Box, Container, Flex, Loader, useComputedColorScheme } from "@mantine/core";
import { Box, Container, Flex, Loader, Title, useComputedColorScheme } from "@mantine/core";
import { PropsWithChildren, Suspense, useEffect, useRef } from "react";
import { Drawer as VaulDrawer } from "vaul";
import styles from "./styles.module.css";
@@ -62,6 +62,22 @@ const Drawer: React.FC<DrawerProps> = ({
useEffect(() => {
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(() => {
if (contentRef.current) {
const drawerContent = contentRef.current.closest('[data-vaul-drawer-wrapper]');
@@ -72,15 +88,24 @@ const Drawer: React.FC<DrawerProps> = ({
}
});
updateDrawerHeight();
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', updateDrawerHeight);
}
resizeObserver.observe(contentRef.current);
return () => {
resizeObserver.disconnect();
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', updateDrawerHeight);
}
};
}, [opened, children]);
return (
<VaulDrawer.Root open={opened} onOpenChange={onChange}>
<VaulDrawer.Root repositionInputs={false} open={opened} onOpenChange={onChange}>
<VaulDrawer.Portal>
<VaulDrawer.Overlay className={styles.drawerOverlay} />
<VaulDrawer.Content className={styles.drawerContent} aria-describedby="drawer" ref={contentRef}>
@@ -95,7 +120,7 @@ const Drawer: React.FC<DrawerProps> = ({
style={{ borderRadius: "9999px" }}
/>
<Container mx="auto" maw="28rem" px={0}>
<VaulDrawer.Title>{title}</VaulDrawer.Title>
<VaulDrawer.Title><Title order={2}>{title}</Title></VaulDrawer.Title>
<Suspense fallback={
<Flex justify='center' align='center' w='100%' h={400}>
<Loader size='lg' />

View File

@@ -23,14 +23,14 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
onChange={onChange}
onClose={handleClose}
>
<ScrollArea
style={{ flex: 1 }}
<ScrollArea.Autosize
style={{ flex: 1, maxHeight: '75dvh' }}
scrollbarSize={8}
scrollbars="y"
type="scroll"
>
{children}
</ScrollArea>
</ScrollArea.Autosize>
</SheetComponent>
);
};

View File

@@ -5,6 +5,7 @@ import {
ActionIcon,
ScrollArea,
Divider,
Stack,
} from "@mantine/core";
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
import { useState, ReactNode } from "react";
@@ -69,6 +70,7 @@ const SlidePanel = ({
overflow: "hidden",
display: "flex",
flexDirection: "column",
width: "100%",
}}
>
<Box
@@ -167,11 +169,17 @@ const SlidePanel = ({
bg="var(--mantine-color-dimmed)"
my="xs"
/>
<panelConfig.Component
value={tempValue}
onChange={setTempValue}
{...(panelConfig.componentProps || {})}
/>
<ScrollArea.Autosize w="100%" p={0} offsetScrollbars>
<panelConfig.Component
value={tempValue}
onChange={setTempValue}
{...(panelConfig.componentProps || {})}
/>
</ScrollArea.Autosize>
<Stack mt="auto" w="100%" gap={2}>
<Button mt="md" onClick={handleConfirm}>Confirm</Button>
<Button variant="subtle" onClick={closePanel} mt="sm" color="red">Cancel</Button>
</Stack>
</>
)}
</Box>

View File

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

View File

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

View File

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

View File

@@ -438,6 +438,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
activeDevice,
isLoading,
error,
// Capture/Resume state
capturedState,
isCaptureLoading,
isResumeLoading,
@@ -452,9 +453,11 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
getDevices,
setActiveDevice,
refreshPlaybackState,
// Capture/Resume methods
capturePlaybackState,
resumePlaybackState,
clearCapturedState,
// Search
searchTracks,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,17 +4,48 @@ import {
DatabaseIcon,
TreeStructureIcon,
TrophyIcon,
MedalIcon,
CrownIcon,
ListIcon,
} from "@phosphor-icons/react";
import ListButton from "@/components/list-button";
import { migrateBadgeProgress } from "@/features/badges/server";
import { useState } from "react";
const AdminPage = () => {
const [isMigrating, setIsMigrating] = useState(false);
const handleMigrateBadges = async () => {
if (isMigrating) return;
setIsMigrating(true);
await migrateBadgeProgress();
setIsMigrating(false);
};
return (
<List>
<List p="0">
<ListLink
label="Manage Tournaments"
Icon={TrophyIcon}
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
label="Open Pocketbase"
Icon={DatabaseIcon}

View File

@@ -0,0 +1,114 @@
import { useState } from "react";
import { Box, Card, Text, Select, Button, Group, Stack, Badge, Divider } from "@mantine/core";
import { awardManualBadge } from "@/features/badges/server";
import { useAllBadges } from "@/features/badges/queries";
import toast from "@/lib/sonner";
import { usePlayers } from "@/features/players/queries";
const AwardBadges = () => {
const { data: players } = usePlayers();
const { data: allBadges } = useAllBadges();
const [selectedPlayerId, setSelectedPlayerId] = useState<string | null>(null);
const [selectedBadgeId, setSelectedBadgeId] = useState<string | null>(null);
const [isAwarding, setIsAwarding] = useState(false);
const manualBadges = allBadges.filter((badge) => badge.type === "manual");
const handleAwardBadge = async () => {
if (!selectedPlayerId || !selectedBadgeId) return;
setIsAwarding(true);
try {
await awardManualBadge({
data: {
playerId: selectedPlayerId,
badgeId: selectedBadgeId,
},
});
const selectedPlayer = players.find((p) => p.id === selectedPlayerId);
const playerName = selectedPlayer
? `${selectedPlayer.first_name} ${selectedPlayer.last_name}`
: "Player";
toast.success(`Badge awarded to ${playerName}`);
setSelectedPlayerId(null);
} catch (error) {
toast.error("Failed to award badge");
} finally {
setIsAwarding(false);
}
};
const playerOptions = players.map((player) => ({
value: player.id,
label: `${player.first_name} ${player.last_name}`,
}));
const badgeOptions = manualBadges.map((badge) => ({
value: badge.id,
label: badge.name,
}));
const selectedBadge = manualBadges.find((b) => b.id === selectedBadgeId);
return (
<Box p="md">
<Card withBorder radius="md" p="md">
<Stack gap="lg">
<Box>
<Text size="lg" fw={600} mb="xs">
Award Manual Badge
</Text>
</Box>
<Select
label="Badge Type"
placeholder="Select a badge"
data={badgeOptions}
value={selectedBadgeId}
onChange={setSelectedBadgeId}
searchable
clearable
size="md"
/>
{selectedBadgeId && (
<>
<Divider />
<Stack gap="md">
<Select
label="Select Player"
placeholder="Choose a player"
data={playerOptions}
value={selectedPlayerId}
onChange={setSelectedPlayerId}
searchable
clearable
size="md"
autoFocus
/>
<Group justify="flex-end">
<Button
onClick={handleAwardBadge}
disabled={!selectedPlayerId}
loading={isAwarding}
size="md"
>
Award Badge
</Button>
</Group>
</Stack>
</>
)}
</Stack>
</Card>
</Box>
);
};
export default AwardBadges;

View File

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

View File

@@ -0,0 +1,47 @@
import { Box, Skeleton, Text } from "@mantine/core";
const BadgeShowcaseSkeleton = () => {
return (
<Box mb="lg">
<Box
px="md"
style={{
maxHeight: '220px',
overflowY: 'auto',
overflowX: 'hidden',
width: '100%',
}}
>
<Box
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(85px, 1fr))',
gap: 'var(--mantine-spacing-md)',
paddingBottom: 'var(--mantine-spacing-sm)',
width: '100%',
}}
>
{[1, 2, 3, 4, 5, 6].map((i) => (
<Box
key={i}
style={{
aspectRatio: '1',
position: 'relative',
}}
>
<Skeleton
height="100%"
radius="12px"
style={{
aspectRatio: '1',
}}
/>
</Box>
))}
</Box>
</Box>
</Box>
);
};
export default BadgeShowcaseSkeleton;

View File

@@ -0,0 +1,311 @@
import { Box, Text, Progress, Image, Popover, Title, Stack } from "@mantine/core";
import { usePlayerBadges, useAllBadges } from "../queries";
import { useAuth } from "@/contexts/auth-context";
import { Badge, BadgeProgress } from "../types";
import { useMemo, useState } from "react";
import { MedalIcon, LockKeyIcon } from "@phosphor-icons/react";
interface BadgeShowcaseProps {
playerId: string;
}
interface BadgeDisplay {
badge: Badge;
progress?: BadgeProgress;
earned: boolean;
progressText: string;
}
interface BadgeIconProps {
badge: Badge;
earned: boolean;
}
const BadgeIcon = ({ badge, earned, size = 48 }: BadgeIconProps & { size?: number }) => {
const [imageError, setImageError] = useState(false);
const imagePath = `/static/img/${badge.key}.png`;
if (imageError) {
// Fallback to icon if image fails to load
return earned ? (
<MedalIcon
size={size}
weight="fill"
color="var(--mantine-primary-color-6)"
/>
) : (
<LockKeyIcon
size={size - 4}
weight="regular"
color="var(--mantine-color-dimmed)"
/>
);
}
return (
<Image
src={imagePath}
alt={badge.name}
width={size}
height={size}
onError={() => setImageError(true)}
style={{
objectFit: 'contain',
opacity: earned ? 1 : 0.4,
}}
/>
);
};
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;
}
const isVeteranBadge = /^veteran_\d+_badge$/.test(badge.key);
if (isVeteranBadge && !earned) {
continue;
}
if (badge.key === 'new_player_badge') {
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={280} position="top" withArrow shadow="md" withinPortal>
<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={{
width: '85px',
height: '85px',
borderRadius: '12px',
background: 'transparent',
border: '2px solid var(--mantine-primary-color-5)',
position: 'absolute',
top: `${(i + 1) * 3}px`,
left: `${(i + 1) * 3}px`,
opacity: 0.4 - (i * 0.15),
zIndex: -(i + 1),
}}
/>
))}
</>
)}
<Box
style={{
width: '100px',
height: '100px',
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',
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,
}}
>
<BadgeIcon badge={display.badge} earned={display.earned} />
{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>
)}
<Text
size="xs"
fw={display.earned ? 600 : 500}
ta="center"
c={display.earned ? undefined : 'dimmed'}
style={{ lineHeight: 1.1 }}
>
{display.badge.name}
</Text>
</Box>
</Box>
</Popover.Target>
<Popover.Dropdown>
<Stack gap={4} align="center">
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<BadgeIcon badge={display.badge} earned={display.earned} size={80} />
</Box>
<Title order={5} ta="center">
{display.badge.name}
</Title>
<Text size="sm" c="dimmed" ta="center">
{display.badge.description}
</Text>
{isCurrentUser && (
<Box>
<Box mb="xs" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text size="sm" fw={500} c="dimmed">
Progress&nbsp;
</Text>
<Text size="sm" 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>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
})}
</Box>
</Box>
</Box>
);
};
function getTargetProgress(badge: Badge): number {
const criteria = badge.criteria;
return (
criteria.matches_played ||
criteria.tournament_wins ||
criteria.tournaments_attended ||
criteria.overtime_matches ||
criteria.overtime_wins ||
criteria.consecutive_wins ||
1
);
}
export default BadgeShowcase;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { Flex } from "@mantine/core";
import { Flex, Box } from "@mantine/core";
import { Match } from "@/features/matches/types";
import { MatchCard } from "./match-card";
import { useEffect, useRef } from "react";
interface BracketProps {
rounds: Match[][];
@@ -13,33 +14,131 @@ export const Bracket: React.FC<BracketProps> = ({
orders,
showControls,
}) => {
return (
<Flex direction="row" gap={24} justify="left">
{rounds.map((round, roundIndex) => (
<Flex
key={roundIndex}
direction="column"
align="center"
pos="relative"
gap={24}
justify="space-around"
p={24}
>
{round.map((match) =>
match.bye ? (
<div key={match.lid}></div>
) : (
<div key={match.lid}>
<MatchCard
match={match}
orders={orders}
showControls={showControls}
/>
</div>
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
)
)}
</Flex>
))}
</Flex>
);
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 (
<Box pos="relative" ref={containerRef}>
<svg
ref={svgRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 0,
}}
/>
<Flex direction="row" gap={24} justify="left" pos="relative" style={{ zIndex: 1 }} className="bracket-flex-container">
{rounds.map((round, roundIndex) => (
<Flex
key={roundIndex}
direction="column"
align="center"
pos="relative"
gap={24}
justify="space-around"
p={24}
>
{round.map((match) =>
match.bye ? (
<div key={match.lid}></div>
) : (
<div key={match.lid}>
<MatchCard
match={match}
orders={orders}
showControls={showControls}
/>
</div>
)
)}
</Flex>
))}
</Flex>
</Box>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ const PlayerList = ({ players, loading = false }: PlayerListProps) => {
))}
</List>
return <List>
return <List p="0">
{players?.map((player) => (
<ListItem key={player.id}
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 fontSize = useMemo(() => {
const baseSize = 24;
const maxLength = 20;
const baseSize = 28;
const maxLength = 24;
if (name.length <= maxLength) {
return `${baseSize}px`;

View File

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

View File

@@ -60,7 +60,6 @@ export const useMe = () => {
const errorData = error?.response?.data;
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
const currentUrl = window.location.pathname + window.location.search;
console.log('redirecting 3')
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
return false;
}

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