Compare commits

8 Commits

Author SHA1 Message Date
yohlo
f99d6efaf9 reactions, match sse, etc 2025-09-19 14:08:36 -05:00
yohlo
602e6e3473 rules, bracket page 2025-09-18 18:17:56 -05:00
yohlo
285a33c488 enroll team polish? 2025-09-17 20:20:52 -05:00
yohlo
cac42c9b29 reactions 2025-09-17 10:30:10 -05:00
yohlo
498010e3e2 various improvements 2025-09-17 09:02:20 -05:00
yohlo
c170e1e1fe working team update/create (still need enroll) 2025-09-16 13:24:39 -05:00
yohlo
cde74a04d5 work on team enrollment 2025-09-16 09:24:21 -05:00
yohlo
9a105b30c6 stats reorg, upcoming refinement 2025-09-14 23:10:05 -05:00
108 changed files with 4620 additions and 1562 deletions

View File

@@ -18,12 +18,17 @@
"@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",
"@tanstack/react-virtual": "^3.13.12",
"@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",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.12", "framer-motion": "^12.23.12",
@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,10 +26,13 @@ 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 AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id' import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id'
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id' import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo' import { 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,6 +119,12 @@ const AuthedAdminTournamentsIndexRoute =
path: '/tournaments/', path: '/tournaments/',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const AuthedTournamentsIdBracketRoute =
AuthedTournamentsIdBracketRouteImport.update({
id: '/tournaments/$id/bracket',
path: '/tournaments/$id/bracket',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedAdminTournamentsIdRoute = const AuthedAdminTournamentsIdRoute =
AuthedAdminTournamentsIdRouteImport.update({ AuthedAdminTournamentsIdRouteImport.update({
id: '/tournaments/$id', id: '/tournaments/$id',
@@ -134,11 +143,22 @@ const ApiTournamentsUploadLogoServerRoute =
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',
@@ -193,6 +213,7 @@ export interface FileRoutesByFullPath {
'/admin/': typeof AuthedAdminIndexRoute '/admin/': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute '/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
} }
@@ -210,6 +231,7 @@ export interface FileRoutesByTo {
'/admin': typeof AuthedAdminIndexRoute '/admin': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute '/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
} }
@@ -230,6 +252,7 @@ export interface FileRoutesById {
'/_authed/admin/': typeof AuthedAdminIndexRoute '/_authed/admin/': typeof AuthedAdminIndexRoute
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
'/_authed/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute '/_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/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
} }
@@ -250,6 +273,7 @@ export interface FileRouteTypes {
| '/admin/' | '/admin/'
| '/tournaments' | '/tournaments'
| '/admin/tournaments/$id' | '/admin/tournaments/$id'
| '/tournaments/$id/bracket'
| '/admin/tournaments' | '/admin/tournaments'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
@@ -267,6 +291,7 @@ export interface FileRouteTypes {
| '/admin' | '/admin'
| '/tournaments' | '/tournaments'
| '/admin/tournaments/$id' | '/admin/tournaments/$id'
| '/tournaments/$id/bracket'
| '/admin/tournaments' | '/admin/tournaments'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
id: id:
@@ -286,6 +311,7 @@ export interface FileRouteTypes {
| '/_authed/admin/' | '/_authed/admin/'
| '/_authed/tournaments/' | '/_authed/tournaments/'
| '/_authed/admin/tournaments/$id' | '/_authed/admin/tournaments/$id'
| '/_authed/tournaments/$id/bracket'
| '/_authed/admin/tournaments/' | '/_authed/admin/tournaments/'
| '/_authed/admin/tournaments/run/$id' | '/_authed/admin/tournaments/run/$id'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
@@ -303,7 +329,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 +342,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 +356,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 +371,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 +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'
id: id:
@@ -361,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'
fileServerRoutesById: FileServerRoutesById fileServerRoutesById: FileServerRoutesById
@@ -373,7 +411,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,6 +525,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
'/_authed/tournaments/$id/bracket': {
id: '/_authed/tournaments/$id/bracket'
path: '/tournaments/$id/bracket'
fullPath: '/tournaments/$id/bracket'
preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/admin/tournaments/$id': { '/_authed/admin/tournaments/$id': {
id: '/_authed/admin/tournaments/$id' id: '/_authed/admin/tournaments/$id'
path: '/tournaments/$id' path: '/tournaments/$id'
@@ -510,6 +557,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 +571,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'
@@ -598,6 +659,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 +671,7 @@ const AuthedRouteChildren: AuthedRouteChildren = {
AuthedTeamsTeamIdRoute: AuthedTeamsTeamIdRoute, AuthedTeamsTeamIdRoute: AuthedTeamsTeamIdRoute,
AuthedTournamentsTournamentIdRoute: AuthedTournamentsTournamentIdRoute, AuthedTournamentsTournamentIdRoute: AuthedTournamentsTournamentIdRoute,
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute, AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute,
} }
const AuthedRouteWithChildren = const AuthedRouteWithChildren =
@@ -630,7 +693,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,

View File

@@ -32,7 +32,7 @@ export function createRouter() {
defaultPreload: "intent", defaultPreload: "intent",
defaultErrorComponent: DefaultCatchBoundary, defaultErrorComponent: DefaultCatchBoundary,
scrollRestoration: true, scrollRestoration: true,
defaultViewTransition: true, defaultViewTransition: false,
}), }),
queryClient queryClient
); );

View File

@@ -1,6 +1,7 @@
import "@mantine/core/styles.css"; import "@mantine/core/styles.css";
import "@mantine/dates/styles.css"; import "@mantine/dates/styles.css";
import "@mantine/carousel/styles.css"; import "@mantine/carousel/styles.css";
import '@mantine/tiptap/styles.css';
import { import {
HeadContent, HeadContent,
Navigate, Navigate,

View File

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

View File

@@ -2,6 +2,7 @@ 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";
export const Route = createFileRoute("/_authed/")({ export const Route = createFileRoute("/_authed/")({
component: Home, component: Home,
@@ -12,7 +13,7 @@ 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"
} }
@@ -21,11 +22,10 @@ export const Route = createFileRoute("/_authed/")({
function Home() { function Home() {
const { data: tournament } = useCurrentTournament(); const { data: tournament } = useCurrentTournament();
const now = new Date();
if (new Date(tournament.start_time) > now) { if (!tournament.matches || tournament.matches.length === 0) {
return <UpcomingTournament tournament={tournament} />; return <UpcomingTournament tournament={tournament} />;
} }
return <p>Started Tournament</p> return <StartedTournament tournament={tournament} />
} }

View File

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

View File

@@ -10,7 +10,7 @@ export const Route = createFileRoute("/_authed/stats")({
await ensureServerQueryData(queryClient, playerQueries.allStats()); await ensureServerQueryData(queryClient, playerQueries.allStats());
}, },
loader: () => ({ loader: () => ({
withPadding: true, withPadding: false,
fullWidth: true, fullWidth: true,
header: { header: {
title: "Player Stats" title: "Player Stats"

View File

@@ -1,5 +1,5 @@
import TeamProfile from "@/features/teams/components/team-profile"; import TeamProfile from "@/features/teams/components/team-profile";
import { teamQueries } from "@/features/teams/queries"; import { teamKeys, teamQueries } from "@/features/teams/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { redirect, createFileRoute } from "@tanstack/react-router"; import { redirect, createFileRoute } from "@tanstack/react-router";
@@ -20,7 +20,8 @@ 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();

View File

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

View File

@@ -20,7 +20,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,

View File

@@ -31,7 +31,7 @@ function RouteComponent() {
const { roles } = useAuth(); const { roles } = useAuth();
const sheet = useSheet(); const sheet = useSheet();
return <Page> return (
<Stack> <Stack>
{ {
roles?.includes("Admin") ? ( roles?.includes("Admin") ? (
@@ -49,5 +49,5 @@ function RouteComponent() {
)) ))
} }
</Stack> </Stack>
</Page> )
} }

View File

@@ -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,8 @@ export const ServerRoute = createServerFileRoute("/api/events/$").middleware([su
}; };
serverEvents.on("test", handleEvent); serverEvents.on("test", handleEvent);
serverEvents.on("match", 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`;

View File

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

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

View File

@@ -0,0 +1,116 @@
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' }
});
}
if (!team.players.map(p => p.id).includes(context.userId) && !isAdmin)
return new Response('Unauthorized', { status: 403 });
logger.info('Uploading team logo', {
teamId,
fileName: logoFile.name,
fileSize: logoFile.size,
userId
});
const pbFormData = new FormData();
pbFormData.append('logo', logoFile);
const updatedTeam= await pbAdmin.updateTeam(teamId, pbFormData as any);
logger.info('Team logo uploaded successfully', {
teamId,
logo: updatedTeam.logo
});
return new Response(JSON.stringify({
success: true,
team: updatedTeam,
message: 'Logo uploaded successfully'
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (error: any) {
logger.error('Error uploading team logo:', error);
return new Response(JSON.stringify({
error: 'Failed to upload logo',
message: error.message || 'Unknown error occurred'
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
});

View File

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

View File

@@ -0,0 +1,40 @@
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,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
});
return (
<MantineRichTextEditor editor={editor}>
<MantineRichTextEditor.Toolbar>
<MantineRichTextEditor.ControlsGroup>
<MantineRichTextEditor.Bold />
<MantineRichTextEditor.Italic />
</MantineRichTextEditor.ControlsGroup>
<MantineRichTextEditor.ControlsGroup>
<MantineRichTextEditor.Blockquote />
<MantineRichTextEditor.Hr />
<MantineRichTextEditor.BulletList />
<MantineRichTextEditor.OrderedList />
</MantineRichTextEditor.ControlsGroup>
</MantineRichTextEditor.Toolbar>
<MantineRichTextEditor.Content h="45vh" />
</MantineRichTextEditor>
);
}

View File

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

View File

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

View File

@@ -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,30 @@ function SwipeableTabs({
} }
}, [activeTab]); }, [activeTab]);
useEffect(() => {
updateHeight();
}, [activeTab, updateHeight]);
useEffect(() => {
const timeoutId = setTimeout(updateHeight, 0);
return () => clearTimeout(timeoutId);
});
useEffect(() => {
const activeSlideRef = slideRefs.current[activeTab];
if (!activeSlideRef) return;
const resizeObserver = new ResizeObserver(() => {
updateHeight();
});
resizeObserver.observe(activeSlideRef);
return () => {
resizeObserver.disconnect();
};
}, [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 +140,7 @@ function SwipeableTabs({
top={0} top={0}
style={{ style={{
display: "flex", display: "flex",
paddingInline: "var(--mantine-spacing-md)",
marginBottom: "var(--mantine-spacing-md)", marginBottom: "var(--mantine-spacing-md)",
zIndex: 100, zIndex: 100,
backgroundColor: "var(--mantine-color-body)", backgroundColor: "var(--mantine-color-body)",

View File

@@ -1,10 +1,15 @@
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 };
@@ -13,9 +18,9 @@ interface AuthData {
export const defaultAuthData: AuthData = { export const defaultAuthData: AuthData = {
user: undefined, user: undefined,
metadata: { accentColor: 'blue', colorScheme: 'auto' }, metadata: { accentColor: "blue", colorScheme: "auto" },
roles: [], roles: [],
} };
export interface AuthContextType extends AuthData { export interface AuthContextType extends AuthData {
set: ({ user, metadata, roles }: Partial<AuthContextType>) => void; set: ({ user, metadata, roles }: Partial<AuthContextType>) => void;
@@ -30,36 +35,42 @@ 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>) => { const set = useCallback(
queryClient.setQueryData(playerKeys.auth, (oldData: AuthData | undefined) => { (updates: Partial<AuthData>) => {
const currentData = oldData || defaultAuthData; queryClient.setQueryData(
return { playerKeys.auth,
...currentData, (oldData: AuthData | undefined) => {
...updates, const currentData = oldData || defaultAuthData;
metadata: updates.metadata return {
? { ...currentData.metadata, ...updates.metadata } ...currentData,
: currentData.metadata ...updates,
}; metadata: updates.metadata
}); ? { ...currentData.metadata, ...updates.metadata }
}, [queryClient]); : currentData.metadata,
};
}
);
},
[queryClient]
);
return ( const value = useMemo(
<AuthContext () => ({
value={{ user: data?.user || defaultAuthData.user,
user: data?.user || defaultAuthData.user, metadata: data?.metadata || defaultAuthData.metadata,
metadata: data?.metadata || defaultAuthData.metadata, roles: data?.roles || defaultAuthData.roles,
roles: data?.roles || defaultAuthData.roles, set,
set }),
}}> [data, defaultAuthData]
{children} );
</AuthContext>
) return <AuthContext value={value}>{children}</AuthContext>;
}; };
export const useAuth = () => { export const useAuth = () => {
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (!context) { if (!context) {
throw new Error('useAuth must be used within an AuthProvider'); throw new Error("useAuth must be used within an AuthProvider");
} }
return context; return context;
}; };

View File

@@ -389,6 +389,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,
@@ -415,6 +433,8 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
capturePlaybackState, capturePlaybackState,
resumePlaybackState, resumePlaybackState,
clearCapturedState, clearCapturedState,
// Search
searchTracks,
}; };
if (!isAdmin) { if (!isAdmin) {

View File

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

View File

@@ -1,19 +1,16 @@
import { Title, AppShell, Flex } from "@mantine/core"; import { Title, AppShell, Flex } from "@mantine/core";
import { HeaderConfig } from "../types/header-config"; import { HeaderConfig } from "../types/header-config";
import useRouterConfig from "../hooks/use-router-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; const Header = ({ collapsed, title }: HeaderProps) => {
}, [collapsed, scrollPosition.y]); const { header } = useRouterConfig();
return ( return (
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}> <AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
{ header.withBackButton && <BackButton /> }
<Flex justify='center' align='center' h='100%' px='md'> <Flex justify='center' align='center' h='100%' px='md'>
<Title order={2}>{title}</Title> <Title order={2}>{title}</Title>
</Flex> </Flex>

View File

@@ -33,7 +33,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
mah='100dvh' mah='100dvh'
style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }} style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
> >
<Header scrollPosition={scrollPosition} {...header} /> <Header {...header} />
<AppShell.Main <AppShell.Main
pos='relative' pos='relative'
h='100%' h='100%'

View File

@@ -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()
@@ -35,4 +36,4 @@ const Navbar = () => {
</AppShell.Navbar> </AppShell.Navbar>
} }
export default Navbar; export default memo(Navbar);

View File

@@ -14,7 +14,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();
@@ -110,10 +109,6 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
pt={(scrolling || scrollY > 40) || !isRefreshing ? 0 : 40 - scrollY} pt={(scrolling || scrollY > 40) || !isRefreshing ? 0 : 40 - scrollY}
> >
<Box pt='1rem'pb='0.285rem' mih={height} style={{ boxSizing: 'content-box' }}> <Box pt='1rem'pb='0.285rem' mih={height} style={{ boxSizing: 'content-box' }}>
{ /* TODO: Remove this debug button */}
<ActionIcon display={!!refresh.length ? 'unset' : 'none' } style={{ zIndex: 1000 }} pos='absolute' top={8} left='calc(50% - 24px)' onClick={onTrigger} variant='filled' color='var(--mantine-color-dimmed)'>
<ArrowClockwiseIcon />
</ActionIcon>
{children} {children}
</Box> </Box>
</ScrollArea> </ScrollArea>

View File

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

View File

@@ -1,17 +1,9 @@
import { import { Text, Group, Stack, Paper, Indicator, Box } 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";
interface MatchCardProps { interface MatchCardProps {
match: Match; match: Match;
@@ -37,6 +29,8 @@ const MatchCard = ({ match }: MatchCardProps) => {
} }
}; };
console.log(match);
return ( return (
<Indicator <Indicator
disabled={!isStarted} disabled={!isStarted}
@@ -44,110 +38,160 @@ 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>
<Text
size="sm"
fw={600}
lineClamp={1}
style={{ minWidth: 0, flex: 1 }}
>
{match.home?.name!}
</Text>
</Group>
<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}> <Stack gap={1}>
- {match.home?.players.map((p) => (
</Text> <Text size="xs" fw={600} c="dimmed" ta="right">
{p.first_name} {p.last_name}
</Text>
))}
</Stack>
</Group>
<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>
<Text
size="sm"
fw={600}
lineClamp={1}
style={{ minWidth: 0, flex: 1 }}
>
{match.away?.name}
</Text>
</Group>
<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 && ( <Stack gap={1}>
<Badge size="xs" color="orange" variant="light"> {match.away?.players.map((p) => (
{match.ot_count}OT <Text size="xs" fw={600} c="dimmed" ta="right">
</Badge> {p.first_name} {p.last_name}
)} </Text>
))}
</Stack>
</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 && ( <EmojiBar matchId={match.id} />
<Box </Paper>
style={{ </Box>
position: "absolute",
top: -10,
right: -4,
transform: "rotate(25deg)",
color: "gold",
}}
>
<CrownIcon size={16} weight="fill" />
</Box>
)}
</Box>
</Group>
</Group>
</Stack>
</Paper>
</Indicator> </Indicator>
); );
}; };

View File

@@ -1,5 +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 { motion, AnimatePresence } from "framer-motion";
import { Match } from "../types"; import { Match } from "../types";
import MatchCard from "./match-card"; import MatchCard from "./match-card";
@@ -14,20 +13,11 @@ 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> <AnimatePresence>
{filteredMatches.map((match, index) => ( {filteredMatches.map((match, index) => (
<motion.div <motion.div

View File

@@ -0,0 +1,18 @@
import { useServerSuspenseQuery, useServerQuery } from "@/lib/tanstack-query/hooks";
import { getMatchReactions } from "./server";
export const matchKeys = {
list: ['matches', 'list'] as const,
details: (id: string) => ['matches', 'details', id] as const,
reactions: (id: string) => ['matches', 'reactions', id] as const,
};
export const matchQueries = {
reactions: (matchId: string) => ({
queryKey: matchKeys.reactions(matchId),
queryFn: () => getMatchReactions({ data: matchId }),
}),
};
export const useMatchReactions = (matchId: string) =>
useServerQuery(matchQueries.reactions(matchId));

View File

@@ -6,6 +6,9 @@ import { z } from "zod";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import brackets from "@/features/bracket/utils"; import brackets from "@/features/bracket/utils";
import { MatchInput } from "@/features/matches/types"; import { MatchInput } from "@/features/matches/types";
import { serverEvents } from "@/lib/events/emitter";
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { PlayerInfo } from "../players/types";
const orderedTeamsSchema = z.object({ const orderedTeamsSchema = z.object({
tournamentId: z.string(), tournamentId: z.string(),
@@ -150,6 +153,13 @@ export const startMatch = createServerFn()
status: "started", status: "started",
}); });
console.log('emitting start match...')
serverEvents.emit("match", {
type: "match",
matchId: match.id,
tournamentId: match.tournament.id
});
return match; return match;
}) })
); );
@@ -184,14 +194,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 +237,114 @@ 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,
tournamentId: match.tournament.id,
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[]
})
);

View File

@@ -13,7 +13,7 @@ const PlayerList = ({ players, loading = false }: PlayerListProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const handleClick = useCallback((playerId: string) => const handleClick = useCallback((playerId: string) =>
navigate({ to: `/profile/${playerId} `}), [navigate]); navigate({ to: `/profile/${playerId}`}), [navigate]);
if (loading) return <List> if (loading) return <List>
{Array.from({ length: 10 }).map((_, i) => ( {Array.from({ length: 10 }).map((_, i) => (

View File

@@ -1,6 +1,5 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { import {
Table,
Text, Text,
TextInput, TextInput,
Stack, Stack,
@@ -9,10 +8,11 @@ import {
ThemeIcon, ThemeIcon,
Container, Container,
Title, Title,
ScrollArea, Divider,
Paper, UnstyledButton,
Popover, Popover,
ActionIcon, ActionIcon,
Skeleton,
} from "@mantine/core"; } from "@mantine/core";
import { import {
MagnifyingGlassIcon, MagnifyingGlassIcon,
@@ -22,7 +22,8 @@ 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 { interface PlayerStatsTableProps {
playerStats: PlayerStats[]; playerStats: PlayerStats[];
@@ -36,60 +37,163 @@ interface SortConfig {
direction: SortDirection; direction: SortDirection;
} }
interface PlayerListItemProps {
stat: PlayerStats;
index: number;
onPlayerClick: (playerId: string) => void;
}
const PlayerListItem = ({ stat, index, onPlayerClick }: PlayerListItemProps) => {
const calculateMMR = (stat: PlayerStats): number => {
if (stat.matches === 0) return 0;
const winScore = stat.win_percentage;
const matchConfidence = Math.min(stat.matches / 15, 1);
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100);
const marginScore = stat.margin_of_victory
? Math.min(stat.margin_of_victory * 20, 50)
: 0;
const volumeBonus = Math.min(stat.matches * 0.5, 10);
const baseMMR =
winScore * 0.5 +
avgCupsScore * 0.25 +
marginScore * 0.15 +
volumeBonus * 0.1;
const finalMMR = baseMMR * matchConfidence;
return Math.round(finalMMR * 10) / 10;
};
const mmr = calculateMMR(stat);
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>
</>
);
};
const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
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) => {
setSortConfig((prev) => ({
key,
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
}));
};
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(() => {
@@ -101,7 +205,6 @@ 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 = calculateMMR(a);
bValue = calculateMMR(b); bValue = calculateMMR(b);
@@ -126,78 +229,29 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
}); });
}, [playerStats, search, sortConfig]); }, [playerStats, search, sortConfig]);
const formatPercentage = (value: number) => `${value.toFixed(1)}%`; const handlePlayerClick = (playerId: string) => {
const formatDecimal = (value: number) => value.toFixed(2); navigate({ to: `/profile/${playerId}` });
};
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 +259,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 +267,97 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
return ( return (
<Container size="100%" px={0}> <Container size="100%" px={0}>
<Stack gap="xs"> <Stack gap="xs">
<Text ml="auto" size="xs" c="dimmed">
{filteredAndSortedStats.length} of {playerStats.length} players
</Text>
<TextInput <TextInput
placeholder="Search players" placeholder="Search players"
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
leftSection={<MagnifyingGlassIcon size={16} />} leftSection={<MagnifyingGlassIcon size={16} />}
size="md" size="md"
px="md"
/> />
<Paper withBorder radius="md" p={0} style={{ overflow: "hidden" }}> <Group px="md" justify="space-between" align="center">
<ScrollArea> <Text size="10px" lh={0} c="dimmed">
<Table {filteredAndSortedStats.length} of {playerStats.length} players
highlightOnHover </Text>
striped <Group gap="xs">
withTableBorder={false} <Text size="xs" c="dimmed">Sort:</Text>
style={{ <UnstyledButton
minWidth: 1000, onClick={() => handleSort("mmr")}
borderRadius: "inherit", style={{ display: "flex", alignItems: "center", gap: 4 }}
}}
> >
<Table.Thead <Text size="xs" fw={sortConfig.key === "mmr" ? 600 : 400} c={sortConfig.key === "mmr" ? "dark" : "dimmed"}>
style={{ MMR
backgroundColor: "var(--mantine-color-body)", </Text>
position: "sticky", {getSortIcon("mmr")}
top: 0, </UnstyledButton>
zIndex: 1, <Text size="xs" c="dimmed"></Text>
}} <UnstyledButton
> onClick={() => handleSort("wins")}
<Table.Tr> style={{ display: "flex", alignItems: "center", gap: 4 }}
{columns.map((column, index) => ( >
<Table.Th <Text size="xs" fw={sortConfig.key === "wins" ? 600 : 400} c={sortConfig.key === "wins" ? "dark" : "dimmed"}>
key={column.key} Wins
style={{ </Text>
cursor: "pointer", {getSortIcon("wins")}
userSelect: "none", </UnstyledButton>
width: column.width, <Text size="xs" c="dimmed"></Text>
minWidth: column.width, <UnstyledButton
padding: "12px 16px", onClick={() => handleSort("matches")}
fontWeight: 600, style={{ display: "flex", alignItems: "center", gap: 4 }}
backgroundColor: "var(--mantine-color-body)", >
borderBottom: <Text size="xs" fw={sortConfig.key === "matches" ? 600 : 400} c={sortConfig.key === "matches" ? "dark" : "dimmed"}>
"2px solid var(--mantine-color-default-border)", Matches
...(index === 0 && { </Text>
position: "sticky", {getSortIcon("matches")}
left: 0, </UnstyledButton>
zIndex: 2, <Popover position="bottom-end" withArrow shadow="md">
borderTopLeftRadius: "var(--mantine-radius-md)", <Popover.Target>
}), <ActionIcon variant="subtle" size="sm">
...(index === columns.length - 1 && { <InfoIcon size={14} />
borderTopRightRadius: "var(--mantine-radius-md)", </ActionIcon>
}), </Popover.Target>
}} <Popover.Dropdown>
onClick={() => handleSort(column.key)} <Box maw={280}>
> <Text size="sm" fw={500} mb="xs">
<Group MMR Calculation:
gap="xs" </Text>
wrap="nowrap" <Text size="xs" mb={2}>
style={{ position: "relative" }} Win Rate (50%)
> </Text>
<Text size="sm" fw={600}> <Text size="xs" mb={2}>
{column.label} Average Cups/Match (25%)
</Text> </Text>
{column.key === "mmr" && ( <Text size="xs" mb={2}>
<div Average Win Margin (15%)
onClick={(e) => { </Text>
e.stopPropagation(); <Text size="xs" mb={2}>
e.preventDefault(); Match Volume Bonus (10%)
}} </Text>
onMouseDown={(e) => { <Text size="xs" mt="xs" c="dimmed">
e.stopPropagation(); * Confidence penalty applied for players with &lt;15 matches
}} </Text>
> <Text size="xs" mt="xs" c="dimmed">
<Popover position="bottom" withArrow shadow="md"> ** Not an official rating
<Popover.Target> </Text>
<ActionIcon variant="subtle" size="xs"> </Box>
<InfoIcon size={12} /> </Popover.Dropdown>
</ActionIcon> </Popover>
</Popover.Target> </Group>
<Popover.Dropdown> </Group>
<Box maw={280}>
<Text size="sm" fw={500} mb="xs"> <Stack gap={0}>
MMR Calculation: {filteredAndSortedStats.map((stat, index) => (
</Text> <Box key={stat.id}>
<Text size="xs" mb={2}> <PlayerListItem
Win Rate (50%) stat={stat}
</Text> index={index}
<Text size="xs" mb={2}> onPlayerClick={handlePlayerClick}
Average Cups/Match (25%) />
</Text> {index < filteredAndSortedStats.length - 1 && <Divider />}
<Text size="xs" mb={2}> </Box>
Average Win Margin (15%) ))}
</Text> </Stack>
<Text size="xs" mb={2}>
Match Volume Bonus (10%)
</Text>
<Text size="xs" mt="xs" c="dimmed">
* Confidence penalty applied for players
with &lt;20 matches
</Text>
<Text size="xs" mt="xs" c="dimmed">
** Not an official rating
</Text>
</Box>
</Popover.Dropdown>
</Popover>
</div>
)}
<Box
style={{
minWidth: 16,
display: "flex",
justifyContent: "center",
}}
>
{getSortIcon(column.key)}
</Box>
{index === 0 && (
<div
style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: "2px",
backgroundColor:
"var(--mantine-color-default-border)",
zIndex: 1,
}}
/>
)}
</Group>
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredAndSortedStats.map((stat, index) => (
<motion.tr
key={stat.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.01 }}
style={{
borderBottom:
"1px solid var(--mantine-color-default-border)",
}}
>
{columns.map((column, columnIndex) => (
<Table.Td
key={`${stat.id}-${column.key}`}
style={{
padding: "12px 16px",
verticalAlign: "middle",
...(columnIndex === 0 && {
position: "sticky",
left: 0,
backgroundColor: "var(--mantine-color-body)",
zIndex: 1,
}),
}}
>
<div style={{ position: "relative" }}>
{renderCellContent(stat, column, index)}
{columnIndex === 0 && (
<div
style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: "2px",
backgroundColor:
"var(--mantine-color-default-border)",
zIndex: 1,
}}
/>
)}
</div>
</Table.Td>
))}
</motion.tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
</Paper>
{filteredAndSortedStats.length === 0 && search && ( {filteredAndSortedStats.length === 0 && search && (
<Text ta="center" c="dimmed" py="xl"> <Text ta="center" c="dimmed" py="xl">

View File

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

View File

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

View File

@@ -1,10 +1,9 @@
import { Box } from "@mantine/core"; import { Box } from "@mantine/core";
import Header from "./header"; import Header from "./header";
import { Player } from "@/features/players/types";
import SwipeableTabs from "@/components/swipeable-tabs"; import SwipeableTabs from "@/components/swipeable-tabs";
import { usePlayer, usePlayerMatches } from "../../queries"; import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
import TeamList from "@/features/teams/components/team-list"; import TeamList from "@/features/teams/components/team-list";
import StatsOverview from "../stats-overview"; import StatsOverview from "@/shared/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,18 @@ 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);
console.log(player.teams)
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 +35,7 @@ const Profile = ({ id }: ProfileProps) => {
return ( return (
<> <>
<Header player={player} /> <Header player={player} />
<Box m='md' mt="lg"> <Box mt="lg">
<SwipeableTabs tabs={tabs} /> <SwipeableTabs tabs={tabs} />
</Box> </Box>
</> </>

View File

@@ -0,0 +1,42 @@
import { Box, Flex, Loader } from "@mantine/core";
import Header from "./header";
import SwipeableTabs from "@/components/swipeable-tabs";
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
import TeamList from "@/features/teams/components/team-list";
import StatsOverview from "@/shared/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: <SkeletonLoader />,
},
{
label: "Matches",
content: <SkeletonLoader />,
},
{
label: "Teams",
content: <SkeletonLoader />,
},
];
return (
<>
<HeaderSkeleton />
<Box mt="lg">
<SwipeableTabs tabs={tabs} />
</Box>
</>
);
};
export default ProfileSkeleton;

View File

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

View File

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

View File

@@ -125,7 +125,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()
@@ -140,3 +140,10 @@ export const getPlayerMatches = createServerFn()
.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))
);

View File

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

View File

@@ -0,0 +1,128 @@
import {
Group,
Button,
Text,
Tabs,
} 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 [activeTab, setActiveTab] = useState<string | null>(null);
const longPressTimeout = useRef<NodeJS.Timeout | null>(null);
const handleLongPressStart = (emoji: string) => {
longPressTimeout.current = setTimeout(() => {
setActiveTab(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 || "");
}, []);
if (!reactions) return;
console.log(reactions)
return (
<>
<Group gap="xs" wrap="wrap" justify="space-between">
<Group gap="xs" wrap="wrap">
{reactions.map((reaction) => (
<Button
key={reaction.emoji}
variant={hasReacted(reaction) ? "filled" : "light"}
color="gray"
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>
))}
</Group>
<EmojiPicker onSelect={onReactionPress || ((emoji) => toggleReaction.mutate({ data: { matchId, emoji } }))} />
</Group>
<Sheet title="Reactions" opened={opened} onChange={() => close()}>
<Tabs value={activeTab || reactions[0]?.emoji} onChange={setActiveTab}>
<Tabs.List grow>
{reactions.map((reaction) => (
<Tabs.Tab key={reaction.emoji} value={reaction.emoji}>
<Group gap="xs" align="center">
<Text size="lg">{reaction.emoji}</Text>
<Text size="xs" c="dimmed">
{reaction.count}
</Text>
</Group>
</Tabs.Tab>
))}
</Tabs.List>
{reactions.map((reaction) => (
<Tabs.Panel key={reaction.emoji} value={reaction.emoji} pt="md">
<PlayerList players={reaction.players} />
</Tabs.Panel>
))}
</Tabs>
</Sheet>
</>
);
};
export default EmojiBar;

View File

@@ -0,0 +1,89 @@
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;
}
const EMOJIS = [
{ emoji: "😊", label: "smile" },
{ emoji: "😢", label: "cry" },
{ emoji: "👀", label: "eyes" },
{ emoji: "🔥", label: "fire" },
{ emoji: "❤️", label: "heart" },
{ emoji: "👑", label: "crown" },
];
const EmojiPicker = ({
onSelect,
disabled = false
}: 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={0}>
{EMOJIS.map(({ emoji, label }) => (
<UnstyledButton
key={emoji}
onClick={() => handleEmojiSelect(emoji)}
style={{
borderRadius: "var(--mantine-radius-sm)",
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 36,
minWidth: 36,
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'var(--mantine-color-gray-1)',
},
'&:active': {
transform: 'scale(0.95)',
},
},
}}
aria-label={label}
>
<Text size="lg" style={{ lineHeight: 1 }}>
{emoji}
</Text>
</UnstyledButton>
))}
</SimpleGrid>
</Popover.Dropdown>
</Popover>
);
};
export default EmojiPicker;

View File

@@ -0,0 +1,34 @@
import { useServerQuery, useServerMutation } from "@/lib/tanstack-query/hooks";
import { getMatchReactions, toggleMatchReaction } from "@/features/matches/server";
import { useQueryClient } from "@tanstack/react-query";
import { matchKeys } from "@/features/matches/queries";
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) =>
useServerQuery(reactionQueries.match(matchId));
export const useToggleMatchReaction = (matchId: string) => {
const queryClient = useQueryClient();
return useServerMutation({
mutationFn: toggleMatchReaction,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: reactionKeys.match(matchId)
});
queryClient.invalidateQueries({
queryKey: matchKeys.reactions(matchId)
});
},
});
};

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,202 @@
import { useState, useEffect, useCallback } from "react";
import { Stack, Text, Group, RangeSlider, Divider } from "@mantine/core";
interface DurationPickerProps {
songDurationMs: number;
initialStart?: number;
initialEnd?: number;
onChange: (startSeconds: number, endSeconds: number) => void;
disabled?: boolean;
}
const DurationPicker = ({
songDurationMs,
initialStart = 0,
initialEnd = 15,
onChange,
disabled = false,
}: DurationPickerProps) => {
const songDurationSeconds = Math.floor(songDurationMs / 1000);
const [range, setRange] = useState<[number, number]>([
initialStart,
initialEnd,
]);
const [isValid, setIsValid] = useState(true);
const formatTime = useCallback((seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
}, []);
const validateRange = 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 handleRangeChange = useCallback(
(newRange: [number, number]) => {
setRange(newRange);
const [start, end] = newRange;
const valid = validateRange(start, end);
setIsValid(valid);
if (valid) {
onChange(start, end);
}
},
[onChange, validateRange]
);
const handleRangeChangeEnd = useCallback(
(newRange: [number, number]) => {
let [start, end] = newRange;
let duration = end - start;
if (duration < 10) {
if (start < songDurationSeconds / 2) {
end = Math.min(start + 10, songDurationSeconds);
} else {
start = Math.max(end - 10, 0);
}
duration = end - start;
}
if (duration > 15) {
const startDiff = Math.abs(start - range[0]);
const endDiff = Math.abs(end - range[1]);
if (startDiff > endDiff) {
end = start + 15;
if (end > songDurationSeconds) {
end = songDurationSeconds;
start = end - 15;
}
} else {
start = end - 15;
if (start < 0) {
start = 0;
end = start + 15;
}
}
}
start = Math.max(0, start);
end = Math.min(songDurationSeconds, end);
const finalRange: [number, number] = [start, end];
setRange(finalRange);
setIsValid(validateRange(start, end));
onChange(start, end);
},
[range, songDurationSeconds, onChange, validateRange]
);
useEffect(() => {
if (!validateRange(initialStart, initialEnd)) {
const defaultStart = Math.min(30, Math.max(0, songDurationSeconds - 15));
const defaultEnd = Math.min(defaultStart + 15, songDurationSeconds);
const defaultRange: [number, number] = [defaultStart, defaultEnd];
setRange(defaultRange);
onChange(defaultStart, defaultEnd);
}
}, [initialStart, initialEnd, songDurationSeconds, validateRange, onChange]);
const segmentDuration = range[1] - range[0];
return (
<Stack gap="md" opacity={disabled ? 0.5 : 1}>
<div>
<Text size="sm" fw={500} mb="xs" c={disabled ? "dimmed" : undefined}>
Start and End
</Text>
<Text size="xs" c="dimmed" mb="md">
{disabled ? "Select a song to choose segment timing" : "Choose a 10-15 second segment for your walkout song"}
</Text>
</div>
<RangeSlider
min={0}
max={songDurationSeconds}
step={1}
value={range}
onChange={disabled ? undefined : handleRangeChange}
onChangeEnd={disabled ? undefined : handleRangeChangeEnd}
marks={[
{ value: 0, label: "0:00" },
{
value: songDurationSeconds,
label: formatTime(songDurationSeconds),
},
]}
size="lg"
m='xs'
color={disabled ? "gray" : (isValid ? "blue" : "red")}
thumbSize={20}
label={disabled ? undefined : (value) => formatTime(value)}
disabled={disabled}
styles={{
track: { height: 8 },
}}
/>
<Divider />
<Group justify="space-between" align="center">
<Stack gap={2} align="center">
<Text size="xs" c="dimmed">
Start
</Text>
<Text size="sm" fw={500}>
{formatTime(range[0])}
</Text>
</Stack>
<Stack gap={2} align="center">
<Text size="xs" c="dimmed">
Duration
</Text>
<Text size="sm" fw={500} c={isValid ? undefined : "red"}>
{segmentDuration}s
</Text>
</Stack>
<Stack gap={2} align="center">
<Text size="xs" c="dimmed">
End
</Text>
<Text size="sm" fw={500}>
{formatTime(range[1])}
</Text>
</Stack>
</Group>
{!isValid && (
<Text size="xs" c="red" ta="center">
{getValidationMessage(range[0], range[1])}
</Text>
)}
</Stack>
);
};
export default DurationPicker;

View File

@@ -0,0 +1,238 @@
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) => {
console.log(team)
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();
console.log('here for some reason', result)
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;

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

View File

@@ -0,0 +1,149 @@
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";
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 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={{ formValues: form.getValues() }}
title={"Select Song"}
label={"Walkout Song"}
placeholder={"Select your walkout song"}
/>
);
};
interface SongPickerComponentProps {
value: Song | undefined;
onChange: (song: Song) => void;
formValues: any;
}
const SongPickerComponent = ({ value: song, onChange, formValues }: 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,
};
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 && <MusicNote 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={180000}
initialStart={song?.song_start || 0}
initialEnd={song?.song_end || 15}
onChange={handleDurationChange}
disabled={!song?.song_id}
/>
</Stack>
</Stack>
</Stack>
);
};
export default SongPicker;

View File

@@ -0,0 +1,121 @@
import { useState } from "react";
import { Text, Combobox, InputBase, useCombobox, Group, Avatar, Loader } 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 combobox = useCombobox();
// Standalone search function that doesn't require Spotify context
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([]);
return;
}
setIsLoading(true);
try {
const results = await searchSpotifyTracks(query);
setSearchResults(results);
combobox.openDropdown();
} catch (error) {
console.error('Search failed:', error);
setSearchResults([]);
} 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(', ')}`);
combobox.closeDropdown();
};
const options = searchResults.map((track) => (
<Combobox.Option value={track.id} key={track.id}>
<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>
</Combobox.Option>
));
return (
<Combobox
store={combobox}
onOptionSubmit={(value) => {
const track = searchResults.find(t => t.id === value);
if (track) handleSongSelect(track);
}}
width='100%'
zIndex={9999}
withinPortal={false}
>
<Combobox.Target>
<InputBase
rightSection={isLoading ? <Loader size="xs" /> : <Combobox.Chevron />}
value={searchQuery}
onChange={(event) => handleSearchChange(event.currentTarget.value)}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
placeholder={placeholder}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{options.length > 0 ? options :
<Combobox.Empty>
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
</Combobox.Empty>
}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
};
export default SongSearch;

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

View File

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

View File

@@ -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,16 +23,16 @@ 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>
); );
}); });
@@ -59,15 +67,33 @@ 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" p="xs"
icon={<Avatar radius="sm" size={40} name={`${team.name}`} />} icon={
style={{ cursor: "pointer" }} <Avatar
onClick={() => handleClick(team.id)} radius="sm"
> size={40}
<TeamListItem team={team} /> name={`${team.name}`}
</ListItem> 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>
); );

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()
@@ -55,13 +56,11 @@ export const updateTeam = createServerFn()
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))
);

View File

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

View File

@@ -34,7 +34,15 @@ 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}
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}

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

