Compare commits
18 Commits
8efc0a7a4b
...
main_old
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ed77dd471 | ||
|
|
94ea44c66e | ||
|
|
7441d1ac58 | ||
|
|
7ff26229d9 | ||
|
|
b93ce38d48 | ||
|
|
ae934e77f4 | ||
|
|
cae5fa1c71 | ||
|
|
fc3f626313 | ||
|
|
1027b49258 | ||
|
|
5e20b94a1f | ||
|
|
f99d6efaf9 | ||
|
|
602e6e3473 | ||
|
|
285a33c488 | ||
|
|
cac42c9b29 | ||
|
|
498010e3e2 | ||
|
|
c170e1e1fe | ||
|
|
cde74a04d5 | ||
|
|
9a105b30c6 |
@@ -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:
|
||||||
|
|||||||
16
package.json
16
package.json
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --host 0.0.0.0",
|
"dev": "vite dev --host 0.0.0.0",
|
||||||
"build": "vite build && tsc --noEmit",
|
"build": "vite build && tsc --noEmit",
|
||||||
"start": "vite start"
|
"start": "node .output/server/index.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
@@ -18,13 +18,18 @@
|
|||||||
"@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.130.15",
|
||||||
|
"@tiptap/pm": "^3.4.3",
|
||||||
|
"@tiptap/react": "^3.4.3",
|
||||||
|
"@tiptap/starter-kit": "^3.4.3",
|
||||||
"@types/ioredis": "^4.28.10",
|
"@types/ioredis": "^4.28.10",
|
||||||
|
"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 +38,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",
|
||||||
|
|||||||
211
pb_migrations/1757800000_created_team_stats.js
Normal file
211
pb_migrations/1757800000_created_team_stats.js
Normal 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);
|
||||||
|
})
|
||||||
167
pb_migrations/1757910262_deleted_player_stats_per_tournament.js
Normal file
167
pb_migrations/1757910262_deleted_player_stats_per_tournament.js
Normal 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);
|
||||||
|
})
|
||||||
96
pb_migrations/1757950535_updated_team_stats.js
Normal file
96
pb_migrations/1757950535_updated_team_stats.js
Normal 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)
|
||||||
|
})
|
||||||
157
pb_migrations/1757950649_created_team_stats.js
Normal file
157
pb_migrations/1757950649_created_team_stats.js
Normal 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);
|
||||||
|
})
|
||||||
55
pb_migrations/1758042930_updated_teams.js
Normal file
55
pb_migrations/1758042930_updated_teams.js
Normal 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)
|
||||||
|
})
|
||||||
37
pb_migrations/1758049631_updated_player_stats.js
Normal file
37
pb_migrations/1758049631_updated_player_stats.js
Normal 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)
|
||||||
|
})
|
||||||
28
pb_migrations/1758054877_updated_tournaments.js
Normal file
28
pb_migrations/1758054877_updated_tournaments.js
Normal 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)
|
||||||
|
})
|
||||||
84
pb_migrations/1758081731_created_reactions.js
Normal file
84
pb_migrations/1758081731_created_reactions.js
Normal 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);
|
||||||
|
})
|
||||||
28
pb_migrations/1758123221_updated_reactions.js
Normal file
28
pb_migrations/1758123221_updated_reactions.js
Normal 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)
|
||||||
|
})
|
||||||
85
pb_migrations/1758379630_created_badges.js
Normal file
85
pb_migrations/1758379630_created_badges.js
Normal 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);
|
||||||
|
})
|
||||||
28
pb_migrations/1758380013_updated_players.js
Normal file
28
pb_migrations/1758380013_updated_players.js
Normal 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)
|
||||||
|
})
|
||||||
28
pb_migrations/1758385120_updated_players.js
Normal file
28
pb_migrations/1758385120_updated_players.js
Normal 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)
|
||||||
|
})
|
||||||
84
pb_migrations/1758388728_created_free_agents.js
Normal file
84
pb_migrations/1758388728_created_free_agents.js
Normal 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);
|
||||||
|
})
|
||||||
28
pb_migrations/1758402128_updated_free_agents.js
Normal file
28
pb_migrations/1758402128_updated_free_agents.js
Normal 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)
|
||||||
|
})
|
||||||
28
pb_migrations/1758402424_updated_tournaments.js
Normal file
28
pb_migrations/1758402424_updated_tournaments.js
Normal 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)
|
||||||
|
})
|
||||||
28
pb_migrations/1758575563_updated_free_agents.js
Normal file
28
pb_migrations/1758575563_updated_free_agents.js
Normal 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)
|
||||||
|
})
|
||||||
28
pb_migrations/1758575597_updated_free_agents.js
Normal file
28
pb_migrations/1758575597_updated_free_agents.js
Normal 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)
|
||||||
|
})
|
||||||
@@ -26,10 +26,14 @@ import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$t
|
|||||||
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 AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
||||||
|
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
|
||||||
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
|
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
|
||||||
|
import { ServerRoute as ApiTeamsUploadLogoServerRouteImport } from './routes/api/teams/upload-logo'
|
||||||
import { ServerRoute as ApiSpotifyTokenServerRouteImport } from './routes/api/spotify/token'
|
import { ServerRoute as ApiSpotifyTokenServerRouteImport } from './routes/api/spotify/token'
|
||||||
|
import { ServerRoute as ApiSpotifySearchServerRouteImport } from './routes/api/spotify/search'
|
||||||
import { ServerRoute as ApiSpotifyResumeServerRouteImport } from './routes/api/spotify/resume'
|
import { ServerRoute as ApiSpotifyResumeServerRouteImport } from './routes/api/spotify/resume'
|
||||||
import { ServerRoute as ApiSpotifyPlaybackServerRouteImport } from './routes/api/spotify/playback'
|
import { ServerRoute as ApiSpotifyPlaybackServerRouteImport } from './routes/api/spotify/playback'
|
||||||
import { ServerRoute as ApiSpotifyCaptureServerRouteImport } from './routes/api/spotify/capture'
|
import { ServerRoute as ApiSpotifyCaptureServerRouteImport } from './routes/api/spotify/capture'
|
||||||
@@ -116,10 +120,16 @@ 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 AuthedAdminTournamentsRunIdRoute =
|
const AuthedAdminTournamentsRunIdRoute =
|
||||||
@@ -128,17 +138,34 @@ const AuthedAdminTournamentsRunIdRoute =
|
|||||||
path: '/tournaments/run/$id',
|
path: '/tournaments/run/$id',
|
||||||
getParentRoute: () => AuthedAdminRoute,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthedAdminTournamentsIdTeamsRoute =
|
||||||
|
AuthedAdminTournamentsIdTeamsRouteImport.update({
|
||||||
|
id: '/tournaments/$id/teams',
|
||||||
|
path: '/tournaments/$id/teams',
|
||||||
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
|
} as any)
|
||||||
const ApiTournamentsUploadLogoServerRoute =
|
const ApiTournamentsUploadLogoServerRoute =
|
||||||
ApiTournamentsUploadLogoServerRouteImport.update({
|
ApiTournamentsUploadLogoServerRouteImport.update({
|
||||||
id: '/api/tournaments/upload-logo',
|
id: '/api/tournaments/upload-logo',
|
||||||
path: '/api/tournaments/upload-logo',
|
path: '/api/tournaments/upload-logo',
|
||||||
getParentRoute: () => rootServerRouteImport,
|
getParentRoute: () => rootServerRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiTeamsUploadLogoServerRoute =
|
||||||
|
ApiTeamsUploadLogoServerRouteImport.update({
|
||||||
|
id: '/api/teams/upload-logo',
|
||||||
|
path: '/api/teams/upload-logo',
|
||||||
|
getParentRoute: () => rootServerRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiSpotifyTokenServerRoute = ApiSpotifyTokenServerRouteImport.update({
|
const ApiSpotifyTokenServerRoute = ApiSpotifyTokenServerRouteImport.update({
|
||||||
id: '/api/spotify/token',
|
id: '/api/spotify/token',
|
||||||
path: '/api/spotify/token',
|
path: '/api/spotify/token',
|
||||||
getParentRoute: () => rootServerRouteImport,
|
getParentRoute: () => rootServerRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiSpotifySearchServerRoute = ApiSpotifySearchServerRouteImport.update({
|
||||||
|
id: '/api/spotify/search',
|
||||||
|
path: '/api/spotify/search',
|
||||||
|
getParentRoute: () => rootServerRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiSpotifyResumeServerRoute = ApiSpotifyResumeServerRouteImport.update({
|
const ApiSpotifyResumeServerRoute = ApiSpotifyResumeServerRouteImport.update({
|
||||||
id: '/api/spotify/resume',
|
id: '/api/spotify/resume',
|
||||||
path: '/api/spotify/resume',
|
path: '/api/spotify/resume',
|
||||||
@@ -192,9 +219,11 @@ export interface FileRoutesByFullPath {
|
|||||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
'/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
|
||||||
|
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
@@ -209,9 +238,11 @@ export interface FileRoutesByTo {
|
|||||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
'/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
|
||||||
|
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -229,9 +260,11 @@ export interface FileRoutesById {
|
|||||||
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
'/_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
|
||||||
|
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
@@ -249,9 +282,11 @@ export interface FileRouteTypes {
|
|||||||
| '/tournaments/$tournamentId'
|
| '/tournaments/$tournamentId'
|
||||||
| '/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'
|
||||||
|
| '/admin/tournaments/$id'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/login'
|
| '/login'
|
||||||
@@ -266,9 +301,11 @@ export interface FileRouteTypes {
|
|||||||
| '/tournaments/$tournamentId'
|
| '/tournaments/$tournamentId'
|
||||||
| '/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'
|
||||||
|
| '/admin/tournaments/$id'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/_authed'
|
| '/_authed'
|
||||||
@@ -285,9 +322,11 @@ export interface FileRouteTypes {
|
|||||||
| '/_authed/tournaments/$tournamentId'
|
| '/_authed/tournaments/$tournamentId'
|
||||||
| '/_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'
|
||||||
|
| '/_authed/admin/tournaments/$id/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -303,7 +342,9 @@ export interface FileServerRoutesByFullPath {
|
|||||||
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||||
|
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||||
|
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||||
}
|
}
|
||||||
@@ -314,7 +355,9 @@ export interface FileServerRoutesByTo {
|
|||||||
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||||
|
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||||
|
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||||
}
|
}
|
||||||
@@ -326,7 +369,9 @@ export interface FileServerRoutesById {
|
|||||||
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||||
|
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||||
|
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||||
}
|
}
|
||||||
@@ -339,7 +384,9 @@ export interface FileServerRouteTypes {
|
|||||||
| '/api/spotify/capture'
|
| '/api/spotify/capture'
|
||||||
| '/api/spotify/playback'
|
| '/api/spotify/playback'
|
||||||
| '/api/spotify/resume'
|
| '/api/spotify/resume'
|
||||||
|
| '/api/spotify/search'
|
||||||
| '/api/spotify/token'
|
| '/api/spotify/token'
|
||||||
|
| '/api/teams/upload-logo'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
fileServerRoutesByTo: FileServerRoutesByTo
|
fileServerRoutesByTo: FileServerRoutesByTo
|
||||||
@@ -350,7 +397,9 @@ export interface FileServerRouteTypes {
|
|||||||
| '/api/spotify/capture'
|
| '/api/spotify/capture'
|
||||||
| '/api/spotify/playback'
|
| '/api/spotify/playback'
|
||||||
| '/api/spotify/resume'
|
| '/api/spotify/resume'
|
||||||
|
| '/api/spotify/search'
|
||||||
| '/api/spotify/token'
|
| '/api/spotify/token'
|
||||||
|
| '/api/teams/upload-logo'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
id:
|
id:
|
||||||
@@ -361,7 +410,9 @@ export interface FileServerRouteTypes {
|
|||||||
| '/api/spotify/capture'
|
| '/api/spotify/capture'
|
||||||
| '/api/spotify/playback'
|
| '/api/spotify/playback'
|
||||||
| '/api/spotify/resume'
|
| '/api/spotify/resume'
|
||||||
|
| '/api/spotify/search'
|
||||||
| '/api/spotify/token'
|
| '/api/spotify/token'
|
||||||
|
| '/api/teams/upload-logo'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
fileServerRoutesById: FileServerRoutesById
|
fileServerRoutesById: FileServerRoutesById
|
||||||
@@ -373,7 +424,9 @@ export interface RootServerRouteChildren {
|
|||||||
ApiSpotifyCaptureServerRoute: typeof ApiSpotifyCaptureServerRoute
|
ApiSpotifyCaptureServerRoute: typeof ApiSpotifyCaptureServerRoute
|
||||||
ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute
|
ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute
|
||||||
ApiSpotifyResumeServerRoute: typeof ApiSpotifyResumeServerRoute
|
ApiSpotifyResumeServerRoute: typeof ApiSpotifyResumeServerRoute
|
||||||
|
ApiSpotifySearchServerRoute: typeof ApiSpotifySearchServerRoute
|
||||||
ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute
|
ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute
|
||||||
|
ApiTeamsUploadLogoServerRoute: typeof ApiTeamsUploadLogoServerRoute
|
||||||
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
|
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
|
||||||
ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute
|
ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||||
}
|
}
|
||||||
@@ -485,11 +538,18 @@ 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
|
||||||
}
|
}
|
||||||
'/_authed/admin/tournaments/run/$id': {
|
'/_authed/admin/tournaments/run/$id': {
|
||||||
@@ -499,6 +559,13 @@ 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'
|
||||||
|
path: '/tournaments/$id/teams'
|
||||||
|
fullPath: '/admin/tournaments/$id/teams'
|
||||||
|
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
|
||||||
|
parentRoute: typeof AuthedAdminRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
declare module '@tanstack/react-start/server' {
|
declare module '@tanstack/react-start/server' {
|
||||||
@@ -510,6 +577,13 @@ declare module '@tanstack/react-start/server' {
|
|||||||
preLoaderRoute: typeof ApiTournamentsUploadLogoServerRouteImport
|
preLoaderRoute: typeof ApiTournamentsUploadLogoServerRouteImport
|
||||||
parentRoute: typeof rootServerRouteImport
|
parentRoute: typeof rootServerRouteImport
|
||||||
}
|
}
|
||||||
|
'/api/teams/upload-logo': {
|
||||||
|
id: '/api/teams/upload-logo'
|
||||||
|
path: '/api/teams/upload-logo'
|
||||||
|
fullPath: '/api/teams/upload-logo'
|
||||||
|
preLoaderRoute: typeof ApiTeamsUploadLogoServerRouteImport
|
||||||
|
parentRoute: typeof rootServerRouteImport
|
||||||
|
}
|
||||||
'/api/spotify/token': {
|
'/api/spotify/token': {
|
||||||
id: '/api/spotify/token'
|
id: '/api/spotify/token'
|
||||||
path: '/api/spotify/token'
|
path: '/api/spotify/token'
|
||||||
@@ -517,6 +591,13 @@ declare module '@tanstack/react-start/server' {
|
|||||||
preLoaderRoute: typeof ApiSpotifyTokenServerRouteImport
|
preLoaderRoute: typeof ApiSpotifyTokenServerRouteImport
|
||||||
parentRoute: typeof rootServerRouteImport
|
parentRoute: typeof rootServerRouteImport
|
||||||
}
|
}
|
||||||
|
'/api/spotify/search': {
|
||||||
|
id: '/api/spotify/search'
|
||||||
|
path: '/api/spotify/search'
|
||||||
|
fullPath: '/api/spotify/search'
|
||||||
|
preLoaderRoute: typeof ApiSpotifySearchServerRouteImport
|
||||||
|
parentRoute: typeof rootServerRouteImport
|
||||||
|
}
|
||||||
'/api/spotify/resume': {
|
'/api/spotify/resume': {
|
||||||
id: '/api/spotify/resume'
|
id: '/api/spotify/resume'
|
||||||
path: '/api/spotify/resume'
|
path: '/api/spotify/resume'
|
||||||
@@ -572,17 +653,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 +681,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 +693,7 @@ const AuthedRouteChildren: AuthedRouteChildren = {
|
|||||||
AuthedTeamsTeamIdRoute: AuthedTeamsTeamIdRoute,
|
AuthedTeamsTeamIdRoute: AuthedTeamsTeamIdRoute,
|
||||||
AuthedTournamentsTournamentIdRoute: AuthedTournamentsTournamentIdRoute,
|
AuthedTournamentsTournamentIdRoute: AuthedTournamentsTournamentIdRoute,
|
||||||
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
|
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
|
||||||
|
AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthedRouteWithChildren =
|
const AuthedRouteWithChildren =
|
||||||
@@ -630,7 +715,9 @@ const rootServerRouteChildren: RootServerRouteChildren = {
|
|||||||
ApiSpotifyCaptureServerRoute: ApiSpotifyCaptureServerRoute,
|
ApiSpotifyCaptureServerRoute: ApiSpotifyCaptureServerRoute,
|
||||||
ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute,
|
ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute,
|
||||||
ApiSpotifyResumeServerRoute: ApiSpotifyResumeServerRoute,
|
ApiSpotifyResumeServerRoute: ApiSpotifyResumeServerRoute,
|
||||||
|
ApiSpotifySearchServerRoute: ApiSpotifySearchServerRoute,
|
||||||
ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute,
|
ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute,
|
||||||
|
ApiTeamsUploadLogoServerRoute: ApiTeamsUploadLogoServerRoute,
|
||||||
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
|
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
|
||||||
ApiFilesCollectionRecordIdFileServerRoute:
|
ApiFilesCollectionRecordIdFileServerRoute:
|
||||||
ApiFilesCollectionRecordIdFileServerRoute,
|
ApiFilesCollectionRecordIdFileServerRoute,
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { routeTree } from "./routeTree.gen";
|
|||||||
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
|
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
|
||||||
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
|
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
|
||||||
|
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
|
||||||
export function createRouter() {
|
export function createRouter() {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -32,7 +36,7 @@ export function createRouter() {
|
|||||||
defaultPreload: "intent",
|
defaultPreload: "intent",
|
||||||
defaultErrorComponent: DefaultCatchBoundary,
|
defaultErrorComponent: DefaultCatchBoundary,
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
defaultViewTransition: true,
|
defaultViewTransition: false,
|
||||||
}),
|
}),
|
||||||
queryClient
|
queryClient
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
@@ -130,7 +134,6 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||||||
>
|
>
|
||||||
<div className="app">{children}</div>
|
<div className="app">{children}</div>
|
||||||
<Scripts />
|
<Scripts />
|
||||||
<ReactQueryDevtools />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
32
src/app/routes/_authed/admin/tournaments/$id/teams.tsx
Normal file
32
src/app/routes/_authed/admin/tournaments/$id/teams.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
82
src/app/routes/_authed/tournaments/$id.bracket.tsx
Normal file
82
src/app/routes/_authed/tournaments/$id.bracket.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ export const ServerRoute = createServerFileRoute("/api/events/$").middleware([su
|
|||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
// Send initial connection messages
|
|
||||||
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`;
|
||||||
@@ -25,8 +23,9 @@ export const ServerRoute = createServerFileRoute("/api/events/$").middleware([su
|
|||||||
};
|
};
|
||||||
|
|
||||||
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`;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ const SPOTIFY_REDIRECT_URI = import.meta.env.VITE_SPOTIFY_REDIRECT_URI!
|
|||||||
|
|
||||||
export const ServerRoute = createServerFileRoute('/api/spotify/callback').methods({
|
export const ServerRoute = createServerFileRoute('/api/spotify/callback').methods({
|
||||||
GET: async ({ request }: { request: Request }) => {
|
GET: async ({ request }: { request: Request }) => {
|
||||||
// Helper function to get return path from state parameter
|
|
||||||
const getReturnPath = (state: string | null): string => {
|
const getReturnPath = (state: string | null): string => {
|
||||||
if (!state) return '/';
|
if (!state) return '/';
|
||||||
try {
|
try {
|
||||||
@@ -26,7 +25,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/callback').method
|
|||||||
|
|
||||||
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, {
|
||||||
@@ -54,7 +52,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/callback').method
|
|||||||
has_state: !!state,
|
has_state: !!state,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Exchange code for tokens
|
|
||||||
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
|
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -77,7 +74,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/callback').method
|
|||||||
redirect_uri: SPOTIFY_REDIRECT_URI,
|
redirect_uri: SPOTIFY_REDIRECT_URI,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Return more detailed error info
|
|
||||||
const errorParam = encodeURIComponent(`${tokenResponse.status}: ${errorText}`)
|
const errorParam = encodeURIComponent(`${tokenResponse.status}: ${errorText}`)
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
@@ -97,7 +93,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/callback').method
|
|||||||
|
|
||||||
console.log('Decoded return path:', returnPath);
|
console.log('Decoded return path:', returnPath);
|
||||||
|
|
||||||
// Create response with redirect to original path
|
|
||||||
const response = new Response(null, {
|
const response = new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -105,14 +100,12 @@ export const ServerRoute = createServerFileRoute('/api/spotify/callback').method
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Set secure cookies for tokens
|
|
||||||
const isSecure = process.env.NODE_ENV === 'production'
|
const isSecure = process.env.NODE_ENV === 'production'
|
||||||
const cookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${tokens.expires_in}`
|
const cookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${tokens.expires_in}`
|
||||||
|
|
||||||
response.headers.append('Set-Cookie', `spotify_access_token=${tokens.access_token}; ${cookieOptions}`)
|
response.headers.append('Set-Cookie', `spotify_access_token=${tokens.access_token}; ${cookieOptions}`)
|
||||||
|
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
// 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
|
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}`)
|
response.headers.append('Set-Cookie', `spotify_refresh_token=${tokens.refresh_token}; ${refreshCookieOptions}`)
|
||||||
}
|
}
|
||||||
@@ -120,7 +113,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/callback').method
|
|||||||
return response
|
return response
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Spotify callback error:', error)
|
console.error('Spotify callback error:', error)
|
||||||
// Try to get return path from query params if available, otherwise use default
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const state = url.searchParams.get('state');
|
const state = url.searchParams.get('state');
|
||||||
const returnPath = getReturnPath(state);
|
const returnPath = getReturnPath(state);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createServerFileRoute } from '@tanstack/react-start/server'
|
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||||
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
|
||||||
@@ -28,7 +27,7 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { action, deviceId, volumePercent } = body
|
const { action, deviceId, volumePercent, trackId, positionMs } = body
|
||||||
|
|
||||||
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
||||||
|
|
||||||
@@ -36,6 +35,18 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
|
|||||||
case 'play':
|
case 'play':
|
||||||
await spotifyClient.play(deviceId)
|
await spotifyClient.play(deviceId)
|
||||||
break
|
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':
|
case 'pause':
|
||||||
await spotifyClient.pause()
|
await spotifyClient.pause()
|
||||||
break
|
break
|
||||||
@@ -89,7 +100,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Playback control error:', error)
|
console.error('Playback control error:', error)
|
||||||
|
|
||||||
// Handle specific Spotify API errors
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
if (error.message.includes('NO_ACTIVE_DEVICE')) {
|
if (error.message.includes('NO_ACTIVE_DEVICE')) {
|
||||||
return new Response(
|
return new Response(
|
||||||
@@ -111,7 +121,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the full error details for debugging
|
|
||||||
console.error('Full error details:', {
|
console.error('Full error details:', {
|
||||||
message: error.message,
|
message: error.message,
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
@@ -129,7 +138,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// GET endpoint for retrieving current playback state and devices
|
|
||||||
GET: async ({ request }: { request: Request }) => {
|
GET: async ({ request }: { request: Request }) => {
|
||||||
try {
|
try {
|
||||||
const accessToken = getAccessTokenFromCookies(request)
|
const accessToken = getAccessTokenFromCookies(request)
|
||||||
@@ -144,7 +152,7 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
const type = url.searchParams.get('type') // 'state' or 'devices'
|
const type = url.searchParams.get('type')
|
||||||
|
|
||||||
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
||||||
|
|
||||||
@@ -167,7 +175,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Return both by default
|
|
||||||
const [devices, playbackState] = await Promise.all([
|
const [devices, playbackState] = await Promise.all([
|
||||||
spotifyClient.getDevices(),
|
spotifyClient.getDevices(),
|
||||||
spotifyClient.getPlaybackState(),
|
spotifyClient.getPlaybackState(),
|
||||||
|
|||||||
81
src/app/routes/api/spotify/search.ts
Normal file
81
src/app/routes/api/spotify/search.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||||
|
|
||||||
|
// Function to get Client Credentials access token
|
||||||
|
async function getClientCredentialsToken(): Promise<string> {
|
||||||
|
const clientId = process.env.VITE_SPOTIFY_CLIENT_ID
|
||||||
|
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET
|
||||||
|
|
||||||
|
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 ServerRoute = createServerFileRoute('/api/spotify/search').methods({
|
||||||
|
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' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -19,7 +19,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/token').methods({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh access token
|
|
||||||
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
|
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -46,7 +45,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/token').methods({
|
|||||||
|
|
||||||
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,
|
||||||
|
|||||||
117
src/app/routes/api/teams/upload-logo.ts
Normal file
117
src/app/routes/api/teams/upload-logo.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { createServerFileRoute } from '@tanstack/react-start/server';
|
||||||
|
import { superTokensRequestMiddleware } from '@/utils/supertokens';
|
||||||
|
import { pbAdmin } from '@/lib/pocketbase/client';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const uploadSchema = z.object({
|
||||||
|
teamId: z.string().min(1, 'Team ID is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ServerRoute = createServerFileRoute('/api/teams/upload-logo')
|
||||||
|
.middleware([superTokensRequestMiddleware])
|
||||||
|
.methods({
|
||||||
|
POST: async ({ request, context }) => {
|
||||||
|
try {
|
||||||
|
const userId = context.userAuthId;
|
||||||
|
const isAdmin = context.roles.includes("Admin");
|
||||||
|
|
||||||
|
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(context.userAuthId)
|
||||||
|
if (!team.players.map(p => p.id).includes(user!.id) && !isAdmin)
|
||||||
|
return new Response('Unauthorized', { status: 403 });
|
||||||
|
|
||||||
|
logger.info('Uploading team logo', {
|
||||||
|
teamId,
|
||||||
|
fileName: logoFile.name,
|
||||||
|
fileSize: logoFile.size,
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
|
||||||
|
const pbFormData = new FormData();
|
||||||
|
pbFormData.append('logo', logoFile);
|
||||||
|
|
||||||
|
const updatedTeam= await pbAdmin.updateTeam(teamId, pbFormData as any);
|
||||||
|
|
||||||
|
logger.info('Team logo uploaded successfully', {
|
||||||
|
teamId,
|
||||||
|
logo: updatedTeam.logo
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
team: updatedTeam,
|
||||||
|
message: 'Logo uploaded successfully'
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error uploading team logo:', error);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Failed to upload logo',
|
||||||
|
message: error.message || 'Unknown error occurred'
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
41
src/components/rich-text-editor.tsx
Normal file
41
src/components/rich-text-editor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,31 @@ const Drawer: React.FC<DrawerProps> = ({
|
|||||||
};
|
};
|
||||||
}, [opened, colorScheme]);
|
}, [opened, colorScheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened || !contentRef.current) return;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(contentRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [opened, children]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VaulDrawer.Root open={opened} onOpenChange={onChange}>
|
<VaulDrawer.Root open={opened} onOpenChange={onChange}>
|
||||||
<VaulDrawer.Portal>
|
<VaulDrawer.Portal>
|
||||||
<VaulDrawer.Overlay className={styles.drawerOverlay} />
|
<VaulDrawer.Overlay className={styles.drawerOverlay} />
|
||||||
<VaulDrawer.Content className={styles.drawerContent}>
|
<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 +94,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}>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -29,7 +29,7 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
|
|||||||
scrollbars="y"
|
scrollbars="y"
|
||||||
type="scroll"
|
type="scroll"
|
||||||
>
|
>
|
||||||
<Box mah="70vh">{children}</Box>
|
{children}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</SheetComponent>
|
</SheetComponent>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -11,10 +11,13 @@
|
|||||||
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;
|
||||||
|
max-height: 100dvh;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
transition: height 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|||||||
161
src/components/stats-overview.tsx
Normal file
161
src/components/stats-overview.tsx
Normal 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;
|
||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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 || defaultAuthData.user,
|
||||||
|
metadata: data?.metadata || defaultAuthData.metadata,
|
||||||
|
roles: data?.roles || defaultAuthData.roles,
|
||||||
|
phone: data?.phone || "",
|
||||||
|
set,
|
||||||
|
}),
|
||||||
|
[data, defaultAuthData]
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -397,13 +438,13 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
activeDevice,
|
activeDevice,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
// Capture/Resume state
|
|
||||||
capturedState,
|
capturedState,
|
||||||
isCaptureLoading,
|
isCaptureLoading,
|
||||||
isResumeLoading,
|
isResumeLoading,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
play,
|
play,
|
||||||
|
playTrack,
|
||||||
pause,
|
pause,
|
||||||
skipNext,
|
skipNext,
|
||||||
skipPrevious,
|
skipPrevious,
|
||||||
@@ -411,10 +452,10 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
getDevices,
|
getDevices,
|
||||||
setActiveDevice,
|
setActiveDevice,
|
||||||
refreshPlaybackState,
|
refreshPlaybackState,
|
||||||
// Capture/Resume methods
|
|
||||||
capturePlaybackState,
|
capturePlaybackState,
|
||||||
resumePlaybackState,
|
resumePlaybackState,
|
||||||
clearCapturedState,
|
clearCapturedState,
|
||||||
|
searchTracks,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
|
|||||||
@@ -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,9 +131,71 @@ 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}>
|
||||||
@@ -175,7 +238,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 +273,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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
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>
|
||||||
|
|||||||
@@ -31,14 +31,18 @@ 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: 0,
|
||||||
|
minHeight: '100dvh',
|
||||||
|
maxHeight: '100dvh'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<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' }}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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()
|
||||||
@@ -12,7 +13,7 @@ const Navbar = () => {
|
|||||||
const links = useLinks(user?.id, roles);
|
const links = useLinks(user?.id, roles);
|
||||||
|
|
||||||
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 radius='lg' h='4rem' w='calc(100% - 1rem)' shadow='sm' pos='fixed' m='0.5rem' bottom='0' style={{ zIndex: 10 }}>
|
||||||
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
<NavLink key={link.href} {...link} />
|
<NavLink key={link.href} {...link} />
|
||||||
@@ -35,4 +36,4 @@ const Navbar = () => {
|
|||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Navbar;
|
export default memo(Navbar);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(),
|
||||||
@@ -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;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -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()
|
||||||
|
.validator(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()
|
||||||
|
.validator(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[]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 <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 <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">
|
||||||
|
|||||||
14
src/features/players/components/profile/header-skeleton.tsx
Normal file
14
src/features/players/components/profile/header-skeleton.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
42
src/features/players/components/profile/skeleton.tsx
Normal file
42
src/features/players/components/profile/skeleton.tsx
Normal 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;
|
||||||
@@ -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;
|
|
||||||
@@ -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 })
|
||||||
@@ -55,6 +60,7 @@ export const useMe = () => {
|
|||||||
const errorData = error?.response?.data;
|
const errorData = error?.response?.data;
|
||||||
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
|
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
|
||||||
const currentUrl = window.location.pathname + window.location.search;
|
const currentUrl = window.location.pathname + window.location.search;
|
||||||
|
console.log('redirecting 3')
|
||||||
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
|
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -81,4 +87,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));
|
||||||
@@ -21,11 +21,18 @@ 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 };
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -125,7 +132,7 @@ export const getPlayerStats = createServerFn()
|
|||||||
.validator(z.string())
|
.validator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<PlayerStats[]>(async () => await pbAdmin.getPlayerStats(data))
|
toServerResult<PlayerStats>(async () => await pbAdmin.getPlayerStats(data))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getAllPlayerStats = createServerFn()
|
export const getAllPlayerStats = createServerFn()
|
||||||
@@ -139,4 +146,11 @@ export const getPlayerMatches = createServerFn()
|
|||||||
.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()
|
||||||
|
.validator(z.string())
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data: tournamentId }) =>
|
||||||
|
toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId))
|
||||||
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
206
src/features/reactions/components/emoji-bar.tsx
Normal file
206
src/features/reactions/components/emoji-bar.tsx
Normal 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;
|
||||||
104
src/features/reactions/components/emoji-picker.tsx
Normal file
104
src/features/reactions/components/emoji-picker.tsx
Normal 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;
|
||||||
30
src/features/reactions/queries.ts
Normal file
30
src/features/reactions/queries.ts
Normal 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)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
165
src/features/teams/components/manage-teams.tsx
Normal file
165
src/features/teams/components/manage-teams.tsx
Normal 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;
|
||||||
18
src/features/teams/components/team-card-skeleton.tsx
Normal file
18
src/features/teams/components/team-card-skeleton.tsx
Normal 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;
|
||||||
76
src/features/teams/components/team-card.tsx
Normal file
76
src/features/teams/components/team-card.tsx
Normal 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;
|
||||||
@@ -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;
|
|
||||||
80
src/features/teams/components/team-form/color-picker.tsx
Normal file
80
src/features/teams/components/team-form/color-picker.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { ColorPicker, TextInput, Stack, Group, ColorSwatch, Text } from '@mantine/core';
|
||||||
|
import React, { useState, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
const presetColors = [
|
||||||
|
'#FF0000', '#00FF00', '#0000FF', '#FFFF00',
|
||||||
|
'#FF00FF', '#00FFFF', '#FFA500', '#800080',
|
||||||
|
'#008000', '#000080', '#800000', '#808000'
|
||||||
|
];
|
||||||
|
|
||||||
|
interface TeamColorPickerProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamColorPicker: React.FC<TeamColorPickerProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label = "Select Color"
|
||||||
|
}) => {
|
||||||
|
const [customHex, setCustomHex] = useState(value || '');
|
||||||
|
|
||||||
|
const isValidHex = useMemo(() => {
|
||||||
|
const hexRegex = /^#[0-9A-F]{6}$/i;
|
||||||
|
return hexRegex.test(customHex);
|
||||||
|
}, [customHex]);
|
||||||
|
|
||||||
|
const handleColorChange = useCallback((color: string) => {
|
||||||
|
setCustomHex(color);
|
||||||
|
onChange(color);
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
const handleHexInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const hex = event.currentTarget.value;
|
||||||
|
setCustomHex(hex);
|
||||||
|
|
||||||
|
if (/^#[0-9A-F]{6}$/i.test(hex)) {
|
||||||
|
onChange(hex);
|
||||||
|
}
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md" p="md" w="100%">
|
||||||
|
<Text fw={500}>{label}</Text>
|
||||||
|
|
||||||
|
<ColorPicker
|
||||||
|
value={value || '#000000'}
|
||||||
|
onChange={handleColorChange}
|
||||||
|
format="hex"
|
||||||
|
swatches={presetColors}
|
||||||
|
withPicker={true}
|
||||||
|
fullWidth
|
||||||
|
size="md"
|
||||||
|
swatchesPerRow={12}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group gap="xs" align="flex-end">
|
||||||
|
<TextInput
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
label="Custom Hex Code"
|
||||||
|
placeholder="#FF0000"
|
||||||
|
value={customHex}
|
||||||
|
onChange={handleHexInputChange}
|
||||||
|
error={customHex && !isValidHex ? 'Invalid hex color format' : undefined}
|
||||||
|
w="100%"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isValidHex && (
|
||||||
|
<ColorSwatch
|
||||||
|
color={customHex}
|
||||||
|
size={36}
|
||||||
|
style={{ marginBottom: customHex && !isValidHex ? 24 : 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TeamColorPicker;
|
||||||
255
src/features/teams/components/team-form/duration-picker.tsx
Normal file
255
src/features/teams/components/team-form/duration-picker.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Stack, Text, Group, TextInput, Button } from "@mantine/core";
|
||||||
|
|
||||||
|
interface DurationPickerProps {
|
||||||
|
songDurationMs: number;
|
||||||
|
initialStart?: number;
|
||||||
|
initialEnd?: number;
|
||||||
|
onChange: (startSeconds: number, endSeconds: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IncrementButtonsProps {
|
||||||
|
onAdjust: (seconds: number) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
isPositive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IncrementButtons = ({ onAdjust, disabled, isPositive = true }: IncrementButtonsProps) => {
|
||||||
|
const increments = [1, 5, 30, 60];
|
||||||
|
const labels = ["1s", "5s", "30s", "1m"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap={3} wrap="nowrap" flex={1}>
|
||||||
|
{increments.map((increment, index) => (
|
||||||
|
<Button
|
||||||
|
key={increment}
|
||||||
|
variant={isPositive ? "light" : "outline"}
|
||||||
|
color={isPositive ? "blue" : "gray"}
|
||||||
|
size="xs"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onAdjust(isPositive ? increment : -increment)}
|
||||||
|
flex={1}
|
||||||
|
h={24}
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 500,
|
||||||
|
minWidth: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPositive ? '+' : '-'}{labels[index]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DurationPicker = ({
|
||||||
|
songDurationMs,
|
||||||
|
initialStart = 0,
|
||||||
|
initialEnd = 15,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
}: DurationPickerProps) => {
|
||||||
|
const songDurationSeconds = Math.floor(songDurationMs / 1000);
|
||||||
|
|
||||||
|
const formatTime = useCallback((seconds: number) => {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [startTime, setStartTime] = useState(initialStart);
|
||||||
|
const [endTime, setEndTime] = useState(initialEnd);
|
||||||
|
const [isValid, setIsValid] = useState(true);
|
||||||
|
const [startInputValue, setStartInputValue] = useState(formatTime(initialStart));
|
||||||
|
const [endInputValue, setEndInputValue] = useState(formatTime(initialEnd));
|
||||||
|
|
||||||
|
const parseTimeInput = useCallback((input: string): number | null => {
|
||||||
|
if (input.includes(':')) {
|
||||||
|
const parts = input.split(':');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const minutes = parseInt(parts[0]) || 0;
|
||||||
|
const seconds = parseInt(parts[1]) || 0;
|
||||||
|
return minutes * 60 + seconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parsed = parseInt(input);
|
||||||
|
return isNaN(parsed) ? null : parsed;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const validateTimes = useCallback(
|
||||||
|
(start: number, end: number) => {
|
||||||
|
const duration = end - start;
|
||||||
|
const withinBounds = start >= 0 && end <= songDurationSeconds;
|
||||||
|
const validDuration = duration >= 10 && duration <= 15;
|
||||||
|
return withinBounds && validDuration;
|
||||||
|
},
|
||||||
|
[songDurationSeconds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getValidationMessage = useCallback((start: number, end: number) => {
|
||||||
|
const duration = end - start;
|
||||||
|
if (start < 0 || end > songDurationSeconds) {
|
||||||
|
return "Segment must be within song duration";
|
||||||
|
}
|
||||||
|
if (duration < 10) {
|
||||||
|
return "Segment must be at least 10 seconds";
|
||||||
|
}
|
||||||
|
if (duration > 15) {
|
||||||
|
return "Segment must be no more than 15 seconds";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [songDurationSeconds]);
|
||||||
|
|
||||||
|
const updateTimes = useCallback((newStart: number, newEnd: number) => {
|
||||||
|
const clampedStart = Math.max(0, Math.min(newStart, songDurationSeconds - 10));
|
||||||
|
const clampedEnd = Math.min(songDurationSeconds, Math.max(newEnd, clampedStart + 10));
|
||||||
|
|
||||||
|
setStartTime(clampedStart);
|
||||||
|
setEndTime(clampedEnd);
|
||||||
|
setStartInputValue(formatTime(clampedStart));
|
||||||
|
setEndInputValue(formatTime(clampedEnd));
|
||||||
|
|
||||||
|
const valid = validateTimes(clampedStart, clampedEnd);
|
||||||
|
setIsValid(valid);
|
||||||
|
|
||||||
|
if (valid) {
|
||||||
|
onChange(clampedStart, clampedEnd);
|
||||||
|
}
|
||||||
|
}, [songDurationSeconds, validateTimes, onChange, formatTime]);
|
||||||
|
|
||||||
|
const handleStartInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setStartInputValue(event.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEndInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setEndInputValue(event.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStartBlur = useCallback(() => {
|
||||||
|
const parsed = parseTimeInput(startInputValue);
|
||||||
|
if (parsed !== null) {
|
||||||
|
updateTimes(parsed, endTime);
|
||||||
|
} else {
|
||||||
|
setStartInputValue(formatTime(startTime));
|
||||||
|
}
|
||||||
|
}, [startInputValue, endTime, updateTimes, parseTimeInput, formatTime, startTime]);
|
||||||
|
|
||||||
|
const handleEndBlur = useCallback(() => {
|
||||||
|
const parsed = parseTimeInput(endInputValue);
|
||||||
|
if (parsed !== null) {
|
||||||
|
updateTimes(startTime, parsed);
|
||||||
|
} else {
|
||||||
|
setEndInputValue(formatTime(endTime));
|
||||||
|
}
|
||||||
|
}, [endInputValue, startTime, updateTimes, parseTimeInput, formatTime, endTime]);
|
||||||
|
|
||||||
|
const adjustStartTime = useCallback((seconds: number) => {
|
||||||
|
updateTimes(startTime + seconds, endTime);
|
||||||
|
}, [startTime, endTime, updateTimes]);
|
||||||
|
|
||||||
|
const adjustEndTime = useCallback((seconds: number) => {
|
||||||
|
updateTimes(startTime, endTime + seconds);
|
||||||
|
}, [startTime, endTime, updateTimes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!validateTimes(initialStart, initialEnd)) {
|
||||||
|
const defaultStart = Math.min(30, Math.max(0, songDurationSeconds - 15));
|
||||||
|
const defaultEnd = Math.min(defaultStart + 15, songDurationSeconds);
|
||||||
|
updateTimes(defaultStart, defaultEnd);
|
||||||
|
}
|
||||||
|
}, [initialStart, initialEnd, songDurationSeconds, validateTimes, updateTimes]);
|
||||||
|
|
||||||
|
const segmentDuration = endTime - startTime;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm" opacity={disabled ? 0.5 : 1}>
|
||||||
|
<Text size="sm" fw={500} c={disabled ? "dimmed" : undefined} ta="center">
|
||||||
|
Walkout Segment ({segmentDuration}s)
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
|
||||||
|
Start
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={startInputValue}
|
||||||
|
onChange={handleStartInputChange}
|
||||||
|
onBlur={handleStartBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
size="xs"
|
||||||
|
w={70}
|
||||||
|
placeholder="0:00"
|
||||||
|
ta="center"
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '12px'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group gap={4}>
|
||||||
|
<IncrementButtons
|
||||||
|
onAdjust={adjustStartTime}
|
||||||
|
disabled={disabled || startTime <= 0}
|
||||||
|
isPositive={false}
|
||||||
|
/>
|
||||||
|
<IncrementButtons
|
||||||
|
onAdjust={adjustStartTime}
|
||||||
|
disabled={disabled || startTime >= songDurationSeconds - 10}
|
||||||
|
isPositive={true}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
|
||||||
|
End
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={endInputValue}
|
||||||
|
onChange={handleEndInputChange}
|
||||||
|
onBlur={handleEndBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
size="xs"
|
||||||
|
w={70}
|
||||||
|
placeholder="0:15"
|
||||||
|
ta="center"
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '12px'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group gap={4}>
|
||||||
|
<IncrementButtons
|
||||||
|
onAdjust={adjustEndTime}
|
||||||
|
disabled={disabled || endTime <= startTime + 10}
|
||||||
|
isPositive={false}
|
||||||
|
/>
|
||||||
|
<IncrementButtons
|
||||||
|
onAdjust={adjustEndTime}
|
||||||
|
disabled={disabled || endTime >= songDurationSeconds}
|
||||||
|
isPositive={true}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{!isValid && (
|
||||||
|
<Text size="xs" c="red" ta="center">
|
||||||
|
{getValidationMessage(startTime, endTime)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DurationPicker;
|
||||||
236
src/features/teams/components/team-form/index.tsx
Normal file
236
src/features/teams/components/team-form/index.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { Badge, FileInput, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||||
|
import { useForm, UseFormInput } from "@mantine/form";
|
||||||
|
import { LinkIcon } from "@phosphor-icons/react";
|
||||||
|
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
|
||||||
|
import { isNotEmpty } from "@mantine/form";
|
||||||
|
import useCreateTeam from "../../hooks/use-create-team";
|
||||||
|
import useUpdateTeam from "../../hooks/use-update-team";
|
||||||
|
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 { TeamInput } from "../../types";
|
||||||
|
import { teamKeys } from "../../queries";
|
||||||
|
import SongPicker from "./song-picker";
|
||||||
|
import TeamColorPicker from "./color-picker";
|
||||||
|
import PlayersPicker from "./players-picker";
|
||||||
|
|
||||||
|
interface TeamFormProps {
|
||||||
|
close: () => void;
|
||||||
|
initialValues?: Partial<TeamInput>;
|
||||||
|
teamId?: string;
|
||||||
|
tournamentId?: string;
|
||||||
|
onSubmit?: (teamId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamForm = ({
|
||||||
|
close,
|
||||||
|
initialValues,
|
||||||
|
teamId,
|
||||||
|
tournamentId,
|
||||||
|
onSubmit
|
||||||
|
}: TeamFormProps) => {
|
||||||
|
const isEditMode = !!teamId;
|
||||||
|
|
||||||
|
const config: UseFormInput<TeamInput> = {
|
||||||
|
initialValues: {
|
||||||
|
name: initialValues?.name || "",
|
||||||
|
// primary_color: initialValues?.primary_color,
|
||||||
|
// accent_color: initialValues?.accent_color,
|
||||||
|
song_id: initialValues?.song_id,
|
||||||
|
song_name: initialValues?.song_name,
|
||||||
|
song_artist: initialValues?.song_artist,
|
||||||
|
song_album: initialValues?.song_album,
|
||||||
|
song_start: initialValues?.song_start,
|
||||||
|
song_end: initialValues?.song_end,
|
||||||
|
song_image_url: initialValues?.song_image_url,
|
||||||
|
logo: undefined,
|
||||||
|
players: initialValues?.players || [],
|
||||||
|
},
|
||||||
|
onSubmitPreventDefault: "always",
|
||||||
|
validate: {
|
||||||
|
name: isNotEmpty("Name is required"),
|
||||||
|
players: (value: string[]) =>
|
||||||
|
value.length > 1 && value[1] !== ""
|
||||||
|
? undefined
|
||||||
|
: "Players are required",
|
||||||
|
song_name: isNotEmpty("Song is required"),
|
||||||
|
song_start: (value, values) => {
|
||||||
|
if (values.song_name && values.song_id) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return "Song start time is required";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
song_end: (value, values) => {
|
||||||
|
if (values.song_name && values.song_id) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return "Song end time is required";
|
||||||
|
}
|
||||||
|
if (values.song_start !== undefined && value !== undefined) {
|
||||||
|
const duration = value - values.song_start;
|
||||||
|
if (duration < 10) {
|
||||||
|
return "Song segment must be at least 10 seconds";
|
||||||
|
}
|
||||||
|
if (duration > 15) {
|
||||||
|
return "Song segment must be no more than 15 seconds";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm(config);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { mutate: createTeam, isPending: createPending } =
|
||||||
|
useCreateTeam();
|
||||||
|
const { mutate: updateTeam, isPending: updatePending } = useUpdateTeam(
|
||||||
|
teamId!
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPending = createPending || updatePending;
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (values: TeamInput) => {
|
||||||
|
const { logo, ...teamData } = values;
|
||||||
|
|
||||||
|
const mutation = isEditMode ? updateTeam : createTeam;
|
||||||
|
const errorMessage = isEditMode
|
||||||
|
? "Failed to update team"
|
||||||
|
: "Failed to create team";
|
||||||
|
|
||||||
|
mutation(teamData, {
|
||||||
|
onSuccess: async (team: any) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: teamKeys.list });
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: teamKeys.details(team.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (logo && team) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("teamId", team.id);
|
||||||
|
formData.append("logo", logo);
|
||||||
|
|
||||||
|
const response = await fetch("/api/teams/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.setQueryData(
|
||||||
|
tournamentKeys.details(result.team!.id),
|
||||||
|
result.team
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
const logoErrorMessage = isEditMode
|
||||||
|
? `Team updated but logo upload failed: ${error.message}`
|
||||||
|
: `Team created but logo upload failed: ${error.message}`;
|
||||||
|
toast.error(logoErrorMessage);
|
||||||
|
logger.error("Team logo upload error", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (team && team.id) {
|
||||||
|
onSubmit?.(team.id)
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(`${errorMessage}: ${error.message}`);
|
||||||
|
logger.error(`Team ${isEditMode ? "update" : "create"} error`, error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[isEditMode, createTeam, updateTeam, queryClient]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlidePanel
|
||||||
|
onSubmit={form.onSubmit(handleSubmit)}
|
||||||
|
onCancel={close}
|
||||||
|
submitText={isEditMode ? "Update Team" : "Create Team"}
|
||||||
|
cancelText="Cancel"
|
||||||
|
loading={isPending}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<TextInput
|
||||||
|
label="Name"
|
||||||
|
withAsterisk
|
||||||
|
disabled={isEditMode}
|
||||||
|
key={form.key("name")}
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{tournamentId && (
|
||||||
|
<PlayersPicker
|
||||||
|
tournamentId={tournamentId}
|
||||||
|
key={form.key("players")}
|
||||||
|
{...form.getInputProps("players")}
|
||||||
|
disabled={isEditMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SongPicker
|
||||||
|
form={form}
|
||||||
|
error={form.errors.song_name as string}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
<SlidePanelField
|
||||||
|
key={form.key("primary_color")}
|
||||||
|
{...form.getInputProps("primary_color")}
|
||||||
|
Component={TeamColorPicker}
|
||||||
|
title="Select Primary Color"
|
||||||
|
placeholder="Select Primary Color"
|
||||||
|
label="Primary Color"
|
||||||
|
formatValue={(value) => (
|
||||||
|
<Group>
|
||||||
|
<Badge variant="filled" radius="sm" color={value} />
|
||||||
|
{value}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SlidePanelField
|
||||||
|
key={form.key("accent_color")}
|
||||||
|
{...form.getInputProps("accent_color")}
|
||||||
|
Component={TeamColorPicker}
|
||||||
|
title="Select Accent Color"
|
||||||
|
placeholder="Select Accent Color"
|
||||||
|
label="Accent Color"
|
||||||
|
formatValue={(value) => (
|
||||||
|
<Group>
|
||||||
|
<Badge variant="filled" radius="sm" color={value} />
|
||||||
|
{value}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
*/}
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
</SlidePanel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TeamForm;
|
||||||
64
src/features/teams/components/team-form/players-picker.tsx
Normal file
64
src/features/teams/components/team-form/players-picker.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { SlidePanelField } from "@/components/sheet/slide-panel";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { useUnenrolledPlayers } from "@/features/players/queries";
|
||||||
|
import { Autocomplete, Stack, Text, TextInput } from "@mantine/core";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
interface PlayersPickerProps {
|
||||||
|
tournamentId: string;
|
||||||
|
value?: string[];
|
||||||
|
onChange: (value: string[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlayersPicker = ({ tournamentId, value = [], onChange, disabled, error }: PlayersPickerProps) => {
|
||||||
|
const { roles, user } = useAuth();
|
||||||
|
const isAdmin = roles.includes("Admin");
|
||||||
|
|
||||||
|
const { data } = useUnenrolledPlayers(tournamentId);
|
||||||
|
|
||||||
|
const options = useMemo(() => data.filter(p => p.id != user?.id).map(p => (p.first_name + " " + p.last_name)), [data])
|
||||||
|
|
||||||
|
const findPlayerId = useCallback((name?: string) => {
|
||||||
|
const player = data.find(player => player?.first_name + " " + player?.last_name === name)
|
||||||
|
return player?.id || ""
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const findPlayerName = useCallback((id?: string) => {
|
||||||
|
const player = data.find(player => player.id === id)
|
||||||
|
return player?.first_name + " " + player?.last_name
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const [playerOne, setPlayerOne] = useState(value.length ? findPlayerName(value[0]) : findPlayerName(user!.id));
|
||||||
|
const [playerTwo, setPlayerTwo] = useState(value.length > 1 ? findPlayerName(value[1]) : undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChange([findPlayerId(playerOne), findPlayerId(playerTwo)])
|
||||||
|
}, [playerOne, playerTwo])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Autocomplete
|
||||||
|
label="Player 1"
|
||||||
|
data={options}
|
||||||
|
disabled
|
||||||
|
value={playerOne}
|
||||||
|
onChange={setPlayerOne}
|
||||||
|
comboboxProps={{ withinPortal: false }}
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
label="Player 2"
|
||||||
|
data={options}
|
||||||
|
disabled={disabled}
|
||||||
|
value={playerTwo}
|
||||||
|
onChange={setPlayerTwo}
|
||||||
|
withAsterisk
|
||||||
|
error={error}
|
||||||
|
comboboxProps={{ withinPortal: false }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlayersPicker;
|
||||||
151
src/features/teams/components/team-form/song-picker.tsx
Normal file
151
src/features/teams/components/team-form/song-picker.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { SlidePanelField } from "@/components/sheet/slide-panel";
|
||||||
|
import { Stack, Text, Group, Avatar } from "@mantine/core";
|
||||||
|
import { UseFormReturnType } from "@mantine/form";
|
||||||
|
import { TeamInput } from "../../types";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { SpotifyTrack } from "@/lib/spotify/types";
|
||||||
|
import SongSearch from "./song-search";
|
||||||
|
import DurationPicker from "./duration-picker";
|
||||||
|
import SongSummary from "./song-summary";
|
||||||
|
import { MusicNote } from "@phosphor-icons/react/dist/ssr";
|
||||||
|
import { MusicNoteIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
interface Song {
|
||||||
|
song_id: string;
|
||||||
|
song_name: string;
|
||||||
|
song_artist: string;
|
||||||
|
song_album: string;
|
||||||
|
song_start?: number;
|
||||||
|
song_end?: number;
|
||||||
|
song_image_url: string;
|
||||||
|
duration_ms?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SongPickerProps {
|
||||||
|
form: UseFormReturnType<TeamInput>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SongPicker = ({ form, error }: SongPickerProps) => {
|
||||||
|
const currentSong = useMemo((): Song | undefined => {
|
||||||
|
const values = form.getValues();
|
||||||
|
if (values.song_id && values.song_name) {
|
||||||
|
return {
|
||||||
|
song_id: values.song_id,
|
||||||
|
song_name: values.song_name,
|
||||||
|
song_artist: values.song_artist || "",
|
||||||
|
song_album: values.song_album || "",
|
||||||
|
song_start: values.song_start,
|
||||||
|
song_end: values.song_end,
|
||||||
|
song_image_url: values.song_image_url || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [form.values.song_id, form.values.song_name, form.values.song_artist, form.values.song_album, form.values.song_image_url, form.values.song_start, form.values.song_end]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlidePanelField
|
||||||
|
key={"song-picker"}
|
||||||
|
value={currentSong}
|
||||||
|
formatValue={(song) => <SongSummary song={song} />}
|
||||||
|
onChange={(updatedSong: Song) => {
|
||||||
|
if (updatedSong) {
|
||||||
|
form.setValues({
|
||||||
|
song_id: updatedSong.song_id,
|
||||||
|
song_name: updatedSong.song_name,
|
||||||
|
song_artist: updatedSong.song_artist,
|
||||||
|
song_album: updatedSong.song_album,
|
||||||
|
song_start: updatedSong.song_start,
|
||||||
|
song_end: updatedSong.song_end,
|
||||||
|
song_image_url: updatedSong.song_image_url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
error={error}
|
||||||
|
Component={SongPickerComponent}
|
||||||
|
componentProps={{}}
|
||||||
|
title={"Select Song"}
|
||||||
|
label={"Walkout Song"}
|
||||||
|
placeholder={"Select your walkout song"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SongPickerComponentProps {
|
||||||
|
value: Song | undefined;
|
||||||
|
onChange: (song: Song) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SongPickerComponent = ({ value: song, onChange }: SongPickerComponentProps) => {
|
||||||
|
const handleSongSelect = (track: SpotifyTrack) => {
|
||||||
|
const defaultStart = 0;
|
||||||
|
const defaultEnd = Math.min(15, Math.floor(track.duration_ms / 1000));
|
||||||
|
|
||||||
|
const newSong: Song = {
|
||||||
|
song_id: track.id,
|
||||||
|
song_name: track.name,
|
||||||
|
song_artist: track.artists.map(a => a.name).join(', '),
|
||||||
|
song_album: track.album.name,
|
||||||
|
song_image_url: track.album.images[0]?.url || '',
|
||||||
|
song_start: defaultStart,
|
||||||
|
song_end: defaultEnd,
|
||||||
|
duration_ms: track.duration_ms,
|
||||||
|
};
|
||||||
|
|
||||||
|
onChange(newSong);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDurationChange = (startSeconds: number, endSeconds: number) => {
|
||||||
|
if (song) {
|
||||||
|
onChange({
|
||||||
|
...song,
|
||||||
|
song_start: startSeconds,
|
||||||
|
song_end: endSeconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack styles={{ root: { width: '100%' }}}>
|
||||||
|
<Text size="lg" fw={500}>Search for a Song</Text>
|
||||||
|
|
||||||
|
<SongSearch onChange={handleSongSelect} />
|
||||||
|
|
||||||
|
<Stack gap="lg" mt="md">
|
||||||
|
<Group gap="sm">
|
||||||
|
<Avatar
|
||||||
|
src={song?.song_image_url || null}
|
||||||
|
size={60}
|
||||||
|
radius="md"
|
||||||
|
bg="transparent"
|
||||||
|
>
|
||||||
|
{!song?.song_image_url && <MusicNoteIcon size={24} color="var(--mantine-color-dimmed)" />}
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} c={song?.song_name ? undefined : "dimmed"}>
|
||||||
|
{song?.song_name || "No song selected"}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{song?.song_artist || "Select a song to see details"}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{song?.song_album || ""}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<DurationPicker
|
||||||
|
songDurationMs={song?.duration_ms || 180000}
|
||||||
|
initialStart={song?.song_start || 0}
|
||||||
|
initialEnd={song?.song_end || 15}
|
||||||
|
onChange={handleDurationChange}
|
||||||
|
disabled={!song?.song_id}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SongPicker;
|
||||||
182
src/features/teams/components/team-form/song-search.tsx
Normal file
182
src/features/teams/components/team-form/song-search.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { Text, TextInput, Group, Avatar, Loader, Paper, Stack, Box } from "@mantine/core";
|
||||||
|
import { SpotifyTrack } from "@/lib/spotify/types";
|
||||||
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
|
|
||||||
|
interface SongSearchProps {
|
||||||
|
onChange: (track: SpotifyTrack) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [searchResults, setSearchResults] = useState<SpotifyTrack[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const searchSpotifyTracks = async (query: string): Promise<SpotifyTrack[]> => {
|
||||||
|
if (!query.trim()) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 debouncedSearch = useDebouncedCallback(async (query: string) => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
setSearchResults([]);
|
||||||
|
setIsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const results = await searchSpotifyTracks(query);
|
||||||
|
setSearchResults(results);
|
||||||
|
setIsOpen(results.length > 0);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
setSearchResults([]);
|
||||||
|
setIsOpen(false);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
const handleSearchChange = (value: string) => {
|
||||||
|
setSearchQuery(value);
|
||||||
|
debouncedSearch(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSongSelect = (track: SpotifyTrack) => {
|
||||||
|
onChange(track);
|
||||||
|
setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`);
|
||||||
|
setIsOpen(false);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!isOpen || searchResults.length === 0) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => (prev < searchResults.length - 1 ? prev + 1 : prev));
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev));
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedIndex >= 0 && searchResults[selectedIndex]) {
|
||||||
|
handleSongSelect(searchResults[selectedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
setIsOpen(false);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box ref={containerRef} pos="relative" w="100%">
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => handleSearchChange(event.currentTarget.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => {
|
||||||
|
if (searchResults.length > 0) setIsOpen(true);
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rightSection={isLoading ? <Loader size="xs" /> : null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<Paper
|
||||||
|
shadow="md"
|
||||||
|
p={0}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
maxHeight: '160px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
touchAction: 'pan-y'
|
||||||
|
}}
|
||||||
|
onTouchMove={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{searchResults.length > 0 ? (
|
||||||
|
<Stack gap={0}>
|
||||||
|
{searchResults.map((track, index) => (
|
||||||
|
<Box
|
||||||
|
key={track.id}
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: selectedIndex === index ? 'var(--mantine-color-gray-1)' : 'transparent',
|
||||||
|
borderBottom: index < searchResults.length - 1 ? '1px solid var(--mantine-color-gray-3)' : 'none'
|
||||||
|
}}
|
||||||
|
onClick={() => handleSongSelect(track)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
>
|
||||||
|
<Group gap="sm">
|
||||||
|
{track.album.images[2] && (
|
||||||
|
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{track.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{track.artists.map(a => a.name).join(', ')} • {track.album.name}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Box p="md">
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SongSearch;
|
||||||
65
src/features/teams/components/team-form/song-summary.tsx
Normal file
65
src/features/teams/components/team-form/song-summary.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Group, Avatar, Text, Box } from "@mantine/core";
|
||||||
|
import { MusicNoteIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
interface Song {
|
||||||
|
song_id: string;
|
||||||
|
song_name: string;
|
||||||
|
song_artist: string;
|
||||||
|
song_album: string;
|
||||||
|
song_start?: number;
|
||||||
|
song_end?: number;
|
||||||
|
song_image_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SongSummaryProps {
|
||||||
|
song: Song | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SongSummary = ({ song }: SongSummaryProps) => {
|
||||||
|
const formatTime = (seconds: number | undefined) => {
|
||||||
|
if (seconds === undefined) return null;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!song?.song_name) {
|
||||||
|
return (
|
||||||
|
<Group gap="xs" c="dimmed">
|
||||||
|
<MusicNoteIcon size={16} />
|
||||||
|
<Text size="sm" c="dimmed">No song selected</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTimeSegment = song.song_start !== undefined && song.song_end !== undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
{song.song_image_url && (
|
||||||
|
<Avatar
|
||||||
|
src={song.song_image_url}
|
||||||
|
size={32}
|
||||||
|
radius="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="sm" fw={500} truncate>
|
||||||
|
{song.song_name}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Text size="xs" c="dimmed" truncate style={{ flex: 1 }}>
|
||||||
|
{song.song_artist}
|
||||||
|
</Text>
|
||||||
|
{hasTimeSegment && (
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
{formatTime(song.song_start)} - {formatTime(song.song_end)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SongSummary;
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { ColorPicker, TextInput, Stack, Group, ColorSwatch, Text } from '@mantine/core';
|
|
||||||
import React, { useState, useCallback, useMemo } from 'react';
|
|
||||||
|
|
||||||
interface TeamColorPickerProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TeamColorPicker: React.FC<TeamColorPickerProps> = ({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
label = "Select Color"
|
|
||||||
}) => {
|
|
||||||
const [customHex, setCustomHex] = useState(value || '');
|
|
||||||
|
|
||||||
const isValidHex = useMemo(() => {
|
|
||||||
const hexRegex = /^#[0-9A-F]{6}$/i;
|
|
||||||
return hexRegex.test(customHex);
|
|
||||||
}, [customHex]);
|
|
||||||
|
|
||||||
const handleColorChange = useCallback((color: string) => {
|
|
||||||
setCustomHex(color);
|
|
||||||
onChange(color);
|
|
||||||
}, [onChange]);
|
|
||||||
|
|
||||||
const handleHexInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const hex = event.currentTarget.value;
|
|
||||||
setCustomHex(hex);
|
|
||||||
|
|
||||||
if (/^#[0-9A-F]{6}$/i.test(hex)) {
|
|
||||||
onChange(hex);
|
|
||||||
}
|
|
||||||
}, [onChange]);
|
|
||||||
|
|
||||||
const presetColors = [
|
|
||||||
'#FF0000', '#00FF00', '#0000FF', '#FFFF00',
|
|
||||||
'#FF00FF', '#00FFFF', '#FFA500', '#800080',
|
|
||||||
'#008000', '#000080', '#800000', '#808000'
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md" p="md">
|
|
||||||
<Text fw={500}>{label}</Text>
|
|
||||||
|
|
||||||
<ColorPicker
|
|
||||||
value={value || '#000000'}
|
|
||||||
onChange={handleColorChange}
|
|
||||||
format="hex"
|
|
||||||
swatches={presetColors}
|
|
||||||
withPicker={true}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group gap="xs" align="flex-end">
|
|
||||||
<TextInput
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
label="Custom Hex Code"
|
|
||||||
placeholder="#FF0000"
|
|
||||||
value={customHex}
|
|
||||||
onChange={handleHexInputChange}
|
|
||||||
error={customHex && !isValidHex ? 'Invalid hex color format' : undefined}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isValidHex && (
|
|
||||||
<ColorSwatch
|
|
||||||
color={customHex}
|
|
||||||
size={36}
|
|
||||||
style={{ marginBottom: customHex && !isValidHex ? 24 : 0 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TeamColorPicker;
|
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
import { List, ListItem, Skeleton, Stack, Text } from "@mantine/core";
|
import {
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
import { TeamInfo } from "@/features/teams/types";
|
import { TeamInfo } from "@/features/teams/types";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
@@ -15,30 +23,37 @@ const TeamListItem = React.memo(({ team }: TeamListItemProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Group justify="space-between" w="100%">
|
||||||
<Stack gap={0}>
|
<Text fw={500}>{`${team.name}`}</Text>
|
||||||
<Text fw={500}>{`${team.name}`}</Text>
|
<Stack ml="auto" gap={0}>
|
||||||
{playerNames.map((name) => (
|
{playerNames.map((name) => (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed" ta="right">
|
||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</Group>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
interface TeamListProps {
|
interface TeamListProps {
|
||||||
teams: TeamInfo[];
|
teams: TeamInfo[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
onTeamClick?: (teamId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TeamList = ({ teams, loading = false }: TeamListProps) => {
|
const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(teamId: string) => navigate({ to: `/teams/${teamId}` }),
|
(teamId: string) => {
|
||||||
[navigate]
|
if (onTeamClick) {
|
||||||
|
onTeamClick(teamId);
|
||||||
|
} else {
|
||||||
|
navigate({ to: `/teams/${teamId}` });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[navigate, onTeamClick]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading)
|
if (loading)
|
||||||
@@ -59,15 +74,34 @@ const TeamList = ({ teams, loading = false }: TeamListProps) => {
|
|||||||
return (
|
return (
|
||||||
<List>
|
<List>
|
||||||
{teams?.map((team) => (
|
{teams?.map((team) => (
|
||||||
<ListItem
|
<div key={team.id}>
|
||||||
key={team.id}
|
<ListItem
|
||||||
py="xs"
|
key={`team-list-${team.id}`}
|
||||||
icon={<Avatar radius="sm" size={40} name={`${team.name}`} />}
|
p="xs"
|
||||||
style={{ cursor: "pointer" }}
|
icon={
|
||||||
onClick={() => handleClick(team.id)}
|
<Avatar
|
||||||
>
|
radius="sm"
|
||||||
<TeamListItem team={team} />
|
size={40}
|
||||||
</ListItem>
|
name={`${team.name}`}
|
||||||
|
src={
|
||||||
|
team.logo
|
||||||
|
? `/api/files/teams/${team.id}/${team.logo}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => handleClick(team.id)}
|
||||||
|
styles={{
|
||||||
|
itemWrapper: { width: "100%" },
|
||||||
|
itemLabel: { width: "100%" },
|
||||||
|
}}
|
||||||
|
w="100%"
|
||||||
|
>
|
||||||
|
<TeamListItem team={team} />
|
||||||
|
</ListItem>
|
||||||
|
<Divider />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Flex, Skeleton } from "@mantine/core";
|
||||||
|
|
||||||
|
const HeaderSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<Flex h="20dvh" px='xl' w='100%' align='flex-end' gap='md'>
|
||||||
|
<Skeleton opacity={0} height={200} width={150} />
|
||||||
|
<Flex align='center' justify='center' gap={4} w='100%'>
|
||||||
|
<Skeleton height={36} width={200} />
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeaderSkeleton;
|
||||||
@@ -5,13 +5,19 @@ import { Team } from "../../types";
|
|||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
name: string;
|
name: string;
|
||||||
logo?: string;
|
logo?: string;
|
||||||
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Header = ({ name, logo }: HeaderProps) => {
|
const Header = ({ name, logo, id }: HeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex px="xl" w="100%" align="self-end" gap="md">
|
<Flex h="20dvh" px="xl" w="100%" align="self-end" gap="md">
|
||||||
<Avatar radius="sm" name={name} size={125} />
|
<Avatar
|
||||||
|
radius="sm"
|
||||||
|
name={name}
|
||||||
|
size={125}
|
||||||
|
src={logo && id ? `/api/files/teams/${id}/${logo}` : undefined}
|
||||||
|
/>
|
||||||
<Flex align="center" justify="center" gap={4} pb={20} w="100%">
|
<Flex align="center" justify="center" gap={4} pb={20} w="100%">
|
||||||
<Title ta="center" order={2}>
|
<Title ta="center" order={2}>
|
||||||
{name}
|
{name}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Box, Text } from "@mantine/core";
|
import { Box, Divider, Text, Stack } from "@mantine/core";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
import TournamentList from "@/features/tournaments/components/tournament-list";
|
import TournamentList from "@/features/tournaments/components/tournament-list";
|
||||||
import { useTeam } from "../../queries";
|
import StatsOverview from "@/components/stats-overview";
|
||||||
|
import { useTeam, useTeamMatches, useTeamStats } from "../../queries";
|
||||||
|
import MatchList from "@/features/matches/components/match-list";
|
||||||
|
import PlayerList from "@/features/players/components/player-list";
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -10,16 +13,28 @@ interface ProfileProps {
|
|||||||
|
|
||||||
const TeamProfile = ({ id }: ProfileProps) => {
|
const TeamProfile = ({ id }: ProfileProps) => {
|
||||||
const { data: team } = useTeam(id);
|
const { data: team } = useTeam(id);
|
||||||
|
const { data: matches } = useTeamMatches(id);
|
||||||
|
const { data: stats, isLoading: statsLoading, error: statsError } = useTeamStats(id);
|
||||||
if (!team) return <Text p="md">Team not found</Text>;
|
if (!team) return <Text p="md">Team not found</Text>;
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
content: <Text p="md">Stats/Badges will go here</Text>,
|
content: <>
|
||||||
|
<Stack px="md">
|
||||||
|
<Text size="md" fw={700}>Players</Text>
|
||||||
|
<PlayerList players={team.players} />
|
||||||
|
</Stack>
|
||||||
|
<Divider my="md" />
|
||||||
|
<Stack>
|
||||||
|
<Text px="md" size="md" fw={700}>Statistics</Text>
|
||||||
|
<StatsOverview statsData={statsError ? null : stats || null} isLoading={statsLoading} />
|
||||||
|
</Stack>
|
||||||
|
</>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Matches",
|
label: "Matches",
|
||||||
content: <Text p="md">Matches feed will go here</Text>,
|
content: <MatchList matches={matches || []} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Tournaments",
|
label: "Tournaments",
|
||||||
@@ -32,8 +47,8 @@ const TeamProfile = ({ id }: ProfileProps) => {
|
|||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header name={team.name} logo={team.logo} />
|
<Header name={team.name} logo={team.logo} id={team.id} />
|
||||||
<Box m="sm" mt="lg">
|
<Box mt="lg">
|
||||||
<SwipeableTabs tabs={tabs} />
|
<SwipeableTabs tabs={tabs} />
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
37
src/features/teams/components/team-profile/skeleton.tsx
Normal file
37
src/features/teams/components/team-profile/skeleton.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Box, Flex, Loader } from "@mantine/core";
|
||||||
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
|
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: <SkeletonLoader />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Matches",
|
||||||
|
content: <SkeletonLoader />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Tournaments",
|
||||||
|
content: <SkeletonLoader />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeaderSkeleton />
|
||||||
|
<Box mt="lg">
|
||||||
|
<SwipeableTabs tabs={tabs} />
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileSkeleton;
|
||||||
@@ -12,9 +12,6 @@ const useCreateTeam = () => {
|
|||||||
onMutate: (data) => {
|
onMutate: (data) => {
|
||||||
logger.info('Creating team', data);
|
logger.info('Creating team', data);
|
||||||
},
|
},
|
||||||
onSuccess: (team) => {
|
|
||||||
navigate({ to: '/teams/$id', params: { id: team.id } });
|
|
||||||
},
|
|
||||||
successMessage: 'Team created successfully!',
|
successMessage: 'Team created successfully!',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
import { useServerSuspenseQuery, useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||||
import { getTeam } from "./server";
|
import { getTeam, getTeamMatches, getTeamStats } from "./server";
|
||||||
|
|
||||||
export const teamKeys = {
|
export const teamKeys = {
|
||||||
|
list: ['teams', 'list'] as const,
|
||||||
details: (id: string) => ['teams', 'details', id] as const,
|
details: (id: string) => ['teams', 'details', id] as const,
|
||||||
|
stats: (id: string) => ['teams', 'stats', id] as const,
|
||||||
|
matches: (id: string) => ['teams', 'matches', id] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const teamQueries = {
|
export const teamQueries = {
|
||||||
@@ -10,7 +13,21 @@ export const teamQueries = {
|
|||||||
queryKey: teamKeys.details(id),
|
queryKey: teamKeys.details(id),
|
||||||
queryFn: () => getTeam({ data: id }),
|
queryFn: () => getTeam({ data: id }),
|
||||||
}),
|
}),
|
||||||
|
stats: (id: string) => ({
|
||||||
|
queryKey: teamKeys.stats(id),
|
||||||
|
queryFn: () => getTeamStats({ data: id }),
|
||||||
|
}),
|
||||||
|
matches: (id: string) => ({
|
||||||
|
queryKey: teamKeys.matches(id),
|
||||||
|
queryFn: () => getTeamMatches({ data: id })
|
||||||
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useTeam = (id: string) =>
|
export const useTeam = (id: string) =>
|
||||||
useServerSuspenseQuery(teamQueries.details(id));
|
useServerSuspenseQuery(teamQueries.details(id));
|
||||||
|
|
||||||
|
export const useTeamStats = (id: string) =>
|
||||||
|
useServerQuery(teamQueries.stats(id));
|
||||||
|
|
||||||
|
export const useTeamMatches = (id: string) =>
|
||||||
|
useServerQuery(teamQueries.matches(id));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 { teamInputSchema, teamUpdateSchema } from "./types";
|
import { teamInputSchema, teamUpdateSchema } from "./types";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { Match } from "../matches/types";
|
||||||
|
|
||||||
|
|
||||||
export const listTeamInfos = createServerFn()
|
export const listTeamInfos = createServerFn()
|
||||||
@@ -50,18 +51,16 @@ export const updateTeam = createServerFn()
|
|||||||
updates: teamUpdateSchema
|
updates: teamUpdateSchema
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: { id, updates }, context }) =>
|
.handler(async ({ data: { id, updates }, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const isAdmin = context.roles.includes("Admin");
|
||||||
|
|
||||||
// Get the team to verify ownership
|
|
||||||
const team = await pbAdmin.getTeam(id);
|
const team = await pbAdmin.getTeam(id);
|
||||||
if (!team) {
|
if (!team) {
|
||||||
throw new Error("Team not found");
|
throw new Error("Team not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has permission to update this team
|
|
||||||
const isPlayerOnTeam = team.players.some(player => player.id === userId);
|
const isPlayerOnTeam = team.players.some(player => player.id === userId);
|
||||||
if (!isAdmin && !isPlayerOnTeam) {
|
if (!isAdmin && !isPlayerOnTeam) {
|
||||||
throw new Error("You can only update teams that you are a member of");
|
throw new Error("You can only update teams that you are a member of");
|
||||||
@@ -71,3 +70,17 @@ export const updateTeam = createServerFn()
|
|||||||
return pbAdmin.updateTeam(id, updates);
|
return pbAdmin.updateTeam(id, updates);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getTeamStats = createServerFn()
|
||||||
|
.validator(z.string())
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data: teamId }) =>
|
||||||
|
toServerResult(() => pbAdmin.getTeamStats(teamId))
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getTeamMatches = createServerFn()
|
||||||
|
.validator(z.string())
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data }) =>
|
||||||
|
toServerResult<Match[]>(async () => await pbAdmin.getTeamMatches(data))
|
||||||
|
);
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export interface Team {
|
|||||||
song_name: string;
|
song_name: string;
|
||||||
song_artist: string;
|
song_artist: string;
|
||||||
song_album: string;
|
song_album: string;
|
||||||
song_year: number;
|
|
||||||
song_start: number;
|
song_start: number;
|
||||||
song_end: number;
|
song_end: number;
|
||||||
song_image_url: string;
|
song_image_url: string;
|
||||||
@@ -47,7 +46,6 @@ export const teamInputSchema = z
|
|||||||
song_name: z.string().max(255).optional(),
|
song_name: z.string().max(255).optional(),
|
||||||
song_artist: z.string().max(255).optional(),
|
song_artist: z.string().max(255).optional(),
|
||||||
song_album: z.string().max(255).optional(),
|
song_album: z.string().max(255).optional(),
|
||||||
song_year: z.number().int().optional(),
|
|
||||||
song_start: z.number().int().optional(),
|
song_start: z.number().int().optional(),
|
||||||
song_end: z.number().int().optional(),
|
song_end: z.number().int().optional(),
|
||||||
song_image_url: z.url("Invalid song image URL").optional(),
|
song_image_url: z.url("Invalid song image URL").optional(),
|
||||||
@@ -79,7 +77,6 @@ export const teamUpdateSchema = z
|
|||||||
song_name: z.string().max(255).optional(),
|
song_name: z.string().max(255).optional(),
|
||||||
song_artist: z.string().max(255).optional(),
|
song_artist: z.string().max(255).optional(),
|
||||||
song_album: z.string().max(255).optional(),
|
song_album: z.string().max(255).optional(),
|
||||||
song_year: z.number().int().optional(),
|
|
||||||
song_start: z.number().int().optional(),
|
song_start: z.number().int().optional(),
|
||||||
song_end: z.number().int().optional(),
|
song_end: z.number().int().optional(),
|
||||||
song_image_url: z.url("Invalid song image URL").optional(),
|
song_image_url: z.url("Invalid song image URL").optional(),
|
||||||
@@ -96,3 +93,16 @@ export const teamUpdateSchema = z
|
|||||||
|
|
||||||
export type TeamInput = z.infer<typeof teamInputSchema>;
|
export type TeamInput = z.infer<typeof teamInputSchema>;
|
||||||
export type TeamUpdateInput = z.infer<typeof teamUpdateSchema>;
|
export type TeamUpdateInput = z.infer<typeof teamUpdateSchema>;
|
||||||
|
|
||||||
|
export interface TeamStats {
|
||||||
|
id: string;
|
||||||
|
team_id: string;
|
||||||
|
team_name: string;
|
||||||
|
matches: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
total_cups_made: number;
|
||||||
|
total_cups_against: number;
|
||||||
|
margin_of_victory: number;
|
||||||
|
margin_of_loss: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,7 +34,16 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
|
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
|
||||||
<Avatar size={32} name={team.name} />
|
<Avatar
|
||||||
|
size={32}
|
||||||
|
radius="sm"
|
||||||
|
name={team.name}
|
||||||
|
src={
|
||||||
|
team.logo
|
||||||
|
? `/api/files/teams/${team.id}/${team.logo}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text fw={500} truncate>
|
<Text fw={500} truncate>
|
||||||
{team.name}
|
{team.name}
|
||||||
|
|||||||
44
src/features/tournaments/components/edit-rules.tsx
Normal file
44
src/features/tournaments/components/edit-rules.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Button
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { useTournament } from "../queries";
|
||||||
|
import useUpdateTournament from "../hooks/use-update-tournament";
|
||||||
|
import { RichTextEditor } from "@/components/rich-text-editor";
|
||||||
|
|
||||||
|
interface EditRulesProps {
|
||||||
|
tournamentId: string;
|
||||||
|
onClose?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditRules = ({ tournamentId, onClose }: EditRulesProps) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const { data: tournament, isLoading: tournamentLoading } =
|
||||||
|
useTournament(tournamentId);
|
||||||
|
|
||||||
|
const { mutate: updateTournament, isPending: updatePending } = useUpdateTournament(tournamentId);
|
||||||
|
const [value, setValue] = useState(tournament.rules);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(rules?: string) => {
|
||||||
|
updateTournament({ rules }, {
|
||||||
|
onSuccess: () => {
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateTournament, tournamentId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" w="100%">
|
||||||
|
<RichTextEditor value={value || ""} onChange={setValue} />
|
||||||
|
<Button onClick={() => handleSubmit(value)}>Submit</Button>
|
||||||
|
<Button variant="subtle" color="red" onClick={onClose}>Cancel</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditRules;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user