30 Commits

Author SHA1 Message Date
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
yohlo
94ea44c66e drawer fixes 2025-09-23 15:04:29 -05:00
yohlo
7441d1ac58 skeletons, tournament stats, polish, bug fixes 2025-09-23 14:48:04 -05:00
yohlo
7ff26229d9 dark mode default, basic tournament stats/podium 2025-09-22 19:33:58 -05:00
yohlo
b93ce38d48 play walkout songs 2025-09-22 17:57:29 -05:00
yohlo
ae934e77f4 manage team data 2025-09-22 17:24:45 -05:00
yohlo
cae5fa1c71 skeletons 2025-09-22 16:45:41 -05:00
yohlo
fc3f626313 minor cleanup 2025-09-21 11:38:10 -05:00
yohlo
1027b49258 free agents 2025-09-20 20:50:44 -05:00
yohlo
5e20b94a1f reactions SSE! 2025-09-19 20:53:05 -05:00
yohlo
f99d6efaf9 reactions, match sse, etc 2025-09-19 14:08:36 -05:00
yohlo
602e6e3473 rules, bracket page 2025-09-18 18:17:56 -05:00
yohlo
285a33c488 enroll team polish? 2025-09-17 20:20:52 -05:00
yohlo
cac42c9b29 reactions 2025-09-17 10:30:10 -05:00
yohlo
498010e3e2 various improvements 2025-09-17 09:02:20 -05:00
yohlo
c170e1e1fe working team update/create (still need enroll) 2025-09-16 13:24:39 -05:00
yohlo
cde74a04d5 work on team enrollment 2025-09-16 09:24:21 -05:00
yohlo
9a105b30c6 stats reorg, upcoming refinement 2025-09-14 23:10:05 -05:00
178 changed files with 10349 additions and 3213 deletions

3
.gitignore vendored
View File

@@ -19,4 +19,5 @@ yarn.lock
/playwright/.cache/ /playwright/.cache/
/scripts/ /scripts/
/pb_data/ /pb_data/
/.tanstack/ /.tanstack/
/dist/

1245
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -32,17 +32,17 @@ services:
- app-network - app-network
restart: unless-stopped restart: unless-stopped
redis: #redis:
image: redis:7-alpine # image: redis:7-alpine
container_name: redis-cache # container_name: redis-cache
ports: # ports:
- "6379:6379" # - "6379:6379"
command: redis-server --appendonly yes # command: redis-server --appendonly yes
volumes: # volumes:
- redis-data:/data # - redis-data:/data
networks: # networks:
- app-network # - app-network
restart: unless-stopped # restart: unless-stopped
supertokens: supertokens:
image: registry.supertokens.io/supertokens/supertokens-postgresql image: registry.supertokens.io/supertokens/supertokens-postgresql
@@ -51,6 +51,7 @@ services:
- postgres - postgres
environment: environment:
POSTGRESQL_CONNECTION_URI: postgresql://supertokens:password@postgres:5432/supertokens POSTGRESQL_CONNECTION_URI: postgresql://supertokens:password@postgres:5432/supertokens
ACCESS_TOKEN_VALIDITY: 360000
ports: ports:
- "3567:3567" - "3567:3567"
env_file: env_file:

View File

@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "vite dev --host 0.0.0.0", "dev": "vite dev --host 0.0.0.0",
"build": "vite build && tsc --noEmit", "build": "vite build && tsc --noEmit",
"start": "vite start" "start": "bun run .output/server/index.mjs",
"start:node": "node .output/server/index.mjs"
}, },
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
@@ -18,13 +19,21 @@
"@mantine/hooks": "^8.2.4", "@mantine/hooks": "^8.2.4",
"@mantine/tiptap": "^8.2.4", "@mantine/tiptap": "^8.2.4",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@svgmoji/noto": "^3.2.0",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-devtools": "^5.66.0", "@tanstack/react-query-devtools": "^5.66.0",
"@tanstack/react-router": "^1.130.12", "@tanstack/react-router": "^1.130.12",
"@tanstack/react-router-devtools": "^1.130.13", "@tanstack/react-router-devtools": "^1.130.13",
"@tanstack/react-router-with-query": "^1.130.12", "@tanstack/react-router-with-query": "^1.130.12",
"@tanstack/react-start": "^1.130.15", "@tanstack/react-start": "^1.132.2",
"@tanstack/react-virtual": "^3.13.12",
"@tiptap/pm": "^3.4.3",
"@tiptap/react": "^3.4.3",
"@tiptap/starter-kit": "^3.4.3",
"@types/bun": "^1.2.22",
"@types/ioredis": "^4.28.10", "@types/ioredis": "^4.28.10",
"browser-image-compression": "^2.0.2",
"dotenv": "^17.2.2",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.12", "framer-motion": "^12.23.12",
"ioredis": "^5.7.0", "ioredis": "^5.7.0",
@@ -33,6 +42,7 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-imask": "^7.6.1", "react-imask": "^7.6.1",
"react-scan": "^0.4.3",
"react-use-draggable-scroll": "^0.4.7", "react-use-draggable-scroll": "^0.4.7",
"recharts": "^3.1.2", "recharts": "^3.1.2",
"redaxios": "^0.5.1", "redaxios": "^0.5.1",
@@ -45,6 +55,8 @@
"zustand": "^5.0.7" "zustand": "^5.0.7"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/react-router-ssr-query": "^1.132.2",
"@tanstack/router-plugin": "^1.132.2",
"@types/node": "^22.5.4", "@types/node": "^22.5.4",
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",
"@types/react": "^19.0.8", "@types/react": "^19.0.8",
@@ -57,7 +69,7 @@
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.3.5", "vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
} }
} }

View File

@@ -0,0 +1,211 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((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_1568971955",
"hidden": false,
"id": "relation694999214",
"maxSelect": 1,
"minSelect": 0,
"name": "team_id",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "_clone_ZNMy",
"max": 0,
"min": 0,
"name": "team_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number103159226",
"max": null,
"min": null,
"name": "matches",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "json2732118329",
"maxSize": 1,
"name": "wins",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json724428801",
"maxSize": 1,
"name": "losses",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3041953980",
"maxSize": 1,
"name": "margin_of_victory",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json1531431708",
"maxSize": 1,
"name": "margin_of_loss",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json1062535948",
"maxSize": 1,
"name": "total_cups_won_by",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json4249694556",
"maxSize": 1,
"name": "total_cups_lost_by",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3154249934",
"maxSize": 1,
"name": "total_cups_made",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3227208027",
"maxSize": 1,
"name": "total_cups_against",
"presentable": false,
"required": false,
"system": false,
"type": "json"
}
],
"id": "pbc_135889472",
"indexes": [],
"listRule": null,
"name": "team_stats",
"system": false,
"type": "view",
"updateRule": null,
"viewQuery": `
SELECT
t.id as id,
t.id as team_id,
t.name as team_name,
COUNT(m.id) as matches,
SUM(CASE
WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR
(m.away = t.id AND m.away_cups > m.home_cups)
THEN 1 ELSE 0
END) as wins,
SUM(CASE
WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR
(m.away = t.id AND m.away_cups < m.home_cups)
THEN 1 ELSE 0
END) as losses,
AVG(CASE
WHEN m.home = t.id AND m.home_cups > m.away_cups
THEN m.home_cups - m.away_cups
WHEN m.away = t.id AND m.away_cups > m.home_cups
THEN m.away_cups - m.home_cups
ELSE NULL
END) as margin_of_victory,
AVG(CASE
WHEN m.home = t.id AND m.home_cups < m.away_cups
THEN m.away_cups - m.home_cups
WHEN m.away = t.id AND m.away_cups < m.home_cups
THEN m.home_cups - m.away_cups
ELSE NULL
END) as margin_of_loss,
SUM(CASE
WHEN m.home = t.id THEN m.home_cups
WHEN m.away = t.id THEN m.away_cups
ELSE 0
END) as total_cups_won_by,
SUM(CASE
WHEN m.home = t.id THEN m.away_cups
WHEN m.away = t.id THEN m.home_cups
ELSE 0
END) as total_cups_lost_by,
SUM(CASE
WHEN m.home = t.id THEN m.home_cups
WHEN m.away = t.id THEN m.away_cups
ELSE 0
END) as total_cups_made,
SUM(CASE
WHEN m.home = t.id THEN m.away_cups
WHEN m.away = t.id THEN m.home_cups
ELSE 0
END) as total_cups_against
FROM teams t
JOIN matches m ON (m.home = t.id OR m.away = t.id)
JOIN tournaments tour ON m.tournament = tour.id
WHERE m.status = 'ended'
GROUP BY t.id`,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_135889472");
return app.delete(collection);
})

View File

@@ -0,0 +1,167 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_135889471");
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"
},
{
"hidden": false,
"id": "json4231605813",
"maxSize": 1,
"name": "player_name",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"cascadeDelete": false,
"collectionId": "pbc_1568971955",
"hidden": false,
"id": "relation694999214",
"maxSelect": 1,
"minSelect": 0,
"name": "team_id",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "_clone_XGbN",
"max": 0,
"min": 0,
"name": "team_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "pbc_340646327",
"hidden": false,
"id": "relation869376999",
"maxSelect": 1,
"minSelect": 0,
"name": "tournament_id",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "_clone_uud6",
"max": 0,
"min": 0,
"name": "tournament_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number103159226",
"max": null,
"min": null,
"name": "matches",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "json2732118329",
"maxSize": 1,
"name": "wins",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json724428801",
"maxSize": 1,
"name": "losses",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3154249934",
"maxSize": 1,
"name": "total_cups_made",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3227208027",
"maxSize": 1,
"name": "total_cups_against",
"presentable": false,
"required": false,
"system": false,
"type": "json"
}
],
"id": "pbc_135889471",
"indexes": [],
"listRule": null,
"name": "player_stats_per_tournament",
"system": false,
"type": "view",
"updateRule": null,
"viewQuery": "SELECT\n (p.id || '_' || t.id || '_' || tour.id) as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n t.id as team_id,\n t.name as team_name,\n tour.id as tournament_id,\n tour.name as tournament_name,\n COUNT(m.id) as matches,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended'\n GROUP BY p.id, t.id, tour.id",
"viewRule": null
});
return app.save(collection);
})

View File

@@ -0,0 +1,96 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_135889472")
// update collection data
unmarshal({
"name": "team_stats_per_tournament"
}, collection)
// remove field
collection.fields.removeById("_clone_2Mic")
// remove field
collection.fields.removeById("_clone_C8ev")
// add field
collection.fields.addAt(2, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "_clone_QmWG",
"max": 0,
"min": 0,
"name": "team_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
}))
// add field
collection.fields.addAt(4, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "_clone_1o7N",
"max": 0,
"min": 0,
"name": "tournament_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_135889472")
// update collection data
unmarshal({
"name": "team_stats"
}, collection)
// add field
collection.fields.addAt(2, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "_clone_2Mic",
"max": 0,
"min": 0,
"name": "team_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
}))
// add field
collection.fields.addAt(4, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "_clone_C8ev",
"max": 0,
"min": 0,
"name": "tournament_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
}))
// remove field
collection.fields.removeById("_clone_QmWG")
// remove field
collection.fields.removeById("_clone_1o7N")
return app.save(collection)
})

View File

@@ -0,0 +1,157 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((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_1568971955",
"hidden": false,
"id": "relation694999214",
"maxSelect": 1,
"minSelect": 0,
"name": "team_id",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "_clone_nYJn",
"max": 0,
"min": 0,
"name": "team_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number103159226",
"max": null,
"min": null,
"name": "matches",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "json2732118329",
"maxSize": 1,
"name": "wins",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json724428801",
"maxSize": 1,
"name": "losses",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3041953980",
"maxSize": 1,
"name": "margin_of_victory",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json1531431708",
"maxSize": 1,
"name": "margin_of_loss",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json1062535948",
"maxSize": 1,
"name": "total_cups_won_by",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json4249694556",
"maxSize": 1,
"name": "total_cups_lost_by",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3154249934",
"maxSize": 1,
"name": "total_cups_made",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3227208027",
"maxSize": 1,
"name": "total_cups_against",
"presentable": false,
"required": false,
"system": false,
"type": "json"
}
],
"id": "pbc_1582517110",
"indexes": [],
"listRule": null,
"name": "team_stats",
"system": false,
"type": "view",
"updateRule": null,
"viewQuery": "\n SELECT\n t.id as id,\n t.id as team_id,\n t.name as team_name,\n COUNT(m.id) as matches,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END) as margin_of_victory,\n AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END) as margin_of_loss,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_won_by,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_lost_by,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against\n FROM teams t\n JOIN matches m ON (m.home = t.id OR m.away = t.id)\n WHERE m.status = 'ended'\n GROUP BY t.id",
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1582517110");
return app.delete(collection);
})

View File