View File

@@ -1,63 +0,0 @@
import Button from "@/components/button";
import Sheet from "@/components/sheet/sheet";
import { useAuth } from "@/contexts/auth-context";
import { useSheet } from "@/hooks/use-sheet";
import { useMemo, useState, useCallback } from "react";
import TeamSelectionView from "./team-selection-view";
const EnrollTeam = () => {
const { open, isOpen, toggle } = useSheet();
const { user } = useAuth();
const hasTeams = useMemo(() => !!user?.teams?.length, [user?.teams]);
const [selectedTeamId, setSelectedTeamId] = useState<string>();
const [showTeamForm, setShowTeamForm] = useState<boolean>(!hasTeams);
const teamOptions = useMemo(() =>
user?.teams?.map(team => ({
value: team.id,
label: team.name
})) || [],
[user?.teams]
);
const handleBack = useCallback(() => {
setShowTeamForm(false);
setSelectedTeamId(undefined);
}, []);
const handleSelect = useCallback((teamId: string | undefined) => {
setSelectedTeamId(teamId);
setShowTeamForm(true);
}, [])
return (
<>
<Button variant="outline" size="sm" onClick={open}>
Enroll Your Team
</Button>
<Sheet title={showTeamForm ? "Team Details" : "Enroll Team"} opened={isOpen} onChange={toggle}>
{showTeamForm ? (
<>
<p>Team Form {selectedTeamId === undefined ? "new team" : selectedTeamId}</p>
<Button variant="subtle" color="red" onClick={handleBack}>Back</Button>
</>
) : (
<>
<TeamSelectionView
options={teamOptions}
onSelect={handleSelect}
/>
<Button mt='sm' variant="subtle" color='red' size="sm" onClick={toggle}>
Cancel
</Button>
</>
)}
</Sheet>
</>
);
};
export default EnrollTeam;

