19 Commits
Author SHA1 Message Date
yohlo 6565714ee4 Merge branch 'predictions' into development
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m36s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 25s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m23s
2026-07-14 14:22:50 -07:00
yohlo 1783f0e6bc predictions 2026-07-14 14:15:34 -07:00
yohlo 0a7f4de321 Merge branch 'style' into development 2026-07-14 00:39:43 -07:00
yohlo e8b7647f3d styling 2026-07-14 00:39:38 -07:00
yohlo 0284b33e50 Merge branch 'style' into development 2026-07-14 00:12:46 -07:00
yohlo d3809b5805 styling 2026-07-14 00:12:39 -07:00
yohlo a50f9b6644 pb 2026-07-13 23:59:10 -07:00
yohlo 3b087850e7 Merge branch 'perf' into development 2026-07-13 23:56:30 -07:00
yohlo 19f61ac454 SSE 2026-07-13 23:56:19 -07:00
yohlo e6c72a5789 more styles
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m36s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m23s
2026-07-12 23:23:14 -07:00
yohlo e9040bb02f generate sw
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m38s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m24s
2026-07-12 21:51:48 -07:00
yohlo 552525d272 remove elevated header
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 1m16s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Has been skipped
2026-07-12 21:43:41 -07:00
yohlo 4420e4d0fe merge
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 1m54s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Has been skipped
2026-07-12 21:30:51 -07:00
yohlo 3cc16bdeb2 Merge branch 'style' into development 2026-07-12 21:30:02 -07:00
yohlo 48eb7d71b2 Merge branch 'perf' into development 2026-07-12 21:28:46 -07:00
yohlo 4f81f3c0ca style upgrades 2026-07-12 21:26:39 -07:00
yohlo 778f0f7994 perf upgrade 2026-07-12 21:26:16 -07:00
yohlo b6085ff329 temp fix
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m24s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m24s
2026-07-12 20:10:56 -07:00
yohlo ec334bbed4 auth fix?
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m55s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Failing after 2m36s
2026-07-11 13:11:45 -07:00
184 changed files with 6022 additions and 1830 deletions
+3
View File
@@ -79,6 +79,9 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: pin rancher host
run: echo "192.168.4.43 rancher.yohler.net" >> /etc/hosts
- name: Set environment variables - name: Set environment variables
run: | run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then if [ "${{ github.ref }}" == "refs/heads/main" ]; then
+1 -1
View File
@@ -17,7 +17,7 @@ yarn.lock
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/scripts/ /_scripts/
/pb_data/ /pb_data/
/.tanstack/ /.tanstack/
/dist/ /dist/
+1 -2
View File
@@ -2,7 +2,7 @@ FROM oven/bun:1 AS builder
WORKDIR /app WORKDIR /app
COPY package.json bun.lockb* ./ COPY package.json bun.lock ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
@@ -21,7 +21,6 @@ EXPOSE 3000
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ENV NITRO_PORT=3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
+747 -508
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -6,7 +6,7 @@ metadata:
app: flxn app: flxn
component: app component: app
spec: spec:
replicas: 1 replicas: 1 # Must stay at 1 for SSE
selector: selector:
matchLabels: matchLabels:
app: flxn app: flxn
+2 -2
View File
@@ -6,8 +6,8 @@ metadata:
data: data:
vite_api_domain: "https://dev.flexxon.app" vite_api_domain: "https://dev.flexxon.app"
vite_website_domain: "https://dev.flexxon.app" vite_website_domain: "https://dev.flexxon.app"
supertokens_uri: "http://192.168.0.50:30568" supertokens_uri: "http://192.168.4.43:30568"
pocketbase_url: "http://192.168.0.50:30096" pocketbase_url: "http://192.168.4.43:30096"
vite_spotify_client_id: "3ffde6b594e84460b3d4b329b8919277" vite_spotify_client_id: "3ffde6b594e84460b3d4b329b8919277"
vite_spotify_redirect_uri: "https://dev.flexxon.app/api/spotify/callback" vite_spotify_redirect_uri: "https://dev.flexxon.app/api/spotify/callback"
s3_endpoint: "https://s3.yohler.net" s3_endpoint: "https://s3.yohler.net"
+2 -2
View File
@@ -6,8 +6,8 @@ metadata:
data: data:
vite_api_domain: "https://flexxon.app" vite_api_domain: "https://flexxon.app"
vite_website_domain: "https://flexxon.app" vite_website_domain: "https://flexxon.app"
supertokens_uri: "http://192.168.0.50:30568" supertokens_uri: "http://192.168.4.43:30568"
pocketbase_url: "http://192.168.0.50:30097" pocketbase_url: "http://192.168.4.43:30097"
vite_spotify_client_id: "3ffde6b594e84460b3d4b329b8919277" vite_spotify_client_id: "3ffde6b594e84460b3d4b329b8919277"
vite_spotify_redirect_uri: "https://flexxon.app/api/spotify/callback" vite_spotify_redirect_uri: "https://flexxon.app/api/spotify/callback"
s3_endpoint: "https://s3.yohler.net" s3_endpoint: "https://s3.yohler.net"
+14 -27
View File
@@ -5,65 +5,53 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev --host 0.0.0.0", "dev": "vite dev --host 0.0.0.0",
"build": "vite build && tsc --noEmit", "build": "vite build && tsc --noEmit && bun scripts/generate-sw.mjs",
"start": "bun run server.ts" "start": "bun run server.ts"
}, },
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@mantine/carousel": "^8.2.4", "@mantine/carousel": "^8.2.4",
"@mantine/charts": "^8.2.4",
"@mantine/core": "^8.2.4", "@mantine/core": "^8.2.4",
"@mantine/dates": "^8.2.4", "@mantine/dates": "^8.2.4",
"@mantine/form": "^8.2.4", "@mantine/form": "^8.2.4",
"@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-devtools": "^0.7.6", "@tanstack/react-devtools": "^0.7.6",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.66.0", "@tanstack/react-query-devtools": "^5.101.2",
"@tanstack/react-router": "^1.143.6", "@tanstack/react-router": "^1.170.17",
"@tanstack/react-router-devtools": "^1.143.6", "@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/react-router-ssr-query": "^1.143.6", "@tanstack/react-router-ssr-query": "^1.167.1",
"@tanstack/react-start": "^1.143.6", "@tanstack/react-start": "^1.168.27",
"@tanstack/react-virtual": "^3.13.12",
"@tiptap/pm": "^3.4.3", "@tiptap/pm": "^3.4.3",
"@tiptap/react": "^3.4.3", "@tiptap/react": "^3.4.3",
"@tiptap/starter-kit": "^3.4.3", "@tiptap/starter-kit": "^3.4.3",
"@types/bun": "^1.2.22", "@types/bun": "^1.2.22",
"@types/ioredis": "^4.28.10",
"browser-image-compression": "^2.0.2", "browser-image-compression": "^2.0.2",
"dotenv": "^17.2.2", "dotenv": "^17.2.2",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"facehash": "^0.0.7", "facehash": "^0.0.7",
"framer-motion": "^12.23.12", "framer-motion": "^12.23.12",
"ioredis": "^5.7.0",
"pg": "^8.16.3",
"pocketbase": "^0.26.2", "pocketbase": "^0.26.2",
"react": "^19.0.0", "react": "^19.2.7",
"react-dom": "^19.0.0", "react-dom": "^19.2.7",
"react-imask": "^7.6.1", "react-imask": "^7.6.1",
"react-scan": "^0.4.3",
"react-use-draggable-scroll": "^0.4.7",
"recharts": "^3.1.2",
"redaxios": "^0.5.1", "redaxios": "^0.5.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"supertokens-node": "^23.0.1", "supertokens-node": "^23.0.1",
"supertokens-web-js": "^0.15.0", "supertokens-web-js": "^0.15.0",
"twilio": "^5.8.0", "twilio": "^5.8.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"xlsx": "^0.18.5", "zod": "^4.0.15"
"zod": "^4.0.15",
"zustand": "^5.0.7"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/router-plugin": "^1.132.2",
"@types/node": "^22.5.4", "@types/node": "^22.5.4",
"@types/pg": "^8.15.5", "@types/react": "^19.2.17",
"@types/react": "^19.0.8", "@types/react-dom": "^19.2.3",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "^1.0.0",
"dotenv-cli": "^10.0.0", "dotenv-cli": "^10.0.0",
"postcss": "^8.5.1", "postcss": "^8.5.1",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",
@@ -71,8 +59,7 @@
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^7.1.7", "vite": "^7.1.7",
"vite-plugin-pwa": "^1.2.0",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"workbox-window": "^7.4.0" "workbox-build": "^7.4.1"
} }
} }
@@ -0,0 +1,56 @@
/// <reference path="../pb_data/types.d.ts" />
// Rewrites the player stats views from a cartesian LIKE-join over
// players x teams x matches (which forced a full re-scan per row and took
// ~1.5-2s per request) to equi-joins driven by matches, with team rosters
// expanded once via json_each. Results are byte-identical; each view now
// runs in well under 100ms. The unary "+" in the regional filter prevents
// the query planner from picking a pathological join order.
migrate((app) => {
{
const collection = app.findCollectionByNameOrId("player_stats");
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(mt.match_id) as matches,\n COUNT(DISTINCT mt.tournament) as tournaments,\n SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) as wins,\n SUM(CASE WHEN mt.cups_for < mt.cups_against THEN 1 ELSE 0 END) as losses,\n SUM(mt.cups_for) as total_cups_made,\n SUM(mt.cups_against) as total_cups_against,\n ROUND((CAST(SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) AS REAL) / COUNT(mt.match_id)) * 100, 2) as win_percentage,\n ROUND(CAST(SUM(mt.cups_for) AS REAL) / COUNT(mt.match_id), 2) as avg_cups_per_match,\n ROUND(AVG(CASE WHEN mt.cups_for > mt.cups_against THEN mt.cups_for - mt.cups_against ELSE NULL END), 2) as margin_of_victory,\n ROUND(AVG(CASE WHEN mt.cups_for < mt.cups_against THEN mt.cups_against - mt.cups_for ELSE NULL END), 2) as margin_of_loss\n FROM (\n SELECT m.id as match_id, m.tournament as tournament, m.home as team_id, m.home_cups as cups_for, m.away_cups as cups_against\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended'\n UNION ALL\n SELECT m.id, m.tournament, m.away, m.away_cups, m.home_cups\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended'\n ) mt\n JOIN (\n SELECT teams.id AS team_id, je.value AS player_id\n FROM teams, json_each(ifnull(nullif(teams.players, ''), '[]')) je\n GROUP BY teams.id, je.value\n ) tp ON tp.team_id = mt.team_id\n JOIN players p ON p.id = tp.player_id\n GROUP BY p.id"
}, collection);
app.save(collection);
}
{
const collection = app.findCollectionByNameOrId("player_mainline_stats");
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(mt.match_id) as matches,\n COUNT(DISTINCT mt.tournament) as tournaments,\n SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) as wins,\n SUM(CASE WHEN mt.cups_for < mt.cups_against THEN 1 ELSE 0 END) as losses,\n SUM(mt.cups_for) as total_cups_made,\n SUM(mt.cups_against) as total_cups_against,\n ROUND((CAST(SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) AS REAL) / COUNT(mt.match_id)) * 100, 2) as win_percentage,\n ROUND(CAST(SUM(mt.cups_for) AS REAL) / COUNT(mt.match_id), 2) as avg_cups_per_match,\n ROUND(AVG(CASE WHEN mt.cups_for > mt.cups_against THEN mt.cups_for - mt.cups_against ELSE NULL END), 2) as margin_of_victory,\n ROUND(AVG(CASE WHEN mt.cups_for < mt.cups_against THEN mt.cups_against - mt.cups_for ELSE NULL END), 2) as margin_of_loss\n FROM (\n SELECT m.id as match_id, m.tournament as tournament, m.home as team_id, m.home_cups as cups_for, m.away_cups as cups_against\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND (tour.regional = false OR tour.regional IS NULL)\n UNION ALL\n SELECT m.id, m.tournament, m.away, m.away_cups, m.home_cups\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND (tour.regional = false OR tour.regional IS NULL)\n ) mt\n JOIN (\n SELECT teams.id AS team_id, je.value AS player_id\n FROM teams, json_each(ifnull(nullif(teams.players, ''), '[]')) je\n GROUP BY teams.id, je.value\n ) tp ON tp.team_id = mt.team_id\n JOIN players p ON p.id = tp.player_id\n GROUP BY p.id"
}, collection);
app.save(collection);
}
{
const collection = app.findCollectionByNameOrId("player_regional_stats");
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(mt.match_id) as matches,\n COUNT(DISTINCT mt.tournament) as tournaments,\n SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) as wins,\n SUM(CASE WHEN mt.cups_for < mt.cups_against THEN 1 ELSE 0 END) as losses,\n SUM(mt.cups_for) as total_cups_made,\n SUM(mt.cups_against) as total_cups_against,\n ROUND((CAST(SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) AS REAL) / COUNT(mt.match_id)) * 100, 2) as win_percentage,\n ROUND(CAST(SUM(mt.cups_for) AS REAL) / COUNT(mt.match_id), 2) as avg_cups_per_match,\n ROUND(AVG(CASE WHEN mt.cups_for > mt.cups_against THEN mt.cups_for - mt.cups_against ELSE NULL END), 2) as margin_of_victory,\n ROUND(AVG(CASE WHEN mt.cups_for < mt.cups_against THEN mt.cups_against - mt.cups_for ELSE NULL END), 2) as margin_of_loss\n FROM (\n SELECT m.id as match_id, m.tournament as tournament, m.home as team_id, m.home_cups as cups_for, m.away_cups as cups_against\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND +tour.regional = true\n UNION ALL\n SELECT m.id, m.tournament, m.away, m.away_cups, m.home_cups\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND +tour.regional = true\n ) mt\n JOIN (\n SELECT teams.id AS team_id, je.value AS player_id\n FROM teams, json_each(ifnull(nullif(teams.players, ''), '[]')) je\n GROUP BY teams.id, je.value\n ) tp ON tp.team_id = mt.team_id\n JOIN players p ON p.id = tp.player_id\n GROUP BY p.id"
}, collection);
app.save(collection);
}
}, (app) => {
{
const collection = app.findCollectionByNameOrId("player_stats");
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);
app.save(collection);
}
{
const collection = app.findCollectionByNameOrId("player_mainline_stats");
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' AND\n (tour.regional = false OR tour.regional IS NULL)\n GROUP BY p.id"
}, collection);
app.save(collection);
}
{
const collection = app.findCollectionByNameOrId("player_regional_stats");
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' AND\n tour.regional = true\n GROUP BY p.id"
}, collection);
app.save(collection);
}
})
@@ -0,0 +1,95 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "pbc_340646327",
"hidden": false,
"id": "relation3177167065",
"maxSelect": 1,
"minSelect": 0,
"name": "tournament",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"cascadeDelete": true,
"collectionId": "pbc_3072146508",
"hidden": false,
"id": "relation2551806565",
"maxSelect": 1,
"minSelect": 0,
"name": "player",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "json2153001328",
"maxSize": 0,
"name": "picks",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"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_1784007613",
"indexes": [
"CREATE UNIQUE INDEX `idx_predictions_tournament_player` ON `predictions` (`tournament`, `player`)"
],
"listRule": null,
"name": "predictions",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1784007613");
return app.delete(collection);
})
+2 -2
View File
@@ -18,8 +18,8 @@
], ],
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"theme_color": "#1e293b", "theme_color": "#242424",
"background_color": "#0f172a", "background_color": "#242424",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"scope": "/", "scope": "/",
"categories": ["games", "social", "beer pong"], "categories": ["games", "social", "beer pong"],
Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 31 KiB