@@ -0,0 +1,55 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1568971955")
// remove field
collection.fields.removeById("number3356599746")
// update field
collection.fields.addAt(9, new Field({
"hidden": false,
"id": "number1329349942",
"max": null,
"min": null,
"name": "song_end",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1568971955")
// add field
collection.fields.addAt(8, new Field({
"hidden": false,
"id": "number3356599746",
"max": null,
"min": null,
"name": "song_year",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}))
// update field
collection.fields.addAt(10, new Field({
"hidden": false,
"id": "number1329349942",
"max": null,
"min": null,
"name": "song_duration",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}))
return app.save(collection)
})

View File

@@ -0,0 +1,37 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1358894712")
// update collection data
unmarshal({
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n COUNT(DISTINCT m.tournament) as tournaments,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended'\n GROUP BY p.id"
}, collection)
// add field
collection.fields.addAt(4, new Field({
"hidden": false,
"id": "number3837590211",
"max": null,
"min": null,
"name": "tournaments",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1358894712")
// update collection data
unmarshal({
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended'\n GROUP BY p.id"
}, collection)
// remove field
collection.fields.removeById("number3837590211")
return app.save(collection)
})

View File

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

View File

@@ -0,0 +1,84 @@
/// <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": "text3058431538",
"max": 0,
"min": 0,
"name": "emoji",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"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": "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_1549310251",
"indexes": [],
"listRule": null,
"name": "reactions",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1549310251");
return app.delete(collection);
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1549310251")
// add field
collection.fields.addAt(3, new Field({
"cascadeDelete": false,
"collectionId": "pbc_2541054544",
"hidden": false,
"id": "relation2052834565",
"maxSelect": 1,
"minSelect": 0,
"name": "match",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1549310251")
// remove field
collection.fields.removeById("relation2052834565")
return app.save(collection)
})

View File

@@ -0,0 +1,85 @@
/// <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": "text1843675174",
"max": 0,
"min": 0,
"name": "description",
"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_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,28 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((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"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3072146508")
// remove field
collection.fields.removeById("relation2029409178")
return app.save(collection)
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3072146508")
// 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)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3072146508")
// remove field
collection.fields.removeById("relation2813965191")
return app.save(collection)
})

View File

@@ -0,0 +1,84 @@
/// <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_3072146508",
"hidden": false,
"id": "relation2551806565",
"maxSelect": 1,
"minSelect": 0,
"name": "player",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1146066909",
"max": 0,
"min": 0,
"name": "phone",
"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_2929550049",
"indexes": [],
"listRule": null,
"name": "free_agents",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2929550049");
return app.delete(collection);
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_2929550049")
// add field
collection.fields.addAt(3, new Field({
"cascadeDelete": false,
"collectionId": "pbc_340646327",
"hidden": false,
"id": "relation3177167065",
"maxSelect": 1,
"minSelect": 0,
"name": "tournament",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2929550049")
// remove field
collection.fields.removeById("relation3177167065")
return app.save(collection)
})

View File

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

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_2929550049")
// update collection data
unmarshal({
"createRule": "",
"deleteRule": "",
"listRule": "",
"updateRule": "",
"viewRule": ""
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2929550049")
// update collection data
unmarshal({
"createRule": null,
"deleteRule": null,
"listRule": null,
"updateRule": null,
"viewRule": null
}, collection)
return app.save(collection)
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_2929550049")
// update collection data
unmarshal({
"createRule": null,
"deleteRule": null,
"listRule": null,
"updateRule": null,
"viewRule": null
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2929550049")
// update collection data
unmarshal({
"createRule": "",
"deleteRule": "",
"listRule": "",
"updateRule": "",
"viewRule": ""
}, collection)
return app.save(collection)
})

364
server.ts Normal file
View File

@@ -0,0 +1,364 @@
/**
* 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,
routes: {
// Serve static assets (preloaded or on-demand)
...routes,
// Fallback to TanStack Start handler for all other routes
'/*': (request) => {
try {
return handler.fetch(request)
} catch (error) {
console.error('Server handler error:', error)
return new Response('Internal Server Error', { status: 500 })
}
},
},
// Global error handler
error(error) {
console.error('Uncaught server error:', error)
return new Response('Internal Server Error', { status: 500 })
},
})
console.log(
`\n🚀 Server running at http://localhost:${String(server.port)}\n`,
)
}
// Start the server
startServer().catch((error: unknown) => {
console.error('Failed to start server:', error)
process.exit(1)
})

View File

@@ -8,8 +8,6 @@
// You should NOT make any changes in this file as it will be overwritten. // You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { createServerRootRoute } from '@tanstack/react-start/server'
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as RefreshSessionRouteImport } from './routes/refresh-session' import { Route as RefreshSessionRouteImport } from './routes/refresh-session'
import { Route as LogoutRouteImport } from './routes/logout' import { Route as LogoutRouteImport } from './routes/logout'
@@ -21,24 +19,26 @@ import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings'
import { Route as AuthedAdminRouteImport } from './routes/_authed/admin' import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index' import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index' import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
import { Route as ApiTournamentsUploadLogoRouteImport } from './routes/api/tournaments/upload-logo'
import { Route as ApiTeamsUploadLogoRouteImport } from './routes/api/teams/upload-logo'
import { Route as ApiSpotifyTokenRouteImport } from './routes/api/spotify/token'
import { Route as ApiSpotifySearchRouteImport } from './routes/api/spotify/search'
import { Route as ApiSpotifyResumeRouteImport } from './routes/api/spotify/resume'
import { Route as ApiSpotifyPlaybackRouteImport } from './routes/api/spotify/playback'
import { Route as ApiSpotifyCaptureRouteImport } from './routes/api/spotify/capture'
import { Route as ApiSpotifyCallbackRouteImport } from './routes/api/spotify/callback'
import { Route as ApiEventsSplatRouteImport } from './routes/api/events.$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId' import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId' import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId' import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview' import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index' import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id' 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 AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo' import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
import { ServerRoute as ApiSpotifyTokenServerRouteImport } from './routes/api/spotify/token'
import { ServerRoute as ApiSpotifyResumeServerRouteImport } from './routes/api/spotify/resume'
import { ServerRoute as ApiSpotifyPlaybackServerRouteImport } from './routes/api/spotify/playback'
import { ServerRoute as ApiSpotifyCaptureServerRouteImport } from './routes/api/spotify/capture'
import { ServerRoute as ApiSpotifyCallbackServerRouteImport } from './routes/api/spotify/callback'
import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$'
import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$'
import { ServerRoute as ApiFilesCollectionRecordIdFileServerRouteImport } from './routes/api/files/$collection/$recordId/$file'
const rootServerRouteImport = createServerRootRoute()
const RefreshSessionRoute = RefreshSessionRouteImport.update({ const RefreshSessionRoute = RefreshSessionRouteImport.update({
id: '/refresh-session', id: '/refresh-session',
@@ -89,6 +89,57 @@ const AuthedAdminIndexRoute = AuthedAdminIndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const ApiTournamentsUploadLogoRoute =
ApiTournamentsUploadLogoRouteImport.update({
id: '/api/tournaments/upload-logo',
path: '/api/tournaments/upload-logo',
getParentRoute: () => rootRouteImport,
} as any)
const ApiTeamsUploadLogoRoute = ApiTeamsUploadLogoRouteImport.update({
id: '/api/teams/upload-logo',
path: '/api/teams/upload-logo',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSpotifyTokenRoute = ApiSpotifyTokenRouteImport.update({
id: '/api/spotify/token',
path: '/api/spotify/token',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSpotifySearchRoute = ApiSpotifySearchRouteImport.update({
id: '/api/spotify/search',
path: '/api/spotify/search',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSpotifyResumeRoute = ApiSpotifyResumeRouteImport.update({
id: '/api/spotify/resume',
path: '/api/spotify/resume',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSpotifyPlaybackRoute = ApiSpotifyPlaybackRouteImport.update({
id: '/api/spotify/playback',
path: '/api/spotify/playback',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSpotifyCaptureRoute = ApiSpotifyCaptureRouteImport.update({
id: '/api/spotify/capture',
path: '/api/spotify/capture',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSpotifyCallbackRoute = ApiSpotifyCallbackRouteImport.update({
id: '/api/spotify/callback',
path: '/api/spotify/callback',
getParentRoute: () => rootRouteImport,
} as any)
const ApiEventsSplatRoute = ApiEventsSplatRouteImport.update({
id: '/api/events/$',
path: '/api/events/$',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
id: '/api/auth/$',
path: '/api/auth/$',
getParentRoute: () => rootRouteImport,
} as any)
const AuthedTournamentsTournamentIdRoute = const AuthedTournamentsTournamentIdRoute =
AuthedTournamentsTournamentIdRouteImport.update({ AuthedTournamentsTournamentIdRouteImport.update({
id: '/tournaments/$tournamentId', id: '/tournaments/$tournamentId',
@@ -116,66 +167,35 @@ const AuthedAdminTournamentsIndexRoute =
path: '/tournaments/', path: '/tournaments/',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const AuthedAdminTournamentsIdRoute = const AuthedTournamentsIdBracketRoute =
AuthedAdminTournamentsIdRouteImport.update({ AuthedTournamentsIdBracketRouteImport.update({
id: '/tournaments/$id', id: '/tournaments/$id/bracket',
path: '/tournaments/$id', path: '/tournaments/$id/bracket',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedAdminTournamentsIdIndexRoute =
AuthedAdminTournamentsIdIndexRouteImport.update({
id: '/tournaments/$id/',
path: '/tournaments/$id/',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const ApiFilesCollectionRecordIdFileRoute =
ApiFilesCollectionRecordIdFileRouteImport.update({
id: '/api/files/$collection/$recordId/$file',
path: '/api/files/$collection/$recordId/$file',
getParentRoute: () => rootRouteImport,
} as any)
const AuthedAdminTournamentsRunIdRoute = const AuthedAdminTournamentsRunIdRoute =
AuthedAdminTournamentsRunIdRouteImport.update({ AuthedAdminTournamentsRunIdRouteImport.update({
id: '/tournaments/run/$id', id: '/tournaments/run/$id',
path: '/tournaments/run/$id', path: '/tournaments/run/$id',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const ApiTournamentsUploadLogoServerRoute = const AuthedAdminTournamentsIdTeamsRoute =
ApiTournamentsUploadLogoServerRouteImport.update({ AuthedAdminTournamentsIdTeamsRouteImport.update({
id: '/api/tournaments/upload-logo', id: '/tournaments/$id/teams',
path: '/api/tournaments/upload-logo', path: '/tournaments/$id/teams',
getParentRoute: () => rootServerRouteImport, getParentRoute: () => AuthedAdminRoute,
} as any)
const ApiSpotifyTokenServerRoute = ApiSpotifyTokenServerRouteImport.update({
id: '/api/spotify/token',
path: '/api/spotify/token',
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) } as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
@@ -190,11 +210,24 @@ export interface FileRoutesByFullPath {
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute '/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute '/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
'/api/spotify/resume': typeof ApiSpotifyResumeRoute
'/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/admin/': typeof AuthedAdminIndexRoute '/admin/': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
@@ -207,11 +240,24 @@ export interface FileRoutesByTo {
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute '/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute '/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
'/api/spotify/resume': typeof ApiSpotifyResumeRoute
'/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/admin': typeof AuthedAdminIndexRoute '/admin': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -227,11 +273,24 @@ export interface FileRoutesById {
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute '/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute '/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
'/api/spotify/resume': typeof ApiSpotifyResumeRoute
'/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/_authed/admin/': typeof AuthedAdminIndexRoute '/_authed/admin/': typeof AuthedAdminIndexRoute
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
'/_authed/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute '/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -247,11 +306,24 @@ export interface FileRouteTypes {
| '/profile/$playerId' | '/profile/$playerId'
| '/teams/$teamId' | '/teams/$teamId'
| '/tournaments/$tournamentId' | '/tournaments/$tournamentId'
| '/api/auth/$'
| '/api/events/$'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
| '/api/spotify/resume'
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/tournaments/upload-logo'
| '/admin/' | '/admin/'
| '/tournaments' | '/tournaments'
| '/admin/tournaments/$id' | '/tournaments/$id/bracket'
| '/admin/tournaments' | '/admin/tournaments'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/login' | '/login'
@@ -264,11 +336,24 @@ export interface FileRouteTypes {
| '/profile/$playerId' | '/profile/$playerId'
| '/teams/$teamId' | '/teams/$teamId'
| '/tournaments/$tournamentId' | '/tournaments/$tournamentId'
| '/api/auth/$'
| '/api/events/$'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
| '/api/spotify/resume'
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/tournaments/upload-logo'
| '/admin' | '/admin'
| '/tournaments' | '/tournaments'
| '/admin/tournaments/$id' | '/tournaments/$id/bracket'
| '/admin/tournaments' | '/admin/tournaments'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id'
id: id:
| '__root__' | '__root__'
| '/_authed' | '/_authed'
@@ -283,11 +368,24 @@ export interface FileRouteTypes {
| '/_authed/profile/$playerId' | '/_authed/profile/$playerId'
| '/_authed/teams/$teamId' | '/_authed/teams/$teamId'
| '/_authed/tournaments/$tournamentId' | '/_authed/tournaments/$tournamentId'
| '/api/auth/$'
| '/api/events/$'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
| '/api/spotify/resume'
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/tournaments/upload-logo'
| '/_authed/admin/' | '/_authed/admin/'
| '/_authed/tournaments/' | '/_authed/tournaments/'
| '/_authed/admin/tournaments/$id' | '/_authed/tournaments/$id/bracket'
| '/_authed/admin/tournaments/' | '/_authed/admin/tournaments/'
| '/_authed/admin/tournaments/$id/teams'
| '/_authed/admin/tournaments/run/$id' | '/_authed/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
| '/_authed/admin/tournaments/$id/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -295,87 +393,17 @@ export interface RootRouteChildren {
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
LogoutRoute: typeof LogoutRoute LogoutRoute: typeof LogoutRoute
RefreshSessionRoute: typeof RefreshSessionRoute RefreshSessionRoute: typeof RefreshSessionRoute
} ApiAuthSplatRoute: typeof ApiAuthSplatRoute
export interface FileServerRoutesByFullPath { ApiEventsSplatRoute: typeof ApiEventsSplatRoute
'/api/auth/$': typeof ApiAuthSplatServerRoute ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
'/api/events/$': typeof ApiEventsSplatServerRoute ApiSpotifyCaptureRoute: typeof ApiSpotifyCaptureRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute ApiSpotifyPlaybackRoute: typeof ApiSpotifyPlaybackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute ApiSpotifyResumeRoute: typeof ApiSpotifyResumeRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute
}
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/token': typeof ApiSpotifyTokenServerRoute
'/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/token': typeof ApiSpotifyTokenServerRoute
'/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/token'
| '/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/token'
| '/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/token'
| '/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
ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -450,6 +478,76 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminIndexRouteImport preLoaderRoute: typeof AuthedAdminIndexRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
'/api/tournaments/upload-logo': {
id: '/api/tournaments/upload-logo'
path: '/api/tournaments/upload-logo'
fullPath: '/api/tournaments/upload-logo'
preLoaderRoute: typeof ApiTournamentsUploadLogoRouteImport
parentRoute: typeof rootRouteImport
}
'/api/teams/upload-logo': {
id: '/api/teams/upload-logo'
path: '/api/teams/upload-logo'
fullPath: '/api/teams/upload-logo'
preLoaderRoute: typeof ApiTeamsUploadLogoRouteImport
parentRoute: typeof rootRouteImport
}
'/api/spotify/token': {
id: '/api/spotify/token'
path: '/api/spotify/token'
fullPath: '/api/spotify/token'
preLoaderRoute: typeof ApiSpotifyTokenRouteImport
parentRoute: typeof rootRouteImport
}
'/api/spotify/search': {
id: '/api/spotify/search'
path: '/api/spotify/search'
fullPath: '/api/spotify/search'
preLoaderRoute: typeof ApiSpotifySearchRouteImport
parentRoute: typeof rootRouteImport
}
'/api/spotify/resume': {
id: '/api/spotify/resume'
path: '/api/spotify/resume'
fullPath: '/api/spotify/resume'
preLoaderRoute: typeof ApiSpotifyResumeRouteImport
parentRoute: typeof rootRouteImport
}
'/api/spotify/playback': {
id: '/api/spotify/playback'
path: '/api/spotify/playback'
fullPath: '/api/spotify/playback'
preLoaderRoute: typeof ApiSpotifyPlaybackRouteImport
parentRoute: typeof rootRouteImport
}
'/api/spotify/capture': {
id: '/api/spotify/capture'
path: '/api/spotify/capture'
fullPath: '/api/spotify/capture'
preLoaderRoute: typeof ApiSpotifyCaptureRouteImport
parentRoute: typeof rootRouteImport
}
'/api/spotify/callback': {
id: '/api/spotify/callback'
path: '/api/spotify/callback'
fullPath: '/api/spotify/callback'
preLoaderRoute: typeof ApiSpotifyCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/api/events/$': {
id: '/api/events/$'
path: '/api/events/$'
fullPath: '/api/events/$'
preLoaderRoute: typeof ApiEventsSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/api/auth/$': {
id: '/api/auth/$'
path: '/api/auth/$'
fullPath: '/api/auth/$'
preLoaderRoute: typeof ApiAuthSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/_authed/tournaments/$tournamentId': { '/_authed/tournaments/$tournamentId': {
id: '/_authed/tournaments/$tournamentId' id: '/_authed/tournaments/$tournamentId'
path: '/tournaments/$tournamentId' path: '/tournaments/$tournamentId'
@@ -485,13 +583,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
'/_authed/admin/tournaments/$id': { '/_authed/tournaments/$id/bracket': {
id: '/_authed/admin/tournaments/$id' id: '/_authed/tournaments/$id/bracket'
path: '/tournaments/$id/bracket'
fullPath: '/tournaments/$id/bracket'
preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/admin/tournaments/$id/': {
id: '/_authed/admin/tournaments/$id/'
path: '/tournaments/$id' path: '/tournaments/$id'
fullPath: '/admin/tournaments/$id' fullPath: '/admin/tournaments/$id'
preLoaderRoute: typeof AuthedAdminTournamentsIdRouteImport preLoaderRoute: typeof AuthedAdminTournamentsIdIndexRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
'/api/files/$collection/$recordId/$file': {
id: '/api/files/$collection/$recordId/$file'
path: '/api/files/$collection/$recordId/$file'
fullPath: '/api/files/$collection/$recordId/$file'
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport
parentRoute: typeof rootRouteImport
}
'/_authed/admin/tournaments/run/$id': { '/_authed/admin/tournaments/run/$id': {
id: '/_authed/admin/tournaments/run/$id' id: '/_authed/admin/tournaments/run/$id'
path: '/tournaments/run/$id' path: '/tournaments/run/$id'
@@ -499,72 +611,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsRunIdRouteImport preLoaderRoute: typeof AuthedAdminTournamentsRunIdRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
} '/_authed/admin/tournaments/$id/teams': {
} id: '/_authed/admin/tournaments/$id/teams'
declare module '@tanstack/react-start/server' { path: '/tournaments/$id/teams'
interface ServerFileRoutesByPath { fullPath: '/admin/tournaments/$id/teams'
'/api/tournaments/upload-logo': { preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
id: '/api/tournaments/upload-logo' parentRoute: typeof AuthedAdminRoute
path: '/api/tournaments/upload-logo'
fullPath: '/api/tournaments/upload-logo'
preLoaderRoute: typeof ApiTournamentsUploadLogoServerRouteImport
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/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
} }
} }
} }
@@ -572,17 +624,19 @@ declare module '@tanstack/react-start/server' {
interface AuthedAdminRouteChildren { interface AuthedAdminRouteChildren {
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
AuthedAdminTournamentsIdRoute: typeof AuthedAdminTournamentsIdRoute
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute
} }
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = { const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute, AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
AuthedAdminIndexRoute: AuthedAdminIndexRoute, AuthedAdminIndexRoute: AuthedAdminIndexRoute,
AuthedAdminTournamentsIdRoute: AuthedAdminTournamentsIdRoute,
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute, AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute, AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
} }
const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren( const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren(
@@ -598,6 +652,7 @@ interface AuthedRouteChildren {
AuthedTeamsTeamIdRoute: typeof AuthedTeamsTeamIdRoute AuthedTeamsTeamIdRoute: typeof AuthedTeamsTeamIdRoute
AuthedTournamentsTournamentIdRoute: typeof AuthedTournamentsTournamentIdRoute AuthedTournamentsTournamentIdRoute: typeof AuthedTournamentsTournamentIdRoute
AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute
AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute
} }
const AuthedRouteChildren: AuthedRouteChildren = { const AuthedRouteChildren: AuthedRouteChildren = {
@@ -609,6 +664,7 @@ const AuthedRouteChildren: AuthedRouteChildren = {
AuthedTeamsTeamIdRoute: AuthedTeamsTeamIdRoute, AuthedTeamsTeamIdRoute: AuthedTeamsTeamIdRoute,
AuthedTournamentsTournamentIdRoute: AuthedTournamentsTournamentIdRoute, AuthedTournamentsTournamentIdRoute: AuthedTournamentsTournamentIdRoute,
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute, AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute,
} }
const AuthedRouteWithChildren = const AuthedRouteWithChildren =
@@ -619,22 +675,27 @@ const rootRouteChildren: RootRouteChildren = {
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
LogoutRoute: LogoutRoute, LogoutRoute: LogoutRoute,
RefreshSessionRoute: RefreshSessionRoute, RefreshSessionRoute: RefreshSessionRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiEventsSplatRoute: ApiEventsSplatRoute,
ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute,
ApiSpotifyCaptureRoute: ApiSpotifyCaptureRoute,
ApiSpotifyPlaybackRoute: ApiSpotifyPlaybackRoute,
ApiSpotifyResumeRoute: ApiSpotifyResumeRoute,
ApiSpotifySearchRoute: ApiSpotifySearchRoute,
ApiSpotifyTokenRoute: ApiSpotifyTokenRoute,
ApiTeamsUploadLogoRoute: ApiTeamsUploadLogoRoute,
ApiTournamentsUploadLogoRoute: ApiTournamentsUploadLogoRoute,
ApiFilesCollectionRecordIdFileRoute: ApiFilesCollectionRecordIdFileRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>() ._addFileTypes<FileRouteTypes>()
const rootServerRouteChildren: RootServerRouteChildren = {
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute, import type { getRouter } from './router.tsx'
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute, import type { createStart } from '@tanstack/react-start'
ApiSpotifyCallbackServerRoute: ApiSpotifyCallbackServerRoute, declare module '@tanstack/react-start' {
ApiSpotifyCaptureServerRoute: ApiSpotifyCaptureServerRoute, interface Register {
ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute, ssr: true
ApiSpotifyResumeServerRoute: ApiSpotifyResumeServerRoute, router: Awaited<ReturnType<typeof getRouter>>
ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute, }
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
ApiFilesCollectionRecordIdFileServerRoute:
ApiFilesCollectionRecordIdFileServerRoute,
} }
export const serverRouteTree = rootServerRouteImport
._addFileChildren(rootServerRouteChildren)
._addFileTypes<FileServerRouteTypes>()

View File

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

View File

@@ -1,6 +1,3 @@
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@mantine/carousel/styles.css";
import { import {
HeadContent, HeadContent,
Navigate, Navigate,
@@ -17,9 +14,12 @@ import Providers from "@/features/core/components/providers";
import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core"; import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
import { HeaderConfig } from "@/features/core/types/header-config"; import { HeaderConfig } from "@/features/core/types/header-config";
import { playerQueries } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import FullScreenLoader from "@/components/full-screen-loader"; import FullScreenLoader from "@/components/full-screen-loader";
import mantineCssUrl from '@mantine/core/styles.css?url'
import mantineDatesCssUrl from '@mantine/dates/styles.css?url'
import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url'
import mantineTiptapCssUrl from '@mantine/tiptap/styles.css?url'
export const Route = createRootRouteWithContext<{ export const Route = createRootRouteWithContext<{
queryClient: QueryClient; queryClient: QueryClient;
@@ -60,6 +60,10 @@ export const Route = createRootRouteWithContext<{
}, },
{ rel: "manifest", href: "/site.webmanifest" }, { rel: "manifest", href: "/site.webmanifest" },
{ rel: "icon", href: "/favicon.ico" }, { rel: "icon", href: "/favicon.ico" },
{ rel: 'stylesheet', href: mantineCssUrl },
{ rel: 'stylesheet', href: mantineCarouselCssUrl },
{ rel: 'stylesheet', href: mantineDatesCssUrl },
{ rel: 'stylesheet', href: mantineTiptapCssUrl }
], ],
}), }),
errorComponent: (props) => { errorComponent: (props) => {
@@ -79,12 +83,20 @@ export const Route = createRootRouteWithContext<{
return {}; return {};
} }
// https://github.com/TanStack/router/discussions/3531 if (location.pathname === '/login' || location.pathname === '/logout') {
const auth = await ensureServerQueryData( return {};
context.queryClient, }
playerQueries.auth()
); try {
return { auth }; // https://github.com/TanStack/router/discussions/3531
const auth = await ensureServerQueryData(
context.queryClient,
playerQueries.auth()
);
return { auth };
} catch (error) {
return {};
}
}, },
pendingComponent: () => <Providers><FullScreenLoader /></Providers>, pendingComponent: () => <Providers><FullScreenLoader /></Providers>,
}); });
@@ -130,7 +142,6 @@ function RootDocument({ children }: { children: React.ReactNode }) {
> >
<div className="app">{children}</div> <div className="app">{children}</div>
<Scripts /> <Scripts />
<ReactQueryDevtools />
</body> </body>
</html> </html>
); );

View File

@@ -1,8 +1,7 @@
import { redirect, createFileRoute, Outlet } from "@tanstack/react-router"; import { redirect, createFileRoute, Outlet } from "@tanstack/react-router";
import Layout from "@/features/core/components/layout"; import Layout from "@/features/core/components/layout";
import { useServerEvents } from "@/hooks/use-server-events"; import { useServerEvents } from "@/hooks/use-server-events";
import { Loader } from "@mantine/core"; import { Flex, Loader } from "@mantine/core";
import FullScreenLoader from "@/components/full-screen-loader";
export const Route = createFileRoute("/_authed")({ export const Route = createFileRoute("/_authed")({
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
@@ -27,7 +26,9 @@ export const Route = createFileRoute("/_authed")({
}, },
pendingComponent: () => ( pendingComponent: () => (
<Layout> <Layout>
<FullScreenLoader /> <Flex w='100%' h="40dvh" justify="center" align="flex-end">
<Loader size='xl' />
</Flex>
</Layout> </Layout>
), ),
}); });

View File

@@ -3,7 +3,7 @@ import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTournament from "@/features/tournaments/components/manage-tournament"; import ManageTournament from "@/features/tournaments/components/manage-tournament";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
export const Route = createFileRoute("/_authed/admin/tournaments/$id")({ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
const { queryClient } = context; const { queryClient } = context;
const tournament = await ensureServerQueryData( const tournament = await ensureServerQueryData(

View File

@@ -0,0 +1,32 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTeams from "@/features/teams/components/manage-teams";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
const tournament = await ensureServerQueryData(
queryClient,
tournamentQueries.details(params.id)
);
if (!tournament) throw redirect({ to: "/admin/tournaments" });
return {
tournament,
};
},
loader: ({ context }) => ({
header: {
withBackButton: true,
title: `${context.tournament.name} Teams`,
},
withPadding: false,
}),
component: RouteComponent,
});
function RouteComponent() {
const { id } = Route.useParams();
const { tournament } = Route.useRouteContext();
return <ManageTeams tournament={tournament} />;
}

View File

@@ -2,9 +2,14 @@ import { createFileRoute } from "@tanstack/react-router";
import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/queries"; import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/queries";
import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament"; import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import StartedTournament from "@/features/tournaments/components/started-tournament";
import { Suspense } from "react";
import UpcomingTournamentSkeleton from "@/features/tournaments/components/upcoming-tournament/skeleton";
export const Route = createFileRoute("/_authed/")({ export const Route = createFileRoute("/_authed/")({
component: Home, component: () => <Suspense fallback={<UpcomingTournamentSkeleton />}>
<Home />
</Suspense>,
beforeLoad: async ({ context }) => { beforeLoad: async ({ context }) => {
const queryClient = context.queryClient; const queryClient = context.queryClient;
const tournament = await ensureServerQueryData(queryClient, tournamentQueries.current()) const tournament = await ensureServerQueryData(queryClient, tournamentQueries.current())
@@ -12,20 +17,19 @@ export const Route = createFileRoute("/_authed/")({
return { tournament } return { tournament }
}, },
loader: ({ context }) => ({ loader: ({ context }) => ({
withPadding: true, withPadding: false,
header: { header: {
title: context.tournament.name || "FLXN" title: context.tournament.name || "FLXN"
} }
}), }),
pendingComponent: () => <UpcomingTournamentSkeleton />
}); });
function Home() { function Home() {
const { data: tournament } = useCurrentTournament(); const { data: tournament } = useCurrentTournament();
const now = new Date(); if (!tournament.matches || tournament.matches.length === 0) {
if (new Date(tournament.start_time) > now) {
return <UpcomingTournament tournament={tournament} />; return <UpcomingTournament tournament={tournament} />;
} }
return <p>Started Tournament</p> return <StartedTournament tournament={tournament} />
} }

View File

@@ -1,7 +1,10 @@
import Profile from "@/features/players/components/profile"; import Profile from "@/features/players/components/profile";
import { playerQueries } from "@/features/players/queries"; import HeaderSkeleton from "@/features/players/components/profile/header-skeleton";
import ProfileSkeleton from "@/features/players/components/profile/skeleton";
import { playerKeys, playerQueries } from "@/features/players/queries";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
import { z } from "zod"; import { z } from "zod";
const searchSchema = z.object({ const searchSchema = z.object({
@@ -31,10 +34,12 @@ export const Route = createFileRoute("/_authed/profile/$playerId")({
context?.auth.user.id === params.playerId ? "/settings" : undefined, context?.auth.user.id === params.playerId ? "/settings" : undefined,
}, },
withPadding: false, withPadding: false,
refresh: [playerQueries.details(params.playerId).queryKey], refresh: [playerKeys.details(params.playerId), playerKeys.matches(params.playerId), playerKeys.stats(params.playerId)],
}), }),
component: () => { component: () => {
const { playerId } = Route.useParams(); const { playerId } = Route.useParams();
return <Profile id={playerId} />; return <Suspense fallback={<ProfileSkeleton />}>
<Profile id={playerId} />
</Suspense>;
}, },
}); });

View File

@@ -1,16 +1,18 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { playerQueries, useAllPlayerStats } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import PlayerStatsTable from "@/features/players/components/player-stats-table"; import PlayerStatsTable from "@/features/players/components/player-stats-table";
import { Suspense } from "react";
import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
export const Route = createFileRoute("/_authed/stats")({ export const Route = createFileRoute("/_authed/stats")({
component: Stats, component: Stats,
beforeLoad: async ({ context }) => { beforeLoad: ({ context }) => {
const queryClient = context.queryClient; const queryClient = context.queryClient;
await ensureServerQueryData(queryClient, playerQueries.allStats()); prefetchServerQuery(queryClient, playerQueries.allStats());
}, },
loader: () => ({ loader: () => ({
withPadding: true, withPadding: false,
fullWidth: true, fullWidth: true,
header: { header: {
title: "Player Stats" title: "Player Stats"
@@ -20,7 +22,7 @@ export const Route = createFileRoute("/_authed/stats")({
}); });
function Stats() { function Stats() {
const { data: playerStats } = useAllPlayerStats(); return <Suspense fallback={<PlayerStatsTableSkeleton />}>
<PlayerStatsTable />
return <PlayerStatsTable playerStats={playerStats} />; </Suspense>;
} }

View File

@@ -1,8 +1,9 @@
import TeamProfile from "@/features/teams/components/team-profile"; import TeamProfile from "@/features/teams/components/team-profile";
import { teamQueries } from "@/features/teams/queries"; import ProfileSkeleton from "@/features/teams/components/team-profile/skeleton";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { teamKeys, teamQueries } from "@/features/teams/queries";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { redirect, createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
import { z } from "zod"; import { z } from "zod";
const searchSchema = z.object({ const searchSchema = z.object({
@@ -20,10 +21,13 @@ export const Route = createFileRoute("/_authed/teams/$teamId")({
collapsed: true, collapsed: true,
withBackButton: true, withBackButton: true,
}, },
refresh: [teamQueries.details(params.teamId).queryKey], refresh: [teamKeys.details(params.teamId), teamKeys.matches(params.teamId), teamKeys.stats(params.teamId)],
withPadding: false
}), }),
component: () => { component: () => {
const { teamId } = Route.useParams(); const { teamId } = Route.useParams();
return <TeamProfile id={teamId} />; return <Suspense fallback={<ProfileSkeleton />}>
<TeamProfile id={teamId} />
</Suspense>;
}, },
}); });

View File

@@ -0,0 +1,82 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import {
tournamentQueries,
useTournament,
} from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import SeedTournament from "@/features/tournaments/components/seed-tournament";
import { Container } from "@mantine/core";
import { useMemo } from "react";
import { BracketData } from "@/features/bracket/types";
import { Match } from "@/features/matches/types";
import BracketView from "@/features/bracket/components/bracket-view";
import { SpotifyControlsBar } from "@/features/spotify/components";
export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
const tournament = await ensureServerQueryData(
queryClient,
tournamentQueries.details(params.id)
);
if (!tournament) throw redirect({ to: "/admin/tournaments" });
return {
tournament,
};
},
loader: ({ context }) => ({
fullWidth: true,
withPadding: false,
showSpotifyPanel: true,
header: {
withBackButton: true,
title: `${context.tournament.name}`,
},
}),
component: RouteComponent,
});
function RouteComponent() {
const { id } = Route.useParams();
const { data: tournament } = useTournament(id);
const bracket: BracketData = useMemo(() => {
if (!tournament.matches || tournament.matches.length === 0) {
return { winners: [], losers: [] };
}
const winnersMap = new Map<number, Match[]>();
const losersMap = new Map<number, Match[]>();
tournament.matches
.sort((a, b) => a.lid - b.lid)
.forEach((match) => {
if (!match.is_losers_bracket) {
if (!winnersMap.has(match.round)) {
winnersMap.set(match.round, []);
}
winnersMap.get(match.round)!.push(match);
} else {
if (!losersMap.has(match.round)) {
losersMap.set(match.round, []);
}
losersMap.get(match.round)!.push(match);
}
});
const winners = Array.from(winnersMap.entries())
.sort(([a], [b]) => a - b)
.map(([, matches]) => matches);
const losers = Array.from(losersMap.entries())
.sort(([a], [b]) => a - b)
.map(([, matches]) => matches);
return { winners, losers };
}, [tournament.matches]);
return (
<Container size="md" px={0}>
<BracketView bracket={bracket} />
</Container>
);
}

View File

@@ -3,6 +3,8 @@ import { tournamentQueries } from '@/features/tournaments/queries';
import Profile from '@/features/tournaments/components/profile'; import Profile from '@/features/tournaments/components/profile';
import { z } from "zod"; import { z } from "zod";
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch'; import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
import { Suspense } from 'react';
import ProfileSkeleton from '@/features/tournaments/components/profile/skeleton';
const searchSchema = z.object({ const searchSchema = z.object({
tab: z.string().optional(), tab: z.string().optional(),
@@ -10,9 +12,9 @@ const searchSchema = z.object({
export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
validateSearch: searchSchema, validateSearch: searchSchema,
beforeLoad: async ({ context, params }) => { beforeLoad: ({ context, params }) => {
const { queryClient } = context; const { queryClient } = context;
await prefetchServerQuery(queryClient, tournamentQueries.details(params.tournamentId)) prefetchServerQuery(queryClient, tournamentQueries.details(params.tournamentId))
}, },
loader: ({ params, context }) => ({ loader: ({ params, context }) => ({
header: { header: {
@@ -20,7 +22,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
withBackButton: true, withBackButton: true,
settingsLink: context.auth.roles.includes("Admin") ? `/admin/tournaments/${params.tournamentId}` : undefined settingsLink: context.auth.roles.includes("Admin") ? `/admin/tournaments/${params.tournamentId}` : undefined
}, },
refresh: tournamentQueries.details(params.tournamentId).queryKey, refresh: [tournamentQueries.details(params.tournamentId).queryKey],
withPadding: false withPadding: false
}), }),
component: RouteComponent, component: RouteComponent,
@@ -28,5 +30,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
function RouteComponent() { function RouteComponent() {
const tournamentId = Route.useParams().tournamentId; const tournamentId = Route.useParams().tournamentId;
return <Profile id={tournamentId} /> return <Suspense fallback={<ProfileSkeleton />}>
<Profile id={tournamentId} />
</Suspense>
} }

View File

@@ -1,20 +1,14 @@
import Page from '@/components/page'
import { Stack } from '@mantine/core'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { TournamentCard } from '@/features/tournaments/components/tournament-card' import { tournamentQueries } from '@/features/tournaments/queries'
import { tournamentQueries, useTournaments } from '@/features/tournaments/queries'
import { useAuth } from '@/contexts/auth-context'
import { useSheet } from '@/hooks/use-sheet'
import Sheet from '@/components/sheet/sheet'
import TournamentForm from '@/features/tournaments/components/tournament-form'
import { PlusIcon } from '@phosphor-icons/react'
import Button from '@/components/button'
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch' import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch'
import { Suspense } from 'react'
import TournamentCardList from '@/features/tournaments/components/tournament-card-list'
import { Skeleton, Stack } from '@mantine/core'
export const Route = createFileRoute('/_authed/tournaments/')({ export const Route = createFileRoute('/_authed/tournaments/')({
beforeLoad: async ({ context }) => { beforeLoad: async ({ context }) => {
const { queryClient } = context; const { queryClient } = context;
await prefetchServerQuery(queryClient, tournamentQueries.list()) prefetchServerQuery(queryClient, tournamentQueries.list())
}, },
loader: () => ({ loader: () => ({
header: { header: {
@@ -27,27 +21,11 @@ export const Route = createFileRoute('/_authed/tournaments/')({
}) })
function RouteComponent() { function RouteComponent() {
const { data: tournaments } = useTournaments(); return <Suspense fallback={<Stack gap="md">
const { roles } = useAuth(); {Array(10).fill(null).map((_, index) => (
const sheet = useSheet(); <Skeleton height="120px" w="100%" />
))}
return <Page> </Stack>}>
<Stack> <TournamentCardList />
{ </Suspense>
roles?.includes("Admin") ? (
<>
<Button leftSection={<PlusIcon />} variant='subtle' onClick={sheet.open}>Create Tournament</Button>
<Sheet {...sheet.props} title='Create Tournament'>
<TournamentForm close={sheet.close} />
</Sheet>
</>
) : null
}
{
tournaments?.map((tournament: any) => (
<TournamentCard key={tournament.id} tournament={tournament} />
))
}
</Stack>
</Page>
} }

View File

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

View File

@@ -1,67 +1,61 @@
import { createServerFileRoute } from "@tanstack/react-start/server"; import { createFileRoute } from "@tanstack/react-router";
import { serverEvents, type ServerEvent } from "@/lib/events/emitter"; import { serverEvents, type ServerEvent } from "@/lib/events/emitter";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { superTokensRequestMiddleware } from "@/utils/supertokens"; import { superTokensRequestMiddleware } from "@/utils/supertokens";
export const ServerRoute = createServerFileRoute("/api/events/$").middleware([superTokensRequestMiddleware]).methods({ export const Route = createFileRoute("/api/events/$")({
GET: ({ request, context }) => { server: {
logger.info('ServerEvents | New connection', context?.userAuthId); middleware: [superTokensRequestMiddleware],
handlers: {
const stream = new ReadableStream({ GET: ({ request, context }) => {
start(controller) { const stream = new ReadableStream({
// Send initial connection messages start(controller) {
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`; const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
controller.enqueue(new TextEncoder().encode(connectMessage)); controller.enqueue(new TextEncoder().encode(connectMessage));
// Listen for events and broadcast to all connections const handleEvent = (event: ServerEvent) => {
const handleEvent = (event: ServerEvent) => { logger.info("ServerEvents | Event received", event);
logger.info('ServerEvents | Event received', event); const message = `data: ${JSON.stringify(event)}\n\n`;
const message = `data: ${JSON.stringify(event)}\n\n`; try {
try { controller.enqueue(new TextEncoder().encode(message));
controller.enqueue(new TextEncoder().encode(message)); } catch (error) {
} catch (error) { logger.error("ServerEvents | Error sending SSE message", error);
logger.error("ServerEvents | Error sending SSE message", error); }
} };
};
serverEvents.on("test", handleEvent); serverEvents.on("test", handleEvent);
serverEvents.on("match", handleEvent);
serverEvents.on("reaction", handleEvent);
// Keep alive ping every 30 seconds const pingInterval = setInterval(() => {
const pingInterval = setInterval(() => { try {
try { const pingMessage = `data: ${JSON.stringify({ type: "ping" })}\n\n`;
const pingMessage = `data: ${JSON.stringify({ type: "ping" })}\n\n`; controller.enqueue(new TextEncoder().encode(pingMessage));
controller.enqueue(new TextEncoder().encode(pingMessage)); } catch (e) {
} catch (e) { clearInterval(pingInterval);
clearInterval(pingInterval); }
controller.close(); }, 30000);
}
}, 30000);
const cleanup = () => { const cleanup = () => {
serverEvents.off("test", handleEvent); serverEvents.off("test", handleEvent);
clearInterval(pingInterval); clearInterval(pingInterval);
try { };
logger.info('ServerEvents | Closing connection', context?.userAuthId);
controller.close();
} catch (e) {
logger.error('ServerEvents | Error closing controller', e);
}
};
request.signal?.addEventListener("abort", cleanup); request.signal?.addEventListener("abort", cleanup);
return cleanup;
},
});
return cleanup; 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",
},
});
}, },
}); },
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,113 @@
import { createServerFileRoute } from "@tanstack/react-start/server"; import { createFileRoute } from "@tanstack/react-router";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
export const ServerRoute = createServerFileRoute("/api/files/$collection/$recordId/$file").methods({ export const Route = createFileRoute(
GET: async ({ params, request }) => { "/api/files/$collection/$recordId/$file"
try { )({
const { collection, recordId, file } = params; server: {
const pocketbaseUrl = process.env.POCKETBASE_URL || 'http://127.0.0.1:8090'; handlers: {
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`; GET: async ({ params, request }) => {
try {
logger.info('File proxy', { const { collection, recordId, file } = params;
collection, const pocketbaseUrl =
recordId, process.env.POCKETBASE_URL || "http://127.0.0.1:8090";
file, const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
targetUrl: fileUrl
});
const response = await fetch(fileUrl, { logger.info("File proxy", {
method: 'GET', collection,
headers: { recordId,
...(request.headers.get('range') && { 'Range': request.headers.get('range')! }), file,
...(request.headers.get('if-none-match') && { 'If-None-Match': request.headers.get('if-none-match')! }), targetUrl: fileUrl,
...(request.headers.get('if-modified-since') && { 'If-Modified-Since': request.headers.get('if-modified-since')! }), });
},
});
if (!response.ok) { const response = await fetch(fileUrl, {
logger.error('PocketBase file request failed', { method: "GET",
status: response.status, headers: {
statusText: response.statusText, ...(request.headers.get("range") && {
url: fileUrl Range: request.headers.get("range")!,
}); }),
...(request.headers.get("if-none-match") && {
"If-None-Match": request.headers.get("if-none-match")!,
}),
...(request.headers.get("if-modified-since") && {
"If-Modified-Since": request.headers.get("if-modified-since")!,
}),
},
});
if (response.status === 404) { if (!response.ok) {
return new Response('File not found', { status: 404 }); 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 });
}
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"
);
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 });
} }
},
return new Response(`PocketBase error: ${response.statusText}`, { OPTIONS: () => {
status: response.status return new Response(null, {
status: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Max-Age": "86400",
},
}); });
} },
},
const body = response.body;
const responseHeaders = new Headers();
const headers = [
'content-type',
'content-length',
'content-disposition',
'etag',
'last-modified',
'cache-control',
'accept-ranges',
'content-range'
];
headers.forEach(header => {
const value = response.headers.get(header);
if (value) {
responseHeaders.set(header, value);
}
});
responseHeaders.set('Access-Control-Allow-Origin', '*');
responseHeaders.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
responseHeaders.set('Access-Control-Allow-Headers', 'Range, If-None-Match, If-Modified-Since');
logger.info('File proxy response', {
status: response.status,
contentType: response.headers.get('content-type'),
contentLength: response.headers.get('content-length')
});
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders
});
} catch (error) {
logger.error('File proxy error', error);
return new Response('Internal server error', { status: 500 });
}
}, },
});
OPTIONS: () => {
return new Response(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Max-Age': '86400',
}
});
}
});

View File

@@ -1,135 +1,145 @@
import { createServerFileRoute } from '@tanstack/react-start/server' import { createFileRoute } from "@tanstack/react-router";
import { SpotifyAuth } from '@/lib/spotify/auth'
const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID! const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!;
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET! const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
const SPOTIFY_REDIRECT_URI = import.meta.env.VITE_SPOTIFY_REDIRECT_URI! const SPOTIFY_REDIRECT_URI = process.env.VITE_SPOTIFY_REDIRECT_URI!;
export const ServerRoute = createServerFileRoute('/api/spotify/callback').methods({ export const Route = createFileRoute("/api/spotify/callback")({
GET: async ({ request }: { request: Request }) => { server: {
// Helper function to get return path from state parameter handlers: {
const getReturnPath = (state: string | null): string => { GET: async ({ request }: { request: Request }) => {
if (!state) return '/'; const getReturnPath = (state: string | null): string => {
try { if (!state) return "/";
const decodedState = JSON.parse(atob(state)); try {
return decodedState.returnPath || '/'; const decodedState = JSON.parse(atob(state));
} catch { return decodedState.returnPath || "/";
return '/'; } catch {
} return "/";
}; }
};
try { try {
const url = new URL(request.url) const url = new URL(request.url);
const code = url.searchParams.get('code') const code = url.searchParams.get("code");
const state = url.searchParams.get('state') const state = url.searchParams.get("state");
const error = url.searchParams.get('error') const error = url.searchParams.get("error");
const returnPath = getReturnPath(state); const returnPath = getReturnPath(state);
// Check for OAuth errors if (error) {
if (error) { console.error("Spotify OAuth error:", error);
console.error('Spotify OAuth error:', error) return new Response(null, {
return new Response(null, { status: 302,
status: 302, headers: {
headers: { Location:
'Location': returnPath + '?spotify_error=' + encodeURIComponent(error), returnPath + "?spotify_error=" + encodeURIComponent(error),
}, },
}) });
} }
if (!code || !state) { if (!code || !state) {
console.error('Missing code or state:', { code: !!code, state: !!state }) console.error("Missing code or state:", {
return new Response(null, { code: !!code,
status: 302, state: !!state,
headers: { });
'Location': returnPath + '?spotify_error=missing_code_or_state', return new Response(null, {
}, status: 302,
}) headers: {
} Location: returnPath + "?spotify_error=missing_code_or_state",
},
});
}
console.log('Token exchange attempt:', { console.log("Token exchange attempt:", {
client_id: SPOTIFY_CLIENT_ID, client_id: SPOTIFY_CLIENT_ID,
redirect_uri: SPOTIFY_REDIRECT_URI, redirect_uri: SPOTIFY_REDIRECT_URI,
has_code: !!code, has_code: !!code,
has_state: !!state, has_state: !!state,
}) });
// Exchange code for tokens const tokenResponse = await fetch(
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', { "https://accounts.spotify.com/api/token",
method: 'POST', {
headers: { method: "POST",
'Content-Type': 'application/x-www-form-urlencoded', headers: {
'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`, "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', body: new URLSearchParams({
code, grant_type: "authorization_code",
redirect_uri: SPOTIFY_REDIRECT_URI, code,
}), redirect_uri: SPOTIFY_REDIRECT_URI,
}) }),
}
);
if (!tokenResponse.ok) { if (!tokenResponse.ok) {
const errorText = await tokenResponse.text() const errorText = await tokenResponse.text();
console.error('Token exchange error:', { console.error("Token exchange error:", {
status: tokenResponse.status, status: tokenResponse.status,
statusText: tokenResponse.statusText, statusText: tokenResponse.statusText,
body: errorText, body: errorText,
redirect_uri: SPOTIFY_REDIRECT_URI, redirect_uri: SPOTIFY_REDIRECT_URI,
}) });
// Return more detailed error info
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 errorParam = encodeURIComponent(
`${tokenResponse.status}: ${errorText}`
);
return new Response(null, {
status: 302,
headers: {
Location: `${returnPath}?spotify_error=token_exchange_failed&details=${errorParam}`,
},
});
}
console.log('Token exchange successful:', { const tokens = await tokenResponse.json();
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("Token exchange successful:", {
has_access_token: !!tokens.access_token,
has_refresh_token: !!tokens.refresh_token,
expires_in: tokens.expires_in,
});
// Create response with redirect to original path console.log("Decoded return path:", returnPath);
const response = new Response(null, {
status: 302,
headers: {
'Location': returnPath + '?spotify_auth=success',
},
})
// Set secure cookies for tokens const response = new Response(null, {
const isSecure = process.env.NODE_ENV === 'production' status: 302,
const cookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${tokens.expires_in}` headers: {
Location: returnPath + "?spotify_auth=success",
},
});
response.headers.append('Set-Cookie', `spotify_access_token=${tokens.access_token}; ${cookieOptions}`) const isSecure = import.meta.env.NODE_ENV === "production";
const cookieOptions = `HttpOnly; ${isSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${tokens.expires_in}`;
if (tokens.refresh_token) {
// Refresh token doesn't expire, set longer max age
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}`)
}
return response response.headers.append(
} catch (error) { "Set-Cookie",
console.error('Spotify callback error:', error) `spotify_access_token=${tokens.access_token}; ${cookieOptions}`
// Try to get return path from query params if available, otherwise use default );
const url = new URL(request.url);
const state = url.searchParams.get('state'); if (tokens.refresh_token) {
const returnPath = getReturnPath(state); const refreshCookieOptions = `HttpOnly; ${isSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${60 * 60 * 24 * 30}`; // 30 days
return new Response(null, { response.headers.append(
status: 302, "Set-Cookie",
headers: { `spotify_refresh_token=${tokens.refresh_token}; ${refreshCookieOptions}`
'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 { createFileRoute } from "@tanstack/react-router";
import { SpotifyWebApiClient } from '@/lib/spotify/client' import { SpotifyWebApiClient } from "@/lib/spotify/client";
import type { SpotifyPlaybackSnapshot } from '@/lib/spotify/types' import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
export const ServerRoute = createServerFileRoute('/api/spotify/capture').methods({ export const Route = createFileRoute("/api/spotify/capture")({
POST: async ({ request }: { request: Request }) => { server: {
try { handlers: {
// Get access token from cookies POST: async ({ request }: { request: Request }) => {
const cookies = request.headers.get('Cookie') || '' try {
const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/) const cookies = request.headers.get("Cookie") || "";
const accessTokenMatch = cookies.match(
if (!accessTokenMatch) { /spotify_access_token=([^;]+)/
return new Response( );
JSON.stringify({ error: 'No access token found' }),
{ if (!accessTokenMatch) {
status: 401, return new Response(
headers: { 'Content-Type': 'application/json' } JSON.stringify({ error: "No access token found" }),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
);
} }
)
}
const accessToken = decodeURIComponent(accessTokenMatch[1]) const accessToken = decodeURIComponent(accessTokenMatch[1]);
const spotifyClient = new SpotifyWebApiClient(accessToken) const spotifyClient = new SpotifyWebApiClient(accessToken);
// Create a snapshot of the current playback state const snapshot = await spotifyClient.createPlaybackSnapshot();
const snapshot = await spotifyClient.createPlaybackSnapshot()
if (!snapshot) {
if (!snapshot) { return new Response(
return new Response( JSON.stringify({ error: "No active playback to capture" }),
JSON.stringify({ error: 'No active playback to capture' }), {
{ status: 400,
status: 400, headers: { "Content-Type": "application/json" },
headers: { 'Content-Type': 'application/json' } }
);
} }
)
}
return new Response( return new Response(JSON.stringify({ snapshot }), {
JSON.stringify({ snapshot }), status: 200,
{ headers: { "Content-Type": "application/json" },
status: 200, });
headers: { 'Content-Type': 'application/json' } } catch (error) {
console.error("Spotify capture error:", error);
const errorMessage =
error instanceof Error
? error.message
: "Failed to capture playback state";
return new Response(JSON.stringify({ error: errorMessage }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
} }
) },
} catch (error) { },
console.error('Spotify capture error:', error)
const errorMessage = error instanceof Error ? error.message : 'Failed to capture playback state'
return new Response(
JSON.stringify({ error: errorMessage }),
{
status: 500,
headers: { 'Content-Type': 'application/json' }
}
)
}
}, },
}) });

View File

@@ -1,195 +1,203 @@
import { createServerFileRoute } from '@tanstack/react-start/server' import { createFileRoute } from "@tanstack/react-router";
import { SpotifyWebApiClient } from '@/lib/spotify/client' import { SpotifyWebApiClient } from "@/lib/spotify/client";
// Helper function to get access token from cookies
function getAccessTokenFromCookies(request: Request): string | null { function getAccessTokenFromCookies(request: Request): string | null {
const cookieHeader = request.headers.get('cookie') const cookieHeader = request.headers.get("cookie");
if (!cookieHeader) return null if (!cookieHeader) return null;
const cookies = Object.fromEntries( const cookies = Object.fromEntries(
cookieHeader.split('; ').map(c => c.split('=')) cookieHeader.split("; ").map((c) => c.split("="))
) );
return cookies.spotify_access_token || null return cookies.spotify_access_token || null;
} }
export const ServerRoute = createServerFileRoute('/api/spotify/playback').methods({ export const Route = createFileRoute("/api/spotify/playback")({
POST: async ({ request }: { request: Request }) => { server: {
try { handlers: {
const accessToken = getAccessTokenFromCookies(request) POST: async ({ request }: { request: Request }) => {
if (!accessToken) { try {
return new Response( const accessToken = getAccessTokenFromCookies(request);
JSON.stringify({ error: 'No access token found' }), if (!accessToken) {
{
status: 401,
headers: { 'Content-Type': 'application/json' },
}
)
}
const body = await request.json()
const { action, deviceId, volumePercent } = body
const spotifyClient = new SpotifyWebApiClient(accessToken)
switch (action) {
case 'play':
await spotifyClient.play(deviceId)
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( return new Response(
JSON.stringify({ error: 'volumePercent must be a number' }), JSON.stringify({ error: "No access token found" }),
{ {
status: 400, status: 401,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} }
await spotifyClient.setVolume(volumePercent)
break const body = await request.json();
case 'transfer': const { action, deviceId, volumePercent, trackId, positionMs } = body;
if (!deviceId) {
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( return new Response(
JSON.stringify({ error: 'deviceId is required for transfer action' }), JSON.stringify({ error: "No access token found" }),
{ {
status: 400, status: 401,
headers: { 'Content-Type': 'application/json' }, 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( const url = new URL(request.url);
JSON.stringify({ success: true }), const type = url.searchParams.get("type");
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
} catch (error) {
console.error('Playback control error:', error)
// Handle specific Spotify API errors
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' },
}
)
}
// Log the full error details for debugging const spotifyClient = new SpotifyWebApiClient(accessToken);
console.error('Full error details:', {
message: error.message,
stack: error.stack,
name: error.name,
})
}
return new Response( if (type === "devices") {
JSON.stringify({ error: 'Playback control failed', details: error instanceof Error ? error.message : 'Unknown error' }), const devices = await spotifyClient.getDevices();
{ return new Response(JSON.stringify({ devices }), {
status: 500, status: 200,
headers: { 'Content-Type': 'application/json' }, 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" },
}
);
} }
) },
} },
}, },
});
// GET endpoint for retrieving current playback state and devices
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') // 'state' or 'devices'
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 {
// Return both by default
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 { createFileRoute } from "@tanstack/react-router";
import { SpotifyWebApiClient } from '@/lib/spotify/client' import { SpotifyWebApiClient } from "@/lib/spotify/client";
import type { SpotifyPlaybackSnapshot } from '@/lib/spotify/types' import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
export const ServerRoute = createServerFileRoute('/api/spotify/resume').methods({ export const Route = createFileRoute("/api/spotify/resume")({
POST: async ({ request }: { request: Request }) => { server: {
try { handlers: {
// Get access token from cookies POST: async ({ request }: { request: Request }) => {
const cookies = request.headers.get('Cookie') || '' try {
const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/) const cookies = request.headers.get("Cookie") || "";
const accessTokenMatch = cookies.match(
if (!accessTokenMatch) { /spotify_access_token=([^;]+)/
return new Response( );
JSON.stringify({ error: 'No access token found' }),
{ if (!accessTokenMatch) {
status: 401, return new Response(
headers: { 'Content-Type': 'application/json' } JSON.stringify({ error: "No access token found" }),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
);
} }
)
}
const accessToken = decodeURIComponent(accessTokenMatch[1]) const accessToken = decodeURIComponent(accessTokenMatch[1]);
const spotifyClient = new SpotifyWebApiClient(accessToken) const spotifyClient = new SpotifyWebApiClient(accessToken);
// Parse the request body to get the snapshot const body = await request.json();
const body = await request.json() const { snapshot } = body as { snapshot: SpotifyPlaybackSnapshot };
const { snapshot } = body as { snapshot: SpotifyPlaybackSnapshot }
if (!snapshot) {
if (!snapshot) { return new Response(
return new Response( JSON.stringify({ error: "No snapshot provided" }),
JSON.stringify({ error: 'No snapshot provided' }), {
{ status: 400,
status: 400, headers: { "Content-Type": "application/json" },
headers: { 'Content-Type': 'application/json' } }
);
} }
)
}
// Restore the playback state from the snapshot await spotifyClient.restorePlaybackSnapshot(snapshot);
await spotifyClient.restorePlaybackSnapshot(snapshot)
return new Response( return new Response(JSON.stringify({ success: true }), {
JSON.stringify({ success: true }), status: 200,
{ headers: { "Content-Type": "application/json" },
status: 200, });
headers: { 'Content-Type': 'application/json' } } catch (error) {
console.error("Spotify resume error:", error);
let errorMessage = "Failed to resume playback state";
if (error instanceof Error) {
if (
error.message.includes("Premium") ||
error.message.includes("403")
) {
errorMessage = "Spotify premium required";
} else {
errorMessage = error.message;
}
}
return new Response(JSON.stringify({ error: errorMessage }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
} }
) },
} catch (error) { },
console.error('Spotify resume error:', error)
let errorMessage = 'Failed to resume playback state'
// Handle common Spotify Premium requirement error
if (error instanceof Error) {
if (error.message.includes('Premium') || error.message.includes('403')) {
errorMessage = 'Spotify Premium required for playback control'
} else {
errorMessage = error.message
}
}
return new Response(
JSON.stringify({ error: errorMessage }),
{
status: 500,
headers: { 'Content-Type': 'application/json' }
}
)
}
}, },
}) });

View File

@@ -0,0 +1,87 @@
import { createFileRoute } from "@tanstack/react-router";
async function getClientCredentialsToken(): Promise<string> {
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");
}
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")}`,
},
body: "grant_type=client_credentials",
});
if (!response.ok) {
throw new Error("Failed to get Spotify access token");
}
const data = await response.json();
return data.access_token;
}
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" },
}
);
}
// 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`;
const searchResponse = await fetch(searchUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!searchResponse.ok) {
throw new Error("Spotify search request failed");
}
const searchResult = await searchResponse.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" },
}
);
}
},
},
},
});

View File

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

View File

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

View File

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

View File

@@ -7,23 +7,22 @@ import {
redirect, redirect,
} from '@tanstack/react-router' } from '@tanstack/react-router'
import type { ErrorComponentProps } from '@tanstack/react-router' import type { ErrorComponentProps } from '@tanstack/react-router'
import { import {
Box, Box,
Button as MantineButton, Button as MantineButton,
Text, Text,
Title, Stack,
Stack, Group,
Group,
Alert,
Collapse, Collapse,
Code, Code,
ThemeIcon Container,
Center
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks' import { useDisclosure } from '@mantine/hooks'
import { useEffect } from 'react' import { useEffect } from 'react'
import toast from '@/lib/sonner' import toast from '@/lib/sonner'
import { logger } from '@/lib/logger' import { logger } from '@/lib/logger'
import { ExclamationMarkIcon, XCircleIcon } from '@phosphor-icons/react' import { XCircleIcon, WarningIcon } from '@phosphor-icons/react'
import Button from './button' import Button from './button'
export function DefaultCatchBoundary({ error }: ErrorComponentProps) { export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
@@ -50,112 +49,90 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
if (errorMessage.toLowerCase().includes('unauthorized')) { if (errorMessage.toLowerCase().includes('unauthorized')) {
return ( return (
<Box <Container size="sm" py="xl">
style={{ <Center>
display: 'flex', <Stack align="center" gap="md">
flexDirection: 'column', <XCircleIcon size={64} color="var(--mantine-color-red-6)" />
alignItems: 'center', <Text size="xl" fw={600}>Access Denied</Text>
justifyContent: 'center', <Text c="dimmed" ta="center">
minHeight: '50vh', You don't have permission to access this page.
padding: 'var(--mantine-spacing-xl)', </Text>
}} <Group gap="sm" mt="md">
> <Button
<Stack align="center" gap="lg"> variant="light"
<ThemeIcon color="red" size={80} radius="xl"> onClick={() => window.history.back()}
<XCircleIcon size={48} /> >
</ThemeIcon> Go Back
<Title order={2} ta="center">Access Denied</Title> </Button>
<Text size="lg" c="dimmed" ta="center"> <MantineButton
You don't have permission to access this. component={Link}
</Text> to="/"
<Group> variant="filled"
<Button >
variant="light" Home
onClick={() => window.history.back()} </MantineButton>
> </Group>
Go Back </Stack>
</Button> </Center>
<MantineButton </Container>
component={Link}
to="/"
variant="filled"
>
Home
</MantineButton>
</Group>
</Stack>
</Box>
) )
} }
return ( return (
<Box <Container size="sm" py="xl">
style={{ <Center>
display: 'flex', <Stack align="center" gap="md" w="100%">
flexDirection: 'column', <WarningIcon size={64} color="var(--mantine-color-red-6)" />
alignItems: 'center',
justifyContent: 'center',
minHeight: '50vh',
padding: 'var(--mantine-spacing-xl)',
}}
>
<Stack align="center" gap="lg" maw={600}>
<ThemeIcon color="red" size={80} radius="xl">
<ExclamationMarkIcon size={48} />
</ThemeIcon>
<Title order={2} ta="center">Something went wrong</Title>
<Text size="lg" c="dimmed" ta="center">
There was an unexpected error. Please try again later.
</Text>
<Alert <Text size="xl" fw={600}>Something went wrong</Text>
variant="light"
color="red"
title="Error Details"
w="100%"
>
<Text mb="sm">{errorMessage}</Text>
<Button
variant="subtle"
size="compact-sm"
onClick={toggleDetails}
>
{detailsOpened ? 'Hide' : 'Show'} stack trace
</Button>
<Collapse in={detailsOpened}>
<Code block mt="md" p="md">
{errorStack}
</Code>
</Collapse>
</Alert>
<Group> <Text c="dimmed" ta="center">
<Button An error occurred while loading this page.
variant="light" </Text>
onClick={() => router.invalidate()}
> <Box w="100%" mt="md">
Try Again <Text size="sm" c="dimmed" mb="xs">Error: {errorMessage}</Text>
</Button>
{isRoot ? (
<MantineButton
component={Link}
to="/"
variant="filled"
>
Home
</MantineButton>
) : (
<Button <Button
variant="filled" variant="subtle"
onClick={() => window.history.back()} size="compact-sm"
onClick={toggleDetails}
fullWidth
> >
Go Back {detailsOpened ? 'Hide' : 'Show'} details
</Button> </Button>
)} <Collapse in={detailsOpened}>
</Group> <Code block mt="sm" p="sm" style={{ fontSize: '11px' }}>
</Stack> {errorStack}
</Box> </Code>
</Collapse>
</Box>
<Group gap="sm" mt="lg">
<Button
variant="light"
onClick={() => router.invalidate()}
>
Retry
</Button>
{isRoot ? (
<MantineButton
component={Link}
to="/"
variant="filled"
>
Home
</MantineButton>
) : (
<Button
variant="filled"
onClick={() => window.history.back()}
>
Go Back
</Button>
)}
</Group>
</Stack>
</Center>
</Container>
) )
} }

View File

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

View File

@@ -6,14 +6,16 @@ interface ListLinkProps {
label: string; label: string;
to: string; to: string;
Icon?: Icon; Icon?: Icon;
disabled?: boolean
} }
const ListLink = ({ label, to, Icon }: ListLinkProps) => { const ListLink = ({ label, to, Icon, disabled=false }: ListLinkProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<> <>
<NavLink <NavLink
disabled={disabled}
w="100%" w="100%"
p="md" p="md"
component={"button"} component={"button"}

View File

@@ -22,10 +22,10 @@ const Page = ({ children, noPadding, fullWidth, ...props }: PageProps) => {
{...props} {...props}
> >
{header.collapsed && header.withBackButton && ( {header.collapsed && header.withBackButton && (
<BackButton offsetY={0} /> <BackButton top={4} />
)} )}
{header.collapsed && header.settingsLink && ( {header.collapsed && header.settingsLink && (
<SettingsButton to={header.settingsLink} offsetY={0} /> <SettingsButton to={header.settingsLink} />
)} )}
{children} {children}
</Container> </Container>

View File

@@ -0,0 +1,41 @@
import { useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { RichTextEditor as MantineRichTextEditor } from '@mantine/tiptap';
interface RichTextEditorProps {
value: string;
onChange: (value: string) => void;
}
export function RichTextEditor({
value,
onChange,
}: RichTextEditorProps) {
const editor = useEditor({
extensions: [StarterKit],
content: value,
immediatelyRender: false,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
});
return (
<MantineRichTextEditor editor={editor}>
<MantineRichTextEditor.Toolbar>
<MantineRichTextEditor.ControlsGroup>
<MantineRichTextEditor.Bold />
<MantineRichTextEditor.Italic />
</MantineRichTextEditor.ControlsGroup>
<MantineRichTextEditor.ControlsGroup>
<MantineRichTextEditor.Blockquote />
<MantineRichTextEditor.Hr />
<MantineRichTextEditor.BulletList />
<MantineRichTextEditor.OrderedList />
</MantineRichTextEditor.ControlsGroup>
</MantineRichTextEditor.Toolbar>
<MantineRichTextEditor.Content h="45vh" />
</MantineRichTextEditor>
);
}

View File

@@ -1,8 +1,7 @@
import { Box, Container, Flex, Loader, useComputedColorScheme } from "@mantine/core"; import { Box, Container, Flex, Loader, useComputedColorScheme } from "@mantine/core";
import { PropsWithChildren, Suspense, useEffect } from "react"; import { PropsWithChildren, Suspense, useEffect, useRef } from "react";
import { Drawer as VaulDrawer } from "vaul"; import { Drawer as VaulDrawer } from "vaul";
import styles from "./styles.module.css"; import styles from "./styles.module.css";
import FullScreenLoader from "../full-screen-loader";
interface DrawerProps extends PropsWithChildren { interface DrawerProps extends PropsWithChildren {
title?: string; title?: string;
@@ -17,6 +16,7 @@ const Drawer: React.FC<DrawerProps> = ({
onChange, onChange,
}) => { }) => {
const colorScheme = useComputedColorScheme("light"); const colorScheme = useComputedColorScheme("light");
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const appElement = document.querySelector(".app") as HTMLElement; const appElement = document.querySelector(".app") as HTMLElement;
@@ -59,11 +59,56 @@ const Drawer: React.FC<DrawerProps> = ({
}; };
}, [opened, colorScheme]); }, [opened, colorScheme]);
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]');
if (drawerContent) {
(drawerContent as HTMLElement).style.height = 'auto';
(drawerContent as HTMLElement).offsetHeight;
}
}
});
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 ( return (
<VaulDrawer.Root open={opened} onOpenChange={onChange}> <VaulDrawer.Root repositionInputs={false} open={opened} onOpenChange={onChange}>
<VaulDrawer.Portal> <VaulDrawer.Portal>
<VaulDrawer.Overlay className={styles.drawerOverlay} /> <VaulDrawer.Overlay className={styles.drawerOverlay} />
<VaulDrawer.Content className={styles.drawerContent}> <VaulDrawer.Content className={styles.drawerContent} aria-describedby="drawer" ref={contentRef}>
<Container flex={1} p="md"> <Container flex={1} p="md">
<Box <Box
mb="sm" mb="sm"
@@ -74,7 +119,7 @@ const Drawer: React.FC<DrawerProps> = ({
mr="auto" mr="auto"
style={{ borderRadius: "9999px" }} style={{ borderRadius: "9999px" }}
/> />
<Container mah="fit-content" mx="auto" maw="28rem" px={0}> <Container mx="auto" maw="28rem" px={0}>
<VaulDrawer.Title>{title}</VaulDrawer.Title> <VaulDrawer.Title>{title}</VaulDrawer.Title>
<Suspense fallback={ <Suspense fallback={
<Flex justify='center' align='center' w='100%' h={400}> <Flex justify='center' align='center' w='100%' h={400}>

View File

@@ -2,7 +2,7 @@ import { PropsWithChildren, useCallback } from "react";
import { useIsMobile } from "@/hooks/use-is-mobile"; import { useIsMobile } from "@/hooks/use-is-mobile";
import Drawer from "./drawer"; import Drawer from "./drawer";
import Modal from "./modal"; import Modal from "./modal";
import { Box, ScrollArea } from "@mantine/core"; import { ScrollArea } from "@mantine/core";
interface SheetProps extends PropsWithChildren { interface SheetProps extends PropsWithChildren {
title?: string; title?: string;
@@ -23,14 +23,14 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
onChange={onChange} onChange={onChange}
onClose={handleClose} onClose={handleClose}
> >
<ScrollArea <ScrollArea.Autosize
style={{ flex: 1 }} style={{ flex: 1, maxHeight: '75dvh' }}
scrollbarSize={8} scrollbarSize={8}
scrollbars="y" scrollbars="y"
type="scroll" type="scroll"
> >
<Box mah="70vh">{children}</Box> {children}
</ScrollArea> </ScrollArea.Autosize>
</SheetComponent> </SheetComponent>
); );
}; };

View File

@@ -1,6 +1,6 @@
import { Box, Text, UnstyledButton, Flex, Stack } from "@mantine/core"; import { Box, Text, UnstyledButton, Flex, Stack } from "@mantine/core";
import { CaretRightIcon } from "@phosphor-icons/react"; import { CaretRightIcon } from "@phosphor-icons/react";
import { ComponentType, useContext } from "react"; import React, { ComponentType, useContext } from "react";
import { SlidePanelContext } from "./slide-panel-context"; import { SlidePanelContext } from "./slide-panel-context";
interface SlidePanelFieldProps { interface SlidePanelFieldProps {
@@ -11,7 +11,7 @@ interface SlidePanelFieldProps {
title: string; title: string;
label?: string; label?: string;
placeholder?: string; placeholder?: string;
formatValue?: (value: any) => string; formatValue?: (value: any) => string | React.ReactNode;
componentProps?: Record<string, any>; componentProps?: Record<string, any>;
withAsterisk?: boolean; withAsterisk?: boolean;
error?: string; error?: string;

View File

@@ -167,14 +167,11 @@ const SlidePanel = ({
bg="var(--mantine-color-dimmed)" bg="var(--mantine-color-dimmed)"
my="xs" my="xs"
/> />
<panelConfig.Component
<Box> value={tempValue}
<panelConfig.Component onChange={setTempValue}
value={tempValue} {...(panelConfig.componentProps || {})}
onChange={setTempValue} />
{...(panelConfig.componentProps || {})}
/>
</Box>
</> </>
)} )}
</Box> </Box>

View File

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

View File

@@ -0,0 +1,161 @@
import {
Box,
Text,
Group,
Stack,
ThemeIcon,
Skeleton,
Divider,
} from "@mantine/core";
import {
CrownIcon,
XIcon,
FireIcon,
ShieldIcon,
ChartLineUpIcon,
ShieldCheckIcon,
BoxingGloveIcon,
Icon,
ArrowUpIcon,
ArrowDownIcon,
} from "@phosphor-icons/react";
import { BaseStats } from "@/types/stats";
interface StatsOverviewProps {
statsData: BaseStats | null;
isLoading?: boolean;
}
const StatItem = ({
label,
value,
suffix = "",
Icon,
}: {
label: string;
value: number | null;
suffix?: string;
Icon?: Icon;
isLoading?: boolean;
}) => {
return (
<Group justify="space-between" align="center" py="md" px="sm">
<Group gap="sm" align="center">
{Icon && (
<ThemeIcon size="md" variant="light" radius="sm" color="gray">
<Icon size={16} />
</ThemeIcon>
)}
<Text size="sm" fw={500}>
{label}
</Text>
</Group>
{value !== null ? (
<Text size="sm" fw={700} c="dimmed">
{`${value}${suffix}`}
</Text>
) : (
<Skeleton width={20} height={20} />
)}
</Group>
);
};
const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) => {
if (!statsData && !isLoading) {
return (
<Box p="sm" h="auto" mih={200}>
<Text ta="center" size="sm" fw={600} c="dimmed">
No stats available yet
</Text>
</Box>
);
}
if (!statsData) return null;
const overallStats = {
matches: statsData.matches,
wins: statsData.wins,
losses: statsData.losses,
total_cups_made: statsData.total_cups_made,
total_cups_against: statsData.total_cups_against,
};
const avgCupsPerMatch =
overallStats.matches > 0
? parseFloat((overallStats.total_cups_made / overallStats.matches).toFixed(1))
: 0;
const avgCupsAgainstPerMatch =
overallStats.matches > 0
? parseFloat((overallStats.total_cups_against / overallStats.matches).toFixed(1))
: 0;
const avgMarginOfVictory = statsData.margin_of_victory ? parseFloat(statsData.margin_of_victory.toFixed(1)) : 0;
const avgMarginOfLoss = statsData.margin_of_loss ? parseFloat(statsData.margin_of_loss.toFixed(1)) : 0;
const allStats = [
{ label: "Matches Played", value: overallStats.matches, Icon: BoxingGloveIcon },
{ label: "Wins", value: overallStats.wins, Icon: CrownIcon },
{ label: "Losses", value: overallStats.losses, Icon: XIcon },
{ label: "Cups Made", value: overallStats.total_cups_made, Icon: FireIcon },
{ label: "Cups Against", value: overallStats.total_cups_against, Icon: ShieldIcon },
{ label: "Avg Cups Per Game", value: avgCupsPerMatch > 0 ? avgCupsPerMatch : null, Icon: ChartLineUpIcon },
{ label: "Avg Cups Against", value: avgCupsAgainstPerMatch > 0 ? avgCupsAgainstPerMatch : null, Icon: ShieldCheckIcon },
{ label: "Avg Win Margin", value: avgMarginOfVictory > 0 ? avgMarginOfVictory : null, Icon: ArrowUpIcon },
{ label: "Avg Loss Margin", value: avgMarginOfLoss > 0 ? avgMarginOfLoss : null, Icon: ArrowDownIcon },
];
return (
<Box>
<Stack gap={0}>
{allStats.map((stat, index) => (
<Box key={stat.label}>
<StatItem
label={stat.label}
value={stat.value}
Icon={stat.Icon}
isLoading={isLoading}
/>
{index < allStats.length - 1 && <Divider />}
</Box>
))}
</Stack>
</Box>
);
};
export const StatsSkeleton = () => {
const skeletonStats = [
{ label: "Matches Played", Icon: BoxingGloveIcon },
{ label: "Wins", Icon: CrownIcon },
{ label: "Losses", Icon: XIcon },
{ label: "Cups Made", Icon: FireIcon },
{ label: "Cups Against", Icon: ShieldIcon },
{ label: "Avg Cups Per Game", Icon: ChartLineUpIcon },
{ label: "Avg Cups Against", Icon: ShieldCheckIcon },
{ label: "Avg Win Margin", Icon: ArrowUpIcon },
{ label: "Avg Loss Margin", Icon: ArrowDownIcon },
];
return (
<Box>
<Stack gap={0}>
{skeletonStats.map((stat, index) => (
<Box key={stat.label}>
<StatItem
label={stat.label}
value={null}
Icon={stat.Icon}
isLoading={true}
/>
{index < skeletonStats.length - 1 && <Divider />}
</Box>
))}
</Stack>
</Box>
);
};
export default StatsOverview;

View File

@@ -86,7 +86,7 @@ function SwipeableTabs({
} }
}, [search?.tab]); }, [search?.tab]);
useEffect(() => { const updateHeight = useCallback(() => {
const activeSlideRef = slideRefs.current[activeTab]; const activeSlideRef = slideRefs.current[activeTab];
if (activeSlideRef) { if (activeSlideRef) {
const height = activeSlideRef.scrollHeight; const height = activeSlideRef.scrollHeight;
@@ -94,6 +94,33 @@ function SwipeableTabs({
} }
}, [activeTab]); }, [activeTab]);
useEffect(() => {
updateHeight();
}, [activeTab, updateHeight]);
useEffect(() => {
const timeoutId = setTimeout(updateHeight, 0);
return () => clearTimeout(timeoutId);
}, [updateHeight]);
useEffect(() => {
const activeSlideRef = slideRefs.current[activeTab];
if (!activeSlideRef) return;
let timeoutId: any;
const resizeObserver = new ResizeObserver(() => {
clearTimeout(timeoutId);
timeoutId = setTimeout(updateHeight, 16);
});
resizeObserver.observe(activeSlideRef);
return () => {
resizeObserver.disconnect();
clearTimeout(timeoutId);
};
}, [activeTab, updateHeight]);
const setControlRef = useCallback( const setControlRef = useCallback(
(index: number) => (node: HTMLSpanElement | null) => { (index: number) => (node: HTMLSpanElement | null) => {
controlsRefs.current[index] = node; controlsRefs.current[index] = node;
@@ -116,6 +143,7 @@ function SwipeableTabs({
top={0} top={0}
style={{ style={{
display: "flex", display: "flex",
paddingInline: "var(--mantine-spacing-md)",
marginBottom: "var(--mantine-spacing-md)", marginBottom: "var(--mantine-spacing-md)",
zIndex: 100, zIndex: 100,
backgroundColor: "var(--mantine-color-body)", backgroundColor: "var(--mantine-color-body)",

View File

@@ -0,0 +1,175 @@
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;
}
const Typeahead = <T,>({
onSelect,
searchFn,
renderOption,
format,
placeholder = "Search...",
debounceMs = 300,
disabled = false,
initialValue = ""
}: 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 debouncedSearch = useDebouncedCallback(async (query: string) => {
if (!query.trim()) {
setSearchResults([]);
setIsOpen(false);
return;
}
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);
}
}, 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={() => {
if (searchResults.length > 0) setIsOpen(true);
}}
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: '160px',
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

@@ -1,21 +1,28 @@
import { createContext, PropsWithChildren, useCallback, useContext, useMemo } from "react"; import {
createContext,
PropsWithChildren,
useCallback,
useContext,
useMemo,
} from "react";
import { MantineColor, MantineColorScheme } from "@mantine/core"; import { MantineColor, MantineColorScheme } from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Player } from "@/features/players/types"; import { Player } from "@/features/players/types";
import { playerKeys, playerQueries, useMe } from "@/features/players/queries"; import { playerKeys, playerQueries, useMe } from "@/features/players/queries";
interface AuthData { interface AuthData {
user: Player | undefined; user: Player | undefined;
metadata: { accentColor: MantineColor; colorScheme: MantineColorScheme }; metadata: { accentColor: MantineColor; colorScheme: MantineColorScheme };
roles: string[]; roles: string[];
phone: string;
} }
export const defaultAuthData: AuthData = { export const defaultAuthData: AuthData = {
user: undefined, user: undefined,
metadata: { accentColor: 'blue', colorScheme: 'auto' }, metadata: { accentColor: "blue", colorScheme: "dark" },
roles: [], roles: [],
} phone: ""
};
export interface AuthContextType extends AuthData { export interface AuthContextType extends AuthData {
set: ({ user, metadata, roles }: Partial<AuthContextType>) => void; set: ({ user, metadata, roles }: Partial<AuthContextType>) => void;
@@ -27,39 +34,46 @@ const AuthContext = createContext<AuthContextType>({
}); });
export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => { export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data } = useMe(); const { data } = useMe();
const set = useCallback((updates: Partial<AuthData>) => {
queryClient.setQueryData(playerKeys.auth, (oldData: AuthData | undefined) => {
const currentData = oldData || defaultAuthData;
return {
...currentData,
...updates,
metadata: updates.metadata
? { ...currentData.metadata, ...updates.metadata }
: currentData.metadata
};
});
}, [queryClient]);
return ( const set = useCallback(
<AuthContext (updates: Partial<AuthData>) => {
value={{ queryClient.setQueryData(
user: data?.user || defaultAuthData.user, playerKeys.auth,
metadata: data?.metadata || defaultAuthData.metadata, (oldData: AuthData | undefined) => {
roles: data?.roles || defaultAuthData.roles, const currentData = oldData || defaultAuthData;
set return {
}}> ...currentData,
{children} ...updates,
</AuthContext> metadata: updates.metadata
) ? { ...currentData.metadata, ...updates.metadata }
: currentData.metadata,
};
}
);
},
[queryClient]
);
const value = useMemo(
() => ({
user: data?.user,
metadata: data?.metadata || { accentColor: "blue" as MantineColor, colorScheme: "dark" as MantineColorScheme },
roles: data?.roles || [],
phone: data?.phone || "",
set,
}),
[data, set]
);
return <AuthContext value={value}>{children}</AuthContext>;
}; };
export const useAuth = () => { export const useAuth = () => {
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (!context) { if (!context) {
throw new Error('useAuth must be used within an AuthProvider'); throw new Error("useAuth must be used within an AuthProvider");
} }
return context; return context;
}; };

View File

@@ -165,16 +165,16 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
const play = useCallback(async (deviceId?: string) => { const play = useCallback(async (deviceId?: string) => {
if (!authState.isAuthenticated) return; if (!authState.isAuthenticated) return;
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
await makeSpotifyRequest('playback', { await makeSpotifyRequest('playback', {
method: 'POST', method: 'POST',
body: JSON.stringify({ action: 'play', deviceId }), body: JSON.stringify({ action: 'play', deviceId }),
}); });
setTimeout(refreshPlaybackState, 500); setTimeout(refreshPlaybackState, 500);
} catch (error) { } catch (error) {
if (error instanceof Error && !error.message.includes('JSON')) { if (error instanceof Error && !error.message.includes('JSON')) {
@@ -186,6 +186,29 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
} }
}, [authState.isAuthenticated]); }, [authState.isAuthenticated]);
const playTrack = useCallback(async (trackId: string, deviceId?: string, positionMs?: number) => {
if (!authState.isAuthenticated) return;
setIsLoading(true);
setError(null);
try {
await makeSpotifyRequest('playback', {
method: 'POST',
body: JSON.stringify({ action: 'playTrack', trackId, deviceId, positionMs }),
});
setTimeout(refreshPlaybackState, 500);
} catch (error) {
if (error instanceof Error && !error.message.includes('JSON')) {
setError(error.message);
}
console.warn('Track playback action completed with warning:', error);
} finally {
setIsLoading(false);
}
}, [authState.isAuthenticated]);
const pause = useCallback(async () => { const pause = useCallback(async () => {
if (!authState.isAuthenticated) return; if (!authState.isAuthenticated) return;
@@ -389,6 +412,24 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
setCapturedState(null); setCapturedState(null);
}, []); }, []);
const searchTracks = useCallback(async (query: string): Promise<SpotifyTrack[]> => {
if (!query.trim()) return [];
try {
const response = await fetch(`/api/spotify/search?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Search failed');
}
const data = await response.json();
return data.tracks || [];
} catch (error) {
console.error('Failed to search tracks:', error);
return [];
}
}, []);
const contextValue: SpotifyContextType = { const contextValue: SpotifyContextType = {
...authState, ...authState,
currentTrack, currentTrack,
@@ -404,6 +445,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
login, login,
logout, logout,
play, play,
playTrack,
pause, pause,
skipNext, skipNext,
skipPrevious, skipPrevious,
@@ -415,6 +457,8 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
capturePlaybackState, capturePlaybackState,
resumePlaybackState, resumePlaybackState,
clearCapturedState, clearCapturedState,
// Search
searchTracks,
}; };
if (!isAdmin) { if (!isAdmin) {

View File

@@ -9,7 +9,7 @@ import ListButton from "@/components/list-button";
const AdminPage = () => { const AdminPage = () => {
return ( return (
<List> <List p="0">
<ListLink <ListLink
label="Manage Tournaments" label="Manage Tournaments"
Icon={TrophyIcon} Icon={TrophyIcon}

View File

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

View File

@@ -0,0 +1,4 @@
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
import { ActionIcon, Card, Flex, Text, Stack, Indicator } from "@mantine/core"; import { ActionIcon, Card, Flex, Text, Indicator } from "@mantine/core";
import { PlayIcon, PencilIcon, SpeakerHighIcon } from "@phosphor-icons/react"; import { PlayIcon, PencilIcon, SpeakerHighIcon } from "@phosphor-icons/react";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { MatchSlot } from "./match-slot"; import { MatchSlot } from "./match-slot";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
import { Team } from "@/features/teams/types";
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
import { MatchForm } from "./match-form"; import { MatchForm } from "./match-form";
import Sheet from "@/components/sheet/sheet"; import Sheet from "@/components/sheet/sheet";
@@ -10,6 +11,7 @@ import { useServerMutation } from "@/lib/tanstack-query/hooks";
import { endMatch, startMatch } from "@/features/matches/server"; import { endMatch, startMatch } from "@/features/matches/server";
import { tournamentKeys } from "@/features/tournaments/queries"; import { tournamentKeys } from "@/features/tournaments/queries";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useSpotifyPlayback } from "@/lib/spotify/hooks";
interface MatchCardProps { interface MatchCardProps {
match: Match; match: Match;
@@ -24,6 +26,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
}) => { }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const editSheet = useSheet(); const editSheet = useSheet();
const { playTrack, pause } = useSpotifyPlayback();
const homeSlot = useMemo( const homeSlot = useMemo(
() => ({ () => ({
from: orders[match.home_from_lid], from: orders[match.home_from_lid],
@@ -65,6 +68,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
[showControls, match.status] [showControls, match.status]
); );
const hasWalkoutData = showControls && match.home && match.away && 'song_id' in match.home && 'song_id' in match.away;
const start = useServerMutation({ const start = useServerMutation({
mutationFn: startMatch, mutationFn: startMatch,
successMessage: "Match started!", successMessage: "Match started!",
@@ -84,19 +89,13 @@ export const MatchCard: React.FC<MatchCardProps> = ({
}, },
}); });
const handleStart = useCallback(async () => {
await start.mutate({
data: match.id,
});
}, [match]);
const handleFormSubmit = useCallback( const handleFormSubmit = useCallback(
async (data: { async (data: {
home_cups: number; home_cups: number;
away_cups: number; away_cups: number;
ot_count: number; ot_count: number;
}) => { }) => {
await end.mutate({ end.mutate({
data: { data: {
...data, ...data,
matchId: match.id, matchId: match.id,
@@ -107,12 +106,14 @@ export const MatchCard: React.FC<MatchCardProps> = ({
[match.id, editSheet] [match.id, editSheet]
); );
const handleSpeakerClick = useCallback(() => { const speak = useCallback((text: string): Promise<void> => {
if ("speechSynthesis" in window && match.home?.name && match.away?.name) { return new Promise((resolve) => {
const utterance = new SpeechSynthesisUtterance( if (!("speechSynthesis" in window)) {
`${match.home.name} vs. ${match.away.name}` resolve();
); return;
}
const utterance = new SpeechSynthesisUtterance(text);
const voices = window.speechSynthesis.getVoices(); const voices = window.speechSynthesis.getVoices();
const preferredVoice = const preferredVoice =
@@ -130,13 +131,83 @@ export const MatchCard: React.FC<MatchCardProps> = ({
utterance.volume = 0.8; utterance.volume = 0.8;
utterance.pitch = 1.0; utterance.pitch = 1.0;
utterance.onend = () => resolve();
utterance.onerror = () => resolve();
window.speechSynthesis.speak(utterance); window.speechSynthesis.speak(utterance);
});
}, []);
const playTeamWalkout = useCallback((team: Team): Promise<void> => {
return new Promise((resolve) => {
const songDuration = (team.song_end - team.song_start) * 1000;
playTrack(team.song_id, undefined, team.song_start * 1000);
setTimeout(async () => {
await pause();
resolve();
}, songDuration);
});
}, [playTrack, pause]);
const handleSpeakerClick = useCallback(async () => {
if (!hasWalkoutData || !match.home?.name || !match.away?.name) {
await speak(`${match.home?.name || "Home"} vs. ${match.away?.name || "Away"}`);
return;
} }
}, [match.home?.name, match.away?.name]);
try {
const homeTeam = match.home as Team;
const awayTeam = match.away as Team;
await playTeamWalkout(homeTeam);
await speak(homeTeam.name);
await speak("versus");
await playTeamWalkout(awayTeam);
await speak(awayTeam.name);
await speak("have fun, good luck!");
} catch (error) {
console.warn('Walkout sequence error:', error);
await speak(`${match.home.name} vs. ${match.away.name}`);
}
}, [hasWalkoutData, match.home, match.away, speak, playTeamWalkout]);
const handleStart = useCallback(async () => {
start.mutate({
data: match.id,
});
// Play walkout sequence after starting the match
if (hasWalkoutData && match.home?.name && match.away?.name) {
try {
const homeTeam = match.home as Team;
const awayTeam = match.away as Team;
await playTeamWalkout(homeTeam);
await speak(homeTeam.name);
await speak("versus");
await playTeamWalkout(awayTeam);
await speak(awayTeam.name);
await speak("have fun, good luck!");
} catch (error) {
console.warn('Auto-walkout sequence error:', error);
}
}
}, [match, start, hasWalkoutData, playTeamWalkout, speak]);
return ( return (
<Flex direction="row" align="center" justify="end" gap={8}> <Flex direction="row" align="center" justify="end" gap={8}>
<Text c="dimmed" fw="bolder"> <Text
c="dimmed"
fw="bolder"
px={6}
py={2}
style={{
backgroundColor: 'var(--mantine-color-body)'
}}
>
{match.order} {match.order}
</Text> </Text>
<Flex align="stretch"> <Flex align="stretch">
@@ -151,7 +222,12 @@ export const MatchCard: React.FC<MatchCardProps> = ({
w={showToolbar || showEditButton ? 200 : 220} w={showToolbar || showEditButton ? 200 : 220}
withBorder withBorder
pos="relative" pos="relative"
style={{ overflow: "visible" }} style={{
overflow: "visible",
backgroundColor: 'var(--mantine-color-body)',
borderColor: 'var(--mantine-color-default-border)',
boxShadow: 'var(--mantine-shadow-sm)',
}}
data-match-lid={match.lid} data-match-lid={match.lid}
> >
<Card.Section withBorder p={0}> <Card.Section withBorder p={0}>
@@ -175,7 +251,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
</Text> </Text>
)} )}
{showControls && ( {showControls && match.status !== "tbd" && (
<ActionIcon <ActionIcon
pos="absolute" pos="absolute"
bottom={-2} bottom={-2}
@@ -210,6 +286,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
</Flex> </Flex>
)} )}
{showEditButton && ( {showEditButton && (
<Flex direction="column" justify="center" align="center"> <Flex direction="column" justify="center" align="center">
<ActionIcon <ActionIcon

View File

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

View File

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

View File

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

View File

@@ -2,11 +2,7 @@ import { Box } from "@mantine/core"
import { ArrowLeftIcon } from "@phosphor-icons/react" import { ArrowLeftIcon } from "@phosphor-icons/react"
import { useRouter } from "@tanstack/react-router" import { useRouter } from "@tanstack/react-router"
interface BackButtonProps { const BackButton = ({ top=20, left=20 }: { top?: number, left?: number }) => {
offsetY: number;
}
const BackButton = ({ offsetY }: BackButtonProps) => {
const router = useRouter() const router = useRouter()
return ( return (
@@ -14,8 +10,8 @@ const BackButton = ({ offsetY }: BackButtonProps) => {
style={{ cursor: 'pointer', zIndex: 1000 }} style={{ cursor: 'pointer', zIndex: 1000 }}
onClick={() => router.history.back()} onClick={() => router.history.back()}
pos='absolute' pos='absolute'
left={16} left={left}
top={0} top={top}
> >
<ArrowLeftIcon weight='bold' size={20} /> <ArrowLeftIcon weight='bold' size={20} />
</Box> </Box>

View File

@@ -1,19 +1,16 @@
import { Title, AppShell, Flex } from "@mantine/core"; import { Title, AppShell, Flex, Box, Paper } from "@mantine/core";
import { HeaderConfig } from "../types/header-config"; import { HeaderConfig } from "../types/header-config";
import BackButton from "./back-button"; import BackButton from "./back-button";
import { useMemo } from "react";
import SettingsButton from "./settings-button";
interface HeaderProps extends HeaderConfig {
scrollPosition: { x: number, y: number };
}
const Header = ({ withBackButton, settingsLink, collapsed, title, scrollPosition }: HeaderProps) => { interface HeaderProps extends HeaderConfig {}
const offsetY = useMemo(() => {
return collapsed ? scrollPosition.y : 0;
}, [collapsed, scrollPosition.y]);
const Header = ({ collapsed, title, withBackButton }: HeaderProps) => {
return ( return (
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}> <AppShell.Header
id='app-header'
display={collapsed ? 'none' : 'block'}
>
{ withBackButton && <BackButton /> }
<Flex justify='center' align='center' h='100%' px='md'> <Flex justify='center' align='center' h='100%' px='md'>
<Title order={2}>{title}</Title> <Title order={2}>{title}</Title>
</Flex> </Flex>

View File

@@ -31,14 +31,14 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
pos='relative' pos='relative'
h='100dvh' h='100dvh'
mah='100dvh' mah='100dvh'
style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }} // style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
> >
<Header scrollPosition={scrollPosition} {...header} /> <Header {...header} />
<AppShell.Main <AppShell.Main
pos='relative' pos='relative'
h='100%' h='100%'
mah='100%' mah='100%'
pb={{ base: 70, md: 0 }} pb={{ base: 65, md: 0 }}
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }} px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
maw='100dvw' maw='100dvw'
style={{ transition: 'none', overflow: 'hidden' }} style={{ transition: 'none', overflow: 'hidden' }}

View File

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

View File

@@ -4,6 +4,7 @@ import useAppShellHeight from "@/hooks/use-appshell-height";
import { ArrowClockwiseIcon, SpinnerIcon } from "@phosphor-icons/react"; import { ArrowClockwiseIcon, SpinnerIcon } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import useRouterConfig from "../hooks/use-router-config"; import useRouterConfig from "../hooks/use-router-config";
import { useLocation } from "@tanstack/react-router";
const THRESHOLD = 80; const THRESHOLD = 80;
@@ -14,7 +15,6 @@ interface PullableProps extends PropsWithChildren {
/** /**
* Pullable is a component that allows the user to pull down to refresh the page * Pullable is a component that allows the user to pull down to refresh the page
* TODO: Need to make the router config nicer
*/ */
const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollPositionChange }) => { const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollPositionChange }) => {
const height = useAppShellHeight(); const height = useAppShellHeight();
@@ -22,6 +22,8 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
const [scrolling, setScrolling] = useState(false); const [scrolling, setScrolling] = useState(false);
const { refresh } = useRouterConfig(); const { refresh } = useRouterConfig();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const location = useLocation();
const scrollAreaRef = useRef<HTMLDivElement>(null);
const scrollY = useMemo(() => scrollPosition.y < 0 && scrolling ? Math.abs(scrollPosition.y) : 0, [scrollPosition.y, scrolling]); const scrollY = useMemo(() => scrollPosition.y < 0 && scrolling ? Math.abs(scrollPosition.y) : 0, [scrollPosition.y, scrolling]);
@@ -80,6 +82,21 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
return () => void ac.abort(); return () => void ac.abort();
}, []); }, []);
useEffect(() => {
const timeoutId = setTimeout(() => {
if (scrollAreaRef.current) {
const viewport = scrollAreaRef.current.querySelector('.mantine-ScrollArea-viewport') as HTMLElement;
if (viewport) {
viewport.scrollTop = 0;
viewport.scrollLeft = 0;
}
}
onScrollPositionChange({ x: 0, y: 0 });
}, 10);
return () => clearTimeout(timeoutId);
}, [location.pathname, onScrollPositionChange]);
return ( return (
<> <>
@@ -104,16 +121,13 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
/> />
</Flex> </Flex>
<ScrollArea <ScrollArea
ref={scrollAreaRef}
id='scroll-wrapper' id='scroll-wrapper'
onScrollPositionChange={onScrollPositionChange} onScrollPositionChange={onScrollPositionChange}
type='never' mah='100%' h='100%' type='never' mah='100%' h='100%'
pt={(scrolling || scrollY > 40) || !isRefreshing ? 0 : 40 - scrollY} pt={(scrolling || scrollY > 40) || !isRefreshing ? 0 : 40 - scrollY}
> >
<Box pt='1rem'pb='0.285rem' mih={height} style={{ boxSizing: 'content-box' }}> <Box pt='1rem'pb='0.285rem' mih={height} style={{ boxSizing: 'content-box' }}>
{ /* TODO: Remove this debug button */}
<ActionIcon display={!!refresh.length ? 'unset' : 'none' } style={{ zIndex: 1000 }} pos='absolute' top={8} left='calc(50% - 24px)' onClick={onTrigger} variant='filled' color='var(--mantine-color-dimmed)'>
<ArrowClockwiseIcon />
</ActionIcon>
{children} {children}
</Box> </Box>
</ScrollArea> </ScrollArea>

View File

@@ -1,13 +1,15 @@
import { Box } from "@mantine/core" import { Box } from "@mantine/core"
import { GearIcon } from "@phosphor-icons/react" import { GearIcon } from "@phosphor-icons/react"
import { useNavigate } from "@tanstack/react-router" import { useNavigate } from "@tanstack/react-router"
import { memo } from "react";
interface SettingButtonProps { interface SettingButtonProps {
offsetY: number;
to: string; to: string;
top?: number;
right?: number;
} }
const SettingsButton = ({ offsetY, to }: SettingButtonProps) => { const SettingsButton = ({ to }: SettingButtonProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
@@ -15,12 +17,12 @@ const SettingsButton = ({ offsetY, to }: SettingButtonProps) => {
style={{ cursor: 'pointer', zIndex: 1000 }} style={{ cursor: 'pointer', zIndex: 1000 }}
onClick={() => navigate({ to })} onClick={() => navigate({ to })}
pos='absolute' pos='absolute'
right={16} right={20}
top={0} top={6}
> >
<GearIcon weight='bold' size={20} /> <GearIcon weight='bold' size={20} />
</Box> </Box>
); );
} }
export default SettingsButton; export default memo(SettingsButton, (prev, next) => prev.to !== next.to);

View File

@@ -1,23 +0,0 @@
import { Alert } from "@mantine/core";
import { Info } from "@phosphor-icons/react";
import { Transition } from "@mantine/core";
import { useMemo } from "react";
const Error = ({ error }: { error?: string }) => {
const show = useMemo(() => (error ? error.length > 0 : false), [error]);
return (
<Transition
mounted={show}
transition="slide-up"
duration={400}
timingFunction="ease"
>
{(styles) => (
<Alert w='95%' color="red" icon={<Info />} style={styles}>{error}</Alert>
)}
</Transition>
)
}
export default Error;

View File

@@ -1,17 +1,10 @@
import { import { Text, Group, Stack, Paper, Indicator, Box, Tooltip } from "@mantine/core";
Text, import { CrownIcon } from "@phosphor-icons/react";
Group,
Stack,
Paper,
ThemeIcon,
Indicator,
Box,
Badge,
} from "@mantine/core";
import { TrophyIcon, CrownIcon } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { Match } from "../types"; import { Match } from "../types";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import EmojiBar from "@/features/reactions/components/emoji-bar";
import { Suspense } from "react";
interface MatchCardProps { interface MatchCardProps {
match: Match; match: Match;
@@ -44,110 +37,174 @@ const MatchCard = ({ match }: MatchCardProps) => {
color="red" color="red"
processing processing
position="top-end" position="top-end"
offset={2} offset={24}
> >
<Paper p="md" withBorder radius="md"> <Box style={{ position: "relative" }}>
<Stack gap="sm"> <Paper
<Group gap="xs"> px="md"
<Text size="xs" fw={600} lineClamp={1} c="dimmed"> py="md"
{match.tournament.name} withBorder
</Text> radius="md"
<Text c="dimmed">-</Text> style={{ position: "relative", zIndex: 2 }}
<Text size="xs" c="dimmed"> >
Round {match.round} <Stack gap="sm">
{match.is_losers_bracket && " (Losers)"} <Group gap="xs">
</Text> <Text size="xs" fw={600} lineClamp={1} c="dimmed">
</Group> {match.tournament.name}
</Text>
<Group justify="space-between" align="center"> <Text c="dimmed">-</Text>
<Group gap="sm" style={{ flex: 1 }}> <Text size="xs" c="dimmed">
<Box Round {match.round + 1}
style={{ position: "relative", cursor: "pointer" }} {match.is_losers_bracket && " (Losers)"}
onClick={handleHomeTeamClick}
>
<Avatar size={40} name={match.home?.name!} radius="sm" />
{isHomeWin && (
<Box
style={{
position: "absolute",
top: -10,
left: -4,
transform: "rotate(-25deg)",
color: "gold",
}}
>
<CrownIcon size={16} weight="fill" />
</Box>
)}
</Box>
<Text
size="sm"
fw={600}
lineClamp={1}
style={{ minWidth: 0, flex: 1 }}
>
{match.home?.name!}
</Text> </Text>
</Group> </Group>
<Group gap="sm" align="center"> <Group justify="space-between" align="center">
<Group gap="sm" style={{ flex: 1 }}>
<Box
style={{ position: "relative", cursor: "pointer" }}
onClick={handleHomeTeamClick}
>
<Avatar
size={40}
name={match.home?.name!}
radius="sm"
src={
match.home?.logo
? `/api/files/teams/${match.home?.id}/${match.home?.logo}`
: undefined
}
/>
{isHomeWin && (
<Box
style={{
position: "absolute",
top: -10,
left: -4,
transform: "rotate(-25deg)",
color: "gold",
}}
>
<CrownIcon size={16} weight="fill" />
</Box>
)}
</Box>
<Tooltip
label={match.home?.name!}
disabled={!match.home?.name}
events={{ hover: true, focus: true, touch: true }}
>
<Text
size="sm"
fw={600}
lineClamp={1}
style={{ minWidth: 0, flex: 1, cursor: 'pointer' }}
>
{match.home?.name!}
</Text>
</Tooltip>
</Group>
<Stack gap={1}>
{match.home?.players.map((p) => (
<Text key={`match-card-p-${p.id}`} size="xs" fw={600} c="dimmed" ta="right">
{p.first_name} {p.last_name}
</Text>
))}
</Stack>
<Text <Text
size="md" size="xl"
fw={700} fw={700}
c={"dimmed"} c={"dimmed"}
display={match.status === "ended" ? undefined : "none"}
> >
{match.home_cups} {match.home_cups}
</Text> </Text>
<Text size="md" c="dimmed" fw={500}> </Group>
-
</Text> <Group justify="space-between" align="center">
<Group gap="sm" style={{ flex: 1 }}>
<Box
style={{ position: "relative", cursor: "pointer" }}
onClick={handleAwayTeamClick}
>
<Avatar
size={40}
name={match.away?.name!}
radius="sm"
src={
match.away?.logo
? `/api/files/teams/${match.away?.id}/${match.away?.logo}`
: undefined
}
/>
{isAwayWin && (
<Box
style={{
position: "absolute",
top: -10,
left: -4,
transform: "rotate(-25deg)",
color: "gold",
}}
>
<CrownIcon size={16} weight="fill" />
</Box>
)}
</Box>
<Tooltip
label={match.away?.name}
disabled={!match.away?.name}
events={{ hover: true, focus: true, touch: true }}
>
<Text
size="sm"
fw={600}
lineClamp={1}
style={{ minWidth: 0, flex: 1, cursor: 'pointer' }}
>
{match.away?.name}
</Text>
</Tooltip>
</Group>
<Stack gap={1}>
{match.away?.players.map((p) => (
<Text key={`match-card-p-${p.id}`} size="xs" fw={600} c="dimmed" ta="right">
{p.first_name} {p.last_name}
</Text>
))}
</Stack>
<Text <Text
size="md" size="xl"
fw={700} fw={700}
c={"dimmed"} c={"dimmed"}
display={match.status === "ended" ? undefined : "none"}
> >
{match.away_cups} {match.away_cups}
</Text> </Text>
{match.ot_count > 0 && (
<Badge size="xs" color="orange" variant="light">
{match.ot_count}OT
</Badge>
)}
</Group> </Group>
</Stack>
</Paper>
<Group gap="sm" style={{ flex: 1 }} justify="flex-end"> <Paper
<Text px="md"
size="sm" pb={4}
fw={600} style={{
lineClamp={1} position: "relative",
ta="right" zIndex: 1,
style={{ minWidth: 0, flex: 1 }} marginTop: -6,
> paddingTop: 8,
{match.away?.name} backgroundColor: "var(--mantine-color-gray-light)",
</Text> borderTop: "none",
<Box borderRadius:
style={{ position: "relative", cursor: "pointer" }} "0 0 var(--mantine-radius-md) var(--mantine-radius-md)",
onClick={handleAwayTeamClick} border: "1px solid var(--mantine-color-default-border)",
> }}
<Avatar size={40} name={match.away?.name!} radius="sm" /> >
{isAwayWin && ( <Suspense>
<Box <EmojiBar matchId={match.id} />
style={{ </Suspense>
position: "absolute", </Paper>
top: -10, </Box>
right: -4,
transform: "rotate(25deg)",
color: "gold",
}}
>
<CrownIcon size={16} weight="fill" />
</Box>
)}
</Box>
</Group>
</Group>
</Stack>
</Paper>
</Indicator> </Indicator>
); );
}; };