View File

@@ -12,6 +12,9 @@ import {
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
import EditEnrolledTeams from "./edit-enrolled-teams"; import EditEnrolledTeams from "./edit-enrolled-teams";
import ListLink from "@/components/list-link"; import ListLink from "@/components/list-link";
import { RichTextEditor } from "@/components/rich-text-editor";
import React from "react";
import EditRules from "./edit-rules";
interface ManageTournamentProps { interface ManageTournamentProps {
tournamentId: string; tournamentId: string;
@@ -37,6 +40,8 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
close: closeEditTeams, close: closeEditTeams,
} = useSheet(); } = useSheet();
const [v, setV] = React.useState("");
return ( return (
<> <>
<List> <List>
@@ -86,7 +91,7 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
opened={editRulesOpened} opened={editRulesOpened}
onChange={closeEditRules} onChange={closeEditRules}
> >
<p>Test</p> <EditRules tournamentId={tournamentId} onClose={closeEditRules} />
</Sheet> </Sheet>
<Sheet <Sheet

View File

@@ -3,6 +3,7 @@ import Header from "./header";
import TeamList from "@/features/teams/components/team-list"; import TeamList from "@/features/teams/components/team-list";
import SwipeableTabs from "@/components/swipeable-tabs"; import SwipeableTabs from "@/components/swipeable-tabs";
import { useTournament } from "../../queries"; import { useTournament } from "../../queries";
import MatchList from "@/features/matches/components/match-list";
interface ProfileProps { interface ProfileProps {
id: string; id: string;
@@ -19,22 +20,19 @@ const Profile = ({ id }: ProfileProps) => {
}, },
{ {
label: "Matches", label: "Matches",
content: <Text p="md">Matches feed will go here</Text> content: <MatchList matches={tournament.matches?.sort((a, b) => b.order - a.order) || []} />
}, },
{ {
label: "Teams", label: "Teams",
content: <> content: <>
<TeamList teams={tournament.teams || []} /> <TeamList teams={tournament.teams || []} />
<TeamList teams={tournament.teams || []} />
<TeamList teams={tournament.teams || []} />
<TeamList teams={tournament.teams || []} />
</> </>
} }
]; ];
return <> return <>
<Header tournament={tournament} /> <Header tournament={tournament} />
<Box m='sm' mt='lg'> <Box mt='lg'>
<SwipeableTabs tabs={tabs} /> <SwipeableTabs tabs={tabs} />
</Box> </Box>
</>; </>;

View File

@@ -27,11 +27,11 @@ interface SeedTournamentProps {
const SeedTournament: React.FC<SeedTournamentProps> = ({ const SeedTournament: React.FC<SeedTournamentProps> = ({
tournamentId, tournamentId,
teams teams,
}) => { }) => {
const [orderedTeams, setOrderedTeams] = useState<TeamInfo[]>(teams); const [orderedTeams, setOrderedTeams] = useState<TeamInfo[]>(teams);
const { data: bracketPreview } = useBracketPreview(teams.length); const { data: bracketPreview } = useBracketPreview(teams.length);
const queryClient = useQueryClient() const queryClient = useQueryClient();
const bracket: BracketData = useMemo( const bracket: BracketData = useMemo(
() => ({ () => ({
@@ -48,7 +48,7 @@ const SeedTournament: React.FC<SeedTournamentProps> = ({
: undefined, : undefined,
})) }))
), ),
losers: bracketPreview.losers losers: bracketPreview.losers,
}), }),
[bracketPreview, orderedTeams] [bracketPreview, orderedTeams]
); );
@@ -58,8 +58,8 @@ const SeedTournament: React.FC<SeedTournamentProps> = ({
successMessage: "Tournament bracket generated successfully!", successMessage: "Tournament bracket generated successfully!",
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: tournamentKeys.details(tournamentId) queryKey: tournamentKeys.details(tournamentId),
}) });
}, },
}); });
@@ -99,7 +99,7 @@ const SeedTournament: React.FC<SeedTournamentProps> = ({
}; };
return ( return (
<div style={{ display: 'flex', gap: '2rem', alignItems: 'flex-start' }}> <div style={{ display: "flex", gap: "2rem", alignItems: "flex-start" }}>
<Stack gap="lg" style={{ flexShrink: 0 }}> <Stack gap="lg" style={{ flexShrink: 0 }}>
<Stack gap={0} pos="relative" w={400}> <Stack gap={0} pos="relative" w={400}>
<LoadingOverlay visible={generateBracket.isPending} /> <LoadingOverlay visible={generateBracket.isPending} />
@@ -171,7 +171,16 @@ const SeedTournament: React.FC<SeedTournamentProps> = ({
}} }}
/> />
<Avatar size={24} radius="sm" name={team.name} /> <Avatar
size={24}
radius="sm"
name={team.name}
src={
team.logo
? `/api/files/teams/${team.id}/${team.logo}`
: undefined
}
/>
<Text fw={500} size="sm" style={{ flex: 1 }}> <Text fw={500} size="sm" style={{ flex: 1 }}>
{team.name} {team.name}
@@ -198,7 +207,7 @@ const SeedTournament: React.FC<SeedTournamentProps> = ({
</Button> </Button>
</Stack> </Stack>
<div style={{ flex: 1, overflow: 'auto' }}> <div style={{ flex: 1, overflow: "auto" }}>
<BracketView bracket={bracket} /> <BracketView bracket={bracket} />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,67 @@
import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
import { Tournament } from "../../types";
import Avatar from "@/components/avatar";
import {
CalendarIcon,
MapPinIcon,
TrophyIcon,
} from "@phosphor-icons/react";
import { useMemo } from "react";
const Header = ({ tournament }: { tournament: Tournament }) => {
const tournamentStart = useMemo(
() => new Date(tournament.start_time),
[tournament.start_time]
);
return (
<Stack align="center" gap={0}>
<Avatar
name={tournament.name}
src={
tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
: undefined
}
radius="md"
size={200}
px="xs"
withBorder={false}
>
<TrophyIcon size={24} />
</Avatar>
<Flex gap="xs" direction="row" wrap="wrap" justify="space-around">
{tournament.location && (
<Group gap="xs">
<ThemeIcon size="sm" variant="light" radius="sm">
<MapPinIcon size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{tournament.location}
</Text>
</Group>
)}
<Group gap="xs">
<ThemeIcon size="sm" variant="light" radius="sm">
<CalendarIcon size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{tournamentStart.toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
})}{" "}
at{" "}
{tournamentStart.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</Group>
</Flex>
</Stack>
);
};
export default Header;

View File

@@ -0,0 +1,79 @@
import { useMemo } from "react";
import { Tournament } from "../../types";
import { useAuth } from "@/contexts/auth-context";
import { Box, Divider, Stack, Text, Card, Center } from "@mantine/core";
import { Carousel } from "@mantine/carousel";
import ListLink from "@/components/list-link";
import { TreeStructureIcon, UsersIcon, ClockIcon } from "@phosphor-icons/react";
import TeamListButton from "../upcoming-tournament/team-list-button";
import RulesListButton from "../upcoming-tournament/rules-list-button";
import MatchCard from "@/features/matches/components/match-card";
import Header from "./header";
const StartedTournament: React.FC<{ tournament: Tournament }> = ({
tournament,
}) => {
const { roles } = useAuth();
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
const startedMatches = useMemo(() =>
tournament.matches?.filter(match => match.status === "started") || [],
[tournament.matches]
);
return (
<Stack gap="lg">
<Header tournament={tournament} />
{startedMatches.length > 0 ? (
<Box>
<Carousel
slideSize="95%"
slideGap="xs"
withControls={false}
>
{startedMatches.map((match, index) => (
<Carousel.Slide key={match.id}>
<Box pl={index === 0 ? "xl" : undefined } pr={index === startedMatches.length - 1 ? "xl" : undefined}>
<MatchCard match={match} />
</Box>
</Carousel.Slide>
))}
</Carousel>
</Box>
) : (
<Card withBorder radius="lg" p="xl" mx="md">
<Center>
<Stack align="center" gap="md">
<ClockIcon size={48} color="var(--mantine-color-dimmed)" />
<Text size="lg" fw={500} c="dimmed">
No active matches
</Text>
</Stack>
</Center>
</Card>
)}
<Box>
<Divider />
{isAdmin && (
<ListLink
label={`Manage ${tournament.name}`}
to={`/admin/tournaments/${tournament.id}`}
Icon={UsersIcon}
/>
)}
<ListLink
label={`View Bracket`}
to={`/tournaments/${tournament.id}/bracket`}
Icon={TreeStructureIcon}
/>
<RulesListButton tournamentId={tournament.id} />
<TeamListButton teams={tournament.teams || []} />
</Box>
</Stack>
);
};
export default StartedTournament;

View File

@@ -1,10 +1,23 @@
import { Badge, Card, Text, Stack, Group, Box, ThemeIcon } from "@mantine/core"; import {
Badge,
Card,
Text,
Stack,
Group,
Box,
ThemeIcon,
UnstyledButton,
} from "@mantine/core";
import { Tournament } from "@/features/tournaments/types"; import { Tournament } from "@/features/tournaments/types";
import { useMemo } from "react"; import { useMemo } from "react";
import { TrophyIcon, CalendarIcon, MapPinIcon, UsersIcon } from "@phosphor-icons/react"; import {
TrophyIcon,
CalendarIcon,
MapPinIcon,
UsersIcon,
} from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { motion } from "framer-motion";
interface TournamentCardProps { interface TournamentCardProps {
tournament: Tournament; tournament: Tournament;
@@ -18,9 +31,9 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
const date = new Date(tournament.start_time); const date = new Date(tournament.start_time);
if (isNaN(date.getTime())) return null; if (isNaN(date.getTime())) return null;
return date.toLocaleDateString(undefined, { return date.toLocaleDateString(undefined, {
month: 'short', month: "short",
day: 'numeric', day: "numeric",
year: 'numeric' year: "numeric",
}); });
}, [tournament.start_time]); }, [tournament.start_time]);
@@ -31,33 +44,42 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
const enrolledTeamsCount = tournament.teams?.length || 0; const enrolledTeamsCount = tournament.teams?.length || 0;
return ( return (
<motion.div <UnstyledButton
whileHover={{ y: -4, scale: 1.02 }} w="100%"
whileTap={{ scale: 0.98 }} onClick={() => navigate({ to: `/tournaments/${tournament.id}` })}
transition={{ type: "spring", stiffness: 300 }} style={{ borderRadius: "var(--mantine-radius-md)" }}
styles={{
root: {
"&:hover": {
transform: "translateY(-2px)",
transition: "transform 0.15s ease",
},
},
}}
> >
<Card <Card
withBorder withBorder
radius="lg" radius="md"
p="lg" p="lg"
w="100%"
style={{ style={{
cursor: "pointer", transition: "all 0.15s ease",
height: "100%", border: "1px solid var(--mantine-color-default-border)",
transition: "box-shadow 0.2s ease"
}} }}
onClick={() => navigate({ to: `/tournaments/${tournament.id}` })} styles={{
onMouseEnter={(e) => { root: {
e.currentTarget.style.boxShadow = "var(--mantine-shadow-md)"; "&:hover": {
}} borderColor: "var(--mantine-primary-color-filled)",
onMouseLeave={(e) => { boxShadow: "var(--mantine-shadow-sm)",
e.currentTarget.style.boxShadow = "none"; },
},
}} }}
> >
<Stack gap="md" h="100%"> <Group justify="space-between" align="center">
<Group gap="md" align="flex-start"> <Group gap="md" align="center">
<Avatar <Avatar
size={60} size={120}
radius="md" radius="sm"
name={tournament.name} name={tournament.name}
src={ src={
tournament.logo tournament.logo
@@ -65,15 +87,41 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
: undefined : undefined
} }
> >
<TrophyIcon size={24} /> <TrophyIcon size={20} />
</Avatar> </Avatar>
<Stack gap="xs">
<Text fw={600} size="lg" lineClamp={2}>
{tournament.name}
</Text>
{displayDate && (
<Group gap="xs">
<ThemeIcon
size="sm"
variant="light"
radius="sm"
color="gray"
>
<CalendarIcon size={12} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{displayDate}
</Text>
</Group>
)}
<Text fw={700} size="lg" lineClamp={2} my='auto'> <Group gap="xs">
{tournament.name} <ThemeIcon size="sm" variant="light" radius="sm" color="gray">
</Text> <UsersIcon size={12} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{enrolledTeamsCount} team
{enrolledTeamsCount !== 1 ? "s" : ""}
</Text>
</Group>
</Stack>
</Group> </Group>
</Stack> </Group>
</Card> </Card>
</motion.div> </UnstyledButton>
); );
}; };