+24
View File
@@ -29,3 +29,27 @@
[data-drawer-level="3"].drawer-scaling { [data-drawer-level="3"].drawer-scaling {
transform: scale(0.90) translateY(-4px); transform: scale(0.90) translateY(-4px);
} }
@media (prefers-reduced-motion: reduce) {
.app,
[data-drawer-level] {
transition: none;
}
.app.drawer-scaling,
[data-drawer-level].drawer-scaling {
transform: none;
border-radius: 0;
}
[data-vaul-drawer],
[data-vaul-overlay] {
animation: none !important;
transition: none !important;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(0deg); }
}
}
+73
View File
@@ -0,0 +1,73 @@
import { generateSW } from 'workbox-build'
const ONE_YEAR = 60 * 60 * 24 * 365
const THIRTY_DAYS = 60 * 60 * 24 * 30
const { count, size, warnings } = await generateSW({
swDest: 'dist/client/sw.js',
globDirectory: 'dist/client',
globPatterns: [
'assets/**/*.{js,css}',
'favicon*.{ico,png}',
'apple-touch-icon.png',
'icon-192x192.png',
'icon-512x512.png',
'site.webmanifest',
'styles.css',
],
navigateFallback: null,
skipWaiting: true,
clientsClaim: true,
cleanupOutdatedCaches: true,
sourcemap: false,
runtimeCaching: [
{
urlPattern: ({ url, request }) =>
url.origin === self.location.origin && request.destination === 'image',
handler: 'CacheFirst',
options: {
cacheName: 'images-cache',
expiration: {
maxEntries: 60,
maxAgeSeconds: THIRTY_DAYS,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: ONE_YEAR,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'gstatic-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: ONE_YEAR,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
})
for (const warning of warnings) console.warn(warning)
console.log(
`sw.js generated: precached ${count} files, ${(size / 1024).toFixed(1)} KiB`,
)
+7 -9
View File
@@ -343,7 +343,10 @@ async function initializeStaticRoutes(
gz, gz,
etag, etag,
type: metadata.type, type: metadata.type,
immutable: true, // Only Vite's content-hashed output under /assets/ is safe to
// cache immutably; unhashed files (favicon, manifest, images)
// can change between deploys and must revalidate.
immutable: route.startsWith('/assets/'),
size: bytes.byteLength, size: bytes.byteLength,
} }
routes[route] = createResponseHandler(asset) routes[route] = createResponseHandler(asset)
@@ -529,14 +532,9 @@ async function initializeServer() {
// Fallback to TanStack Start handler for all other routes // Fallback to TanStack Start handler for all other routes
'/*': async (req: Request) => { '/*': async (req: Request) => {
try { try {
const h3Response = await handler.fetch(req) // Return the handler's Response as-is so streaming bodies
// (SSR streaming, SSE event streams) pass through unbuffered.
const body = await h3Response.arrayBuffer() return await handler.fetch(req)
return new Response(body, {
status: h3Response.status,
statusText: h3Response.statusText,
headers: h3Response.headers,
})
} catch (error) { } catch (error) {
log.error(`Server handler error: ${String(error)}`) log.error(`Server handler error: ${String(error)}`)
return new Response('Internal Server Error', { status: 500 }) return new Response('Internal Server Error', { status: 500 })
+68
View File
@@ -38,10 +38,13 @@ import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/p
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges' import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities' import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index' import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
import { Route as AuthedTournamentsIdPredictionsRouteImport } from './routes/_authed/tournaments/$id.predictions'
import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups' import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket' import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index' import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file' import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
import { Route as AuthedTournamentsIdPredictionsMakeRouteImport } from './routes/_authed/tournaments/$id.predictions_.make'
import { Route as AuthedTournamentsIdPredictionsPlayerIdRouteImport } from './routes/_authed/tournaments/$id.predictions_.$playerId'
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id' import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams' import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners' import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners'
@@ -193,6 +196,12 @@ const AuthedAdminTournamentsIndexRoute =
path: '/tournaments/', path: '/tournaments/',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const AuthedTournamentsIdPredictionsRoute =
AuthedTournamentsIdPredictionsRouteImport.update({
id: '/tournaments/$id/predictions',
path: '/tournaments/$id/predictions',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedTournamentsIdGroupsRoute = const AuthedTournamentsIdGroupsRoute =
AuthedTournamentsIdGroupsRouteImport.update({ AuthedTournamentsIdGroupsRouteImport.update({
id: '/tournaments/$id/groups', id: '/tournaments/$id/groups',
@@ -217,6 +226,18 @@ const ApiFilesCollectionRecordIdFileRoute =
path: '/api/files/$collection/$recordId/$file', path: '/api/files/$collection/$recordId/$file',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AuthedTournamentsIdPredictionsMakeRoute =
AuthedTournamentsIdPredictionsMakeRouteImport.update({
id: '/tournaments/$id/predictions_/make',
path: '/tournaments/$id/predictions/make',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedTournamentsIdPredictionsPlayerIdRoute =
AuthedTournamentsIdPredictionsPlayerIdRouteImport.update({
id: '/tournaments/$id/predictions_/$playerId',
path: '/tournaments/$id/predictions/$playerId',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedAdminTournamentsRunIdRoute = const AuthedAdminTournamentsRunIdRoute =
AuthedAdminTournamentsRunIdRouteImport.update({ AuthedAdminTournamentsRunIdRouteImport.update({
id: '/tournaments/run/$id', id: '/tournaments/run/$id',
@@ -266,10 +287,13 @@ export interface FileRoutesByFullPath {
'/tournaments/': typeof AuthedTournamentsIndexRoute '/tournaments/': typeof AuthedTournamentsIndexRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
'/tournaments/$id/predictions/make': typeof AuthedTournamentsIdPredictionsMakeRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute '/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
} }
@@ -302,10 +326,13 @@ export interface FileRoutesByTo {
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
'/tournaments/$id/predictions/make': typeof AuthedTournamentsIdPredictionsMakeRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute '/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
} }
@@ -341,10 +368,13 @@ export interface FileRoutesById {
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/_authed/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/_authed/tournaments/$id/predictions_/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
'/_authed/tournaments/$id/predictions_/make': typeof AuthedTournamentsIdPredictionsMakeRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute '/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
} }
@@ -380,10 +410,13 @@ export interface FileRouteTypes {
| '/tournaments/' | '/tournaments/'
| '/tournaments/$id/bracket' | '/tournaments/$id/bracket'
| '/tournaments/$id/groups' | '/tournaments/$id/groups'
| '/tournaments/$id/predictions'
| '/admin/tournaments/' | '/admin/tournaments/'
| '/admin/tournaments/$id/assign-partners' | '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/teams' | '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
| '/tournaments/$id/predictions/$playerId'
| '/tournaments/$id/predictions/make'
| '/api/files/$collection/$recordId/$file' | '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id/' | '/admin/tournaments/$id/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
@@ -416,10 +449,13 @@ export interface FileRouteTypes {
| '/tournaments' | '/tournaments'
| '/tournaments/$id/bracket' | '/tournaments/$id/bracket'
| '/tournaments/$id/groups' | '/tournaments/$id/groups'
| '/tournaments/$id/predictions'
| '/admin/tournaments' | '/admin/tournaments'
| '/admin/tournaments/$id/assign-partners' | '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/teams' | '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
| '/tournaments/$id/predictions/$playerId'
| '/tournaments/$id/predictions/make'
| '/api/files/$collection/$recordId/$file' | '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id' | '/admin/tournaments/$id'
id: id:
@@ -454,10 +490,13 @@ export interface FileRouteTypes {
| '/_authed/tournaments/' | '/_authed/tournaments/'
| '/_authed/tournaments/$id/bracket' | '/_authed/tournaments/$id/bracket'
| '/_authed/tournaments/$id/groups' | '/_authed/tournaments/$id/groups'
| '/_authed/tournaments/$id/predictions'
| '/_authed/admin/tournaments/' | '/_authed/admin/tournaments/'
| '/_authed/admin/tournaments/$id/assign-partners' | '/_authed/admin/tournaments/$id/assign-partners'
| '/_authed/admin/tournaments/$id/teams' | '/_authed/admin/tournaments/$id/teams'
| '/_authed/admin/tournaments/run/$id' | '/_authed/admin/tournaments/run/$id'
| '/_authed/tournaments/$id/predictions_/$playerId'
| '/_authed/tournaments/$id/predictions_/make'
| '/api/files/$collection/$recordId/$file' | '/api/files/$collection/$recordId/$file'
| '/_authed/admin/tournaments/$id/' | '/_authed/admin/tournaments/$id/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
@@ -686,6 +725,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
'/_authed/tournaments/$id/predictions': {
id: '/_authed/tournaments/$id/predictions'
path: '/tournaments/$id/predictions'
fullPath: '/tournaments/$id/predictions'
preLoaderRoute: typeof AuthedTournamentsIdPredictionsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/tournaments/$id/groups': { '/_authed/tournaments/$id/groups': {
id: '/_authed/tournaments/$id/groups' id: '/_authed/tournaments/$id/groups'
path: '/tournaments/$id/groups' path: '/tournaments/$id/groups'
@@ -714,6 +760,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/_authed/tournaments/$id/predictions_/make': {
id: '/_authed/tournaments/$id/predictions_/make'
path: '/tournaments/$id/predictions/make'
fullPath: '/tournaments/$id/predictions/make'
preLoaderRoute: typeof AuthedTournamentsIdPredictionsMakeRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/tournaments/$id/predictions_/$playerId': {
id: '/_authed/tournaments/$id/predictions_/$playerId'
path: '/tournaments/$id/predictions/$playerId'
fullPath: '/tournaments/$id/predictions/$playerId'
preLoaderRoute: typeof AuthedTournamentsIdPredictionsPlayerIdRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/admin/tournaments/run/$id': { '/_authed/admin/tournaments/run/$id': {
id: '/_authed/admin/tournaments/run/$id' id: '/_authed/admin/tournaments/run/$id'
path: '/tournaments/run/$id' path: '/tournaments/run/$id'
@@ -779,6 +839,9 @@ interface AuthedRouteChildren {
AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute
AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute
AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute
AuthedTournamentsIdPredictionsRoute: typeof AuthedTournamentsIdPredictionsRoute
AuthedTournamentsIdPredictionsPlayerIdRoute: typeof AuthedTournamentsIdPredictionsPlayerIdRoute
AuthedTournamentsIdPredictionsMakeRoute: typeof AuthedTournamentsIdPredictionsMakeRoute
} }
const AuthedRouteChildren: AuthedRouteChildren = { const AuthedRouteChildren: AuthedRouteChildren = {
@@ -793,6 +856,11 @@ const AuthedRouteChildren: AuthedRouteChildren = {
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute, AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute, AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute,
AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute, AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute,
AuthedTournamentsIdPredictionsRoute: AuthedTournamentsIdPredictionsRoute,
AuthedTournamentsIdPredictionsPlayerIdRoute:
AuthedTournamentsIdPredictionsPlayerIdRoute,
AuthedTournamentsIdPredictionsMakeRoute:
AuthedTournamentsIdPredictionsMakeRoute,
} }
const AuthedRouteWithChildren = const AuthedRouteWithChildren =
+1 -2
View File
@@ -29,9 +29,8 @@ export function getRouter() {
fullWidth: false, fullWidth: false,
}, },
defaultPreload: "intent", defaultPreload: "intent",
defaultPreloadStaleTime: 60_000,
defaultErrorComponent: DefaultCatchBoundary, defaultErrorComponent: DefaultCatchBoundary,
scrollRestoration: true,
defaultViewTransition: false,
}); });
setupRouterSsrQueryIntegration({ setupRouterSsrQueryIntegration({
+19 -3
View File
@@ -4,6 +4,7 @@ import {
Outlet, Outlet,
Scripts, Scripts,
createRootRouteWithContext, createRootRouteWithContext,
isRedirect,
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import * as React from "react"; import * as React from "react";
import { DefaultCatchBoundary } from "@/components/DefaultCatchBoundary"; import { DefaultCatchBoundary } from "@/components/DefaultCatchBoundary";
@@ -18,6 +19,7 @@ import { HeaderConfig } from "@/features/core/types/header-config";
import { playerQueries } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import FullScreenLoader from "@/components/full-screen-loader"; import FullScreenLoader from "@/components/full-screen-loader";
import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
import mantineCssUrl from '@mantine/core/styles.css?url' import mantineCssUrl from '@mantine/core/styles.css?url'
import mantineDatesCssUrl from '@mantine/dates/styles.css?url' import mantineDatesCssUrl from '@mantine/dates/styles.css?url'
import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url' import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url'
@@ -40,11 +42,10 @@ export const Route = createRootRouteWithContext<{
{ {
name: "viewport", name: "viewport",
content: content:
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content", "width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content, viewport-fit=cover",
}, },
{ name: 'description', content: 'Amicus meus madidus' }, { name: 'description', content: 'Amicus meus madidus' },
{ name: 'keywords', content: 'FLXN, beer pong, tournament, sports, statistics, pong' }, { name: 'keywords', content: 'FLXN, beer pong, tournament, sports, statistics, pong' },
{ name: 'theme-color', content: '#1e293b' },
{ property: 'og:title', content: 'FLXN' }, { property: 'og:title', content: 'FLXN' },
{ property: 'og:description', content: 'Amicus meus madidus' }, { property: 'og:description', content: 'Amicus meus madidus' },
{ property: 'og:url', content: 'https://flexxon.app' }, { property: 'og:url', content: 'https://flexxon.app' },
@@ -61,7 +62,7 @@ export const Route = createRootRouteWithContext<{
{ name: 'twitter:image', content: 'https://flexxon.app/favicon.png' }, { name: 'twitter:image', content: 'https://flexxon.app/favicon.png' },
{ name: 'mobile-web-app-capable', content: 'yes' }, { name: 'mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-capable', content: 'yes' }, { name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' }, { name: 'apple-mobile-web-app-status-bar-style', content: 'default' },
{ name: 'apple-mobile-web-app-title', content: 'FLXN' }, { name: 'apple-mobile-web-app-title', content: 'FLXN' },
], ],
links: [ links: [
@@ -124,6 +125,8 @@ export const Route = createRootRouteWithContext<{
); );
return { auth }; return { auth };
} catch (error: any) { } catch (error: any) {
if (isRedirect(error) || error instanceof Response) throw error;
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const { doesSessionExist, attemptRefreshingSession } = await import('supertokens-web-js/recipe/session'); const { doesSessionExist, attemptRefreshingSession } = await import('supertokens-web-js/recipe/session');
@@ -150,6 +153,9 @@ export const Route = createRootRouteWithContext<{
function RootComponent() { function RootComponent() {
React.useEffect(() => { React.useEffect(() => {
ensureSuperTokensFrontend(); ensureSuperTokensFrontend();
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
}, []); }, []);
return ( return (
@@ -176,6 +182,16 @@ function RootDocument({ children }: { children: React.ReactNode }) {
> >
<head> <head>
<HeadContent /> <HeadContent />
<meta
name="theme-color"
media="(prefers-color-scheme: light)"
content={CHROME_COLORS.light.base}
/>
<meta
name="theme-color"
media="(prefers-color-scheme: dark)"
content={CHROME_COLORS.dark.base}
/>
<ColorSchemeScript /> <ColorSchemeScript />
<link rel="stylesheet" href="/styles.css" /> <link rel="stylesheet" href="/styles.css" />
</head> </head>
+16 -4
View File
@@ -1,7 +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 { Flex, Loader } from "@mantine/core"; import { Group, Skeleton, Stack } from "@mantine/core";
export const Route = createFileRoute("/_authed")({ export const Route = createFileRoute("/_authed")({
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
@@ -26,9 +26,21 @@ export const Route = createFileRoute("/_authed")({
}, },
pendingComponent: () => ( pendingComponent: () => (
<Layout> <Layout>
<Flex w='100%' h="40dvh" justify="center" align="flex-end"> <Stack gap="md" p="md" w="100%">
<Loader size='xl' /> <Group gap="sm">
</Flex> <Skeleton height={40} width={40} radius="sm" />
<Skeleton height={24} width="45%" radius="sm" />
</Group>
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={`authed-pending-${index}`}
height={96}
w="100%"
radius="md"
style={{ opacity: Math.max(1 - index * 0.18, 0.3) }}
/>
))}
</Stack>
</Layout> </Layout>
), ),
}); });
+30 -2
View File
@@ -2,8 +2,8 @@ import { createFileRoute } from "@tanstack/react-router";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { ActivitiesTable, activityQueries } from "@/features/activities"; import { ActivitiesTable, activityQueries } from "@/features/activities";
import { PlayersActivityTable, playerQueries } from "@/features/players"; import { PlayersActivityTable, playerQueries } from "@/features/players";
import { Tabs } from "@mantine/core"; import { Box, Divider, Group, Skeleton, Stack, Tabs } from "@mantine/core";
import { useState } from "react"; import { Suspense, useState } from "react";
export const Route = createFileRoute("/_authed/admin/activities")({ export const Route = createFileRoute("/_authed/admin/activities")({
component: Stats, component: Stats,
@@ -23,6 +23,30 @@ export const Route = createFileRoute("/_authed/admin/activities")({
}), }),
}); });
function ActivityRowsSkeleton({ withSearch = false }: { withSearch?: boolean }) {
return (
<Stack gap={0}>
{withSearch && (
<Box px="md" pb="sm">
<Skeleton height={42} radius="sm" />
</Box>
)}
{Array.from({ length: 8 }).map((_, index) => (
<div key={`activity-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.1, 0.35) }}>
<Group p="md" wrap="nowrap" w="100%" justify="space-between">
<Stack gap={6}>
<Skeleton height={14} width={160} radius="sm" />
<Skeleton height={10} width={100} radius="sm" />
</Stack>
<Skeleton height={10} width={60} radius="sm" />
</Group>
<Divider />
</div>
))}
</Stack>
);
}
function Stats() { function Stats() {
const [activeTab, setActiveTab] = useState<string | null>("server-functions"); const [activeTab, setActiveTab] = useState<string | null>("server-functions");
@@ -34,11 +58,15 @@ function Stats() {
</Tabs.List> </Tabs.List>
<Tabs.Panel value="server-functions"> <Tabs.Panel value="server-functions">
<Suspense fallback={<ActivityRowsSkeleton withSearch />}>
<ActivitiesTable /> <ActivitiesTable />
</Suspense>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="player-activity"> <Tabs.Panel value="player-activity">
<Suspense fallback={<ActivityRowsSkeleton />}>
<PlayersActivityTable /> <PlayersActivityTable />
</Suspense>
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>
); );
@@ -1,7 +1,7 @@
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries"; import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Stack, Text, Button, Alert, LoadingOverlay, Group } from "@mantine/core"; import { Stack, Text, Button, Alert, LoadingOverlay, Group, Skeleton } from "@mantine/core";
import { useState } from "react"; import { useState } from "react";
import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams"; import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams";
import useConfirmTeamAssignments from "@/features/tournaments/hooks/use-confirm-team-assignments"; import useConfirmTeamAssignments from "@/features/tournaments/hooks/use-confirm-team-assignments";
@@ -27,8 +27,23 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/assign-part
}, },
}), }),
component: RouteComponent, component: RouteComponent,
pendingComponent: AssignPartnersPending,
}); });
function AssignPartnersPending() {
return (
<Stack gap="lg">
<Stack gap="xs">
<Group gap="xs" align="baseline">
<Skeleton height={28} width={36} radius="sm" />
<Skeleton height={14} width={110} radius="sm" />
</Group>
<Skeleton height={36} w="100%" radius="sm" style={{ opacity: 0.7 }} />
</Stack>
</Stack>
);
}
interface TeamAssignment { interface TeamAssignment {
player1: PlayerInfo; player1: PlayerInfo;
player2: PlayerInfo; player2: PlayerInfo;
@@ -2,6 +2,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries"; import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTournament from "@/features/tournaments/components/manage-tournament"; import ManageTournament from "@/features/tournaments/components/manage-tournament";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Divider, Group, Skeleton, Stack } from "@mantine/core";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
@@ -23,8 +24,26 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
withPadding: false, withPadding: false,
}), }),
component: RouteComponent, component: RouteComponent,
pendingComponent: ManageTournamentPending,
}); });
function ManageTournamentPending() {
return (
<Stack gap={0}>
{Array.from({ length: 5 }).map((_, index) => (
<div key={`manage-tournament-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.14, 0.4) }}>
<Group p="md" wrap="nowrap" w="100%">
<Skeleton height={20} width={20} radius="sm" />
<Skeleton height={16} width="45%" radius="sm" />
<Skeleton ml="auto" height={20} width={20} radius="sm" />
</Group>
<Divider />
</div>
))}
</Stack>
);
}
function RouteComponent() { function RouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
return <ManageTournament tournamentId={id} />; return <ManageTournament tournamentId={id} />;
@@ -2,6 +2,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries"; import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTeams from "@/features/teams/components/manage-teams"; import ManageTeams from "@/features/teams/components/manage-teams";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Box, Divider, Group, Skeleton, Stack } from "@mantine/core";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({ export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
@@ -23,8 +24,37 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
withPadding: false, withPadding: false,
}), }),
component: RouteComponent, component: RouteComponent,
pendingComponent: ManageTeamsPending,
}); });
function ManageTeamsPending() {
return (
<Stack gap="xs">
<Box px="md">
<Skeleton height={42} radius="sm" />
</Box>
<Box px="md">
<Skeleton height={12} width={90} radius="sm" />
</Box>
<Stack gap={0}>
{Array.from({ length: 8 }).map((_, index) => (
<div key={`manage-teams-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.1, 0.35) }}>
<Group p="xs" wrap="nowrap" w="100%">
<Skeleton height={40} width={40} radius="sm" />
<Skeleton height={16} width="45%" radius="sm" />
<Stack ml="auto" gap={6} align="flex-end">
<Skeleton height={10} width={90} radius="sm" />
<Skeleton height={10} width={70} radius="sm" />
</Stack>
</Group>
<Divider />
</div>
))}
</Stack>
</Stack>
);
}
function RouteComponent() { function RouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { tournament } = Route.useRouteContext(); const { tournament } = Route.useRouteContext();
@@ -1,12 +1,14 @@
import ManageTournaments from "@/features/admin/components/manage-tournaments"; import ManageTournaments from "@/features/admin/components/manage-tournaments";
import { tournamentQueries } from "@/features/tournaments/queries"; import { tournamentQueries } from "@/features/tournaments/queries";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { Divider, Group, Skeleton, Stack } from "@mantine/core";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
export const Route = createFileRoute("/_authed/admin/tournaments/")({ export const Route = createFileRoute("/_authed/admin/tournaments/")({
beforeLoad: async ({ context }) => { beforeLoad: ({ context }) => {
const { queryClient } = context; const { queryClient } = context;
await prefetchServerQuery(queryClient, tournamentQueries.list()); prefetchServerQuery(queryClient, tournamentQueries.list());
}, },
loader: () => ({ loader: () => ({
header: { header: {
@@ -19,6 +21,26 @@ export const Route = createFileRoute("/_authed/admin/tournaments/")({
component: RouteComponent, component: RouteComponent,
}); });
function TournamentListSkeleton() {
return (
<Stack gap={0}>
{Array.from({ length: 6 }).map((_, index) => (
<div key={`manage-tournaments-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}>
<Group p="md" wrap="nowrap" w="100%">
<Skeleton height={16} width="55%" radius="sm" />
<Skeleton ml="auto" height={20} width={20} radius="sm" />
</Group>
<Divider />
</div>
))}
</Stack>
);
}
function RouteComponent() { function RouteComponent() {
return <ManageTournaments />; return (
<Suspense fallback={<TournamentListSkeleton />}>
<ManageTournaments />
</Suspense>
);
} }
@@ -7,7 +7,7 @@ import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import SeedTournament from "@/features/tournaments/components/seed-tournament"; import SeedTournament from "@/features/tournaments/components/seed-tournament";
import SetupGroupStage from "@/features/tournaments/components/setup-group-stage"; import SetupGroupStage from "@/features/tournaments/components/setup-group-stage";
import GroupStageView from "@/features/tournaments/components/group-stage-view"; import GroupStageView from "@/features/tournaments/components/group-stage-view";
import { Container, Stack, Divider, Title } from "@mantine/core"; import { Container, Stack, Divider, Title, Box, Card, Group, Skeleton, SimpleGrid } from "@mantine/core";
import { useMemo } from "react"; import { useMemo } from "react";
import { BracketData } from "@/features/bracket/types"; import { BracketData } from "@/features/bracket/types";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
@@ -37,8 +37,46 @@ export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
}, },
}), }),
component: RouteComponent, component: RouteComponent,
pendingComponent: RunTournamentPending,
}); });
function RunTournamentPending() {
return (
<Container size="md" px={0}>
<Box p="md">
<Stack gap="md">
<Group gap={0} grow mb="md">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={`run-pending-tab-${index}`}
height={44}
radius="sm"
style={{ opacity: 0.85 - index * 0.1 }}
/>
))}
</Group>
<Card withBorder radius="md" p={0}>
<Group justify="space-between" p="sm">
<Skeleton height={16} width={120} radius="sm" />
<Skeleton height={22} width={22} radius="xl" />
</Group>
</Card>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton
key={`run-pending-match-${index}`}
height={100}
radius="md"
style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}
/>
))}
</SimpleGrid>
</Stack>
</Box>
</Container>
);
}
function RouteComponent() { function RouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { data: tournament } = useTournament(id); const { data: tournament } = useTournament(id);
+1 -1
View File
@@ -64,7 +64,7 @@ function Stats() {
</Button> </Button>
</Group> </Group>
<Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}> <Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
<Suspense key={deferredViewType} fallback={<PlayerStatsTableSkeleton hideFilters />}> <Suspense fallback={<PlayerStatsTableSkeleton hideFilters />}>
<PlayerStatsTable viewType={deferredViewType} /> <PlayerStatsTable viewType={deferredViewType} />
</Suspense> </Suspense>
</Box> </Box>
@@ -7,8 +7,9 @@ import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Container } from "@mantine/core"; import { Container } from "@mantine/core";
import { useMemo } from "react"; import { useMemo } from "react";
import { BracketData } from "@/features/bracket/types"; import { BracketData } from "@/features/bracket/types";
import { Match } from "@/features/matches/types"; import { groupMatchesIntoBracket } from "@/features/bracket/utils/group";
import BracketView from "@/features/bracket/components/bracket-view"; import BracketView from "@/features/bracket/components/bracket-view";
import { BracketPending } from "@/features/bracket/components/bracket-pending";
export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
@@ -31,46 +32,17 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
}, },
}), }),
component: RouteComponent, component: RouteComponent,
pendingComponent: BracketPending,
}); });
function RouteComponent() { function RouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { data: tournament } = useTournament(id); const { data: tournament } = useTournament(id);
const bracket: BracketData = useMemo(() => { const bracket: BracketData = useMemo(
if (!tournament.matches || tournament.matches.length === 0) { () => groupMatchesIntoBracket(tournament.matches),
return { winners: [], losers: [] }; [tournament.matches]
} );
const winnersMap = new Map<number, Match[]>();
const losersMap = new Map<number, Match[]>();
tournament.matches
.filter((match) => match.round !== -1)
.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 ( return (
<Container size="md" px={0}> <Container size="md" px={0}>
@@ -5,7 +5,7 @@ import {
} from "@/features/tournaments/queries"; } from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import GroupStageView from "@/features/tournaments/components/group-stage-view"; import GroupStageView from "@/features/tournaments/components/group-stage-view";
import { Container } from "@mantine/core"; import { Box, Card, Container, Group, Skeleton, SimpleGrid, Stack } from "@mantine/core";
export const Route = createFileRoute("/_authed/tournaments/$id/groups")({ export const Route = createFileRoute("/_authed/tournaments/$id/groups")({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
@@ -28,8 +28,46 @@ export const Route = createFileRoute("/_authed/tournaments/$id/groups")({
}, },
}), }),
component: RouteComponent, component: RouteComponent,
pendingComponent: GroupsPending,
}); });
function GroupsPending() {
return (
<Container size="md" px={0}>
<Box p="md">
<Stack gap="md">
<Group gap={0} grow mb="md">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={`groups-pending-tab-${index}`}
height={44}
radius="sm"
style={{ opacity: 0.85 - index * 0.1 }}
/>
))}
</Group>
<Card withBorder radius="md" p={0}>
<Group justify="space-between" p="sm">
<Skeleton height={16} width={120} radius="sm" />
<Skeleton height={22} width={22} radius="xl" />
</Group>
</Card>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton
key={`groups-pending-match-${index}`}
height={100}
radius="md"
style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}
/>
))}
</SimpleGrid>
</Stack>
</Box>
</Container>
);
}
function RouteComponent() { function RouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { data: tournament } = useTournament(id); const { data: tournament } = useTournament(id);
@@ -0,0 +1,37 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Container } from "@mantine/core";
import { PredictionLeaderboard } from "@/features/predictions/components/prediction-leaderboard";
export const Route = createFileRoute("/_authed/tournaments/$id/predictions")({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
const tournament = await ensureServerQueryData(
queryClient,
tournamentQueries.details(params.id)
);
if (!tournament) throw redirect({ to: "/tournaments" });
return {
tournament,
};
},
loader: () => ({
header: {
withBackButton: true,
title: "Predictions",
},
}),
component: RouteComponent,
});
function RouteComponent() {
const { id } = Route.useParams();
const { data: tournament } = useTournament(id);
return (
<Container size="md" px={0}>
<PredictionLeaderboard tournament={tournament} />
</Container>
);
}
@@ -0,0 +1,124 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useMemo } from "react";
import { Box, Container, Group, Paper, Stack, Text } from "@mantine/core";
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
import { predictionQueries, usePlayerPrediction } from "@/features/predictions/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { BracketPending } from "@/features/bracket/components/bracket-pending";
import { PredictionBracket } from "@/features/predictions/components/prediction-bracket";
import { computePredictionScore } from "@/features/predictions/utils";
import PlayerAvatar from "@/components/player-avatar";
export const Route = createFileRoute(
"/_authed/tournaments/$id/predictions_/$playerId"
)({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
const tournament = await ensureServerQueryData(
queryClient,
tournamentQueries.details(params.id)
);
if (!tournament) throw redirect({ to: "/tournaments" });
const prediction = await ensureServerQueryData(
queryClient,
predictionQueries.player(params.id, params.playerId)
);
if (!prediction) {
throw redirect({
to: "/tournaments/$id/predictions",
params: { id: params.id },
});
}
return {
tournament,
prediction,
};
},
loader: ({ context }) => ({
fullWidth: true,
withPadding: false,
header: {
withBackButton: true,
title: `${context.prediction.player.first_name}'s Bracket`,
},
}),
component: RouteComponent,
pendingComponent: BracketPending,
});
function RouteComponent() {
const { id, playerId } = Route.useParams();
const { data: tournament } = useTournament(id);
const { data: prediction } = usePlayerPrediction(id, playerId);
const matches = tournament.matches || [];
const picks = prediction?.picks ?? {};
const score = useMemo(
() => computePredictionScore(matches, picks),
[matches, picks]
);
return (
<Container size="md" px={0}>
<Box pos="relative">
<PredictionBracket
matches={matches}
picks={picks}
mode="view"
perMatch={score.perMatch}
/>
<Box
pos="absolute"
left={0}
right={0}
bottom={0}
p="md"
style={{ zIndex: 2, pointerEvents: "none" }}
>
<Paper
withBorder
shadow="md"
radius="lg"
p="sm"
style={{ pointerEvents: "auto" }}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="sm" align="center" wrap="nowrap">
<PlayerAvatar
name={`${prediction?.player.first_name} ${prediction?.player.last_name}`}
size={32}
disableFullscreen
/>
<Text size="sm" fw={600} lineClamp={1}>
{prediction?.player.first_name} {prediction?.player.last_name}
</Text>
</Group>
<Group gap="md" wrap="nowrap">
<Stack gap={0} ta="center">
<Text size="xs" c="dimmed" fw={700}>
PTS
</Text>
<Text size="sm" fw={700}>
{score.points}
</Text>
</Stack>
<Stack gap={0} ta="center">
<Text size="xs" c="dimmed" fw={700}>
PICKS
</Text>
<Text size="xs" c="dimmed">
{score.correct}/{score.total}
</Text>
</Stack>
</Group>
</Group>
</Paper>
</Box>
</Box>
</Container>
);
}
@@ -0,0 +1,60 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
import { predictionQueries, useMyPrediction } from "@/features/predictions/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Container } from "@mantine/core";
import { BracketPending } from "@/features/bracket/components/bracket-pending";
import { PredictionEditor } from "@/features/predictions/components/prediction-editor";
export const Route = createFileRoute(
"/_authed/tournaments/$id/predictions_/make"
)({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
const tournament = await ensureServerQueryData(
queryClient,
tournamentQueries.details(params.id)
);
if (!tournament) throw redirect({ to: "/tournaments" });
const myPrediction = await ensureServerQueryData(
queryClient,
predictionQueries.mine(params.id)
);
if (!myPrediction.eligible || myPrediction.locked) {
throw redirect({
to: "/tournaments/$id/predictions",
params: { id: params.id },
});
}
return {
tournament,
};
},
loader: ({ context }) => ({
fullWidth: true,
withPadding: false,
header: {
withBackButton: true,
title: `${context.tournament.name}`,
},
}),
component: RouteComponent,
pendingComponent: BracketPending,
});
function RouteComponent() {
const { id } = Route.useParams();
const { data: tournament } = useTournament(id);
const { data: myPrediction } = useMyPrediction(id);
return (
<Container size="md" px={0}>
<PredictionEditor
tournament={tournament}
initialPicks={myPrediction.prediction?.picks ?? {}}
/>
</Container>
);
}
+7 -1
View File
@@ -23,7 +23,13 @@ export const Route = createFileRoute('/_authed/tournaments/')({
function RouteComponent() { function RouteComponent() {
return <Suspense fallback={<Stack gap="md"> return <Suspense fallback={<Stack gap="md">
{Array(10).fill(null).map((_, index) => ( {Array(10).fill(null).map((_, index) => (
<Skeleton height="120px" w="100%" /> <Skeleton
key={`tournament-card-skeleton-${index}`}
height="120px"
w="100%"
radius="md"
style={{ opacity: Math.max(1 - index * 0.08, 0.35) }}
/>
))} ))}
</Stack>}> </Stack>}>
<TournamentCardList /> <TournamentCardList />
+25 -46
View File
@@ -1,9 +1,10 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { serverEvents, type ServerEvent } from "@/lib/events/emitter"; import { serverEvents, EVENT_TYPES, type ServerEvent } from "@/lib/events/emitter";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { superTokensRequestMiddleware } from "@/utils/supertokens"; import { superTokensRequestMiddleware } from "@/utils/supertokens";
let activeConnections = 0; let activeConnections = 0;
const encoder = new TextEncoder();
export const Route = createFileRoute("/api/events/$")({ export const Route = createFileRoute("/api/events/$")({
server: { server: {
@@ -13,63 +14,47 @@ export const Route = createFileRoute("/api/events/$")({
activeConnections++; activeConnections++;
const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
logger.info(`ServerEvents | New connection ${connectionId}. Active: ${activeConnections}`); logger.info(`ServerEvents | New connection ${connectionId}. Active: ${activeConnections}`);
let cleanedUp = false;
let cleanup = () => {};
const stream = new ReadableStream({ const stream = new ReadableStream({
start(controller) { start(controller) {
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`; const send = (payload: unknown) => {
controller.enqueue(new TextEncoder().encode(connectMessage));
const handleEvent = (event: ServerEvent) => {
logger.info("ServerEvents | Event received", event);
const message = `data: ${JSON.stringify(event)}\n\n`;
try { try {
if (!controller.desiredSize || controller.desiredSize <= 0) { controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
logger.warn("ServerEvents | Stream closed, skipping event");
return;
}
controller.enqueue(new TextEncoder().encode(message));
} catch (error) { } catch (error) {
logger.error("ServerEvents | Error sending SSE message", error); logger.error("ServerEvents | Error sending SSE message", error);
cleanup();
} }
}; };
serverEvents.on("test", handleEvent); const handleEvent = (event: ServerEvent) => send(event);
serverEvents.on("match", handleEvent); for (const type of EVENT_TYPES) {
serverEvents.on("reaction", handleEvent); serverEvents.on(type, handleEvent);
}
const pingInterval = setInterval(() => { const pingInterval = setInterval(() => {
try { send({ type: "ping", timestamp: Date.now() });
if (!controller.desiredSize || controller.desiredSize <= 0) {
clearInterval(pingInterval);
return;
}
const pingMessage = `data: ${JSON.stringify({ type: "ping", timestamp: Date.now() })}\n\n`;
controller.enqueue(new TextEncoder().encode(pingMessage));
} catch (e) {
logger.error("ServerEvents | Ping interval error", e);
clearInterval(pingInterval);
}
}, 15000); }, 15000);
setTimeout(() => { cleanup = () => {
try { if (cleanedUp) return;
const heartbeatMessage = `data: ${JSON.stringify({ type: "heartbeat", timestamp: Date.now() })}\n\n`; cleanedUp = true;
controller.enqueue(new TextEncoder().encode(heartbeatMessage));
} catch (e) {
logger.error("ServerEvents | Heartbeat error", e);
}
}, 1000);
const cleanup = () => {
activeConnections--; activeConnections--;
serverEvents.off("test", handleEvent); for (const type of EVENT_TYPES) {
serverEvents.off("match", handleEvent); serverEvents.off(type, handleEvent);
serverEvents.off("reaction", handleEvent); }
clearInterval(pingInterval); clearInterval(pingInterval);
logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${activeConnections}`); logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${activeConnections}`);
}; };
request.signal?.addEventListener("abort", cleanup); request.signal?.addEventListener("abort", cleanup);
return cleanup;
send({ type: "connected" });
},
cancel() {
cleanup();
}, },
}); });
@@ -77,13 +62,7 @@ export const Route = createFileRoute("/api/events/$")({
headers: { headers: {
"Content-Type": "text/event-stream", "Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-store, must-revalidate", "Cache-Control": "no-cache, no-store, must-revalidate",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Cache-Control",
"X-Accel-Buffering": "no", "X-Accel-Buffering": "no",
"X-Proxy-Buffering": "no",
"Proxy-Buffering": "off",
"Transfer-Encoding": "chunked",
}, },
}); });
}, },
+9 -2
View File
@@ -29,9 +29,16 @@ function RouteComponent() {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const redirect = urlParams.get('redirect'); const redirect = urlParams.get('redirect');
if (redirect && !redirect.includes('_serverFn') && !redirect.includes('/api/')) { const safe =
redirect &&
redirect.startsWith('/') &&
!redirect.startsWith('/refresh-session') &&
!redirect.includes('_serverFn') &&
!redirect.includes('/api/');
if (safe) {
logger.info("Refresh session route: redirecting to", redirect); logger.info("Refresh session route: redirecting to", redirect);
window.location.href = decodeURIComponent(redirect); window.location.href = redirect;
} else { } else {
logger.info("Refresh session route: redirecting to home"); logger.info("Refresh session route: redirecting to home");
window.location.href = '/'; window.location.href = '/';
+4 -3
View File
@@ -98,20 +98,21 @@ const Avatar = ({
> >
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}> <div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
<ActionIcon <ActionIcon
variant="filled" variant="white"
color="dark" color="dark"
size="lg" size="lg"
radius="xl" radius="xl"
aria-label="Close image preview"
style={{ style={{
position: 'absolute', position: 'absolute',
top: -10, top: -10,
right: -10, right: -10,
zIndex: 1000, zIndex: 1000,
backgroundColor: 'rgba(0, 0, 0, 0.7)', backgroundColor: 'rgba(255, 255, 255, 0.9)',
}} }}
onClick={() => setIsFullscreenOpen(false)} onClick={() => setIsFullscreenOpen(false)}
> >
<XIcon size={18} color="white" /> <XIcon size={18} />
</ActionIcon> </ActionIcon>
<Image <Image
+36
View File
@@ -0,0 +1,36 @@
.time {
transition: color 300ms ease-out;
}
.seconds {
display: inline-block;
}
.tick {
animation: digit-tick 200ms ease-out;
}
.finalTick {
animation: final-tick 250ms ease-out;
}
@keyframes digit-tick {
from {
opacity: 0.2;
transform: translateY(-0.12em);
}
}
@keyframes final-tick {
from {
opacity: 0.4;
transform: scale(1.15);
}
}
@media (prefers-reduced-motion: reduce) {
.tick,
.finalTick {
animation: none;
}
}
+28 -10
View File
@@ -1,6 +1,7 @@
import useNow from '@/hooks/use-now'; import useNow from '@/hooks/use-now';
import { Text, Group } from '@mantine/core'; import { Text, Group } from '@mantine/core';
import { useMemo } from 'react'; import { useMemo } from 'react';
import classes from './countdown.module.css';
interface CountdownProps { interface CountdownProps {
date: Date; date: Date;
@@ -30,25 +31,42 @@ function calculateTimeLeft(targetDate: Date, currentTime = new Date()): TimeLeft
}; };
} }
const pad = (num: number) => num.toString().padStart(2, '0');
export function Countdown({ date, label, color }: CountdownProps) { export function Countdown({ date, label, color }: CountdownProps) {
const now = useNow(); const now = useNow();
const timeLeft = useMemo(() => calculateTimeLeft(date, now), [date, now]); const timeLeft = useMemo(() => calculateTimeLeft(date, now), [date, now]);
const formatTime = () => { const totalSecondsLeft = Math.max(
const pad = (num: number) => num.toString().padStart(2, '0'); 0,
Math.floor((date.getTime() - now.getTime()) / 1000)
);
const isFinalStretch = totalSecondsLeft > 0 && totalSecondsLeft <= 10;
if (timeLeft.days > 0) { const prefix =
return `${timeLeft.days}d ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:${pad(timeLeft.seconds)}`; timeLeft.days > 0
} else { ? `${timeLeft.days}d ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`
return `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:${pad(timeLeft.seconds)}`; : `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`;
}
};
return ( return (
<Group gap="xs"> <Group gap="xs">
{label && <Text size='sm' fw={500}>{label}:</Text>} {label && <Text size='sm' fw={500}>{label}:</Text>}
<Text size='sm' fw={600} c={color} ff="monospace"> <Text
{formatTime()} size='sm'
fw={600}
c={isFinalStretch ? 'red' : color}
ff="monospace"
className={classes.time}
>
{prefix}
<span
key={timeLeft.seconds}
className={`${classes.seconds} ${
isFinalStretch ? classes.finalTick : classes.tick
}`}
>
{pad(timeLeft.seconds)}
</span>
</Text> </Text>
</Group> </Group>
); );
+32
View File
@@ -0,0 +1,32 @@
import { Stack, Text, ThemeIcon, Title } from "@mantine/core";
import { ReactNode } from "react";
interface EmptyStateProps {
icon: ReactNode;
title: string;
description?: string;
action?: ReactNode;
}
const EmptyState = ({ icon, title, description, action }: EmptyStateProps) => {
return (
<Stack align="center" gap="md" py="xl">
<ThemeIcon size="xl" variant="light" radius="md">
{icon}
</ThemeIcon>
<Stack align="center" gap={4}>
<Title order={3} c="dimmed" ta="center">
{title}
</Title>
{description && (
<Text size="sm" c="dimmed" ta="center" maw={280}>
{description}
</Text>
)}
</Stack>
{action}
</Stack>
);
};
export default EmptyState;
+58 -80
View File
@@ -1,34 +1,29 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Paper, Box } from "@mantine/core"; import { Box, Avatar as MantineAvatar } from "@mantine/core";
import {
Avatar as MantineAvatar,
AvatarProps as MantineAvatarProps,
} from "@mantine/core";
interface GlitchAvatarProps interface GlitchAvatarProps {
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
name: string; name: string;
src?: string; src?: string;
glitchSrc?: string; glitchSrc?: string;
size?: number; size?: number;
radius?: string | number; radius?: string | number;
withBorder?: boolean; withBorder?: boolean;
contain?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
px?: string | number;
} }
const FRAME_PADDING = 8;
const toCssRadius = (radius: string | number) =>
typeof radius === "number" ? `${radius}px` : `var(--mantine-radius-${radius})`;
const GlitchAvatar = ({ const GlitchAvatar = ({
name, name,
src, src,
glitchSrc, glitchSrc,
size = 35, size = 35,
radius = "100%", radius = "md",
withBorder = true, withBorder = true,
contain = false,
children, children,
px,
...props
}: GlitchAvatarProps) => { }: GlitchAvatarProps) => {
const [showGlitch, setShowGlitch] = useState(false); const [showGlitch, setShowGlitch] = useState(false);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
@@ -37,13 +32,16 @@ const GlitchAvatar = ({
useEffect(() => { useEffect(() => {
if (!glitchSrc) return; if (!glitchSrc) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let timeoutId: ReturnType<typeof setTimeout>;
const scheduleNextGlitch = () => { const scheduleNextGlitch = () => {
const delay = Math.random() * 10000 + 5000; const delay = Math.random() * 10000 + 5000;
return setTimeout(() => { timeoutId = setTimeout(() => {
setShowGlitch(true); setShowGlitch(true);
setIsPlaying(true); setIsPlaying(true);
setTimeout(() => { timeoutId = setTimeout(() => {
setShowGlitch(false); setShowGlitch(false);
setIsPlaying(false); setIsPlaying(false);
scheduleNextGlitch(); scheduleNextGlitch();
@@ -51,7 +49,7 @@ const GlitchAvatar = ({
}, delay); }, delay);
}; };
const timeoutId = scheduleNextGlitch(); scheduleNextGlitch();
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [glitchSrc]); }, [glitchSrc]);
@@ -85,33 +83,61 @@ const GlitchAvatar = ({
}); });
}, [showGlitch, isPlaying]); }, [showGlitch, isPlaying]);
const innerRadius = toCssRadius(radius);
return ( return (
<Box <Box
style={{ style={{
padding: "8px",
borderRadius:
typeof radius === "number"
? `${radius + 8}px`
: "calc(var(--mantine-radius-md) + 8px)",
position: "relative", position: "relative",
width: "fit-content",
padding: FRAME_PADDING,
border: withBorder
? "1px solid var(--mantine-color-default-border)"
: "1px solid transparent",
borderRadius: `calc(${innerRadius} + ${FRAME_PADDING}px)`,
}} }}
> >
<Box {src ? (
<Box style={{ position: "relative" }}>
<img
src={src}
alt={name}
style={{ style={{
display: "block",
maxWidth: size,
maxHeight: size,
width: "auto",
height: "auto",
borderRadius: innerRadius,
opacity: showGlitch ? 0 : 1, opacity: showGlitch ? 0 : 1,
transition: "opacity 0.05s ease-in-out", transition: showGlitch
? "opacity 0.05s ease-in"
: "opacity 0.25s ease-out",
}} }}
> />
<Paper {glitchSrc && (
py={size / 12.5} <video
px={size / 20} ref={videoRef}
bg="var(--mantine-color-default-border)" src={glitchSrc}
radius={radius}
withBorder={false}
style={{ style={{
cursor: "default", position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "contain",
borderRadius: innerRadius,
opacity: showGlitch ? 1 : 0,
visibility: showGlitch ? "visible" : "hidden",
transition: showGlitch ? "opacity 0.05s ease-in" : "none",
pointerEvents: "none",
}} }}
> muted
playsInline
preload="auto"
/>
)}
</Box>
) : (
<MantineAvatar <MantineAvatar
alt={name} alt={name}
key={name} key={name}
@@ -120,57 +146,9 @@ const GlitchAvatar = ({
size={size} size={size}
radius={radius} radius={radius}
w={size} w={size}
styles={{
image: {
objectFit: contain ? "contain" : "cover",
},
}}
src={src}
{...props}
> >
{children} {children}
</MantineAvatar> </MantineAvatar>
</Paper>
</Box>
{glitchSrc && (
<Box
style={{
position: "absolute",
top: "8px",
left: "8px",
opacity: showGlitch ? 1 : 0,
visibility: showGlitch ? "visible" : "hidden",
transition: "opacity 0.05s ease-in-out",
pointerEvents: "none",
}}
>
<Paper
py={size / 12.5}
px={size / 20}
bg="var(--mantine-color-default-border)"
radius={radius}
withBorder={false}
style={{
overflow: "hidden",
}}
>
<video
ref={videoRef}
src={glitchSrc}
style={{
width: `${size}px`,
height: `${size}px`,
objectFit: contain ? "contain" : "cover",
borderRadius: typeof radius === "number" ? `${radius}px` : radius,
display: "block",
}}
muted
playsInline
preload="auto"
/>
</Paper>
</Box>
)} )}
</Box> </Box>
); );
+2
View File
@@ -1,5 +1,6 @@
import { Divider, Group, Loader, Text, UnstyledButton } from "@mantine/core"; import { Divider, Group, Loader, Text, UnstyledButton } from "@mantine/core";
import { CaretRightIcon, Icon } from "@phosphor-icons/react"; import { CaretRightIcon, Icon } from "@phosphor-icons/react";
import styles from "./list-row.module.css";
interface ListButtonProps { interface ListButtonProps {
label: string; label: string;
@@ -12,6 +13,7 @@ const ListButton = ({ label, onClick, Icon, loading }: ListButtonProps) => {
return ( return (
<> <>
<UnstyledButton <UnstyledButton
className={styles.listRow}
w="100%" w="100%"
p="md" p="md"
component={"button"} component={"button"}
+3 -1
View File
@@ -1,6 +1,7 @@
import { Divider, NavLink, Text } from "@mantine/core"; import { Divider, NavLink, Text } from "@mantine/core";
import { CaretRightIcon, Icon } from "@phosphor-icons/react"; import { CaretRightIcon, Icon } from "@phosphor-icons/react";
import { Link, useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import styles from "./list-row.module.css";
interface ListLinkProps { interface ListLinkProps {
label: string; label: string;
@@ -15,6 +16,7 @@ const ListLink = ({ label, to, Icon, disabled=false }: ListLinkProps) => {
return ( return (
<> <>
<NavLink <NavLink
className={styles.listRow}
disabled={disabled} disabled={disabled}
w="100%" w="100%"
p="md" p="md"
+23
View File
@@ -0,0 +1,23 @@
.listRow {
-webkit-tap-highlight-color: transparent;
}
@media (hover: hover) {
.listRow:hover:not(:disabled) {
background-color: var(--mantine-color-default-hover);
}
}
.listRow:active:not(:disabled) {
background-color: var(--mantine-color-default-hover);
}
@media (prefers-reduced-motion: no-preference) {
.listRow {
transition: background-color 120ms ease-out, transform 120ms ease-out;
}
.listRow:active:not(:disabled) {
transform: translateY(1px);
}
}
+16 -45
View File
@@ -1,17 +1,15 @@
import { import { Box, Container, Title, useComputedColorScheme } from "@mantine/core";
Box,
Container,
Title,
useComputedColorScheme,
} from "@mantine/core";
import { PropsWithChildren, useEffect, useRef } from "react"; import { PropsWithChildren, useEffect, useRef } from "react";
import { Drawer as VaulDrawer } from "vaul"; import { Drawer as VaulDrawer } from "vaul";
import { CHROME_COLORS, setThemeColorMeta } from "@/lib/mantine/theme-colors";
import styles from "./styles.module.css"; import styles from "./styles.module.css";
interface DrawerProps extends PropsWithChildren { interface DrawerProps extends PropsWithChildren {
title?: string; title?: string;
opened: boolean; opened: boolean;
onChange: (next: boolean) => void; onChange: (next: boolean) => void;
onClose?: () => void;
onExited?: () => void;
} }
const Drawer: React.FC<DrawerProps> = ({ const Drawer: React.FC<DrawerProps> = ({
@@ -19,55 +17,25 @@ const Drawer: React.FC<DrawerProps> = ({
children, children,
opened, opened,
onChange, onChange,
onExited,
}) => { }) => {
const colorScheme = useComputedColorScheme("light"); const colorScheme = useComputedColorScheme("light");
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const openedRef = useRef(opened);
useEffect(() => { useEffect(() => {
openedRef.current = opened; if (!opened) return;
}, [opened]);
useEffect(() => { const appElement = document.querySelector<HTMLElement>(".app");
const appElement = document.querySelector(".app") as HTMLElement; const colors = CHROME_COLORS[colorScheme];
if (!appElement) return; appElement?.classList.add("drawer-scaling");
setThemeColorMeta(colors.dimmed);
let themeColorMeta = document.querySelector(
'meta[name="theme-color"]'
) as HTMLMetaElement;
if (!themeColorMeta) {
themeColorMeta = document.createElement("meta");
themeColorMeta.name = "theme-color";
document.head.appendChild(themeColorMeta);
}
const colors = {
light: {
normal: "rgb(255,255,255)",
overlay: "rgb(153,153,153)",
},
dark: {
normal: "rgb(36,36,36)",
overlay: "rgb(22,22,22)",
},
};
const currentColors = colors[colorScheme] || colors.light;
if (opened) {
appElement.classList.add("drawer-scaling");
themeColorMeta.content = currentColors.overlay;
} else {
appElement.classList.remove("drawer-scaling");
themeColorMeta.content = currentColors.normal;
}
return () => { return () => {
appElement.classList.remove("drawer-scaling"); appElement?.classList.remove("drawer-scaling");
themeColorMeta.content = currentColors.normal; setThemeColorMeta(colors.base);
}; };
}, [opened]); }, [opened, colorScheme]);
useEffect(() => { useEffect(() => {
if (!opened || !contentRef.current) return; if (!opened || !contentRef.current) return;
@@ -109,6 +77,9 @@ const Drawer: React.FC<DrawerProps> = ({
repositionInputs={false} repositionInputs={false}
open={opened} open={opened}
onOpenChange={onChange} onOpenChange={onChange}
onAnimationEnd={(open) => {
if (!open) onExited?.();
}}
> >
<VaulDrawer.Portal> <VaulDrawer.Portal>
<VaulDrawer.Overlay className={styles.drawerOverlay} /> <VaulDrawer.Overlay className={styles.drawerOverlay} />
+18 -1
View File
@@ -5,13 +5,30 @@ interface ModalProps extends PropsWithChildren {
title?: string; title?: string;
opened: boolean; opened: boolean;
onClose: () => void; onClose: () => void;
onChange?: (next: boolean) => void;
onExited?: () => void;
} }
const Modal: React.FC<ModalProps> = ({ title, children, opened, onClose }) => ( const Modal: React.FC<ModalProps> = ({
title,
children,
opened,
onClose,
onExited,
}) => (
<MantineModal <MantineModal
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={<Title order={3}>{title}</Title>} title={<Title order={3}>{title}</Title>}
radius={20}
transitionProps={{
transition: "pop",
duration: 200,
timingFunction: "ease-out",
onExited,
}}
overlayProps={{ backgroundOpacity: 0.4 }}
closeButtonProps={{ "aria-label": "Close" }}
> >
<Suspense <Suspense
fallback={ fallback={
+32 -2
View File
@@ -1,4 +1,11 @@
import { PropsWithChildren, Suspense, useCallback } from "react"; import {
PropsWithChildren,
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useIsMobile } from "@/hooks/use-is-mobile"; import { useIsMobile } from "@/hooks/use-is-mobile";
import Drawer from "./drawer"; import Drawer from "./drawer";
import Modal from "./modal"; import Modal from "./modal";
@@ -10,13 +17,35 @@ interface SheetProps extends PropsWithChildren {
onChange: (next: boolean) => void; onChange: (next: boolean) => void;
} }
const DRAWER_EXIT_MS = 500;
const MODAL_EXIT_MS = 200;
const EXIT_BUFFER_MS = 100;
const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => { const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const handleClose = useCallback(() => onChange(false), [onChange]); const handleClose = useCallback(() => onChange(false), [onChange]);
const [mounted, setMounted] = useState(opened);
const openedRef = useRef(opened);
openedRef.current = opened;
const handleExited = useCallback(() => {
if (!openedRef.current) setMounted(false);
}, []);
useEffect(() => {
if (opened) {
setMounted(true);
return;
}
const exitDuration =
(isMobile ? DRAWER_EXIT_MS : MODAL_EXIT_MS) + EXIT_BUFFER_MS;
const timer = window.setTimeout(() => setMounted(false), exitDuration);
return () => window.clearTimeout(timer);
}, [opened, isMobile]);
const SheetComponent = isMobile ? Drawer : Modal; const SheetComponent = isMobile ? Drawer : Modal;
if (!opened) return null; if (!opened && !mounted) return null;
return ( return (
<SheetComponent <SheetComponent
@@ -24,6 +53,7 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
opened={opened} opened={opened}
onChange={onChange} onChange={onChange}
onClose={handleClose} onClose={handleClose}
onExited={handleExited}
> >
<Suspense fallback={ <Suspense fallback={
<Flex justify='center' align='center' w='100%' style={{ minHeight: '25vh' }}> <Flex justify='center' align='center' w='100%' style={{ minHeight: '25vh' }}>
@@ -150,7 +150,11 @@ const SlidePanel = ({
{panelConfig && ( {panelConfig && (
<> <>
<Group justify="space-between" p="md" align="center" w="100%"> <Group justify="space-between" p="md" align="center" w="100%">
<ActionIcon variant="transparent" onClick={closePanel}> <ActionIcon
variant="transparent"
onClick={closePanel}
aria-label="Back"
>
<ArrowLeftIcon size={24} /> <ArrowLeftIcon size={24} />
</ActionIcon> </ActionIcon>
<Text fw={500}>{panelConfig.title}</Text> <Text fw={500}>{panelConfig.title}</Text>
@@ -158,6 +162,7 @@ const SlidePanel = ({
variant="transparent" variant="transparent"
color="green" color="green"
onClick={handleConfirm} onClick={handleConfirm}
aria-label="Confirm"
> >
<CheckIcon size={24} /> <CheckIcon size={24} />
</ActionIcon> </ActionIcon>
+6
View File
@@ -20,3 +20,9 @@
outline: none; outline: none;
transition: height 0.2s ease-out, max-height 0.2s ease-out; transition: height 0.2s ease-out, max-height 0.2s ease-out;
} }
@media (prefers-reduced-motion: reduce) {
.drawerContent {
transition: none;
}
}
+5
View File
@@ -57,6 +57,11 @@ function SwipeableTabs({
embla?.scrollTo(index); embla?.scrollTo(index);
onTabChange?.(index, tabs[index]); onTabChange?.(index, tabs[index]);
document
.getElementById("scroll-wrapper")
?.querySelector(".mantine-ScrollArea-viewport")
?.scrollTo({ top: 0 });
const tabLabel = tabs[index].label.toLowerCase(); const tabLabel = tabs[index].label.toLowerCase();
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const url = new URL(window.location.href); const url = new URL(window.location.href);
+58
View File
@@ -0,0 +1,58 @@
import { forwardRef } from "react";
import type { Icon, IconProps } from "@phosphor-icons/react";
export const WizardOrbIcon = forwardRef<SVGSVGElement, IconProps>(
(
{ size = 24, color = "currentColor", weight = "regular", mirrored, alt, style, ...rest },
ref
) => {
const strokeWidth = weight === "bold" ? 20 : 16;
return (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
width={size}
height={size}
fill="none"
style={{
transform: mirrored ? "scale(-1, 1)" : undefined,
...style,
}}
{...(alt ? { role: "img", "aria-label": alt } : { "aria-hidden": true })}
{...rest}
>
<circle
cx="128"
cy="112"
r="66"
stroke={color}
strokeWidth={strokeWidth}
fill={weight === "duotone" ? color : "none"}
fillOpacity={weight === "duotone" ? 0.18 : undefined}
/>
<path
d="M94 96 a 44 44 0 0 1 30 -26"
stroke={color}
strokeWidth={strokeWidth * 0.7}
strokeLinecap="round"
fill="none"
/>
<path
d="M96 194 h64 l16 26 h-96 z"
stroke={color}
strokeWidth={strokeWidth}
strokeLinejoin="round"
fill="none"
/>
<path
d="M208 26 l8 16 16 8 -16 8 -8 16 -8 -16 -16 -8 16 -8 z"
fill={color}
/>
</svg>
);
}
) as Icon;
export default WizardOrbIcon;
+114 -50
View File
@@ -1,4 +1,4 @@
import { createContext, useCallback, useEffect, useState, PropsWithChildren } from 'react'; import { createContext, useCallback, useEffect, useMemo, useState, PropsWithChildren } from 'react';
import { SpotifyAuth } from '@/lib/spotify/auth'; import { SpotifyAuth } from '@/lib/spotify/auth';
import { useAuth } from './auth-context'; import { useAuth } from './auth-context';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
@@ -21,6 +21,62 @@ const defaultSpotifyState: SpotifyAuthState = {
export const SpotifyContext = createContext<SpotifyContextType | null>(null); export const SpotifyContext = createContext<SpotifyContextType | null>(null);
const deepEqual = (a: unknown, b: unknown): boolean => {
if (a === b) return true;
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
return false;
}
if (Array.isArray(a) !== Array.isArray(b)) return false;
const aKeys = Object.keys(a as Record<string, unknown>);
const bKeys = Object.keys(b as Record<string, unknown>);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every((key) =>
deepEqual(
(a as Record<string, unknown>)[key],
(b as Record<string, unknown>)[key]
)
);
};
const makeSpotifyRequest = async (endpoint: string, options: RequestInit = {}) => {
const response = await fetch(`/api/spotify/${endpoint}`, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
let errorMessage = 'Request failed';
try {
const errorData = await response.json();
errorMessage = errorData.error || errorMessage;
} catch {
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
}
throw new Error(errorMessage);
}
if (response.status === 204 || response.headers.get('content-length') === '0') {
return {};
}
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
console.warn(`Non-JSON response from ${endpoint}:`, contentType);
return {};
}
try {
return await response.json();
} catch (error) {
console.warn(`Failed to parse JSON response from ${endpoint}:`, error);
return {};
}
};
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => { export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
const { roles } = useAuth(); const { roles } = useAuth();
const isAdmin = roles?.includes('Admin') || false; const isAdmin = roles?.includes('Admin') || false;
@@ -41,9 +97,9 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
const [isCaptureLoading, setIsCaptureLoading] = useState(false); const [isCaptureLoading, setIsCaptureLoading] = useState(false);
const [isResumeLoading, setIsResumeLoading] = useState(false); const [isResumeLoading, setIsResumeLoading] = useState(false);
const spotifyAuth = new SpotifyAuth( const spotifyAuth = useMemo(
config.spotifyClientId, () => new SpotifyAuth(config.spotifyClientId, config.spotifyRedirectUri),
config.spotifyRedirectUri [config.spotifyClientId, config.spotifyRedirectUri]
); );
useEffect(() => { useEffect(() => {
@@ -126,45 +182,6 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
document.cookie = 'spotify_refresh_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; document.cookie = 'spotify_refresh_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
}, []); }, []);
const makeSpotifyRequest = async (endpoint: string, options: RequestInit = {}) => {
const response = await fetch(`/api/spotify/${endpoint}`, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
let errorMessage = 'Request failed';
try {
const errorData = await response.json();
errorMessage = errorData.error || errorMessage;
} catch {
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
}
throw new Error(errorMessage);
}
if (response.status === 204 || response.headers.get('content-length') === '0') {
return {};
}
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
console.warn(`Non-JSON response from ${endpoint}:`, contentType);
return {};
}
try {
return await response.json();
} catch (error) {
console.warn(`Failed to parse JSON response from ${endpoint}:`, error);
return {};
}
};
const play = useCallback(async (deviceId?: string) => { const play = useCallback(async (deviceId?: string) => {
if (!authState.isAuthenticated) return; if (!authState.isAuthenticated) return;
@@ -351,11 +368,16 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
const data = await makeSpotifyRequest('playback?type=state'); const data = await makeSpotifyRequest('playback?type=state');
const state = data.playbackState; const state = data.playbackState;
setPlaybackState(state); setPlaybackState((prev) => (deepEqual(prev, state ?? null) ? prev : state));
setCurrentTrack(state?.item || null); setCurrentTrack((prev) => {
const next = state?.item || null;
return deepEqual(prev, next) ? prev : next;
});
if (state?.device) { if (state?.device) {
setActiveDeviceState(state.device); setActiveDeviceState((prev) =>
deepEqual(prev, state.device) ? prev : state.device
);
} }
} catch (error) { } catch (error) {
console.warn('Failed to refresh playback state:', error); console.warn('Failed to refresh playback state:', error);
@@ -365,8 +387,24 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
useEffect(() => { useEffect(() => {
if (!authState.isAuthenticated) return; if (!authState.isAuthenticated) return;
const interval = setInterval(refreshPlaybackState, 5000); const poll = () => {
return () => clearInterval(interval); if (document.hidden) return;
refreshPlaybackState();
};
const handleVisibilityChange = () => {
if (!document.hidden) {
refreshPlaybackState();
}
};
const interval = setInterval(poll, 5000);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
clearInterval(interval);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [authState.isAuthenticated, refreshPlaybackState]); }, [authState.isAuthenticated, refreshPlaybackState]);
const capturePlaybackState = useCallback(async () => { const capturePlaybackState = useCallback(async () => {
@@ -432,7 +470,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
} }
}, []); }, []);
const contextValue: SpotifyContextType = { const contextValue: SpotifyContextType = useMemo(() => ({
...authState, ...authState,
currentTrack, currentTrack,
playbackState, playbackState,
@@ -461,7 +499,33 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
clearCapturedState, clearCapturedState,
// Search // Search
searchTracks, searchTracks,
}; }), [
authState,
currentTrack,
playbackState,
devices,
activeDevice,
isLoading,
error,
capturedState,
isCaptureLoading,
isResumeLoading,
login,
logout,
play,
playTrack,
pause,
skipNext,
skipPrevious,
setVolume,
getDevices,
setActiveDevice,
refreshPlaybackState,
capturePlaybackState,
resumePlaybackState,
clearCapturedState,
searchTracks,
]);
if (!isAdmin) { if (!isAdmin) {
return <>{children}</>; return <>{children}</>;
@@ -12,6 +12,8 @@ import {
Pagination, Pagination,
Code, Code,
Alert, Alert,
ThemeIcon,
Title,
} from "@mantine/core"; } from "@mantine/core";
import { import {
MagnifyingGlassIcon, MagnifyingGlassIcon,
@@ -20,6 +22,7 @@ import {
CheckIcon, CheckIcon,
XIcon, XIcon,
ChecksIcon, ChecksIcon,
PulseIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { Activity, ActivitySearchParams } from "../types"; import { Activity, ActivitySearchParams } from "../types";
import { useActivities } from "../queries"; import { useActivities } from "../queries";
@@ -220,9 +223,14 @@ const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any
</Stack> </Stack>
{result.items.length === 0 && ( {result.items.length === 0 && (
<Text ta="center" c="dimmed" py="xl"> <Stack align="center" gap="md" py="xl">
No activities found <ThemeIcon size="xl" variant="light" radius="md">
</Text> <PulseIcon size={32} />
</ThemeIcon>
<Title order={3} c="dimmed">
No Activities Found
</Title>
</Stack>
)} )}
{result.totalPages > 1 && ( {result.totalPages > 1 && (
@@ -339,7 +347,7 @@ export const ActivitiesTable = () => {
<Text <Text
size="xs" size="xs"
fw={sortBy.includes("created") ? 600 : 400} fw={sortBy.includes("created") ? 600 : 400}
c={sortBy.includes("created") ? "dark" : "dimmed"} c={sortBy.includes("created") ? "var(--mantine-color-text)" : "dimmed"}
> >
Date Date
</Text> </Text>
@@ -355,7 +363,7 @@ export const ActivitiesTable = () => {
<Text <Text
size="xs" size="xs"
fw={sortBy.includes("duration") ? 600 : 400} fw={sortBy.includes("duration") ? 600 : 400}
c={sortBy.includes("duration") ? "dark" : "dimmed"} c={sortBy.includes("duration") ? "var(--mantine-color-text)" : "dimmed"}
> >
Duration Duration
</Text> </Text>
+1 -1
View File
@@ -16,7 +16,7 @@ const activitySearchParamsSchema = z.object({
}); });
export const searchActivities = createServerFn() export const searchActivities = createServerFn()
.inputValidator(activitySearchParamsSchema) .validator(activitySearchParamsSchema)
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult<ActivityListResult>(async () => { toServerResult<ActivityListResult>(async () => {
@@ -7,7 +7,7 @@ const ManageTournaments = () => {
return ( return (
<List p="0"> <List p="0">
{tournaments.map((t) => ( {tournaments.map((t) => (
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} /> <ListLink key={t.id} label={t.name} to={`/admin/tournaments/${t.id}`} />
))} ))}
</List> </List>
); );
@@ -47,6 +47,7 @@ export const BadgeIcon = ({ badge, filled, size = 48 }: BadgeIconProps & { size?
alt={badge.name} alt={badge.name}
width={size} width={size}
height={size} height={size}
loading="lazy"
onError={() => setImageError(true)} onError={() => setImageError(true)}
style={{ style={{
objectFit: 'contain', objectFit: 'contain',
@@ -57,6 +58,28 @@ export const BadgeIcon = ({ badge, filled, size = 48 }: BadgeIconProps & { size?
}; };
const badgeTileCss = `
.flxn-badge-tile {
transition: transform 160ms cubic-bezier(0.32, 0.72, 0, 1);
}
@media (hover: hover) {
.flxn-badge-tile:hover {
transform: translateY(-2px);
}
}
.flxn-badge-tile:active {
transform: scale(0.97);
}
@media (prefers-reduced-motion: reduce) {
.flxn-badge-tile,
.flxn-badge-tile:hover,
.flxn-badge-tile:active {
transition: none;
transform: none;
}
}
`;
const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => { const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
const { user } = useAuth(); const { user } = useAuth();
const { data: badgeProgress } = usePlayerBadges(playerId); const { data: badgeProgress } = usePlayerBadges(playerId);
@@ -138,6 +161,9 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
return ( return (
<Box mb="lg"> <Box mb="lg">
<style href="flxn-badge-tile" precedence="medium">
{badgeTileCss}
</style>
<Box <Box
px="md" px="md"
style={{ style={{
@@ -167,9 +193,9 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
<Popover key={display.badge.id} width={280} position="top" withArrow shadow="md" withinPortal> <Popover key={display.badge.id} width={280} position="top" withArrow shadow="md" withinPortal>
<Popover.Target> <Popover.Target>
<Box <Box
className="flxn-badge-tile"
style={{ style={{
cursor: "pointer", cursor: "pointer",
transition: 'all 0.2s ease',
position: 'relative', position: 'relative',
}} }}
> >
+13 -4
View File
@@ -3,9 +3,10 @@ import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } fro
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod"; import { z } from "zod";
import { emitServerEvent } from "@/lib/events/emitter";
export const getPlayerBadges = createServerFn() export const getPlayerBadges = createServerFn()
.inputValidator(z.string()) .validator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: playerId }) => .handler(async ({ data: playerId }) =>
toServerResult(() => pbAdmin.getPlayerBadgeProgress(playerId)) toServerResult(() => pbAdmin.getPlayerBadgeProgress(playerId))
@@ -14,7 +15,11 @@ export const getPlayerBadges = createServerFn()
export const migrateBadgeProgress = createServerFn() export const migrateBadgeProgress = createServerFn()
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async () => .handler(async () =>
toServerResult(() => pbAdmin.migrateBadgeProgress()) toServerResult(async () => {
const result = await pbAdmin.migrateBadgeProgress();
emitServerEvent({ type: "badge" });
return result;
})
); );
export const getAllBadges = createServerFn() export const getAllBadges = createServerFn()
@@ -28,11 +33,15 @@ export const getAllEarnedBadges = createServerFn()
.handler(async () => toServerResult(() => pbAdmin.listEarnedBadges())); .handler(async () => toServerResult(() => pbAdmin.listEarnedBadges()));
export const awardManualBadge = createServerFn() export const awardManualBadge = createServerFn()
.inputValidator(z.object({ .validator(z.object({
playerId: z.string(), playerId: z.string(),
badgeId: z.string(), badgeId: z.string(),
})) }))
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult(() => pbAdmin.awardManualBadge(data.playerId, data.badgeId)) toServerResult(async () => {
const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId);
emitServerEvent({ type: "badge", playerId: data.playerId });
return result;
})
); );
@@ -0,0 +1,41 @@
import { Box, Container, Flex, Skeleton, Stack } from "@mantine/core";
export function BracketPending() {
const columns = [4, 2, 1];
return (
<Container size="md" px={0}>
<Box
p={0}
style={{
overflow: "hidden",
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 8px 8px",
minHeight: "70dvh",
}}
>
<Skeleton height={18} width={140} radius="sm" m={16} />
<Flex gap="xl" px={16} align="stretch">
{columns.map((count, columnIndex) => (
<Stack
key={`bracket-pending-round-${columnIndex}`}
gap="xl"
justify="space-around"
style={{ opacity: 1 - columnIndex * 0.25 }}
>
{Array.from({ length: count }).map((_, matchIndex) => (
<Skeleton
key={`bracket-pending-match-${columnIndex}-${matchIndex}`}
height={84}
width={220}
radius="md"
/>
))}
</Stack>
))}
</Flex>
</Box>
</Container>
);
}
@@ -1,9 +1,11 @@
import React, { useMemo } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Text, ScrollArea } from "@mantine/core"; import { Text, ScrollArea, Box } from "@mantine/core";
import { BracketData } from "../types"; import { BracketData } from "../types";
import { Bracket } from "./bracket"; import { Bracket } from "./bracket";
import MatchDock from "./match-dock";
import useAppShellHeight from "@/hooks/use-appshell-height"; import useAppShellHeight from "@/hooks/use-appshell-height";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
import styles from "./styles.module.css";
interface BracketViewProps { interface BracketViewProps {
bracket: BracketData; bracket: BracketData;
@@ -12,10 +14,14 @@ interface BracketViewProps {
num_groups: number; num_groups: number;
advance_per_group: number; advance_per_group: number;
}; };
renderMatch?: (match: Match) => React.ReactNode;
bottomOffset?: number;
} }
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig }) => { const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig, renderMatch, bottomOffset }) => {
const height = useAppShellHeight(); const height = useAppShellHeight();
const viewportRef = useRef<HTMLDivElement>(null);
const hasAutoScrolled = useRef(false);
const orders = useMemo(() => { const orders = useMemo(() => {
const map: Record<number, number> = {}; const map: Record<number, number> = {};
bracket.winners.flat().forEach(match => map[match.lid] = match.order); bracket.winners.flat().forEach(match => map[match.lid] = match.order);
@@ -23,9 +29,62 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
return map; return map;
}, [bracket.winners, bracket.losers]); }, [bracket.winners, bracket.losers]);
return <ScrollArea const [selectedLid, setSelectedLid] = useState<number | null>(null);
const handleMatchTap = useCallback((match: Match) => {
setSelectedLid((prev) => (prev === match.lid ? null : match.lid));
}, []);
const closeDock = useCallback(() => setSelectedLid(null), []);
const selectedMatch = useMemo(() => {
if (selectedLid == null) return null;
return (
[...bracket.winners.flat(), ...bracket.losers.flat()].find(
(match) => match.lid === selectedLid
) ?? null
);
}, [bracket, selectedLid]);
useEffect(() => {
if (hasAutoScrolled.current) return;
const matches = [...bracket.winners.flat(), ...bracket.losers.flat()].filter(
(match) => !match.bye
);
const target =
matches.find((match) => match.status === "started") ??
matches.find((match) => match.status === "ready");
if (!target) return;
const viewport = viewportRef.current;
const element = viewport?.querySelector(`[data-match-lid="${target.lid}"]`);
if (!viewport || !element) return;
hasAutoScrolled.current = true;
const viewportRect = viewport.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const left =
elementRect.left - viewportRect.left + viewport.scrollLeft - 40;
const top =
elementRect.top -
viewportRect.top +
viewport.scrollTop -
(viewport.clientHeight - elementRect.height) / 2;
viewport.scrollTo({
left: Math.max(0, left),
top: Math.max(0, top),
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
? "auto"
: "smooth",
});
}, [bracket]);
return <Box pos="relative">
<ScrollArea
viewportRef={viewportRef}
h={`calc(${height})`} h={`calc(${height})`}
className="bracket-container" className={styles["bracket-container"]}
style={{ style={{
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`, backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
backgroundSize: "16px 16px", backgroundSize: "16px 16px",
@@ -36,17 +95,20 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
<Text fw={600} size="md" m={16}> <Text fw={600} size="md" m={16}>
Winners Bracket Winners Bracket
</Text> </Text>
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} /> <Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} />
</div> </div>
{bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && ( {bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
<div> <div>
<Text fw={600} size="md" m={16}> <Text fw={600} size="md" m={16}>
Losers Bracket Losers Bracket
</Text> </Text>
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} /> <Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} />
</div> </div>
)} )}
{bottomOffset ? <div style={{ height: bottomOffset }} /> : null}
</ScrollArea> </ScrollArea>
<MatchDock match={selectedMatch} onClose={closeDock} />
</Box>
}; };
export default BracketView; export default BracketView;
@@ -11,6 +11,9 @@ interface BracketProps {
num_groups: number; num_groups: number;
advance_per_group: number; advance_per_group: number;
}; };
renderMatch?: (match: Match) => React.ReactNode;
onMatchTap?: (match: Match) => void;
selectedMatchLid?: number | null;
} }
export const Bracket: React.FC<BracketProps> = ({ export const Bracket: React.FC<BracketProps> = ({
@@ -18,6 +21,9 @@ export const Bracket: React.FC<BracketProps> = ({
orders, orders,
showControls, showControls,
groupConfig, groupConfig,
renderMatch,
onMatchTap,
selectedMatchLid,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null); const svgRef = useRef<SVGSVGElement>(null);
@@ -133,12 +139,18 @@ export const Bracket: React.FC<BracketProps> = ({
<div key={match.lid}></div> <div key={match.lid}></div>
) : ( ) : (
<div key={match.lid}> <div key={match.lid}>
{renderMatch ? (
renderMatch(match)
) : (
<MatchCard <MatchCard
match={match} match={match}
orders={orders} orders={orders}
showControls={showControls} showControls={showControls}
groupConfig={groupConfig} groupConfig={groupConfig}
onTap={onMatchTap}
selected={selectedMatchLid === match.lid}
/> />
)}
</div> </div>
) )
)} )}
+45 -47
View File
@@ -12,6 +12,8 @@ import { endMatch, startMatch } from "@/features/matches/server";
import { tournamentKeys } from "@/features/tournaments/queries"; import { tournamentKeys } from "@/features/tournaments/queries";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useSpotifyPlayback } from "@/lib/spotify/hooks"; import { useSpotifyPlayback } from "@/lib/spotify/hooks";
import { getGroupLabel } from "../utils/group-label";
import styles from "./styles.module.css";
interface MatchCardProps { interface MatchCardProps {
match: Match; match: Match;
@@ -21,6 +23,8 @@ interface MatchCardProps {
num_groups: number; num_groups: number;
advance_per_group: number; advance_per_group: number;
}; };
onTap?: (match: Match) => void;
selected?: boolean;
} }
export const MatchCard: React.FC<MatchCardProps> = ({ export const MatchCard: React.FC<MatchCardProps> = ({
@@ -28,50 +32,14 @@ export const MatchCard: React.FC<MatchCardProps> = ({
orders, orders,
showControls, showControls,
groupConfig, groupConfig,
onTap,
selected,
}) => { }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const editSheet = useSheet(); const editSheet = useSheet();
const { playTrack, pause } = useSpotifyPlayback(); const { playTrack, pause } = useSpotifyPlayback();
const getGroupLabel = useCallback((seed: number | undefined) => { const canTap = !!(onTap && match.home && match.away);
if (!seed || !groupConfig) return undefined;
const groupNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
const numGroups = groupConfig.num_groups;
const advancePerGroup = groupConfig.advance_per_group;
const totalQualifiedTeams = numGroups * advancePerGroup;
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(totalQualifiedTeams)));
const wildcardsNeeded = nextPowerOf2 - totalQualifiedTeams;
if (seed > totalQualifiedTeams && wildcardsNeeded > 0) {
const wildcardNumber = seed - totalQualifiedTeams;
return `Wildcard ${wildcardNumber}`;
}
const pairIndex = Math.floor((seed - 1) / 2);
const isFirstInPair = (seed - 1) % 2 === 0;
if (isFirstInPair) {
const groupIndex = pairIndex % numGroups;
const rankIndex = Math.floor(pairIndex / numGroups);
const rank = rankIndex + 1;
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
const rankSuffix = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`;
return `${groupName} ${rankSuffix}`;
} else {
const groupIndex = (pairIndex + 1) % numGroups;
const rankIndex = advancePerGroup - 1 - Math.floor(pairIndex / numGroups);
const rank = rankIndex + 1;
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
const rankSuffix = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`;
return `${groupName} ${rankSuffix}`;
}
}, [groupConfig]);
const homeSlot = useMemo( const homeSlot = useMemo(
() => ({ () => ({
@@ -85,9 +53,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({
match.home_cups !== undefined && match.home_cups !== undefined &&
match.away_cups !== undefined && match.away_cups !== undefined &&
match.home_cups > match.away_cups, match.home_cups > match.away_cups,
groupLabel: !match.home && match.home_seed ? getGroupLabel(match.home_seed) : undefined, groupLabel: !match.home && match.home_seed ? getGroupLabel(match.home_seed, groupConfig) : undefined,
}), }),
[match, getGroupLabel] [match, orders, groupConfig]
); );
const awaySlot = useMemo( const awaySlot = useMemo(
() => ({ () => ({
@@ -101,9 +69,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({
match.away_cups !== undefined && match.away_cups !== undefined &&
match.home_cups !== undefined && match.home_cups !== undefined &&
match.away_cups > match.home_cups, match.away_cups > match.home_cups,
groupLabel: !match.away && match.away_seed ? getGroupLabel(match.away_seed) : undefined, groupLabel: !match.away && match.away_seed ? getGroupLabel(match.away_seed, groupConfig) : undefined,
}), }),
[match, getGroupLabel] [match, orders, groupConfig]
); );
const showToolbar = useMemo( const showToolbar = useMemo(
@@ -134,6 +102,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: tournamentKeys.details(match.tournament.id), queryKey: tournamentKeys.details(match.tournament.id),
}); });
editSheet.close();
}, },
}); });
@@ -149,9 +118,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
matchId: match.id, matchId: match.id,
}, },
}); });
editSheet.close();
}, },
[match.id, editSheet] [end, match.id]
); );
const speak = useCallback((text: string): Promise<void> => { const speak = useCallback((text: string): Promise<void> => {
@@ -273,10 +241,31 @@ export const MatchCard: React.FC<MatchCardProps> = ({
w={showToolbar || showEditButton ? 200 : 220} w={showToolbar || showEditButton ? 200 : 220}
withBorder withBorder
pos="relative" pos="relative"
className={canTap ? styles["tappable-card"] : undefined}
onClick={canTap ? () => onTap!(match) : undefined}
role={canTap ? "button" : undefined}
tabIndex={canTap ? 0 : undefined}
onKeyDown={
canTap
? (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onTap!(match);
}
}
: undefined
}
aria-label={
canTap
? `Match ${match.order}: ${match.home!.name} vs ${match.away!.name}`
: undefined
}
style={{ style={{
overflow: "visible", overflow: "visible",
backgroundColor: 'var(--mantine-color-body)', backgroundColor: 'var(--mantine-color-body)',
borderColor: 'var(--mantine-color-default-border)', borderColor: selected
? 'var(--mantine-primary-color-filled)'
: 'var(--mantine-color-default-border)',
boxShadow: 'var(--mantine-shadow-sm)', boxShadow: 'var(--mantine-shadow-sm)',
}} }}
data-match-lid={match.lid} data-match-lid={match.lid}
@@ -310,7 +299,11 @@ export const MatchCard: React.FC<MatchCardProps> = ({
size="sm" size="sm"
variant="subtle" variant="subtle"
color="gray" color="gray"
onClick={handleSpeakerClick} onClick={(e) => {
e.stopPropagation();
handleSpeakerClick();
}}
aria-label="Announce matchup"
> >
<SpeakerHighIcon size={12} /> <SpeakerHighIcon size={12} />
</ActionIcon> </ActionIcon>
@@ -323,10 +316,12 @@ export const MatchCard: React.FC<MatchCardProps> = ({
<ActionIcon <ActionIcon
color="green" color="green"
onClick={handleStart} onClick={handleStart}
loading={start.isPending}
size="sm" size="sm"
h="100%" h="100%"
radius="sm" radius="sm"
ml={-4} ml={-4}
aria-label="Start match"
style={{ style={{
borderTopLeftRadius: 0, borderTopLeftRadius: 0,
borderBottomLeftRadius: 0, borderBottomLeftRadius: 0,
@@ -343,10 +338,12 @@ export const MatchCard: React.FC<MatchCardProps> = ({
<ActionIcon <ActionIcon
color="blue" color="blue"
onClick={editSheet.open} onClick={editSheet.open}
loading={end.isPending}
size="sm" size="sm"
h="100%" h="100%"
radius="sm" radius="sm"
ml={-4} ml={-4}
aria-label="Edit match score"
style={{ style={{
borderTopLeftRadius: 0, borderTopLeftRadius: 0,
borderBottomLeftRadius: 0, borderBottomLeftRadius: 0,
@@ -363,6 +360,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
match={match} match={match}
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
onCancel={editSheet.close} onCancel={editSheet.close}
loading={end.isPending}
/> />
</Sheet> </Sheet>
</Flex> </Flex>
@@ -0,0 +1,219 @@
import { Suspense } from "react";
import {
ActionIcon,
Box,
Center,
CloseButton,
Group,
Indicator,
Loader,
Paper,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { FootballHelmetIcon } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import { Match } from "@/features/matches/types";
import TeamAvatar from "@/components/team-avatar";
import AnimatedScore from "@/features/matches/components/animated-score";
import EmojiBar from "@/features/reactions/components/emoji-bar";
import TeamHeadToHeadSheet from "@/features/matches/components/team-head-to-head-sheet";
import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet";
const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
interface MatchDockProps {
match: Match | null;
onClose: () => void;
}
const TeamRow = ({
team,
cups,
isWinner,
isRegional,
ended,
}: {
team: NonNullable<Match["home"]>;
cups: number;
isWinner: boolean;
isRegional: boolean;
ended: boolean;
}) => {
const navigate = useNavigate();
return (
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group
gap="sm"
wrap="nowrap"
style={{ flex: 1, minWidth: 0, cursor: "pointer" }}
onClick={() => navigate({ to: `/teams/${team.id}` })}
>
<TeamAvatar
team={team}
size={32}
radius="sm"
winner={ended && isWinner}
isRegional={isRegional}
/>
<Text size="sm" fw={ended && isWinner ? 700 : 500} lineClamp={1}>
{team.name}
</Text>
</Group>
{ended && (
<AnimatedScore
value={cups}
size="lg"
fw={700}
c={isWinner ? "green" : "dimmed"}
style={{ minWidth: 28, textAlign: "center" }}
/>
)}
</Group>
);
};
const MatchDock = ({ match, onClose }: MatchDockProps) => {
const reduceMotion = useReducedMotion();
const h2hSheet = useSheet();
const hasPrivate = match?.home?.private || match?.away?.private;
const ended = match?.status === "ended";
const started = match?.status === "started";
return (
<>
<AnimatePresence>
{match && match.home && match.away && (
<motion.div
key="match-dock"
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 16 }}
transition={{ duration: 0.2, ease: EASE }}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
zIndex: 5,
padding: "0 12px 12px",
}}
>
<Paper withBorder shadow="md" radius="lg" px="md" py="sm">
<Stack gap="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
{started && (
<Indicator
size={8}
color="red"
processing
position="middle-start"
offset={0}
/>
)}
<Text size="xs" fw={600} c="dimmed" lineClamp={1}>
Match {match.order} · Round {match.round + 1}
{match.is_losers_bracket && " (Losers)"}
</Text>
</Group>
<CloseButton
size="sm"
onClick={onClose}
aria-label="Close match actions"
/>
</Group>
<TeamRow
team={match.home}
cups={match.home_cups}
isWinner={match.home_cups > match.away_cups}
isRegional={match.tournament.regional === true}
ended={ended}
/>
<Box
style={{
height: 1,
backgroundColor: "var(--mantine-color-default-border)",
}}
/>
<TeamRow
team={match.away}
cups={match.away_cups}
isWinner={match.away_cups > match.home_cups}
isRegional={match.tournament.regional === true}
ended={ended}
/>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ flex: 1, minWidth: 0 }}>
<Suspense
fallback={
<Center py={4}>
<Loader size="xs" />
</Center>
}
>
<EmojiBar matchId={match.id} />
</Suspense>
</Box>
{!hasPrivate && (
<Tooltip label="Head to Head" withArrow position="top">
<ActionIcon
variant="subtle"
size="sm"
onClick={h2hSheet.open}
aria-label="View head-to-head"
w={40}
style={{ flexShrink: 0 }}
>
<Group
style={{ position: "relative", width: 27.5, height: 16 }}
>
<FootballHelmetIcon
size={14}
style={{
position: "absolute",
left: 0,
top: 0,
transform: "rotate(25deg)",
}}
/>
<FootballHelmetIcon
size={14}
style={{
position: "absolute",
right: 0,
top: 0,
transform: "scaleX(-1) rotate(25deg)",
}}
/>
</Group>
</ActionIcon>
</Tooltip>
)}
</Group>
</Stack>
</Paper>
</motion.div>
)}
</AnimatePresence>
{match?.home && match?.away && h2hSheet.isOpen && (
<Sheet title="Head to Head" {...h2hSheet.props}>
<TeamHeadToHeadSheet
team1={match.home}
team2={match.away}
isOpen={h2hSheet.props.opened}
/>
</Sheet>
)}
</>
);
};
export default MatchDock;
+11 -3
View File
@@ -10,12 +10,14 @@ interface MatchFormProps {
ot_count: number; ot_count: number;
}) => void; }) => void;
onCancel: () => void; onCancel: () => void;
loading?: boolean;
} }
export const MatchForm: React.FC<MatchFormProps> = ({ export const MatchForm: React.FC<MatchFormProps> = ({
match, match,
onSubmit, onSubmit,
onCancel, onCancel,
loading = false,
}) => { }) => {
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
@@ -35,7 +37,6 @@ export const MatchForm: React.FC<MatchFormProps> = ({
return "At least one team must have 10 cups"; return "At least one team must have 10 cups";
} }
// Both teams can't have 10 cups
if (homeCups === 10 && awayCups === 10) { if (homeCups === 10 && awayCups === 10) {
return "Both teams cannot have 10 cups"; return "Both teams cannot have 10 cups";
} }
@@ -145,8 +146,15 @@ export const MatchForm: React.FC<MatchFormProps> = ({
</Flex> </Flex>
<Stack mt="md"> <Stack mt="md">
<Button type="submit">Update Match</Button> <Button type="submit" loading={loading}>
<Button variant="subtle" color="red" onClick={onCancel}> Update Match
</Button>
<Button
variant="subtle"
color="red"
onClick={onCancel}
disabled={loading}
>
Cancel Cancel
</Button> </Button>
</Stack> </Stack>
@@ -0,0 +1,16 @@
.slotEnter {
animation: slot-enter 300ms ease-out;
}
@keyframes slot-enter {
from {
opacity: 0;
transform: translateY(4px);
}
}
@media (prefers-reduced-motion: reduce) {
.slotEnter {
animation: none;
}
}
+43 -8
View File
@@ -1,8 +1,12 @@
import { Flex, Text } from "@mantine/core"; import { Flex, Text } from "@mantine/core";
import React from "react"; import React, { useEffect, useRef, useState } from "react";
import { CrownIcon } from "@phosphor-icons/react"; import { CrownIcon } from "@phosphor-icons/react";
import { SeedBadge } from "./seed-badge"; import { SeedBadge } from "./seed-badge";
import { TeamInfo } from "@/features/teams/types"; import { TeamInfo } from "@/features/teams/types";
import AnimatedScore from "@/features/matches/components/animated-score";
import classes from "./match-slot.module.css";
export type MatchSlotState = "winner" | "correct" | "incorrect";
interface MatchSlotProps { interface MatchSlotProps {
from?: number; from?: number;
@@ -12,6 +16,7 @@ interface MatchSlotProps {
cups?: number; cups?: number;
isWinner?: boolean; isWinner?: boolean;
groupLabel?: string; groupLabel?: string;
state?: MatchSlotState;
} }
export const MatchSlot: React.FC<MatchSlotProps> = ({ export const MatchSlot: React.FC<MatchSlotProps> = ({
@@ -21,29 +26,58 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
seed, seed,
cups, cups,
isWinner, isWinner,
groupLabel groupLabel,
}) => ( state,
}) => {
const teamId = team?.id;
const previousTeamIdRef = useRef<string | undefined>(teamId);
const [entranceKey, setEntranceKey] = useState(0);
useEffect(() => {
const previousTeamId = previousTeamIdRef.current;
previousTeamIdRef.current = teamId;
if (teamId && previousTeamId !== teamId) {
setEntranceKey((key) => key + 1);
}
}, [teamId]);
const slotState: MatchSlotState | undefined =
state ?? (isWinner ? "winner" : undefined);
const highlighted = slotState === "winner" || slotState === "correct";
return (
<Flex <Flex
align="stretch" align="stretch"
style={{ style={{
backgroundColor: isWinner ? 'var(--mantine-color-green-light)' : 'transparent', backgroundColor: highlighted
? 'var(--mantine-color-green-light)'
: slotState === "incorrect"
? 'var(--mantine-color-red-light)'
: 'transparent',
borderRadius: 'var(--mantine-radius-sm)', borderRadius: 'var(--mantine-radius-sm)',
transition: 'background-color 200ms ease', transition: 'background-color 200ms ease',
}} }}
> >
{(seed && seed > 0) ? <SeedBadge seed={seed} /> : undefined} {(seed && seed > 0) ? <SeedBadge seed={seed} /> : undefined}
<Flex p="6px 10px" w='100%' align="center"> <Flex p="6px 10px" w='100%' align="center">
<Flex align="center" gap={4} flex={1}> <Flex
key={entranceKey}
className={entranceKey > 0 ? classes.slotEnter : undefined}
align="center"
gap={4}
flex={1}
>
{team ? ( {team ? (
<> <>
<Text <Text
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'} size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
truncate truncate
c={slotState === "incorrect" ? "dimmed" : undefined}
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }} style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
> >
{team.name} {team.name}
</Text> </Text>
{isWinner && ( {highlighted && (
<CrownIcon <CrownIcon
size={14} size={14}
weight="fill" weight="fill"
@@ -73,9 +107,10 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
</Flex> </Flex>
{ {
cups !== undefined ? ( cups !== undefined ? (
<Text ta='center' w={15} fw="800" ml={4} size="xs">{cups}</Text> <AnimatedScore ta='center' w={15} fw={800} ml={4} size="xs" value={cups} />
) : undefined ) : undefined
} }
</Flex> </Flex>
</Flex> </Flex>
); );
};
@@ -7,3 +7,19 @@
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05); box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
} }
} }
.tappable-card {
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
@media (prefers-reduced-motion: no-preference) {
.tappable-card {
transition: transform 120ms cubic-bezier(0.32, 0.72, 0, 1),
border-color 150ms ease-out;
}
.tappable-card:active {
transform: scale(0.98);
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
const logger = new Logger("Bracket Generation"); const logger = new Logger("Bracket Generation");
export const previewBracket = createServerFn() export const previewBracket = createServerFn()
.inputValidator(z.number()) .validator(z.number())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teams }) => .handler(async ({ data: teams }) =>
toServerResult(async () => { toServerResult(async () => {
+49
View File
@@ -0,0 +1,49 @@
export interface GroupConfig {
num_groups: number;
advance_per_group: number;
}
export function getGroupLabel(
seed: number | undefined,
groupConfig: GroupConfig | undefined
): string | undefined {
if (!seed || !groupConfig) return undefined;
const groupNames = ["A", "B", "C", "D", "E", "F", "G", "H"];
const numGroups = groupConfig.num_groups;
const advancePerGroup = groupConfig.advance_per_group;
const totalQualifiedTeams = numGroups * advancePerGroup;
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(totalQualifiedTeams)));
const wildcardsNeeded = nextPowerOf2 - totalQualifiedTeams;
if (seed > totalQualifiedTeams && wildcardsNeeded > 0) {
const wildcardNumber = seed - totalQualifiedTeams;
return `Wildcard ${wildcardNumber}`;
}
const pairIndex = Math.floor((seed - 1) / 2);
const isFirstInPair = (seed - 1) % 2 === 0;
if (isFirstInPair) {
const groupIndex = pairIndex % numGroups;
const rankIndex = Math.floor(pairIndex / numGroups);
const rank = rankIndex + 1;
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
const rankSuffix =
rank === 1 ? "1st" : rank === 2 ? "2nd" : rank === 3 ? "3rd" : `${rank}th`;
return `${groupName} ${rankSuffix}`;
} else {
const groupIndex = (pairIndex + 1) % numGroups;
const rankIndex = advancePerGroup - 1 - Math.floor(pairIndex / numGroups);
const rank = rankIndex + 1;
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
const rankSuffix =
rank === 1 ? "1st" : rank === 2 ? "2nd" : rank === 3 ? "3rd" : `${rank}th`;
return `${groupName} ${rankSuffix}`;
}
}
+37
View File
@@ -0,0 +1,37 @@
import { Match } from "@/features/matches/types";
import { BracketData } from "../types";
export const groupMatchesIntoBracket = (matches?: Match[]): BracketData => {
if (!matches || matches.length === 0) {
return { winners: [], losers: [] };
}
const winnersMap = new Map<number, Match[]>();
const losersMap = new Map<number, Match[]>();
matches
.filter((match) => match.round !== -1)
.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 };
};
+5 -4
View File
@@ -1,4 +1,4 @@
import { Box } from "@mantine/core" import { UnstyledButton } 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"
@@ -6,15 +6,16 @@ const BackButton = ({ top=20, left=20 }: { top?: number, left?: number }) => {
const router = useRouter() const router = useRouter()
return ( return (
<Box <UnstyledButton
style={{ cursor: 'pointer', zIndex: 1000 }} aria-label='Go back'
style={{ cursor: 'pointer', zIndex: 1000, display: 'flex' }}
onClick={() => router.history.back()} onClick={() => router.history.back()}
pos='absolute' pos='absolute'
left={left} left={left}
top={top} top={top}
> >
<ArrowLeftIcon weight='bold' size={20} /> <ArrowLeftIcon weight='bold' size={20} />
</Box> </UnstyledButton>
); );
} }

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