View File

@@ -1,6 +1,4 @@
import { Stack, Text, ThemeIcon, Box } from "@mantine/core"; import { Stack } from "@mantine/core";
import { TrophyIcon } from "@phosphor-icons/react";
import { motion, AnimatePresence } from "framer-motion";
import { Match } from "../types"; import { Match } from "../types";
import MatchCard from "./match-card"; import MatchCard from "./match-card";
@@ -14,33 +12,18 @@ const MatchList = ({ matches }: MatchListProps) => {
) || []; ) || [];
if (!filteredMatches.length) { if (!filteredMatches.length) {
return ( return undefined;
<Box ta="center" py="xl">
<ThemeIcon size="xl" variant="light" radius="md" mb="md">
<TrophyIcon size={32} />
</ThemeIcon>
<Text c="dimmed" size="lg">
No matches found
</Text>
</Box>
);
} }
return ( return (
<Stack gap="sm"> <Stack p="md" gap="sm">
<AnimatePresence> {filteredMatches.map((match, index) => (
{filteredMatches.map((match, index) => ( <div
<motion.div key={`match-${match.id}-${index}`}
key={`match-${match.id}-${index}`} >
initial={{ opacity: 0, y: 10 }} <MatchCard match={match} />
animate={{ opacity: 1, y: 0 }} </div>
exit={{ opacity: 0, y: -10 }} ))}
transition={{ duration: 0.2, delay: index * 0.01 }}
>
<MatchCard match={match} />
</motion.div>
))}
</AnimatePresence>
</Stack> </Stack>
); );
}; };