View File

@@ -1,9 +1,9 @@
import { Stack, Skeleton, Text, Group, Box, ThemeIcon } from "@mantine/core"; import { List, ListItem, Divider, Skeleton, Text, Group, Box, ThemeIcon, Stack } from "@mantine/core";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { TournamentInfo } from "../types"; import { TournamentInfo } from "../types";
import { useCallback } from "react"; import { useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion"; import React from "react";
import { TrophyIcon, CalendarIcon, MapPinIcon } from "@phosphor-icons/react"; import { TrophyIcon, CalendarIcon, MapPinIcon } from "@phosphor-icons/react";
interface TournamentListProps { interface TournamentListProps {
@@ -11,6 +11,50 @@ interface TournamentListProps {
loading?: boolean; loading?: boolean;
} }
interface TournamentListItemProps {
tournament: TournamentInfo;
}
const TournamentListItem = React.memo(({ tournament }: TournamentListItemProps) => {
const startDate = tournament.start_time ? new Date(tournament.start_time) : null;
return (
<Group justify="space-between" w="100%">
<Stack gap={2}>
<Text fw={500} size="sm">
{tournament.name}
</Text>
<Group gap="md">
{tournament.location && (
<Group gap={4}>
<ThemeIcon size="xs" variant="light" radius="sm" color="gray">
<MapPinIcon size={10} />
</ThemeIcon>
<Text size="xs" c="dimmed">
{tournament.location}
</Text>
</Group>
)}
{startDate && !isNaN(startDate.getTime()) && (
<Group gap={4}>
<ThemeIcon size="xs" variant="light" radius="sm" color="gray">
<CalendarIcon size={10} />
</ThemeIcon>
<Text size="xs" c="dimmed">
{startDate.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric'
})}
</Text>
</Group>
)}
</Group>
</Stack>
</Group>
);
});
const TournamentList = ({ tournaments, loading = false }: TournamentListProps) => { const TournamentList = ({ tournaments, loading = false }: TournamentListProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -19,20 +63,23 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
if (loading) { if (loading) {
return ( return (
<Stack gap="sm"> <List>
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<Box key={`skeleton-${i}`} p="md"> <ListItem
<Group gap="md"> key={`skeleton-${i}`}
<Skeleton height={60} width={60} radius="md" /> py="xs"
<Stack gap="xs" style={{ flex: 1 }}> icon={<Skeleton height={40} width={40} radius="sm" />}
<Skeleton height={20} width="60%" /> >
<Skeleton height={16} width="40%" /> <Stack gap={4}>
<Skeleton height={16} width="30%" /> <Skeleton height={16} width={200} />
</Stack> <Group gap="md">
</Group> <Skeleton height={12} width={80} />
</Box> <Skeleton height={12} width={100} />
</Group>
</Stack>
</ListItem>
))} ))}
</Stack> </List>
); );
} }
@@ -50,96 +97,40 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
} }
return ( return (
<Stack gap="xs"> <List>
<AnimatePresence> {tournaments.map((tournament) => (
{tournaments.map((tournament, index) => { <>
const startDate = tournament.start_time ? new Date(tournament.start_time) : null; <ListItem
key={tournament.id}
return ( p="xs"
<motion.div icon={
key={`tournament-${tournament.id}-${index}`} <Avatar
initial={{ opacity: 0, y: 10 }} radius="sm"
animate={{ opacity: 1, y: 0 }} size={40}
exit={{ opacity: 0, y: -10 }} name={tournament.name}
transition={{ duration: 0.2, delay: index * 0.01 }} src={
whileHover={{ y: -2 }} tournament.logo
whileTap={{ scale: 0.98 }} ? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
: undefined
}
>
<TrophyIcon size={16} />
</Avatar>
}
style={{ cursor: "pointer" }}
onClick={() => handleClick(tournament.id)}
styles={{
itemWrapper: { width: "100%" },
itemLabel: { width: "100%" }
}}
w="100%"
> >
<Box <TournamentListItem tournament={tournament} />
p="md" </ListItem>
style={{ <Divider />
borderRadius: "var(--mantine-radius-md)", </>
border: "1px solid var(--mantine-color-gray-3)", ))}
cursor: "pointer", </List>
transition: "all 0.2s ease",
}}
onClick={() => handleClick(tournament.id)}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "var(--mantine-color-gray-0)";
e.currentTarget.style.borderColor = "var(--mantine-primary-color-filled)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.borderColor = "var(--mantine-color-gray-3)";
}}
>
<Group gap="md" wrap="nowrap">
<Avatar
size={60}
radius="md"
name={tournament.name}
src={
tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
: undefined
}
style={{
border: "2px solid var(--mantine-primary-color-light)",
}}
>
<TrophyIcon size={24} />
</Avatar>
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
<Text fw={600} size="lg" lineClamp={1}>
{tournament.name}
</Text>
<Group gap="lg" wrap="wrap">
{tournament.location && (
<Group gap="xs">
<ThemeIcon size="xs" variant="light" radius="sm">
<MapPinIcon size={12} />
</ThemeIcon>
<Text size="sm" c="dimmed" lineClamp={1}>
{tournament.location}
</Text>
</Group>
)}
{startDate && !isNaN(startDate.getTime()) && (
<Group gap="xs">
<ThemeIcon size="xs" variant="light" radius="sm">
<CalendarIcon size={12} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{startDate.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric'
})}
</Text>
</Group>
)}
</Group>
</Stack>
</Group>
</Box>
</motion.div>
);
})}
</AnimatePresence>
</Stack>
); );
} }

View File

@@ -1,162 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { Tournament } from "../types";
import { useAuth } from "@/contexts/auth-context";
import {
Box,
Button,
Card,
Divider,
Group,
Stack,
Title,
Text,
ThemeIcon,
} from "@mantine/core";
import Avatar from "@/components/avatar";
import Countdown from "@/components/countdown";
import ListLink from "@/components/list-link";
import ListButton from "@/components/list-button";
import {
TrophyIcon,
CalendarIcon,
MapPinIcon,
UsersIcon,
ListIcon,
} from "@phosphor-icons/react";
import EnrollTeam from "./enroll-team";
import EnrollFreeAgent from "./enroll-free-agent";
const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
tournament,
}) => {
const { user, roles } = useAuth();
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
const userTeam = useMemo(
() =>
tournament.teams?.find((team) =>
team.players?.some((player) => player.id === user?.id)
),
[tournament.teams, user?.id]
);
const isUserEnrolled = !!userTeam;
const enrollmentDeadline = tournament.enroll_time
? new Date(tournament.enroll_time)
: new Date(tournament.start_time);
const tournamentStart = new Date(tournament.start_time);
const isEnrollmentOpen = enrollmentDeadline > new Date();
const enrolledTeamsCount = tournament.teams?.length || 0;
return (
<Stack gap="lg">
<Group justify="space-around" align="flex-start" w='100%'>
<Group align="center" gap="lg">
<Avatar
name={tournament.name}
src={
tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
: undefined
}
radius="md"
size={120}
px='md'
withBorder={false}
>
<TrophyIcon size={32} />
</Avatar>
<Stack gap="xs">
{tournament.location && (
<Group gap="xs">
<ThemeIcon size="sm" variant="light" radius="sm">
<MapPinIcon size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{tournament.location}
</Text>
</Group>
)}
<Group gap="xs">
<ThemeIcon size="sm" variant="light" radius="sm">
<CalendarIcon size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{tournamentStart.toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
})}{" "}
at{" "}
{tournamentStart.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</Group>
<Group gap="xs">
<ThemeIcon size="sm" variant="light" radius="sm">
<UsersIcon size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{enrolledTeamsCount} teams enrolled
</Text>
</Group>
</Stack>
</Group>
</Group>
{tournament.desc && <Text size="sm">{tournament.desc}</Text>}
<Card withBorder radius="md" p="lg">
<Stack gap="md">
<Group gap="xs" align="center">
<UsersIcon size={16} />
<Text size="sm" fw={500}>
Enrollment
</Text>
{isEnrollmentOpen && (
<Box ml="auto">
<Countdown
date={enrollmentDeadline}
label="Time left"
color="yellow"
/>
</Box>
)}
</Group>
{!isUserEnrolled && isEnrollmentOpen && (
<>
<EnrollTeam />
<Divider label="or" />
<EnrollFreeAgent />
</>
)}
</Stack>
</Card>
<Box>
<Divider />
{isAdmin && (
<ListLink
label={`Manage ${tournament.name}`}
to={`/admin/tournaments/${tournament.id}`}
Icon={UsersIcon}
/>
)}
<ListButton label="View Rules" Icon={ListIcon} onClick={() => {}} />
<ListButton
label={`View Teams (${enrolledTeamsCount})`}
Icon={UsersIcon}
onClick={() => {}}
/>
</Box>
</Stack>
);
};
export default UpcomingTournament;