View File

@@ -6,6 +6,9 @@ import { z } from "zod";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import brackets from "@/features/bracket/utils"; import brackets from "@/features/bracket/utils";
import { MatchInput } from "@/features/matches/types"; import { MatchInput } from "@/features/matches/types";
import { serverEvents } from "@/lib/events/emitter";
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { PlayerInfo } from "../players/types";
const orderedTeamsSchema = z.object({ const orderedTeamsSchema = z.object({
tournamentId: z.string(), tournamentId: z.string(),
@@ -13,7 +16,7 @@ const orderedTeamsSchema = z.object({
}); });
export const generateTournamentBracket = createServerFn() export const generateTournamentBracket = createServerFn()
.validator(orderedTeamsSchema) .inputValidator(orderedTeamsSchema)
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: { tournamentId, orderedTeamIds } }) => .handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -134,7 +137,7 @@ export const generateTournamentBracket = createServerFn()
); );
export const startMatch = createServerFn() export const startMatch = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -150,6 +153,12 @@ export const startMatch = createServerFn()
status: "started", status: "started",
}); });
serverEvents.emit("match", {
type: "match",
matchId: match.id,
tournamentId: match.tournament.id
});
return match; return match;
}) })
); );
@@ -161,7 +170,7 @@ const endMatchSchema = z.object({
ot_count: z.number(), ot_count: z.number(),
}); });
export const endMatch = createServerFn() export const endMatch = createServerFn()
.validator(endMatchSchema) .inputValidator(endMatchSchema)
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) => .handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -184,14 +193,9 @@ export const endMatch = createServerFn()
const matchLoser = home_cups < away_cups ? match.home : match.away; const matchLoser = home_cups < away_cups ? match.home : match.away;
if (!matchWinner || !matchLoser) throw new Error("Something went wrong"); if (!matchWinner || !matchLoser) throw new Error("Something went wrong");
console.log(matchWinner)
console.log(matchLoser)
// winner -> where to send match winner to, loser same // winner -> where to send match winner to, loser same
const { winner, loser } = await pbAdmin.getChildMatches(matchId); const { winner, loser } = await pbAdmin.getChildMatches(matchId);
console.log(winner, loser)
// reset match check // reset match check
if (winner && winner.reset) { if (winner && winner.reset) {
const awayTeamWon = match.away === matchWinner; const awayTeamWon = match.away === matchWinner;
@@ -232,8 +236,113 @@ export const endMatch = createServerFn()
}); });
} }
// TODO: send SSE serverEvents.emit("match", {
type: "match",
matchId: match.id,
tournamentId: match.tournament.id
});
return match; return match;
}) })
); );
const toggleReactionSchema = z.object({
matchId: z.string(),
emoji: z.string(),
});
export const toggleMatchReaction = createServerFn()
.inputValidator(toggleReactionSchema)
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: { matchId, emoji }, context }) =>
toServerResult(async () => {
const user = await pbAdmin.getPlayerByAuthId(context.userAuthId);
const userId = user?.id;
if (!userId) return;
const match = await pbAdmin.getMatch(matchId);
if (!match) {
throw new Error("Match not found");
}
const existingReaction = await pbAdmin.getUserReaction(matchId, userId, emoji);
if (existingReaction) {
await pbAdmin.deleteReaction(existingReaction.id);
logger.info("Removed reaction", { matchId, emoji, userId });
} else {
await pbAdmin.createReaction(matchId, userId, emoji);
logger.info("Added reaction", { matchId, emoji, userId });
}
const all = await pbAdmin.getReactionsForMatch(matchId);
const reactionsByEmoji = all.reduce((acc, reaction) => {
const emoji = reaction.emoji;
if (!acc[emoji]) {
acc[emoji] = {
emoji,
count: 0,
players: [],
};
}
acc[emoji].count++;
acc[emoji].players.push({
id: reaction?.player?.id,
first_name: reaction.player?.first_name,
last_name: reaction.player?.last_name,
});
return acc;
}, {} as Record<string, any>);
const reactions = Object.values(reactionsByEmoji);
serverEvents.emit("reaction", {
type: "reaction",
matchId,
reactions,
});
return reactions as Reaction[]
})
);
export interface Reaction {
emoji: string;
count: number;
players: PlayerInfo[];
}
export const getMatchReactions = createServerFn()
.inputValidator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: matchId, context }) =>
toServerResult(async () => {
const match = await pbAdmin.getMatch(matchId);
if (!match) {
throw new Error("Match not found");
}
const all = await pbAdmin.getReactionsForMatch(matchId);
const reactionsByEmoji = all.reduce((acc, reaction) => {
const emoji = reaction.emoji;
if (!acc[emoji]) {
acc[emoji] = {
emoji,
count: 0,
players: [],
};
}
acc[emoji].count++;
acc[emoji].players.push({
id: reaction?.player?.id,
first_name: reaction.player?.first_name,
last_name: reaction.player?.last_name,
});
return acc;
}, {} as Record<string, any>);
const reactions = Object.values(reactionsByEmoji);
return reactions as Reaction[]
})
);