View File

@@ -9,7 +9,7 @@ const EnrollFreeAgent = () => {
const { user } = useAuth(); const { user } = useAuth();
return ( return (
<> <>
<Button variant="outline" size="sm" onClick={open}> <Button variant="subtle" size="sm" onClick={open}>
Enroll As Free Agent Enroll As Free Agent
</Button> </Button>

View File

@@ -0,0 +1,121 @@
import Button from "@/components/button";
import Sheet from "@/components/sheet/sheet";
import { useAuth } from "@/contexts/auth-context";
import { useSheet } from "@/hooks/use-sheet";
import { useMemo, useState, useCallback, useEffect } from "react";
import TeamSelectionView from "./team-selection-view";
import TeamForm from "@/features/teams/components/team-form";
import { teamQueries } from "@/features/teams/queries";
import { Team } from "@/features/teams/types";
import { useQuery } from "@tanstack/react-query";
import { Loader } from "@mantine/core";
import useEnrollTeam from "@/features/tournaments/hooks/use-enroll-team";
import { useServerQuery } from "@/lib/tanstack-query/hooks";
interface EnrollTeamProps {
tournamentId: string;
onSubmit: () => void;
}
const EnrollTeam = ({ tournamentId, onSubmit }: EnrollTeamProps) => {
const { open, isOpen, toggle } = useSheet();
const { user } = useAuth();
const hasTeams = useMemo(() => !!user?.teams?.length, [user?.teams]);
const [selectedTeamId, setSelectedTeamId] = useState<string>();
const [showTeamForm, setShowTeamForm] = useState<boolean>(!hasTeams);
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
const { data: teamData } = useServerQuery({
...teamQueries.details(selectedTeamId!),
options: { enabled: !!selectedTeamId }
});
useEffect(() => {
if (teamData) {
setSelectedTeam(teamData);
setShowTeamForm(true);
}
}, [teamData]);
const teamOptions = useMemo(() =>
user?.teams?.map(team => ({
value: team.id,
label: team.name
})) || [],
[user?.teams]
);
const handleBack = useCallback(() => {
setSelectedTeamId(undefined);
setSelectedTeam(null);
setShowTeamForm(false);
}, []);
const handleSelect = useCallback((teamId: string | undefined) => {
setSelectedTeamId(teamId);
if (!teamId) {
setShowTeamForm(true);
}
}, []);
const initialValues = useMemo(() => {
if (!selectedTeam) return undefined;
return {
name: selectedTeam.name,
song_id: selectedTeam.song_id,
song_name: selectedTeam.song_name,
song_artist: selectedTeam.song_artist,
song_album: selectedTeam.song_album,
song_start: selectedTeam.song_start,
song_end: selectedTeam.song_end,
song_image_url: selectedTeam.song_image_url,
players: selectedTeam.players.map(player => player.id),
};
}, [selectedTeam]);
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
const handleEnrollTeam = (teamId: string) => {
enrollTeam({ tournamentId, teamId }, {
onSuccess: () => {
toggle();
onSubmit();
}
});
}
return (
<>
<Button size="sm" onClick={open}>
Enroll Your Team
</Button>
<Sheet title={showTeamForm ? "Team Details" : "Enroll Team"} opened={isOpen} onChange={toggle}>
{ isEnrolling && <Loader size="lg" /> }
{showTeamForm && !isEnrolling ? (
<TeamForm
close={handleBack}
tournamentId={tournamentId}
initialValues={initialValues}
teamId={selectedTeamId}
onSubmit={handleEnrollTeam}
/>
) : (
<>
<TeamSelectionView
options={teamOptions}
onSelect={handleSelect}
/>
<Button mt='sm' variant="subtle" color='red' size="sm" onClick={toggle}>
Cancel
</Button>
</>
)}
</Sheet>
</>
);
};
export default EnrollTeam;

View File

@@ -11,9 +11,8 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
options, options,
onSelect onSelect
}) => { }) => {
const [value, setValue] = useState<string>(); const [value, setValue] = useState<string>('');
const selectedOption = useMemo(() => options.find(option => option.label === value), [value]) const selectedOption = useMemo(() => options.find(option => option.label === value), [value, options])
const handleCreateNewTeamClicked = () => onSelect(undefined); const handleCreateNewTeamClicked = () => onSelect(undefined);
const handleSelectExistingTeam = () => onSelect(selectedOption?.value) const handleSelectExistingTeam = () => onSelect(selectedOption?.value)
@@ -34,7 +33,7 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
<Stack gap="sm"> <Stack gap="sm">
<Autocomplete <Autocomplete
placeholder="Select one of your existing teams" placeholder="Select one of your existing teams"
value={selectedOption?.label || ''} value={value}
onChange={setValue} onChange={setValue}
data={options.map(option => option.label)} data={options.map(option => option.label)}
comboboxProps={{ withinPortal: false }} comboboxProps={{ withinPortal: false }}

View File

@@ -0,0 +1,72 @@
import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
import { Tournament } from "../../types";
import Avatar from "@/components/avatar";
import {
CalendarIcon,
MapPinIcon,
TrophyIcon,
UsersIcon,
} from "@phosphor-icons/react";
import { useMemo } from "react";
const Header = ({ tournament }: { tournament: Tournament }) => {
const tournamentStart = useMemo(
() => new Date(tournament.start_time),
[tournament.start_time]
);
const teamCount = useMemo(
() => tournament.teams?.length || 0,
[tournament.teams]
);
return (
<Stack align="center" gap={0}>
<Avatar
name={tournament.name}
src={
tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
: undefined
}
radius="md"
size={300}
px="xs"
withBorder={false}
>
<TrophyIcon size={32} />
</Avatar>
<Flex gap="xs" direction="row" wrap="wrap" justify="space-around">
{tournament.location && (
<Group gap="xs">
<ThemeIcon size="sm" variant="light" radius="sm">
<MapPinIcon size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{tournament.location}
</Text>
</Group>
)}
<Group gap="xs">
<ThemeIcon size="sm" variant="light" radius="sm">
<CalendarIcon size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{tournamentStart.toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
})}{" "}
at{" "}
{tournamentStart.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</Group>
</Flex>
</Stack>
);
};
export default Header;