View File

@@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { TeamInfo } from "../teams/types"; import { TeamInfo, Team } from "../teams/types";
import { TournamentInfo } from "../tournaments/types"; import { TournamentInfo } from "../tournaments/types";
export type MatchStatus = "tbd" | "ready" | "started" | "ended"; export type MatchStatus = "tbd" | "ready" | "started" | "ended";
@@ -23,8 +23,8 @@ export interface Match {
is_losers_bracket: boolean; is_losers_bracket: boolean;
status: MatchStatus; status: MatchStatus;
tournament: TournamentInfo; tournament: TournamentInfo;
home?: TeamInfo; home?: TeamInfo | Team;
away?: TeamInfo; away?: TeamInfo | Team;
created: string; created: string;
updated: string; updated: string;
home_seed?: number; home_seed?: number;

View File

@@ -13,7 +13,7 @@ const PlayerList = ({ players, loading = false }: PlayerListProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const handleClick = useCallback((playerId: string) => const handleClick = useCallback((playerId: string) =>
navigate({ to: `/profile/${playerId} `}), [navigate]); navigate({ to: `/profile/${playerId}`}), [navigate]);
if (loading) return <List> if (loading) return <List>
{Array.from({ length: 10 }).map((_, i) => ( {Array.from({ length: 10 }).map((_, i) => (
@@ -25,7 +25,7 @@ const PlayerList = ({ players, loading = false }: PlayerListProps) => {
))} ))}
</List> </List>
return <List> return <List p="0">
{players?.map((player) => ( {players?.map((player) => (
<ListItem key={player.id} <ListItem key={player.id}
py='xs' py='xs'

View File

@@ -0,0 +1,87 @@
import {
Stack,
Group,
Box,
Container,
Divider,
Skeleton,
} from "@mantine/core";
const PlayerListItemSkeleton = () => {
return (
<Box p="md">
<Group justify="space-between" align="center" w="100%">
<Group gap="sm" align="center">
<Skeleton height={45} circle />
<Stack gap={2}>
<Group gap='xs'>
<Skeleton height={16} width={120} />
<Skeleton height={12} width={60} />
<Skeleton height={12} width={80} />
</Group>
<Group gap="md" ta="center">
<Stack gap={0}>
<Skeleton height={10} width={25} />
<Skeleton height={10} width={30} />
</Stack>
<Stack gap={0}>
<Skeleton height={10} width={10} />
<Skeleton height={10} width={15} />
</Stack>
<Stack gap={0}>
<Skeleton height={10} width={10} />
<Skeleton height={10} width={15} />
</Stack>
<Stack gap={0}>
<Skeleton height={10} width={20} />
<Skeleton height={10} width={25} />
</Stack>
<Stack gap={0}>
<Skeleton height={10} width={25} />
<Skeleton height={10} width={20} />
</Stack>
<Stack gap={0}>
<Skeleton height={10} width={15} />
<Skeleton height={10} width={25} />
</Stack>
<Stack gap={0}>
<Skeleton height={10} width={15} />
<Skeleton height={10} width={25} />
</Stack>
</Group>
</Stack>
</Group>
</Group>
</Box>
);
};
const PlayerStatsTableSkeleton = () => {
return (
<Container size="100%" px={0}>
<Stack gap="xs">
<Box px="md" pb="xs">
<Skeleton height={40} />
</Box>
<Group px="md" justify="space-between" align="center">
<Skeleton height={12} width={100} />
<Group gap="xs">
<Skeleton height={12} width={200} />
</Group>
</Group>
<Stack>
{Array(10).fill(null).map((_, index) => (
<Box key={index}>
<PlayerListItemSkeleton />
{index < 9 && <Divider />}
</Box>
))}
</Stack>
</Stack>
</Container>
);
};
export default PlayerStatsTableSkeleton;

View File

@@ -1,6 +1,5 @@
import { useState, useMemo } from "react"; import { useState, useMemo, useCallback, memo } from "react";
import { import {
Table,
Text, Text,
TextInput, TextInput,
Stack, Stack,
@@ -9,8 +8,8 @@ import {
ThemeIcon, ThemeIcon,
Container, Container,
Title, Title,
ScrollArea, Divider,
Paper, UnstyledButton,
Popover, Popover,
ActionIcon, ActionIcon,
} from "@mantine/core"; } from "@mantine/core";
@@ -22,11 +21,9 @@ import {
InfoIcon, InfoIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { PlayerStats } from "../types"; import { PlayerStats } from "../types";
import { motion } from "framer-motion"; import Avatar from "@/components/avatar";
import { useNavigate } from "@tanstack/react-router";
interface PlayerStatsTableProps { import { useAllPlayerStats } from "../queries";
playerStats: PlayerStats[];
}
type SortKey = keyof PlayerStats | "mmr"; type SortKey = keyof PlayerStats | "mmr";
type SortDirection = "asc" | "desc"; type SortDirection = "asc" | "desc";
@@ -36,64 +33,155 @@ interface SortConfig {
direction: SortDirection; direction: SortDirection;
} }
const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { interface PlayerListItemProps {
stat: PlayerStats;
onPlayerClick: (playerId: string) => void;
mmr: number;
}
const PlayerListItem = memo(({ stat, onPlayerClick, mmr }: PlayerListItemProps) => {
return (
<>
<UnstyledButton
w="100%"
p="md"
onClick={() => onPlayerClick(stat.id)}
style={{
borderRadius: 0,
transition: "background-color 0.15s ease",
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'var(--mantine-color-gray-0)',
},
},
}}
>
<Group justify="space-between" align="center" w="100%">
<Group gap="sm" align="center">
<Avatar name={stat.player_name} size={40} />
<Stack gap={2}>
<Group gap='xs'>
<Text size="sm" fw={600}>
{stat.player_name}
</Text>
<Text size="xs" c="dimmed" ta="right">
{stat.matches} matches
</Text>
<Text size="xs" c="dimmed" ta="right">
{stat.tournaments} tournaments
</Text>
</Group>
<Group gap="md" ta="center">
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
MMR
</Text>
<Text size="xs" c="dimmed">
{mmr.toFixed(1)}
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
W
</Text>
<Text size="xs" c="dimmed">
{stat.wins}
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
L
</Text>
<Text size="xs" c="dimmed">
{stat.losses}
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
W%
</Text>
<Text size="xs" c="dimmed">
{stat.win_percentage.toFixed(1)}%
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
AVG
</Text>
<Text size="xs" c="dimmed">
{stat.avg_cups_per_match.toFixed(1)}
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
CF
</Text>
<Text size="xs" c="dimmed">
{stat.total_cups_made}
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
CA
</Text>
<Text size="xs" c="dimmed">
{stat.total_cups_against}
</Text>
</Stack>
</Group>
</Stack>
</Group>
</Group>
</UnstyledButton>
</>
);
});
PlayerListItem.displayName = 'PlayerListItem';
const PlayerStatsTable = () => {
const { data: playerStats } = useAllPlayerStats();
const navigate = useNavigate();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [sortConfig, setSortConfig] = useState<SortConfig>({ const [sortConfig, setSortConfig] = useState<SortConfig>({
key: "mmr" as SortKey, key: "mmr" as SortKey,
direction: "desc", direction: "desc",
}); });
// Calculate MMR (Match Making Rating) based on multiple factors
const calculateMMR = (stat: PlayerStats): number => { const calculateMMR = (stat: PlayerStats): number => {
if (stat.matches === 0) return 0; if (stat.matches === 0) return 0;
// Base score from win percentage (0-100)
const winScore = stat.win_percentage; const winScore = stat.win_percentage;
const matchConfidence = Math.min(stat.matches / 15, 1);
// Match confidence factor (more matches = more reliable) const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100);
// Cap at 20 matches for full confidence
const matchConfidence = Math.min(stat.matches / 20, 1);
// Performance metrics
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100); // Cap at 10 avg cups
const marginScore = stat.margin_of_victory const marginScore = stat.margin_of_victory
? Math.min(stat.margin_of_victory * 20, 50) ? Math.min(stat.margin_of_victory * 20, 50)
: 0; // Cap at 2.5 margin : 0;
const volumeBonus = Math.min(stat.matches * 0.5, 10);
// Volume bonus for active players (small bonus for playing more)
const volumeBonus = Math.min(stat.matches * 0.5, 10); // Max 10 point bonus
// Weighted calculation
const baseMMR = const baseMMR =
winScore * 0.5 + // Win % is 50% of score winScore * 0.5 +
avgCupsScore * 0.25 + // Avg cups is 25% of score avgCupsScore * 0.25 +
marginScore * 0.15 + // Win margin is 15% of score marginScore * 0.15 +
volumeBonus * 0.1; // Volume bonus is 10% of score volumeBonus * 0.1;
// Apply confidence factor (players with few matches get penalized)
const finalMMR = baseMMR * matchConfidence; const finalMMR = baseMMR * matchConfidence;
return Math.round(finalMMR * 10) / 10;
return Math.round(finalMMR * 10) / 10; // Round to 1 decimal
}; };
const handleSort = (key: SortKey) => { const statsWithMMR = useMemo(() => {
setSortConfig((prev) => ({ return playerStats.map((stat) => ({
key, ...stat,
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc", mmr: calculateMMR(stat),
})); }));
}; }, [playerStats]);
const getSortIcon = (key: SortKey) => {
if (sortConfig.key !== key) return null;
return sortConfig.direction === "desc" ? (
<CaretDownIcon size={14} />
) : (
<CaretUpIcon size={14} />
);
};
const filteredAndSortedStats = useMemo(() => { const filteredAndSortedStats = useMemo(() => {
let filtered = playerStats.filter((stat) => let filtered = statsWithMMR.filter((stat) =>
stat.player_name.toLowerCase().includes(search.toLowerCase()) stat.player_name.toLowerCase().includes(search.toLowerCase())
); );
@@ -101,10 +189,9 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
let aValue: number | string; let aValue: number | string;
let bValue: number | string; let bValue: number | string;
// Special handling for MMR
if (sortConfig.key === "mmr") { if (sortConfig.key === "mmr") {
aValue = calculateMMR(a); aValue = a.mmr;
bValue = calculateMMR(b); bValue = b.mmr;
} else { } else {
aValue = a[sortConfig.key]; aValue = a[sortConfig.key];
bValue = b[sortConfig.key]; bValue = b[sortConfig.key];
@@ -124,80 +211,31 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
return 0; return 0;
}); });
}, [playerStats, search, sortConfig]); }, [statsWithMMR, search, sortConfig]);
const formatPercentage = (value: number) => `${value.toFixed(1)}%`; const handlePlayerClick = useCallback((playerId: string) => {
const formatDecimal = (value: number) => value.toFixed(2); navigate({ to: `/profile/${playerId}` });
}, [navigate]);
const columns = [ const handleSort = (key: SortKey) => {
{ key: "player_name" as SortKey, label: "Player", width: 175 }, setSortConfig((prev) => ({
{ key: "mmr" as SortKey, label: "MMR", width: 90 }, key,
{ key: "win_percentage" as SortKey, label: "Win %", width: 110 }, direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
{ key: "matches" as SortKey, label: "Matches", width: 90 }, }));
{ key: "wins" as SortKey, label: "Wins", width: 80 }, };
{ key: "losses" as SortKey, label: "Losses", width: 80 },
{ key: "total_cups_made" as SortKey, label: "Cups Made", width: 110 },
{ key: "total_cups_against" as SortKey, label: "Cups Against", width: 120 },
{ key: "avg_cups_per_match" as SortKey, label: "Avg/Match", width: 100 },
{ key: "margin_of_victory" as SortKey, label: "Win Margin", width: 110 },
{ key: "margin_of_loss" as SortKey, label: "Loss Margin", width: 110 },
];
const renderCellContent = ( const getSortIcon = (key: SortKey) => {
stat: PlayerStats, if (sortConfig.key !== key) return null;
column: (typeof columns)[0], return sortConfig.direction === "desc" ? (
index: number <CaretDownIcon size={14} />
) => { ) : (
switch (column.key) { <CaretUpIcon size={14} />
case "player_name": );
return (
<Text size="sm" fw={600}>
{stat.player_name}
</Text>
);
case "mmr":
const mmr = calculateMMR(stat);
return (
<Box>
<Text fw={700} size="md">
{mmr.toFixed(1)}
</Text>
</Box>
);
case "win_percentage":
return <Text size="sm">{formatPercentage(stat.win_percentage)}</Text>;
case "wins":
return <Text fw={500}>{stat.wins}</Text>;
case "losses":
return <Text fw={500}>{stat.losses}</Text>;
case "total_cups_made":
return <Text fw={500}>{stat.total_cups_made}</Text>;
case "matches":
return <Text fw={500}>{stat.matches}</Text>;
case "avg_cups_per_match":
return <Text>{formatDecimal(stat.avg_cups_per_match)}</Text>;
case "margin_of_victory":
return (
<Text>
{stat.margin_of_victory
? formatDecimal(stat.margin_of_victory)
: "N/A"}
</Text>
);
case "margin_of_loss":
return (
<Text>
{stat.margin_of_loss ? formatDecimal(stat.margin_of_loss) : "N/A"}
</Text>
);
default:
return <Text>{(stat as any)[column.key]}</Text>;
}
}; };
if (playerStats.length === 0) { if (playerStats.length === 0) {
return ( return (
<Container size="md"> <Container px={0} size="md">
<Stack align="center" gap="md" py="xl"> <Stack align="center" gap="md" py="xl">
<ThemeIcon size="xl" variant="light" radius="md"> <ThemeIcon size="xl" variant="light" radius="md">
<ChartBarIcon size={32} /> <ChartBarIcon size={32} />
@@ -205,9 +243,6 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
<Title order={3} c="dimmed"> <Title order={3} c="dimmed">
No Stats Available No Stats Available
</Title> </Title>
<Text c="dimmed" ta="center">
Player statistics will appear here once matches have been played.
</Text>
</Stack> </Stack>
</Container> </Container>
); );
@@ -216,194 +251,97 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
return ( return (
<Container size="100%" px={0}> <Container size="100%" px={0}>
<Stack gap="xs"> <Stack gap="xs">
<Text ml="auto" size="xs" c="dimmed">
{filteredAndSortedStats.length} of {playerStats.length} players
</Text>
<TextInput <TextInput
placeholder="Search players" placeholder="Search players"
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
leftSection={<MagnifyingGlassIcon size={16} />} leftSection={<MagnifyingGlassIcon size={16} />}
size="md" size="md"
px="md"
/> />
<Paper withBorder radius="md" p={0} style={{ overflow: "hidden" }}> <Group px="md" justify="space-between" align="center">
<ScrollArea> <Text size="10px" lh={0} c="dimmed">
<Table {filteredAndSortedStats.length} of {playerStats.length} players
highlightOnHover </Text>
striped <Group gap="xs">
withTableBorder={false} <Text size="xs" c="dimmed">Sort:</Text>
style={{ <UnstyledButton
minWidth: 1000, onClick={() => handleSort("mmr")}
borderRadius: "inherit", style={{ display: "flex", alignItems: "center", gap: 4 }}
}}
> >
<Table.Thead <Text size="xs" fw={sortConfig.key === "mmr" ? 600 : 400} c={sortConfig.key === "mmr" ? "dark" : "dimmed"}>
style={{ MMR
backgroundColor: "var(--mantine-color-body)", </Text>
position: "sticky", {getSortIcon("mmr")}
top: 0, </UnstyledButton>
zIndex: 1, <Text size="xs" c="dimmed"></Text>
}} <UnstyledButton
> onClick={() => handleSort("wins")}
<Table.Tr> style={{ display: "flex", alignItems: "center", gap: 4 }}
{columns.map((column, index) => ( >
<Table.Th <Text size="xs" fw={sortConfig.key === "wins" ? 600 : 400} c={sortConfig.key === "wins" ? "dark" : "dimmed"}>
key={column.key} Wins
style={{ </Text>
cursor: "pointer", {getSortIcon("wins")}
userSelect: "none", </UnstyledButton>
width: column.width, <Text size="xs" c="dimmed"></Text>
minWidth: column.width, <UnstyledButton
padding: "12px 16px", onClick={() => handleSort("matches")}
fontWeight: 600, style={{ display: "flex", alignItems: "center", gap: 4 }}
backgroundColor: "var(--mantine-color-body)", >
borderBottom: <Text size="xs" fw={sortConfig.key === "matches" ? 600 : 400} c={sortConfig.key === "matches" ? "dark" : "dimmed"}>
"2px solid var(--mantine-color-default-border)", Matches
...(index === 0 && { </Text>
position: "sticky", {getSortIcon("matches")}
left: 0, </UnstyledButton>
zIndex: 2, <Popover position="bottom-end" withArrow shadow="md">
borderTopLeftRadius: "var(--mantine-radius-md)", <Popover.Target>
}), <ActionIcon variant="subtle" size="sm">
...(index === columns.length - 1 && { <InfoIcon size={14} />
borderTopRightRadius: "var(--mantine-radius-md)", </ActionIcon>
}), </Popover.Target>
}} <Popover.Dropdown>
onClick={() => handleSort(column.key)} <Box maw={280}>
> <Text size="sm" fw={500} mb="xs">
<Group MMR Calculation:
gap="xs" </Text>
wrap="nowrap" <Text size="xs" mb={2}>
style={{ position: "relative" }} Win Rate (50%)
> </Text>
<Text size="sm" fw={600}> <Text size="xs" mb={2}>
{column.label} Average Cups/Match (25%)
</Text> </Text>
{column.key === "mmr" && ( <Text size="xs" mb={2}>
<div Average Win Margin (15%)
onClick={(e) => { </Text>
e.stopPropagation(); <Text size="xs" mb={2}>
e.preventDefault(); Match Volume Bonus (10%)
}} </Text>
onMouseDown={(e) => { <Text size="xs" mt="xs" c="dimmed">
e.stopPropagation(); * Confidence penalty applied for players with &lt;15 matches
}} </Text>
> <Text size="xs" mt="xs" c="dimmed">
<Popover position="bottom" withArrow shadow="md"> ** Not an official rating
<Popover.Target> </Text>
<ActionIcon variant="subtle" size="xs"> </Box>
<InfoIcon size={12} /> </Popover.Dropdown>
</ActionIcon> </Popover>
</Popover.Target> </Group>
<Popover.Dropdown> </Group>
<Box maw={280}>
<Text size="sm" fw={500} mb="xs"> <Stack gap={0}>
MMR Calculation: {filteredAndSortedStats.map((stat, index) => (
</Text> <Box key={stat.id}>
<Text size="xs" mb={2}> <PlayerListItem
Win Rate (50%) stat={stat}
</Text> onPlayerClick={handlePlayerClick}
<Text size="xs" mb={2}> mmr={stat.mmr}
Average Cups/Match (25%) />
</Text> {index < filteredAndSortedStats.length - 1 && <Divider />}
<Text size="xs" mb={2}> </Box>
Average Win Margin (15%) ))}
</Text> </Stack>
<Text size="xs" mb={2}>
Match Volume Bonus (10%)
</Text>
<Text size="xs" mt="xs" c="dimmed">
* Confidence penalty applied for players
with &lt;20 matches
</Text>
<Text size="xs" mt="xs" c="dimmed">
** Not an official rating
</Text>
</Box>
</Popover.Dropdown>
</Popover>
</div>
)}
<Box
style={{
minWidth: 16,
display: "flex",
justifyContent: "center",
}}
>
{getSortIcon(column.key)}
</Box>
{index === 0 && (
<div
style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: "2px",
backgroundColor:
"var(--mantine-color-default-border)",
zIndex: 1,
}}
/>
)}
</Group>
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredAndSortedStats.map((stat, index) => (
<motion.tr
key={stat.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.01 }}
style={{
borderBottom:
"1px solid var(--mantine-color-default-border)",
}}
>
{columns.map((column, columnIndex) => (
<Table.Td
key={`${stat.id}-${column.key}`}
style={{
padding: "12px 16px",
verticalAlign: "middle",
...(columnIndex === 0 && {
position: "sticky",
left: 0,
backgroundColor: "var(--mantine-color-body)",
zIndex: 1,
}),
}}
>
<div style={{ position: "relative" }}>
{renderCellContent(stat, column, index)}
{columnIndex === 0 && (
<div
style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: "2px",
backgroundColor:
"var(--mantine-color-default-border)",
zIndex: 1,
}}
/>
)}
</div>
</Table.Td>
))}
</motion.tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
</Paper>
{filteredAndSortedStats.length === 0 && search && ( {filteredAndSortedStats.length === 0 && search && (
<Text ta="center" c="dimmed" py="xl"> <Text ta="center" c="dimmed" py="xl">

View File

@@ -0,0 +1,14 @@
import { Flex, Skeleton } from "@mantine/core";
const HeaderSkeleton = () => {
return (
<Flex h="15dvh" px='xl' w='100%' align='self-end' gap='md'>
<Skeleton opacity={0} height={100} width={100} radius="50%" />
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Skeleton height={24} width={200} />
</Flex>
</Flex>
);
};
export default HeaderSkeleton;

View File

@@ -13,7 +13,6 @@ interface HeaderProps {
} }
const Header = ({ player }: HeaderProps) => { const Header = ({ player }: HeaderProps) => {
const sheet = useSheet(); const sheet = useSheet();
const { user: authUser } = useAuth(); const { user: authUser } = useAuth();
@@ -34,7 +33,7 @@ const Header = ({ player }: HeaderProps) => {
return ( return (
<> <>
<Flex px='lg' w='100%' align='self-end' gap='md'> <Flex h="15dvh" px='xl' w='100%' align='self-end' gap='md'>
<Avatar name={name} size={100} /> <Avatar name={name} size={100} />
<Flex align='center' justify='center' gap={4} pb={20} w='100%'> <Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Title ta='center' style={{ fontSize, lineHeight: 1.2 }}>{name}</Title> <Title ta='center' style={{ fontSize, lineHeight: 1.2 }}>{name}</Title>

View File

@@ -1,10 +1,9 @@
import { Box } from "@mantine/core"; import { Box } from "@mantine/core";
import Header from "./header"; import Header from "./header";
import { Player } from "@/features/players/types";
import SwipeableTabs from "@/components/swipeable-tabs"; import SwipeableTabs from "@/components/swipeable-tabs";
import { usePlayer, usePlayerMatches } from "../../queries"; import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
import TeamList from "@/features/teams/components/team-list"; import TeamList from "@/features/teams/components/team-list";
import StatsOverview from "../stats-overview"; import StatsOverview from "@/components/stats-overview";
import MatchList from "@/features/matches/components/match-list"; import MatchList from "@/features/matches/components/match-list";
interface ProfileProps { interface ProfileProps {
@@ -14,15 +13,16 @@ interface ProfileProps {
const Profile = ({ id }: ProfileProps) => { const Profile = ({ id }: ProfileProps) => {
const { data: player } = usePlayer(id); const { data: player } = usePlayer(id);
const { data: matches } = usePlayerMatches(id); const { data: matches } = usePlayerMatches(id);
const { data: stats, isLoading: statsLoading } = usePlayerStats(id);
const tabs = [ const tabs = [
{ {
label: "Overview", label: "Overview",
content: <StatsOverview playerId={id} />, content: <StatsOverview statsData={stats} isLoading={statsLoading} />,
}, },
{ {
label: "Matches", label: "Matches",
content: <Box p="md"><MatchList matches={matches || []} /></Box>, content: <MatchList matches={matches || []} />,
}, },
{ {
label: "Teams", label: "Teams",
@@ -33,7 +33,7 @@ const Profile = ({ id }: ProfileProps) => {
return ( return (
<> <>
<Header player={player} /> <Header player={player} />
<Box m='md' mt="lg"> <Box mt="lg">
<SwipeableTabs tabs={tabs} /> <SwipeableTabs tabs={tabs} />
</Box> </Box>
</> </>

View File

@@ -0,0 +1,42 @@
import { Box, Flex, Loader } from "@mantine/core";
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, { StatsSkeleton } from "@/components/stats-overview";
import MatchList from "@/features/matches/components/match-list";
import HeaderSkeleton from "./header-skeleton";
const SkeletonLoader = () => (
<Flex h="30vh" w="100%" align="center" justify="center">
<Loader />
</Flex>
)
const ProfileSkeleton = () => {
const tabs = [
{
label: "Overview",
content: <StatsSkeleton />,
},
{
label: "Matches",
content: <SkeletonLoader />,
},
{
label: "Teams",
content: <SkeletonLoader />,
},
];
return (
<>
<HeaderSkeleton />
<Box mt="lg">
<SwipeableTabs tabs={tabs} />
</Box>
</>
);
};
export default ProfileSkeleton;

View File

@@ -1,270 +0,0 @@
import { Box, Grid, Text, Group, Stack, ThemeIcon, Card, Avatar, Progress, Badge, Divider } from "@mantine/core";
import { CrownIcon, XIcon, FireIcon, ShieldIcon, ChartLineUpIcon, ShieldCheckIcon, BoxingGloveIcon, Icon, TrendUpIcon, ArrowUpIcon, ArrowDownIcon } from "@phosphor-icons/react";
import { usePlayerStats } from "../queries";
interface StatsOverviewProps {
playerId: string;
}
const StatCard = ({
label,
value,
suffix = "",
Icon,
variant = "default"
}: {
label: string;
value: number | null;
suffix?: string;
Icon?: Icon;
variant?: "default" | "compact";
}) => {
if (variant === "compact") {
return (
<Card p="xs" radius="md" withBorder>
<Group gap={2} justify="space-between" align="flex-start">
<Stack gap={2} flex={1}>
<Text size='xs' c="dimmed" fw={600} tt="uppercase" lts="0.3px">
{label}
</Text>
<Text size="md" fw={700} lh={1}>
{value !== null ? `${value}${suffix}` : "—"}
</Text>
</Stack>
{Icon && (
<ThemeIcon size="sm" variant="light" radius="md">
<Icon size={12} />
</ThemeIcon>
)}
</Group>
</Card>
);
}
return (
<Card p="sm" radius="md" withBorder>
<Stack gap="xs">
<Group justify="space-between" align="center">
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lts="0.3px">
{label}
</Text>
{Icon && (
<ThemeIcon size="sm" variant="light" radius="sm">
<Icon size={14} />
</ThemeIcon>
)}
</Group>
<Text size="xl" fw={700} lh={1}>
{value !== null ? `${value}${suffix}` : "—"}
</Text>
</Stack>
</Card>
);
};
const StatsOverview = ({ playerId }: StatsOverviewProps) => {
const { data: statsData } = usePlayerStats(playerId);
if (!statsData || statsData.length === 0) {
return (
<Box p="sm">
<div style={{ padding: '12px', border: '1px solid var(--mantine-color-gray-3)', borderRadius: '4px', textAlign: 'center' }}>
<Text size="sm" c="dimmed">
No stats available yet
</Text>
</div>
</Box>
);
}
const overallStats = statsData.reduce(
(acc, stat) => ({
matches: acc.matches + stat.matches,
wins: acc.wins + stat.wins,
losses: acc.losses + stat.losses,
total_cups_made: acc.total_cups_made + stat.total_cups_made,
total_cups_against: acc.total_cups_against + stat.total_cups_against,
}),
{ matches: 0, wins: 0, losses: 0, total_cups_made: 0, total_cups_against: 0 }
);
const winPercentage = overallStats.matches > 0
? ((overallStats.wins / overallStats.matches) * 100)
: 0;
const avgCupsPerMatch = overallStats.matches > 0
? (overallStats.total_cups_made / overallStats.matches)
: 0;
const avgCupsAgainstPerMatch = overallStats.matches > 0
? (overallStats.total_cups_against / overallStats.matches)
: 0;
const validMarginOfVictory = statsData.filter(stat => stat.margin_of_victory > 0);
const validMarginOfLoss = statsData.filter(stat => stat.margin_of_loss > 0);
const avgMarginOfVictory = validMarginOfVictory.length > 0
? (validMarginOfVictory.reduce((acc, stat) => acc + stat.margin_of_victory, 0) / validMarginOfVictory.length)
: 0;
const avgMarginOfLoss = validMarginOfLoss.length > 0
? (validMarginOfLoss.reduce((acc, stat) => acc + stat.margin_of_loss, 0) / validMarginOfLoss.length)
: 0;
const getWinRateColor = (rate: number) => {
if (rate >= 70) return "green";
if (rate >= 50) return "blue";
if (rate >= 30) return "orange";
return "red";
};
return (
<Box>
<Stack gap="lg">
<Divider />
<Stack gap="sm">
<Text size="md" fw={600} c="dark">Match Statistics</Text>
<Grid gutter="xs">
<Grid.Col span={4}>
<StatCard
label="Matches"
value={overallStats.matches}
Icon={BoxingGloveIcon}
variant="compact"
/>
</Grid.Col>
<Grid.Col span={4}>
<StatCard
label="Wins"
value={overallStats.wins}
Icon={CrownIcon}
variant="compact"
/>
</Grid.Col>
<Grid.Col span={4}>
<StatCard
label="Losses"
value={overallStats.losses}
Icon={XIcon}
variant="compact"
/>
</Grid.Col>
</Grid>
</Stack>
<Stack gap="sm">
<Text size="md" fw={600} c="dark">Metrics</Text>
<Grid gutter="xs">
<Grid.Col span={6}>
<StatCard
label="Cups Made"
value={overallStats.total_cups_made}
Icon={FireIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Cups Against"
value={overallStats.total_cups_against}
Icon={ShieldIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Avg Per Game"
value={parseFloat(avgCupsPerMatch.toFixed(1))}
Icon={ChartLineUpIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Avg Against"
value={parseFloat(avgCupsAgainstPerMatch.toFixed(1))}
Icon={ShieldCheckIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Win Margin"
value={avgMarginOfVictory > 0 ? parseFloat(avgMarginOfVictory.toFixed(1)) : null}
Icon={ArrowUpIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Loss Margin"
value={avgMarginOfLoss > 0 ? parseFloat(avgMarginOfLoss.toFixed(1)) : null}
Icon={ArrowDownIcon}
/>
</Grid.Col>
</Grid>
</Stack>
{/* Team Performance */}
{statsData.length > 1 && (
<>
<Divider />
<Stack gap="sm">
<Text size="md" fw={600} c="dark">Team Performance</Text>
<Stack gap="xs">
{statsData.map((stat) => {
const teamWinRate = (stat.wins / stat.matches) * 100;
return (
<Card key={stat.id} p="md" radius="md" withBorder>
<Group justify="space-between" align="center">
<Group gap="sm">
<Avatar
size="md"
color={getWinRateColor(teamWinRate)}
radius="lg"
>
{stat.player_name.split(' ').map(n => n[0]).join('')}
</Avatar>
<Stack gap={2}>
<Text size="sm" fw={600}>{stat.player_name}</Text>
<Group gap="xs">
<Text size="xs" c="dimmed">
{stat.matches} matches
</Text>
<Text size="xs" c="dimmed"></Text>
<Text size="xs" c="green.6">
{stat.wins}W
</Text>
<Text size="xs" c="red.6">
{stat.losses}L
</Text>
</Group>
</Stack>
</Group>
<Stack gap={4} align="flex-end">
<Badge
size="sm"
variant="light"
color={getWinRateColor(teamWinRate)}
>
{teamWinRate.toFixed(0)}%
</Badge>
<Progress
value={teamWinRate}
size="xs"
w={60}
color={getWinRateColor(teamWinRate)}
/>
</Stack>
</Group>
</Card>
);
})}
</Stack>
</Stack>
</>
)}
</Stack>
</Box>
);
};
export default StatsOverview;

View File

@@ -1,11 +1,12 @@
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks"; import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches } from "./server"; import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches, getUnenrolledPlayers } from "./server";
export const playerKeys = { export const playerKeys = {
auth: ['auth'], auth: ['auth'],
list: ['players', 'list'], list: ['players', 'list'],
details: (id: string) => ['players', 'details', id], details: (id: string) => ['players', 'details', id],
unassociated: ['players','unassociated'], unassociated: ['players','unassociated'],
unenrolled: (tournamentId: string) => ['players', 'unenrolled', tournamentId],
stats: (id: string) => ['players', 'stats', id], stats: (id: string) => ['players', 'stats', id],
allStats: ['players', 'stats', 'all'], allStats: ['players', 'stats', 'all'],
matches: (id: string) => ['players', 'matches', id], matches: (id: string) => ['players', 'matches', id],
@@ -28,6 +29,10 @@ export const playerQueries = {
queryKey: playerKeys.unassociated, queryKey: playerKeys.unassociated,
queryFn: async () => await getUnassociatedPlayers() queryFn: async () => await getUnassociatedPlayers()
}), }),
unenrolled: (tournamentId: string) => ({
queryKey: playerKeys.unenrolled(tournamentId),
queryFn: async () => await getUnenrolledPlayers({ data: tournamentId })
}),
stats: (id: string) => ({ stats: (id: string) => ({
queryKey: playerKeys.stats(id), queryKey: playerKeys.stats(id),
queryFn: async () => await getPlayerStats({ data: id }) queryFn: async () => await getPlayerStats({ data: id })
@@ -81,4 +86,7 @@ export const useAllPlayerStats = () =>
useServerSuspenseQuery(playerQueries.allStats()); useServerSuspenseQuery(playerQueries.allStats());
export const usePlayerMatches = (id: string) => export const usePlayerMatches = (id: string) =>
useServerSuspenseQuery(playerQueries.matches(id)); useServerSuspenseQuery(playerQueries.matches(id));
export const useUnenrolledPlayers = (tournamentId: string) =>
useServerSuspenseQuery(playerQueries.unenrolled(tournamentId));

View File

@@ -5,13 +5,13 @@ import { Match } from "@/features/matches/types";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod"; import { z } from "zod";
import { logger } from "."; import { logger } from ".";
import { getWebRequest } from "@tanstack/react-start/server"; import { getRequest } from "@tanstack/react-start/server";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
export const fetchMe = createServerFn() export const fetchMe = createServerFn()
.handler(async () => .handler(async () =>
toServerResult(async () => { toServerResult(async () => {
const request = getWebRequest(); const request = getRequest();
try { try {
const context = await getSessionContext(request); const context = await getSessionContext(request);
@@ -21,24 +21,31 @@ export const fetchMe = createServerFn()
return { return {
user: result || undefined, user: result || undefined,
roles: context.roles, roles: context.roles,
metadata: context.metadata metadata: context.metadata,
phone: context.phone
}; };
} catch (error: any) { } catch (error: any) {
logger.info('fetchMe: Session error', error.message); // logger.info("FetchMe: Session error", error)
return { user: undefined, roles: [], metadata: {} }; if (error?.response?.status === 401) {
const errorData = error?.response?.data;
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
throw error;
}
}
return { user: undefined, roles: [], metadata: {}, phone: undefined };
} }
}) })
); );
export const getPlayer = createServerFn() export const getPlayer = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult<Player>(async () => await pbAdmin.getPlayer(data)) toServerResult<Player>(async () => await pbAdmin.getPlayer(data))
); );
export const updatePlayer = createServerFn() export const updatePlayer = createServerFn()
.validator(playerUpdateSchema) .inputValidator(playerUpdateSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -65,7 +72,7 @@ export const updatePlayer = createServerFn()
); );
export const createPlayer = createServerFn() export const createPlayer = createServerFn()
.validator(playerInputSchema) .inputValidator(playerInputSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -90,7 +97,7 @@ export const createPlayer = createServerFn()
); );
export const associatePlayer = createServerFn() export const associatePlayer = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -122,10 +129,10 @@ export const getUnassociatedPlayers = createServerFn()
); );
export const getPlayerStats = createServerFn() export const getPlayerStats = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult<PlayerStats[]>(async () => await pbAdmin.getPlayerStats(data)) toServerResult<PlayerStats>(async () => await pbAdmin.getPlayerStats(data))
); );
export const getAllPlayerStats = createServerFn() export const getAllPlayerStats = createServerFn()
@@ -135,8 +142,15 @@ export const getAllPlayerStats = createServerFn()
); );
export const getPlayerMatches = createServerFn() export const getPlayerMatches = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult<Match[]>(async () => await pbAdmin.getPlayerMatches(data)) toServerResult<Match[]>(async () => await pbAdmin.getPlayerMatches(data))
); );
export const getUnenrolledPlayers = createServerFn()
.inputValidator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: tournamentId }) =>
toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId))
);

View File

@@ -33,6 +33,7 @@ export interface PlayerStats {
player_id: string; player_id: string;
player_name: string; player_name: string;
matches: number; matches: number;
tournaments: number;
wins: number; wins: number;
losses: number; losses: number;
total_cups_made: number; total_cups_made: number;

View File

@@ -0,0 +1,206 @@
import {
Group,
Button,
Text,
Stack,
ScrollArea,
Paper,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useState, useRef, useCallback } from "react";
import Sheet from "@/components/sheet/sheet";
import PlayerList from "@/features/players/components/player-list";
import EmojiPicker from "./emoji-picker";
import { useMatchReactions, useToggleMatchReaction } from "../queries";
import { useAuth } from "@/contexts/auth-context";
import { Reaction } from "@/features/matches/server";
interface EmojiBarProps {
matchId: string;
onReactionPress?: (emoji: string) => void;
}
const EmojiBar = ({
matchId,
onReactionPress,
}: EmojiBarProps) => {
const { user } = useAuth();
const { data: reactions } = useMatchReactions(matchId);
const toggleReaction = useToggleMatchReaction(matchId);
const [opened, { open, close }] = useDisclosure(false);
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
const longPressTimeout = useRef<NodeJS.Timeout | null>(null);
const handleLongPressStart = (emoji: string) => {
longPressTimeout.current = setTimeout(() => {
setSelectedEmoji(emoji);
open();
}, 500);
};
const handleLongPressEnd = () => {
if (longPressTimeout.current) {
clearTimeout(longPressTimeout.current);
}
};
const handleReactionClick = (emoji: string) => {
handleLongPressEnd();
if (onReactionPress) {
onReactionPress(emoji);
} else {
toggleReaction.mutate({ data: { matchId, emoji } });
}
};
const hasReacted = useCallback((reaction: Reaction) => {
return reaction.players.map(p => p.id).includes(user?.id || "");
}, [user?.id]);
const userReactions = reactions?.filter(r => hasReacted(r)).map(r => r.emoji) || [];
if (!reactions) return;
const sortedReactions = [...reactions].sort((a, b) => b.count - a.count);
const visibleReactions = sortedReactions.slice(0, 3);
const groupedReactions = sortedReactions.slice(3);
const hasGrouped = groupedReactions.length > 0;
const groupedCount = groupedReactions.reduce((sum, r) => sum + r.count, 0);
const userHasReactedToGrouped = groupedReactions.some(r => hasReacted(r));
return (
<>
<Group gap="xs" wrap="wrap" justify="space-between">
<Group gap="xs" wrap="wrap">
{visibleReactions.map((reaction) => (
<Button
key={reaction.emoji}
variant={"light"}
bd={hasReacted(reaction) ? "1px solid var(--mantine-primary-color-filled)" : undefined}
size="compact-xs"
radius="xl"
onMouseDown={() => handleLongPressStart(reaction.emoji)}
onMouseUp={handleLongPressEnd}
onMouseLeave={handleLongPressEnd}
onTouchStart={() => handleLongPressStart(reaction.emoji)}
onTouchEnd={handleLongPressEnd}
onClick={() => handleReactionClick(reaction.emoji)}
style={{
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
}}
>
<Group gap={2} align="center">
<Text size="xs" style={{ lineHeight: 1 }}>
{reaction.emoji}
</Text>
<Text size="xs" fw={600}>
{reaction.count}
</Text>
</Group>
</Button>
))}
{hasGrouped && (
<Button
variant={"light"}
bd={userHasReactedToGrouped ? "1px solid var(--mantine-primary-color-filled)" : undefined}
size="compact-xs"
radius="xl"
onMouseDown={() => handleLongPressStart(groupedReactions[0]?.emoji || "")}
onMouseUp={handleLongPressEnd}
onMouseLeave={handleLongPressEnd}
onTouchStart={() => handleLongPressStart(groupedReactions[0]?.emoji || "")}
onTouchEnd={handleLongPressEnd}
onClick={() => {
setSelectedEmoji(groupedReactions[0]?.emoji || "");
open();
}}
style={{
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
position: "relative",
}}
>
<Group gap={2} align="center">
<div style={{
display: "flex",
gap: "1px",
alignItems: "center",
fontSize: "10px",
lineHeight: 1
}}>
{groupedReactions.slice(0, 2).map((reaction) => (
<span key={reaction.emoji}>{reaction.emoji}</span>
))}
{groupedReactions.length > 2 && (
<Text size="8px" fw={600} c="dimmed">
+{groupedReactions.length - 2}
</Text>
)}
</div>
<Text size="xs" fw={600}>
{groupedCount}
</Text>
</Group>
</Button>
)}
</Group>
<EmojiPicker
onSelect={onReactionPress || ((emoji) => toggleReaction.mutate({ data: { matchId, emoji } }))}
userReactions={userReactions}
/>
</Group>
<Sheet title="Reactions" opened={opened} onChange={() => close()}>
<Stack gap="md">
<ScrollArea w="100%" offsetScrollbars>
<Group gap="xs" wrap="nowrap" px="xs">
{sortedReactions.map((reaction) => (
<Button
key={reaction.emoji}
variant={selectedEmoji === reaction.emoji ? "filled" : "light"}
color="gray"
size="compact-sm"
radius="xl"
onClick={() => setSelectedEmoji(reaction.emoji)}
style={{ flexShrink: 0 }}
>
<Group gap={4} align="center">
<Text size="sm">{reaction.emoji}</Text>
<Text size="xs" fw={600}>
{reaction.count}
</Text>
</Group>
</Button>
))}
</Group>
</ScrollArea>
{selectedEmoji && (
<Paper p="md" withBorder radius="md">
<Group gap="sm" mb="md">
<Text size="2xl">{selectedEmoji}</Text>
<div>
<Text size="lg" fw={600}>
{sortedReactions.find(r => r.emoji === selectedEmoji)?.count || 0}
</Text>
</div>
</Group>
<PlayerList players={sortedReactions.find(r => r.emoji === selectedEmoji)?.players || []} />
</Paper>
)}
</Stack>
</Sheet>
</>
);
};
export default EmojiBar;

View File

@@ -0,0 +1,104 @@
import { ActionIcon, Popover, SimpleGrid, UnstyledButton, Text } from "@mantine/core";
import { SmileyStickerIcon } from "@phosphor-icons/react";
import { useState } from "react";
interface EmojiPickerProps {
onSelect: (emoji: string) => void;
disabled?: boolean;
userReactions?: string[];
}
const EMOJIS = [
{ emoji: "🫡", label: "salute" },
{ emoji: "😭", label: "crying" },
{ emoji: "🫦", label: "lip" },
{ emoji: "🏗️", label: "crane" },
{ emoji: "👀", label: "eyes" },
{ emoji: "😱", label: "scream" },
{ emoji: "🥹", label: "owo" },
{ emoji: "🤣", label: "rofl" },
{ emoji: "🤪", label: "crazy" },
{ emoji: "🤓", label: "nerd" },
{ emoji: "🥵", label: "hot" },
{ emoji: "🥶", label: "cold" },
];
const EmojiPicker = ({
onSelect,
disabled = false,
userReactions = []
}: EmojiPickerProps) => {
const [opened, setOpened] = useState(false);
const handleEmojiSelect = (emoji: string) => {
onSelect(emoji);
setOpened(false);
};
return (
<Popover
position="bottom"
withArrow
shadow="sm"
opened={opened}
onChange={setOpened}
trapFocus
closeOnEscape
closeOnClickOutside
>
<Popover.Target>
<ActionIcon
variant="subtle"
onClick={() => setOpened((o) => !o)}
disabled={disabled}
aria-label="Select emoji"
>
<SmileyStickerIcon size={16} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown p="xs">
<SimpleGrid cols={6} spacing={4}>
{EMOJIS.map(({ emoji, label }) => {
const hasReacted = userReactions.includes(emoji);
return (
<UnstyledButton
key={emoji}
onClick={() => handleEmojiSelect(emoji)}
style={{
borderRadius: "var(--mantine-radius-sm)",
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 36,
minWidth: 36,
backgroundColor: hasReacted ? 'var(--mantine-primary-color-light)' : undefined,
border: hasReacted ? '1px solid var(--mantine-primary-color-filled)' : undefined,
}}
styles={{
root: {
'&:hover': {
backgroundColor: hasReacted
? 'var(--mantine-primary-color-light-hover)'
: 'var(--mantine-color-gray-1)',
},
'&:active': {
transform: 'scale(0.95)',
},
},
}}
aria-label={label}
>
<Text size="lg" style={{ lineHeight: 1 }}>
{emoji}
</Text>
</UnstyledButton>
);
})}
</SimpleGrid>
</Popover.Dropdown>
</Popover>
);
};
export default EmojiPicker;

View File

@@ -0,0 +1,30 @@
import { useServerMutation, useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { getMatchReactions, toggleMatchReaction } from "@/features/matches/server";
import { useQueryClient } from "@tanstack/react-query";
export const reactionKeys = {
match: (matchId: string) => ['reactions', 'match', matchId] as const,
};
export const reactionQueries = {
match: (matchId: string) => ({
queryKey: reactionKeys.match(matchId),
queryFn: () => getMatchReactions({ data: matchId }),
}),
};
export const useMatchReactions = (matchId: string) =>
useServerSuspenseQuery(reactionQueries.match(matchId));
export const useToggleMatchReaction = (matchId: string) => {
const queryClient = useQueryClient();
return useServerMutation({
mutationFn: toggleMatchReaction,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: reactionKeys.match(matchId)
});
},
});
};

View File

@@ -0,0 +1,165 @@
import { useState, useMemo } from "react";
import {
Text,
TextInput,
Stack,
Container,
Box,
ThemeIcon,
Title,
} from "@mantine/core";
import {
MagnifyingGlassIcon,
UsersIcon,
} from "@phosphor-icons/react";
import { Tournament } from "@/features/tournaments/types";
import TeamList from "./team-list";
import Sheet from "@/components/sheet/sheet";
import TeamForm from "./team-form";
import { useSheet } from "@/hooks/use-sheet";
import { useTeam } from "../queries";
interface TeamEditSheetProps {
teamId: string;
isOpen: boolean;
onClose: () => void;
}
const TeamEditSheet = ({ teamId, isOpen, onClose }: TeamEditSheetProps) => {
const { data: team } = useTeam(teamId);
return (
<Sheet
title={team ? `Edit ${team.name}` : "Edit Team"}
opened={isOpen}
onChange={onClose}
>
{team && (
<TeamForm
teamId={team.id}
initialValues={{
...team,
players: team.players ? team.players.map((p) => p.id) : [],
logo: typeof team.logo === "string" ? undefined : team.logo,
}}
close={onClose}
/>
)}
</Sheet>
);
};
interface ManageTeamsProps {
tournament: Tournament;
}
const ManageTeams = ({ tournament }: ManageTeamsProps) => {
const [search, setSearch] = useState("");
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const {
isOpen: editTeamOpened,
open: openEditTeam,
close: closeEditTeam,
} = useSheet();
const teams = tournament.teams || [];
const filteredTeams = useMemo(() => {
if (!search.trim()) return teams;
const searchLower = search.toLowerCase();
return teams.filter((team) => {
if (team.name.toLowerCase().includes(searchLower)) {
return true;
}
if (team.players) {
return team.players.some((player) => {
const firstName = player.first_name?.toLowerCase() || "";
const lastName = player.last_name?.toLowerCase() || "";
const fullName = `${firstName} ${lastName}`.toLowerCase();
return fullName.includes(searchLower) ||
firstName.includes(searchLower) ||
lastName.includes(searchLower);
});
}
return false;
});
}, [teams, search]);
const handleTeamClick = (teamId: string) => {
setSelectedTeamId(teamId);
openEditTeam();
};
const handleCloseEditTeam = () => {
setSelectedTeamId(null);
closeEditTeam();
};
if (!teams.length) {
return (
<Container px={0} size="md">
<Stack align="center" gap="md" py="xl">
<ThemeIcon size="xl" variant="light" radius="md">
<UsersIcon size={32} />
</ThemeIcon>
<Title order={3} c="dimmed">
No Teams Enrolled
</Title>
<Text c="dimmed" ta="center">
This tournament has no enrolled teams yet.
</Text>
</Stack>
</Container>
);
}
return (
<>
<Container size="100%" px={0}>
<Stack gap="xs">
<TextInput
placeholder="Search teams by name or player..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
leftSection={<MagnifyingGlassIcon size={16} />}
size="md"
px="md"
/>
<Box px="md">
<Text size="xs" c="dimmed">
{filteredTeams.length} of {teams.length} teams
</Text>
</Box>
<TeamList
teams={filteredTeams}
onTeamClick={handleTeamClick}
/>
{filteredTeams.length === 0 && search && (
<Text ta="center" c="dimmed" py="xl">
No teams found matching "{search}"
</Text>
)}
</Stack>
</Container>
{selectedTeamId && (
<TeamEditSheet
teamId={selectedTeamId}
isOpen={editTeamOpened}
onClose={handleCloseEditTeam}
/>
)}
</>
);
};
export default ManageTeams;

View File

@@ -0,0 +1,18 @@
import { Paper, Group, Skeleton, Box } from "@mantine/core";
const TeamCardSkeleton = () => {
return (
<Paper p="sm" withBorder radius="md">
<Group gap="sm" align="center">
<Skeleton height={32} width={32} radius="sm" />
<Box style={{ flex: 1 }}>
<Skeleton height={16} width="70%" mb={4} />
<Skeleton height={12} width="40%" />
</Box>
<Skeleton height={20} width={60} radius="xl" />
</Group>
</Paper>
);
};
export default TeamCardSkeleton;

View File

@@ -0,0 +1,76 @@
import {
Paper,
Text,
Group,
Box,
Stack,
Divider
} from "@mantine/core";
import { useTeam } from "../queries";
import Avatar from "@/components/avatar";
import SongSummary from "./team-form/song-summary";
interface TeamCardProps {
teamId: string;
}
const TeamCard = ({ teamId }: TeamCardProps) => {
const { data: team, error } = useTeam(teamId);
if (error || !team) {
return (
<Paper p="sm" withBorder radius="md">
<Text c="red" ta="center" size="sm">
Failed to load team
</Text>
</Paper>
);
}
const song = {
song_id: team.song_id,
song_name: team.song_name,
song_artist: team.song_artist,
song_album: team.song_album,
song_start: team.song_start,
song_end: team.song_end,
song_image_url: team.song_image_url,
};
return (
<Paper
withBorder
radius="lg"
shadow="xs"
>
<Stack gap={2}>
<Group gap="md" align="center" p="xs">
<Avatar
name={team.name}
size={40}
radius="md"
src={team.logo ? `/api/files/teams/${team.id}/${team.logo}` : undefined}
style={{
backgroundColor: team.primary_color || undefined,
color: team.accent_color || undefined,
}}
/>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="md" fw={600} lineClamp={1} mb={2}>
{team.name}
</Text>
<Text size="sm" c="dimmed" lineClamp={1}>
{team.players?.map(p => `${p.first_name} ${p.last_name}`).join(', ')}
</Text>
</Box>
</Group>
<Divider />
<Box p="sm">
<SongSummary song={song} />
</Box>
</Stack>
</Paper>
);
};
export default TeamCard;

View File

@@ -1,229 +0,0 @@
import { FileInput, Stack, TextInput, Textarea } from "@mantine/core";
import { useForm, UseFormInput } from "@mantine/form";
import { LinkIcon } from "@phosphor-icons/react";
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
import { TournamentInput } from "@/features/tournaments/types";
import { isNotEmpty } from "@mantine/form";
import useCreateTournament from "../hooks/use-create-team";
import useUpdateTournament from "../hooks/use-update-tournament";
import toast from "@/lib/sonner";
import { logger } from "..";
import { useQueryClient } from "@tanstack/react-query";
import { tournamentKeys } from "@/features/tournaments/queries";
import { useCallback } from "react";
import { DateTimePicker } from "@/components/date-time-picker";
interface TournamentFormProps {
close: () => void;
initialValues?: Partial<TournamentInput>;
tournamentId?: string;
}
const TournamentForm = ({
close,
initialValues,
tournamentId,
}: TournamentFormProps) => {
const isEditMode = !!tournamentId;
const config: UseFormInput<TournamentInput> = {
initialValues: {
name: initialValues?.name || "",
location: initialValues?.location || "",
desc: initialValues?.desc || "",
start_time: initialValues?.start_time || "",
enroll_time: initialValues?.enroll_time || "",
end_time: initialValues?.end_time || "",
logo: undefined,
},
onSubmitPreventDefault: "always",
validate: {
name: isNotEmpty("Name is required"),
location: isNotEmpty("Location is required"),
start_time: isNotEmpty("Start time is required"),
enroll_time: isNotEmpty("Enrollment time is required"),
},
};
const form = useForm(config);
const queryClient = useQueryClient();
const { mutate: createTournament, isPending: createPending } =
useCreateTournament();
const { mutate: updateTournament, isPending: updatePending } =
useUpdateTournament(tournamentId || "");
const isPending = createPending || updatePending;
const handleSubmit = useCallback(
async (values: TournamentInput) => {
const { logo, ...tournamentData } = values;
const mutation = isEditMode ? updateTournament : createTournament;
const successMessage = isEditMode
? "Tournament updated successfully!"
: "Tournament created successfully!";
const errorMessage = isEditMode
? "Failed to update tournament"
: "Failed to create tournament";
mutation(tournamentData, {
onSuccess: async (tournament) => {
if (logo && tournament) {
try {
const formData = new FormData();
formData.append("tournamentId", tournament.id);
formData.append("logo", logo);
const response = await fetch("/api/tournaments/upload-logo", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to upload logo");
}
const result = await response.json();
queryClient.invalidateQueries({ queryKey: tournamentKeys.list });
queryClient.invalidateQueries({
queryKey: tournamentKeys.details(result.tournament!.id),
});
queryClient.setQueryData(
tournamentKeys.details(result.tournament!.id),
result.tournament
);
toast.success(successMessage);
} catch (error: any) {
const logoErrorMessage = isEditMode
? `Tournament updated but logo upload failed: ${error.message}`
: `Tournament created but logo upload failed: ${error.message}`;
toast.error(logoErrorMessage);
logger.error("Tournament logo upload error", error);
}
} else {
toast.success(successMessage);
}
close();
},
onError: (error: any) => {
toast.error(`${errorMessage}: ${error.message}`);
logger.error(
`Tournament ${isEditMode ? "update" : "create"} error`,
error
);
},
});
},
[isEditMode, createTournament, updateTournament, queryClient]
);
return (
<SlidePanel
onSubmit={form.onSubmit(handleSubmit)}
onCancel={close}
submitText={isEditMode ? "Update Tournament" : "Create Tournament"}
cancelText="Cancel"
loading={isPending}
>
<Stack>
<TextInput
label="Name"
withAsterisk
key={form.key("name")}
{...form.getInputProps("name")}
/>
<TextInput
label="Location"
withAsterisk
key={form.key("location")}
{...form.getInputProps("location")}
/>
<Textarea
label="Description"
key={form.key("desc")}
{...form.getInputProps("desc")}
minRows={3}
/>
<FileInput
key={form.key("logo")}
accept="image/png,image/jpeg,image/gif,image/jpg"
label={isEditMode ? "Change Logo" : "Logo"}
leftSection={<LinkIcon size={16} />}
{...form.getInputProps("logo")}
/>
<SlidePanelField
key={form.key("start_time")}
{...form.getInputProps("start_time")}
Component={DateTimePicker}
title="Select Start Date"
label="Start Date"
withAsterisk
formatValue={(date) =>
!date ? 'Select a time' :
new Date(date).toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
})
}
/>
<SlidePanelField
key={form.key("enroll_time")}
{...form.getInputProps("enroll_time")}
Component={DateTimePicker}
title="Select Enrollment Due Date"
label="Enrollment Due"
withAsterisk
formatValue={(date) =>
!date ? 'Select a time' :
new Date(date).toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
})
}
/>
{isEditMode && (
<SlidePanelField
key={form.key("end_time")}
{...form.getInputProps("end_time")}
Component={DateTimePicker}
title="Select End Date"
label="End Date (Optional)"
formatValue={(date) =>
!date ? 'Select a time' :
new Date(date).toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
})
}
/>
)}
</Stack>
</SlidePanel>
);
};
export default TournamentForm;

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