View File

@@ -0,0 +1,116 @@
import { Suspense, useCallback, useMemo } from "react";
import { Tournament } from "../../types";
import { useAuth } from "@/contexts/auth-context";
import { Box, Button, Card, Divider, Group, Stack, Text } from "@mantine/core";
import Countdown from "@/components/countdown";
import ListLink from "@/components/list-link";
import ListButton from "@/components/list-button";
import { TreeStructureIcon, UsersIcon } from "@phosphor-icons/react";
import EnrollTeam from "./enroll-team";
import EnrollFreeAgent from "./enroll-free-agent";
import TeamListButton from "./team-list-button";
import Header from "./header";
import TeamCardSkeleton from "@/features/teams/components/team-card-skeleton";
import TeamCard from "@/features/teams/components/team-card";
import UpdateTeam from "./update-team";
import UnenrollTeam from "./unenroll-team";
import { useQueryClient } from "@tanstack/react-query";
import { tournamentKeys } from "../../queries";
import RulesListButton from "./rules-list-button";
const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
tournament,
}) => {
const { user, roles } = useAuth();
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
const userTeam = useMemo(
() =>
tournament.teams?.find((team) =>
team.players?.some((player) => player.id === user?.id)
),
[tournament.teams, user?.id]
);
const isUserEnrolled = !!userTeam;
const enrollmentDeadline = tournament.enroll_time
? new Date(tournament.enroll_time)
: new Date(tournament.start_time);
const isEnrollmentOpen = enrollmentDeadline > new Date();
const queryClient = useQueryClient();
const handleSubmit = () => {
queryClient.invalidateQueries({ queryKey: tournamentKeys.current })
}
return (
<Stack gap="lg">
<Header tournament={tournament} />
{tournament.desc && <Text size="sm">{tournament.desc}</Text>}
<Card withBorder radius="lg" p="lg">
<Stack gap="xs">
<Group mb="sm" gap="xs" align="center">
<UsersIcon size={16} />
<Text size="sm" fw={500}>
Enrollment
</Text>
{isEnrollmentOpen && (
<Box ml="auto">
<Countdown
date={enrollmentDeadline}
label="Time left"
color="yellow"
/>
</Box>
)}
</Group>
{!isUserEnrolled &&!isEnrollmentOpen && (
<Text fw={600} c="dimmed" size="sm">
Enrollment has been closed for this tournament.
</Text>
)}
{!isUserEnrolled &&isEnrollmentOpen && (
<>
<EnrollTeam tournamentId={tournament.id} onSubmit={handleSubmit} />
<Divider my={0} label="or" />
<EnrollFreeAgent />
</>
)}
{
isUserEnrolled && <>
<Suspense fallback={<TeamCardSkeleton />}>
<TeamCard teamId={userTeam.id} />
</Suspense>
<UpdateTeam tournamentId={tournament.id} teamId={userTeam.id} />
{ isEnrollmentOpen && <UnenrollTeam tournamentId={tournament.id} teamId={userTeam.id} onSubmit={handleSubmit} />}
</>
}
</Stack>
</Card>
<Box>
<Divider />
{isAdmin && (
<ListLink
label={`Manage ${tournament.name}`}
to={`/admin/tournaments/${tournament.id}`}
Icon={UsersIcon}
/>
)}
<ListLink
label={`View Bracket`}
to={`/tournaments/${tournament.id}/bracket`}
Icon={TreeStructureIcon}
/>
<RulesListButton tournamentId={tournament.id} />
<TeamListButton teams={tournament.teams || []} />
</Box>
</Stack>
);
};
export default UpcomingTournament;

View File

@@ -0,0 +1,45 @@
import ListButton from "@/components/list-button"
import Sheet from "@/components/sheet/sheet"
import { useSheet } from "@/hooks/use-sheet"
import { ListIcon } from "@phosphor-icons/react"
import { useTournament } from "../../queries"
import { useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { RichTextEditor } from '@mantine/tiptap';
import { Button, Stack } from "@mantine/core"
interface RulesListButtonProps {
tournamentId: string;
}
const RulesListButton: React.FC<RulesListButtonProps> = ({ tournamentId }) => {
const { data: tournament } = useTournament(tournamentId);
const { open, isOpen, toggle } = useSheet();
const editor = useEditor({
extensions: [StarterKit],
content: tournament?.rules || '',
editable: false,
});
return (
<>
<ListButton
label={`View Rules`}
Icon={ListIcon}
onClick={open}
/>
<Sheet title="Tournament Rules" opened={isOpen} onChange={toggle}>
<Stack gap="xs">
<RichTextEditor editor={editor}>
<RichTextEditor.Content />
</RichTextEditor>
<Button variant="subtle" c="red" onClick={toggle}>Close</Button>
</Stack>
</Sheet>
</>
)
}
export default RulesListButton;

View File

@@ -0,0 +1,31 @@
import ListButton from "@/components/list-button"
import Sheet from "@/components/sheet/sheet"
import TeamList from "@/features/teams/components/team-list"
import { TeamInfo } from "@/features/teams/types"
import { useSheet } from "@/hooks/use-sheet"
import { UsersIcon } from "@phosphor-icons/react"
import { useMemo } from "react"
interface TeamListButtonProps {
teams: TeamInfo[]
}
const TeamListButton: React.FC<TeamListButtonProps> = ({ teams }) => {
const count = useMemo(() => teams.length, [teams]);
const { open, isOpen, toggle } = useSheet();
return (
<>
<ListButton
label={`View Teams (${count})`}
Icon={UsersIcon}
onClick={open}
/>
<Sheet title="Enrolled Teams" opened={isOpen} onChange={toggle}>
<TeamList teams={teams} />
</Sheet>
</>
)
}
export default TeamListButton;

View File

@@ -0,0 +1,46 @@
import Button from "@/components/button";
import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet";
import { useCallback } from "react";
import useUnenrollTeam from "../../hooks/use-unenroll-team";
import { Stack, Text } from "@mantine/core";
interface UnenrollTeamProps {
tournamentId: string;
teamId: string
onSubmit: () => void;
}
const UnenrollTeam = ({ tournamentId, teamId, onSubmit }: UnenrollTeamProps) => {
const { open, isOpen, toggle } = useSheet();
const { mutate: unenrollTeam } = useUnenrollTeam();
const handleUnenrollTeam = useCallback(
async () => {
await unenrollTeam({ tournamentId, teamId }, {
onSuccess: () => {
toggle();
onSubmit();
}
});
},
[unenrollTeam, tournamentId]
);
return (
<>
<Button size="sm" onClick={open} variant="subtle">
Unenroll
</Button>
<Sheet title={"Unenroll Team"} opened={isOpen} onChange={toggle}>
<Stack gap='xs'>
<Text size="sm" fw={500}>Are you sure you want to unenroll from this tournament? You can enroll again at any point before the deadline.</Text>
<Button onClick={handleUnenrollTeam}>Confirm</Button>
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
</Stack>
</Sheet>
</>
);
};
export default UnenrollTeam;

View File

@@ -0,0 +1,59 @@
import Button from "@/components/button";
import Sheet from "@/components/sheet/sheet";
import { useAuth } from "@/contexts/auth-context";
import { useSheet } from "@/hooks/use-sheet";
import { useMemo, useState, useCallback, useEffect } from "react";
import TeamSelectionView from "./enroll-team/team-selection-view";
import TeamForm from "@/features/teams/components/team-form";
import { teamQueries, useTeam } from "@/features/teams/queries";
import { Team } from "@/features/teams/types";
import { useQuery } from "@tanstack/react-query";
import { Loader } from "@mantine/core";
import useEnrollTeam from "@/features/tournaments/hooks/use-enroll-team";
interface UpdateTeamProps {
tournamentId: string;
teamId: string
}
const UpdateTeam = ({ tournamentId, teamId }: UpdateTeamProps) => {
const { open, isOpen, toggle } = useSheet();
const { data: team } = useTeam(teamId);
const initialValues = useMemo(() => {
if (!team) return undefined;
return {
name: team.name,
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,
players: team.players?.map(player => player.id),
};
}, [team]);
return (
<>
<Button size="sm" onClick={open}>
Update Team
</Button>
<Sheet title={"Update Team"} opened={isOpen} onChange={toggle}>
<TeamForm
close={toggle}
tournamentId={tournamentId}
initialValues={initialValues}
teamId={teamId}
onSubmit={(_) => close()}
/>
</Sheet>
</>
);
};
export default UpdateTeam;

View File

@@ -2,19 +2,13 @@ import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Logger } from "@/lib/logger"; import { Logger } from "@/lib/logger";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
/* import { tournamentKeys, tournamentQueries } from "@/features/tournaments/queries";
import { authClient } from "~/lib/auth-client";
import { getIdeaFn } from "~/routes/-fn/getIdeaFn";
import { useSession } from "~/lib/sessionContext";
*/
// Global state for new ideas notification
let newIdeasAvailable = false; let newIdeasAvailable = false;
let newIdeasCallbacks: (() => void)[] = []; let newIdeasCallbacks: (() => void)[] = [];
const logger = new Logger('ServerEvents'); const logger = new Logger('ServerEvents');
// Event handler types for better type safety
type SSEEvent = { type SSEEvent = {
type: string; type: string;
[key: string]: any; [key: string]: any;
@@ -22,29 +16,26 @@ type SSEEvent = {
type EventHandler = (event: SSEEvent, queryClient: ReturnType<typeof useQueryClient>, currentSessionId?: string) => void; type EventHandler = (event: SSEEvent, queryClient: ReturnType<typeof useQueryClient>, currentSessionId?: string) => void;
// Event handlers map - add new handlers here for easy extension
const eventHandlers: Record<string, EventHandler> = { const eventHandlers: Record<string, EventHandler> = {
"connected": () => { "connected": () => {
logger.info("ServerEvents | New Connection"); logger.info("ServerEvents | New Connection");
}, },
"ping": () => { "ping": () => {},
// Keep-alive ping, no action needed
},
"test": (event, queryClient) => { "test": (event, queryClient) => {
}, },
// Add new event handlers here: "match": (event, queryClient) => {
// "idea-updated": (event, queryClient) => { console.log(event);
// queryClient.invalidateQueries({ queryKey: ["ideas"] });
// }, queryClient.invalidateQueries(tournamentQueries.details(event.tournamentId))
// "vote-changed": (event, queryClient) => { queryClient.invalidateQueries(tournamentQueries.current())
// queryClient.invalidateQueries({ queryKey: ["idea", event.ideaId] }); }
// },
}; };
// Functions to manage new ideas notification state
export function getNewIdeasAvailable(): boolean { export function getNewIdeasAvailable(): boolean {
return newIdeasAvailable; return newIdeasAvailable;
} }
@@ -55,7 +46,6 @@ export function clearNewIdeasAvailable(): void {
export function subscribeToNewIdeas(callback: () => void): () => void { export function subscribeToNewIdeas(callback: () => void): () => void {
newIdeasCallbacks.push(callback); newIdeasCallbacks.push(callback);
// Return unsubscribe function
return () => { return () => {
const index = newIdeasCallbacks.indexOf(callback); const index = newIdeasCallbacks.indexOf(callback);
if (index > -1) { if (index > -1) {
@@ -84,7 +74,6 @@ export function useServerEvents() {
const eventSource = new EventSource(`/api/events/$`); const eventSource = new EventSource(`/api/events/$`);
eventSource.onopen = () => { eventSource.onopen = () => {
// Reset retry count on successful connection
retryCountRef.current = 0; retryCountRef.current = 0;
}; };
@@ -93,7 +82,6 @@ export function useServerEvents() {
const data: SSEEvent = JSON.parse(event.data); const data: SSEEvent = JSON.parse(event.data);
logger.info("Event received", data); logger.info("Event received", data);
// Use the event handler pattern for extensible event processing
const handler = eventHandlers[data.type]; const handler = eventHandlers[data.type];
if (handler) { if (handler) {
handler(data, queryClient, user?.id); handler(data, queryClient, user?.id);
@@ -109,13 +97,12 @@ export function useServerEvents() {
logger.error("SSE connection error", error); logger.error("SSE connection error", error);
eventSource.close(); eventSource.close();
// Only retry if we should still be connecting and haven't exceeded max retries
if (shouldConnectRef.current && retryCountRef.current < 5) { if (shouldConnectRef.current && retryCountRef.current < 5) {
retryCountRef.current += 1; retryCountRef.current += 1;
const delay = Math.min( const delay = Math.min(
1000 * Math.pow(2, retryCountRef.current - 1), 1000 * Math.pow(2, retryCountRef.current - 1),
30000 30000
); // Cap at 30 seconds );
logger.info( logger.info(
`SSE reconnection attempt ${retryCountRef.current}/5 in ${delay}ms` `SSE reconnection attempt ${retryCountRef.current}/5 in ${delay}ms`

View File

@@ -7,4 +7,10 @@ export type TestEvent = {
playerId: string; playerId: string;
}; };
export type ServerEvent = TestEvent; export type MatchEvent = {
type: "match";
matchId: string;
tournamentId: string;
}
export type ServerEvent = TestEvent | MatchEvent;

View File

@@ -3,6 +3,7 @@ import { createPlayersService } from "./services/players";
import { createTournamentsService } from "./services/tournaments"; import { createTournamentsService } from "./services/tournaments";
import { createTeamsService } from "./services/teams"; import { createTeamsService } from "./services/teams";
import { createMatchesService } from "./services/matches"; import { createMatchesService } from "./services/matches";
import { createReactionsService } from "./services/reactions";
class PocketBaseAdminClient { class PocketBaseAdminClient {
private pb: PocketBase; private pb: PocketBase;
@@ -31,6 +32,7 @@ class PocketBaseAdminClient {
Object.assign(this, createTeamsService(this.pb)); Object.assign(this, createTeamsService(this.pb));
Object.assign(this, createTournamentsService(this.pb)); Object.assign(this, createTournamentsService(this.pb));
Object.assign(this, createMatchesService(this.pb)); Object.assign(this, createMatchesService(this.pb));
Object.assign(this, createReactionsService(this.pb));
}); });
} }
@@ -49,7 +51,8 @@ interface AdminClient
ReturnType<typeof createPlayersService>, ReturnType<typeof createPlayersService>,
ReturnType<typeof createTeamsService>, ReturnType<typeof createTeamsService>,
ReturnType<typeof createTournamentsService>, ReturnType<typeof createTournamentsService>,
ReturnType<typeof createMatchesService> { ReturnType<typeof createMatchesService>,
ReturnType<typeof createReactionsService> {
authPromise: Promise<void>; authPromise: Promise<void>;
} }

View File

@@ -6,7 +6,6 @@ import { transformMatch } from "../util/transform-types";
export function createMatchesService(pb: PocketBase) { export function createMatchesService(pb: PocketBase) {
return { return {
async getMatch(id: string): Promise<Match | null> { async getMatch(id: string): Promise<Match | null> {
logger.info("PocketBase | Getting match", id);
const result = await pb.collection("matches").getOne(id, { const result = await pb.collection("matches").getOne(id, {
expand: "tournament, home, away", expand: "tournament, home, away",
}); });
@@ -15,7 +14,6 @@ export function createMatchesService(pb: PocketBase) {
// match Ids where the current lid is home_from_lid or away_from_lid // match Ids where the current lid is home_from_lid or away_from_lid
async getChildMatches(matchId: string): Promise<{ winner: Match | undefined, loser: Match | undefined }> { async getChildMatches(matchId: string): Promise<{ winner: Match | undefined, loser: Match | undefined }> {
logger.info("PocketBase | Getting child matches", matchId);
const match = await this.getMatch(matchId); const match = await this.getMatch(matchId);
if (!match) throw new Error("Match not found") if (!match) throw new Error("Match not found")
@@ -52,7 +50,7 @@ export function createMatchesService(pb: PocketBase) {
async updateMatch(id: string, data: Partial<MatchInput>): Promise<Match> { async updateMatch(id: string, data: Partial<MatchInput>): Promise<Match> {
logger.info("PocketBase | Updating match", { id, data }); logger.info("PocketBase | Updating match", { id, data });
const result = await pb.collection("matches").update<Match>(id, data, { const result = await pb.collection("matches").update<Match>(id, data, {
expand: 'home, away' expand: 'home, away, tournament'
}); });
return transformMatch(result); return transformMatch(result);
}, },

View File

@@ -65,10 +65,10 @@ export function createPlayersService(pb: PocketBase) {
return result.map(transformPlayer); return result.map(transformPlayer);
}, },
async getPlayerStats(playerId: string): Promise<PlayerStats[]> { async getPlayerStats(playerId: string): Promise<PlayerStats> {
const result = await pb.collection("player_stats").getFullList<PlayerStats>({ const result = await pb.collection("player_stats").getFirstListItem<PlayerStats>(
filter: `player_id = "${playerId}"`, `player_id = "${playerId}"`
}); );
return result; return result;
}, },
@@ -80,7 +80,9 @@ export function createPlayersService(pb: PocketBase) {
}, },
async getPlayerMatches(playerId: string): Promise<Match[]> { async getPlayerMatches(playerId: string): Promise<Match[]> {
const player = await pb.collection("players").getOne(playerId, { console.log('----------------')
console.log(playerId)
const player = await pb.collection("players").getOne(playerId.trim(), {
expand: "teams", expand: "teams",
}); });
@@ -102,5 +104,56 @@ export function createPlayersService(pb: PocketBase) {
return result.map(transformMatch); return result.map(transformMatch);
}, },
async getUnenrolledPlayers(tournamentId: string): Promise<Player[]> {
try {
// Get the tournament with its enrolled teams
const tournament = await pb.collection("tournaments").getOne(tournamentId, {
fields: "teams",
expand: "teams,teams.players"
});
// Extract player IDs from all enrolled teams
const enrolledPlayerIds: string[] = [];
if (tournament.expand?.teams) {
const teams = Array.isArray(tournament.expand.teams) ? tournament.expand.teams : [tournament.expand.teams];
teams.forEach((team: any) => {
if (team.expand?.players) {
const players = Array.isArray(team.expand.players) ? team.expand.players : [team.expand.players];
players.forEach((player: any) => {
enrolledPlayerIds.push(player.id);
});
}
});
}
// If no players are enrolled, return all players
if (enrolledPlayerIds.length === 0) {
const allPlayers = await pb.collection("players").getFullList<Player>({
fields: "id,first_name,last_name,email",
});
return allPlayers.map(transformPlayer);
}
// Build filter to exclude enrolled players
const filter = enrolledPlayerIds
.map((playerId: string) => `id != "${playerId}"`)
.join(" && ");
const availablePlayers = await pb.collection("players").getFullList<Player>({
filter,
fields: "id,first_name,last_name,email",
});
return availablePlayers.map(transformPlayer);
} catch (error) {
console.error("Error getting unenrolled players:", error);
// Fallback to all players if there's an error
const allPlayers = await pb.collection("players").getFullList<Player>({
fields: "id,first_name,last_name,email",
});
return allPlayers.map(transformPlayer);
}
},
}; };
} }

View File

@@ -0,0 +1,35 @@
import PocketBase from "pocketbase";
import { transformReaction } from "../util/transform-types";
export const createReactionsService = (pb: PocketBase) => ({
async getReactionsForMatch(matchId: string) {
const reactions = await pb.collection('reactions').getFullList({
filter: `match="${matchId}"`,
expand: 'player',
});
return reactions.map(transformReaction)
},
async getUserReaction(matchId: string, userId: string, emoji: string) {
try {
return await pb.collection('reactions').getFirstListItem(`match="${matchId}" && player="${userId}" && emoji="${emoji}"`);
} catch (error) {
return;
}
},
async createReaction(matchId: string, userId: string, emoji: string) {
const reaction = await pb.collection('reactions').create({
match: matchId,
player: userId,
emoji: emoji,
}, {
expand: 'player'
});
return transformReaction(reaction)
},
async deleteReaction(reactionId: string) {
return await pb.collection('reactions').delete(reactionId);
},
});

View File

@@ -1,12 +1,12 @@
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import PocketBase from "pocketbase"; import PocketBase from "pocketbase";
import { transformTeam, transformTeamInfo } from "@/lib/pocketbase/util/transform-types"; import { transformMatch, transformTeam, transformTeamInfo } from "@/lib/pocketbase/util/transform-types";
import { Team, TeamInfo, TeamInput, TeamUpdateInput } from "@/features/teams/types"; import { Team, TeamInfo, TeamInput, TeamUpdateInput, TeamStats } from "@/features/teams/types";
import { Match } from "@/features/matches/types";
export function createTeamsService(pb: PocketBase) { export function createTeamsService(pb: PocketBase) {
return { return {
async getTeamInfo(id: string): Promise<TeamInfo> { async getTeamInfo(id: string): Promise<TeamInfo> {
logger.info("PocketBase | Getting team info", id);
const result = await pb.collection("teams").getOne(id, { const result = await pb.collection("teams").getOne(id, {
fields: "id,name,primary_color,accent_color,logo", fields: "id,name,primary_color,accent_color,logo",
expand: "players" expand: "players"
@@ -24,7 +24,6 @@ export function createTeamsService(pb: PocketBase) {
}, },
async getTeam(id: string): Promise<Team | null> { async getTeam(id: string): Promise<Team | null> {
logger.info("PocketBase | Getting team", id);
const result = await pb.collection("teams").getOne(id, { const result = await pb.collection("teams").getOne(id, {
expand: "players, tournaments", expand: "players, tournaments",
}); });
@@ -81,5 +80,27 @@ export function createTeamsService(pb: PocketBase) {
throw error; throw error;
} }
}, },
async getTeamStats(id: string): Promise<TeamStats | null> {
try {
const result = await pb.collection("team_stats").getFirstListItem(`team_id="${id}"`);
return result as unknown as TeamStats;
} catch (error) {
logger.info("PocketBase | No team stats found", id);
return null;
}
},
async getTeamMatches(teamId: string): Promise<Match[]> {
const teamFilter = `home = "${teamId}" || away = "${teamId}"`
const result = await pb.collection("matches").getFullList({
filter: `(${teamFilter}) && (status = "ended" || status = "started")`,
sort: "-start_time",
expand: "tournament,home,away",
});
return result.map(transformMatch);
},
}; };
} }

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