10 Commits

Author SHA1 Message Date
yohlo
6ed77dd471 idk 2025-09-24 07:59:13 -05:00
yohlo
94ea44c66e drawer fixes 2025-09-23 15:04:29 -05:00
yohlo
7441d1ac58 skeletons, tournament stats, polish, bug fixes 2025-09-23 14:48:04 -05:00
yohlo
7ff26229d9 dark mode default, basic tournament stats/podium 2025-09-22 19:33:58 -05:00
yohlo
b93ce38d48 play walkout songs 2025-09-22 17:57:29 -05:00
yohlo
ae934e77f4 manage team data 2025-09-22 17:24:45 -05:00
yohlo
cae5fa1c71 skeletons 2025-09-22 16:45:41 -05:00
yohlo
fc3f626313 minor cleanup 2025-09-21 11:38:10 -05:00
yohlo
1027b49258 free agents 2025-09-20 20:50:44 -05:00
yohlo
5e20b94a1f reactions SSE! 2025-09-19 20:53:05 -05:00
108 changed files with 2828 additions and 866 deletions

View File

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

View File

@@ -6,7 +6,7 @@
"scripts": {
"dev": "vite dev --host 0.0.0.0",
"build": "vite build && tsc --noEmit",
"start": "vite start"
"start": "node .output/server/index.mjs"
},
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
@@ -21,15 +21,15 @@
"@svgmoji/noto": "^3.2.0",
"@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-devtools": "^5.66.0",
"@tanstack/react-router": "^1.130.12",
"@tanstack/react-router-devtools": "^1.130.13",
"@tanstack/react-router-with-query": "^1.130.12",
"@tanstack/react-start": "^1.130.15",
"@tanstack/react-virtual": "^3.13.12",
"@tanstack/react-router": "1.130.12",
"@tanstack/react-router-devtools": "1.130.13",
"@tanstack/react-router-with-query": "1.130.12",
"@tanstack/react-start": "1.130.15",
"@tiptap/pm": "^3.4.3",
"@tiptap/react": "^3.4.3",
"@tiptap/starter-kit": "^3.4.3",
"@types/ioredis": "^4.28.10",
"dotenv": "^17.2.2",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.12",
"ioredis": "^5.7.0",

View File

@@ -0,0 +1,85 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1843675174",
"max": 0,
"min": 0,
"name": "description",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_1340419796",
"indexes": [],
"listRule": null,
"name": "badges",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1340419796");
return app.delete(collection);
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,8 +27,9 @@ import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/prof
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id'
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
import { ServerRoute as ApiTeamsUploadLogoServerRouteImport } from './routes/api/teams/upload-logo'
import { ServerRoute as ApiSpotifyTokenServerRouteImport } from './routes/api/spotify/token'
@@ -125,10 +126,10 @@ const AuthedTournamentsIdBracketRoute =
path: '/tournaments/$id/bracket',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedAdminTournamentsIdRoute =
AuthedAdminTournamentsIdRouteImport.update({
id: '/tournaments/$id',
path: '/tournaments/$id',
const AuthedAdminTournamentsIdIndexRoute =
AuthedAdminTournamentsIdIndexRouteImport.update({
id: '/tournaments/$id/',
path: '/tournaments/$id/',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminTournamentsRunIdRoute =
@@ -137,6 +138,12 @@ const AuthedAdminTournamentsRunIdRoute =
path: '/tournaments/run/$id',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminTournamentsIdTeamsRoute =
AuthedAdminTournamentsIdTeamsRouteImport.update({
id: '/tournaments/$id/teams',
path: '/tournaments/$id/teams',
getParentRoute: () => AuthedAdminRoute,
} as any)
const ApiTournamentsUploadLogoServerRoute =
ApiTournamentsUploadLogoServerRouteImport.update({
id: '/api/tournaments/upload-logo',
@@ -212,10 +219,11 @@ export interface FileRoutesByFullPath {
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/admin/': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
@@ -230,10 +238,11 @@ export interface FileRoutesByTo {
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/admin': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -251,10 +260,11 @@ export interface FileRoutesById {
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/_authed/admin/': typeof AuthedAdminIndexRoute
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
'/_authed/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -272,10 +282,11 @@ export interface FileRouteTypes {
| '/tournaments/$tournamentId'
| '/admin/'
| '/tournaments'
| '/admin/tournaments/$id'
| '/tournaments/$id/bracket'
| '/admin/tournaments'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id'
| '/admin/tournaments/$id'
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
@@ -290,10 +301,11 @@ export interface FileRouteTypes {
| '/tournaments/$tournamentId'
| '/admin'
| '/tournaments'
| '/admin/tournaments/$id'
| '/tournaments/$id/bracket'
| '/admin/tournaments'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id'
| '/admin/tournaments/$id'
id:
| '__root__'
| '/_authed'
@@ -310,10 +322,11 @@ export interface FileRouteTypes {
| '/_authed/tournaments/$tournamentId'
| '/_authed/admin/'
| '/_authed/tournaments/'
| '/_authed/admin/tournaments/$id'
| '/_authed/tournaments/$id/bracket'
| '/_authed/admin/tournaments/'
| '/_authed/admin/tournaments/$id/teams'
| '/_authed/admin/tournaments/run/$id'
| '/_authed/admin/tournaments/$id/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -532,11 +545,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/admin/tournaments/$id': {
id: '/_authed/admin/tournaments/$id'
'/_authed/admin/tournaments/$id/': {
id: '/_authed/admin/tournaments/$id/'
path: '/tournaments/$id'
fullPath: '/admin/tournaments/$id'
preLoaderRoute: typeof AuthedAdminTournamentsIdRouteImport
preLoaderRoute: typeof AuthedAdminTournamentsIdIndexRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/admin/tournaments/run/$id': {
@@ -546,6 +559,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsRunIdRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/admin/tournaments/$id/teams': {
id: '/_authed/admin/tournaments/$id/teams'
path: '/tournaments/$id/teams'
fullPath: '/admin/tournaments/$id/teams'
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
parentRoute: typeof AuthedAdminRoute
}
}
}
declare module '@tanstack/react-start/server' {
@@ -633,17 +653,19 @@ declare module '@tanstack/react-start/server' {
interface AuthedAdminRouteChildren {
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
AuthedAdminTournamentsIdRoute: typeof AuthedAdminTournamentsIdRoute
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute
}
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
AuthedAdminTournamentsIdRoute: AuthedAdminTournamentsIdRoute,
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
}
const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren(

View File

@@ -5,6 +5,10 @@ import { routeTree } from "./routeTree.gen";
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
import dotenv from 'dotenv';
dotenv.config();
export function createRouter() {
const queryClient = new QueryClient({
defaultOptions: {

View File

@@ -1,7 +1,3 @@
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@mantine/carousel/styles.css";
import '@mantine/tiptap/styles.css';
import {
HeadContent,
Navigate,
@@ -18,9 +14,12 @@ import Providers from "@/features/core/components/providers";
import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
import { HeaderConfig } from "@/features/core/types/header-config";
import { playerQueries } from "@/features/players/queries";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import FullScreenLoader from "@/components/full-screen-loader";
import mantineCssUrl from '@mantine/core/styles.css?url'
import mantineDatesCssUrl from '@mantine/dates/styles.css?url'
import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url'
import mantineTiptapCssUrl from '@mantine/tiptap/styles.css?url'
export const Route = createRootRouteWithContext<{
queryClient: QueryClient;
@@ -61,6 +60,10 @@ export const Route = createRootRouteWithContext<{
},
{ rel: "manifest", href: "/site.webmanifest" },
{ rel: "icon", href: "/favicon.ico" },
{ rel: 'stylesheet', href: mantineCssUrl },
{ rel: 'stylesheet', href: mantineCarouselCssUrl },
{ rel: 'stylesheet', href: mantineDatesCssUrl },
{ rel: 'stylesheet', href: mantineTiptapCssUrl }
],
}),
errorComponent: (props) => {
@@ -131,7 +134,6 @@ function RootDocument({ children }: { children: React.ReactNode }) {
>
<div className="app">{children}</div>
<Scripts />
<ReactQueryDevtools />
</body>
</html>
);

View File

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

View File

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

View File

@@ -3,9 +3,13 @@ import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/
import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import StartedTournament from "@/features/tournaments/components/started-tournament";
import { Suspense } from "react";
import UpcomingTournamentSkeleton from "@/features/tournaments/components/upcoming-tournament/skeleton";
export const Route = createFileRoute("/_authed/")({
component: Home,
component: () => <Suspense fallback={<UpcomingTournamentSkeleton />}>
<Home />
</Suspense>,
beforeLoad: async ({ context }) => {
const queryClient = context.queryClient;
const tournament = await ensureServerQueryData(queryClient, tournamentQueries.current())
@@ -18,11 +22,11 @@ export const Route = createFileRoute("/_authed/")({
title: context.tournament.name || "FLXN"
}
}),
pendingComponent: () => <UpcomingTournamentSkeleton />
});
function Home() {
const { data: tournament } = useCurrentTournament();
if (!tournament.matches || tournament.matches.length === 0) {
return <UpcomingTournament tournament={tournament} />;
}

View File

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

View File

@@ -1,8 +1,9 @@
import TeamProfile from "@/features/teams/components/team-profile";
import ProfileSkeleton from "@/features/teams/components/team-profile/skeleton";
import { teamKeys, teamQueries } from "@/features/teams/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { redirect, createFileRoute } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
import { z } from "zod";
const searchSchema = z.object({
@@ -25,6 +26,8 @@ export const Route = createFileRoute("/_authed/teams/$teamId")({
}),
component: () => {
const { teamId } = Route.useParams();
return <TeamProfile id={teamId} />;
return <Suspense fallback={<ProfileSkeleton />}>
<TeamProfile id={teamId} />
</Suspense>;
},
});

View File

@@ -3,6 +3,8 @@ import { tournamentQueries } from '@/features/tournaments/queries';
import Profile from '@/features/tournaments/components/profile';
import { z } from "zod";
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
import { Suspense } from 'react';
import ProfileSkeleton from '@/features/tournaments/components/profile/skeleton';
const searchSchema = z.object({
tab: z.string().optional(),
@@ -10,9 +12,9 @@ const searchSchema = z.object({
export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
validateSearch: searchSchema,
beforeLoad: async ({ context, params }) => {
beforeLoad: ({ context, params }) => {
const { queryClient } = context;
await prefetchServerQuery(queryClient, tournamentQueries.details(params.tournamentId))
prefetchServerQuery(queryClient, tournamentQueries.details(params.tournamentId))
},
loader: ({ params, context }) => ({
header: {
@@ -28,5 +30,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
function RouteComponent() {
const tournamentId = Route.useParams().tournamentId;
return <Profile id={tournamentId} />
return <Suspense fallback={<ProfileSkeleton />}>
<Profile id={tournamentId} />
</Suspense>
}

View File

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

View File

@@ -24,6 +24,7 @@ export const ServerRoute = createServerFileRoute("/api/events/$").middleware([su
serverEvents.on("test", handleEvent);
serverEvents.on("match", handleEvent);
serverEvents.on("reaction", handleEvent);
const pingInterval = setInterval(() => {
try {

View File

@@ -1,7 +1,6 @@
import { createServerFileRoute } from '@tanstack/react-start/server'
import { SpotifyWebApiClient } from '@/lib/spotify/client'
// Helper function to get access token from cookies
function getAccessTokenFromCookies(request: Request): string | null {
const cookieHeader = request.headers.get('cookie')
if (!cookieHeader) return null
@@ -28,7 +27,7 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
}
const body = await request.json()
const { action, deviceId, volumePercent } = body
const { action, deviceId, volumePercent, trackId, positionMs } = body
const spotifyClient = new SpotifyWebApiClient(accessToken)
@@ -36,6 +35,18 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
case 'play':
await spotifyClient.play(deviceId)
break
case 'playTrack':
if (!trackId) {
return new Response(
JSON.stringify({ error: 'trackId is required for playTrack action' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
)
}
await spotifyClient.playTrack(trackId, deviceId, positionMs)
break
case 'pause':
await spotifyClient.pause()
break
@@ -89,7 +100,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
} catch (error) {
console.error('Playback control error:', error)
// Handle specific Spotify API errors
if (error instanceof Error) {
if (error.message.includes('NO_ACTIVE_DEVICE')) {
return new Response(
@@ -111,7 +121,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
)
}
// Log the full error details for debugging
console.error('Full error details:', {
message: error.message,
stack: error.stack,
@@ -129,7 +138,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
}
},
// GET endpoint for retrieving current playback state and devices
GET: async ({ request }: { request: Request }) => {
try {
const accessToken = getAccessTokenFromCookies(request)
@@ -144,7 +152,7 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
}
const url = new URL(request.url)
const type = url.searchParams.get('type') // 'state' or 'devices'
const type = url.searchParams.get('type')
const spotifyClient = new SpotifyWebApiClient(accessToken)
@@ -167,7 +175,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
}
)
} else {
// Return both by default
const [devices, playbackState] = await Promise.all([
spotifyClient.getDevices(),
spotifyClient.getPlaybackState(),

View File

@@ -19,7 +19,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/token').methods({
)
}
// Refresh access token
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
@@ -46,7 +45,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/token').methods({
const tokens = await tokenResponse.json()
// Return new tokens
return new Response(
JSON.stringify({
access_token: tokens.access_token,

View File

@@ -72,7 +72,8 @@ export const ServerRoute = createServerFileRoute('/api/teams/upload-logo')
});
}
if (!team.players.map(p => p.id).includes(context.userId) && !isAdmin)
const user = await pbAdmin.getPlayerByAuthId(context.userAuthId)
if (!team.players.map(p => p.id).includes(user!.id) && !isAdmin)
return new Response('Unauthorized', { status: 403 });
logger.info('Uploading team logo', {

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ export function RichTextEditor({
const editor = useEditor({
extensions: [StarterKit],
content: value,
immediatelyRender: false,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},

View File

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

View File

@@ -2,7 +2,7 @@ import { PropsWithChildren, useCallback } from "react";
import { useIsMobile } from "@/hooks/use-is-mobile";
import Drawer from "./drawer";
import Modal from "./modal";
import { Box, ScrollArea } from "@mantine/core";
import { ScrollArea } from "@mantine/core";
interface SheetProps extends PropsWithChildren {
title?: string;
@@ -29,7 +29,7 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
scrollbars="y"
type="scroll"
>
<Box mah="70vh">{children}</Box>
{children}
</ScrollArea>
</SheetComponent>
);

View File

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

View File

@@ -19,7 +19,7 @@ import {
ArrowUpIcon,
ArrowDownIcon,
} from "@phosphor-icons/react";
import { BaseStats } from "@/shared/types/stats";
import { BaseStats } from "@/types/stats";
interface StatsOverviewProps {
statsData: BaseStats | null;
@@ -50,18 +50,18 @@ const StatItem = ({
{label}
</Text>
</Group>
<Text size="sm" fw={700} c="dimmed">
{value !== null ? `${value}${suffix}` : "—"}
</Text>
{value !== null ? (
<Text size="sm" fw={700} c="dimmed">
{`${value}${suffix}`}
</Text>
) : (
<Skeleton width={20} height={20} />
)}
</Group>
);
};
const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) => {
if (isLoading || (!statsData && isLoading)) {
return <StatsSkeleton />
}
if (!statsData && !isLoading) {
return (
<Box p="sm" h="auto" mih={200}>
@@ -126,7 +126,7 @@ const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) =>
);
};
const StatsSkeleton = () => {
export const StatsSkeleton = () => {
const skeletonStats = [
{ label: "Matches Played", Icon: BoxingGloveIcon },
{ label: "Wins", Icon: CrownIcon },

View File

@@ -101,20 +101,23 @@ function SwipeableTabs({
useEffect(() => {
const timeoutId = setTimeout(updateHeight, 0);
return () => clearTimeout(timeoutId);
});
}, [updateHeight]);
useEffect(() => {
const activeSlideRef = slideRefs.current[activeTab];
if (!activeSlideRef) return;
let timeoutId: any;
const resizeObserver = new ResizeObserver(() => {
updateHeight();
clearTimeout(timeoutId);
timeoutId = setTimeout(updateHeight, 16);
});
resizeObserver.observe(activeSlideRef);
return () => {
resizeObserver.disconnect();
clearTimeout(timeoutId);
};
}, [activeTab, updateHeight]);

View File

@@ -14,12 +14,14 @@ interface AuthData {
user: Player | undefined;
metadata: { accentColor: MantineColor; colorScheme: MantineColorScheme };
roles: string[];
phone: string;
}
export const defaultAuthData: AuthData = {
user: undefined,
metadata: { accentColor: "blue", colorScheme: "auto" },
metadata: { accentColor: "blue", colorScheme: "dark" },
roles: [],
phone: ""
};
export interface AuthContextType extends AuthData {
@@ -59,6 +61,7 @@ export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
user: data?.user || defaultAuthData.user,
metadata: data?.metadata || defaultAuthData.metadata,
roles: data?.roles || defaultAuthData.roles,
phone: data?.phone || "",
set,
}),
[data, defaultAuthData]

View File

@@ -165,16 +165,16 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
const play = useCallback(async (deviceId?: string) => {
if (!authState.isAuthenticated) return;
setIsLoading(true);
setError(null);
try {
await makeSpotifyRequest('playback', {
method: 'POST',
body: JSON.stringify({ action: 'play', deviceId }),
});
setTimeout(refreshPlaybackState, 500);
} catch (error) {
if (error instanceof Error && !error.message.includes('JSON')) {
@@ -186,6 +186,29 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
}
}, [authState.isAuthenticated]);
const playTrack = useCallback(async (trackId: string, deviceId?: string, positionMs?: number) => {
if (!authState.isAuthenticated) return;
setIsLoading(true);
setError(null);
try {
await makeSpotifyRequest('playback', {
method: 'POST',
body: JSON.stringify({ action: 'playTrack', trackId, deviceId, positionMs }),
});
setTimeout(refreshPlaybackState, 500);
} catch (error) {
if (error instanceof Error && !error.message.includes('JSON')) {
setError(error.message);
}
console.warn('Track playback action completed with warning:', error);
} finally {
setIsLoading(false);
}
}, [authState.isAuthenticated]);
const pause = useCallback(async () => {
if (!authState.isAuthenticated) return;
@@ -415,13 +438,13 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
activeDevice,
isLoading,
error,
// Capture/Resume state
capturedState,
isCaptureLoading,
isResumeLoading,
login,
logout,
play,
playTrack,
pause,
skipNext,
skipPrevious,
@@ -429,11 +452,9 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
getDevices,
setActiveDevice,
refreshPlaybackState,
// Capture/Resume methods
capturePlaybackState,
resumePlaybackState,
clearCapturedState,
// Search
searchTracks,
};

View File

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

View File

@@ -1,16 +1,13 @@
import { Title, AppShell, Flex } from "@mantine/core";
import { Title, AppShell, Flex, Box, Paper } from "@mantine/core";
import { HeaderConfig } from "../types/header-config";
import useRouterConfig from "../hooks/use-router-config";
import BackButton from "./back-button";
interface HeaderProps extends HeaderConfig {}
const Header = ({ collapsed, title }: HeaderProps) => {
const { header } = useRouterConfig();
const Header = ({ collapsed, title, withBackButton }: HeaderProps) => {
return (
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
{ header.withBackButton && <BackButton /> }
{ withBackButton && <BackButton /> }
<Flex justify='center' align='center' h='100%' px='md'>
<Title order={2}>{title}</Title>
</Flex>

View File

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

View File

@@ -13,7 +13,7 @@ const Navbar = () => {
const links = useLinks(user?.id, roles);
if (isMobile) return (
<Paper component='nav' role='navigation' withBorder radius='lg' h='4rem' w='calc(100% - 2rem)' shadow='sm' pos='fixed' m='1rem' bottom='0' style={{ zIndex: 10 }}>
<Paper component='nav' role='navigation' withBorder radius='lg' h='4rem' w='calc(100% - 1rem)' shadow='sm' pos='fixed' m='0.5rem' bottom='0' style={{ zIndex: 10 }}>
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
{links.map((link) => (
<NavLink key={link.href} {...link} />

View File

@@ -4,6 +4,7 @@ import useAppShellHeight from "@/hooks/use-appshell-height";
import { ArrowClockwiseIcon, SpinnerIcon } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
import useRouterConfig from "../hooks/use-router-config";
import { useLocation } from "@tanstack/react-router";
const THRESHOLD = 80;
@@ -21,6 +22,8 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
const [scrolling, setScrolling] = useState(false);
const { refresh } = useRouterConfig();
const queryClient = useQueryClient();
const location = useLocation();
const scrollAreaRef = useRef<HTMLDivElement>(null);
const scrollY = useMemo(() => scrollPosition.y < 0 && scrolling ? Math.abs(scrollPosition.y) : 0, [scrollPosition.y, scrolling]);
@@ -79,6 +82,21 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
return () => void ac.abort();
}, []);
useEffect(() => {
const timeoutId = setTimeout(() => {
if (scrollAreaRef.current) {
const viewport = scrollAreaRef.current.querySelector('.mantine-ScrollArea-viewport') as HTMLElement;
if (viewport) {
viewport.scrollTop = 0;
viewport.scrollLeft = 0;
}
}
onScrollPositionChange({ x: 0, y: 0 });
}, 10);
return () => clearTimeout(timeoutId);
}, [location.pathname, onScrollPositionChange]);
return (
<>
@@ -103,6 +121,7 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
/>
</Flex>
<ScrollArea
ref={scrollAreaRef}
id='scroll-wrapper'
onScrollPositionChange={onScrollPositionChange}
type='never' mah='100%' h='100%'

View File

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

View File

@@ -1,9 +1,10 @@
import { Text, Group, Stack, Paper, Indicator, Box } from "@mantine/core";
import { Text, Group, Stack, Paper, Indicator, Box, Tooltip } from "@mantine/core";
import { CrownIcon } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import { Match } from "../types";
import Avatar from "@/components/avatar";
import EmojiBar from "@/features/reactions/components/emoji-bar";
import { Suspense } from "react";
interface MatchCardProps {
match: Match;
@@ -29,8 +30,6 @@ const MatchCard = ({ match }: MatchCardProps) => {
}
};
console.log(match);
return (
<Indicator
disabled={!isStarted}
@@ -90,15 +89,28 @@ const MatchCard = ({ match }: MatchCardProps) => {
</Box>
)}
</Box>
<Text
size="sm"
fw={600}
lineClamp={1}
style={{ minWidth: 0, flex: 1 }}
<Tooltip
label={match.home?.name!}
disabled={!match.home?.name}
events={{ hover: true, focus: true, touch: true }}
>
{match.home?.name!}
</Text>
<Text
size="sm"
fw={600}
lineClamp={1}
style={{ minWidth: 0, flex: 1, cursor: 'pointer' }}
>
{match.home?.name!}
</Text>
</Tooltip>
</Group>
<Stack gap={1}>
{match.home?.players.map((p) => (
<Text key={`match-card-p-${p.id}`} size="xs" fw={600} c="dimmed" ta="right">
{p.first_name} {p.last_name}
</Text>
))}
</Stack>
<Text
size="xl"
fw={700}
@@ -107,13 +119,6 @@ const MatchCard = ({ match }: MatchCardProps) => {
>
{match.home_cups}
</Text>
<Stack gap={1}>
{match.home?.players.map((p) => (
<Text size="xs" fw={600} c="dimmed" ta="right">
{p.first_name} {p.last_name}
</Text>
))}
</Stack>
</Group>
<Group justify="space-between" align="center">
@@ -146,15 +151,28 @@ const MatchCard = ({ match }: MatchCardProps) => {
</Box>
)}
</Box>
<Text
size="sm"
fw={600}
lineClamp={1}
style={{ minWidth: 0, flex: 1 }}
<Tooltip
label={match.away?.name}
disabled={!match.away?.name}
events={{ hover: true, focus: true, touch: true }}
>
{match.away?.name}
</Text>
<Text
size="sm"
fw={600}
lineClamp={1}
style={{ minWidth: 0, flex: 1, cursor: 'pointer' }}
>
{match.away?.name}
</Text>
</Tooltip>
</Group>
<Stack gap={1}>
{match.away?.players.map((p) => (
<Text key={`match-card-p-${p.id}`} size="xs" fw={600} c="dimmed" ta="right">
{p.first_name} {p.last_name}
</Text>
))}
</Stack>
<Text
size="xl"
fw={700}
@@ -163,13 +181,6 @@ const MatchCard = ({ match }: MatchCardProps) => {
>
{match.away_cups}
</Text>
<Stack gap={1}>
{match.away?.players.map((p) => (
<Text size="xs" fw={600} c="dimmed" ta="right">
{p.first_name} {p.last_name}
</Text>
))}
</Stack>
</Group>
</Stack>
</Paper>
@@ -189,7 +200,9 @@ const MatchCard = ({ match }: MatchCardProps) => {
border: "1px solid var(--mantine-color-default-border)",
}}
>
<EmojiBar matchId={match.id} />
<Suspense>
<EmojiBar matchId={match.id} />
</Suspense>
</Paper>
</Box>
</Indicator>

View File

@@ -1,5 +1,4 @@
import { Stack } from "@mantine/core";
import { motion, AnimatePresence } from "framer-motion";
import { Match } from "../types";
import MatchCard from "./match-card";
@@ -18,19 +17,13 @@ const MatchList = ({ matches }: MatchListProps) => {
return (
<Stack p="md" gap="sm">
<AnimatePresence>
{filteredMatches.map((match, index) => (
<motion.div
key={`match-${match.id}-${index}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2, delay: index * 0.01 }}
>
<MatchCard match={match} />
</motion.div>
))}
</AnimatePresence>
{filteredMatches.map((match, index) => (
<div
key={`match-${match.id}-${index}`}
>
<MatchCard match={match} />
</div>
))}
</Stack>
);
};

View File

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

View File

@@ -153,7 +153,6 @@ export const startMatch = createServerFn()
status: "started",
});
console.log('emitting start match...')
serverEvents.emit("match", {
type: "match",
matchId: match.id,
@@ -300,7 +299,6 @@ export const toggleMatchReaction = createServerFn()
serverEvents.emit("reaction", {
type: "reaction",
matchId,
tournamentId: match.tournament.id,
reactions,
});

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useCallback, memo } from "react";
import {
Text,
TextInput,
@@ -12,7 +12,6 @@ import {
UnstyledButton,
Popover,
ActionIcon,
Skeleton,
} from "@mantine/core";
import {
MagnifyingGlassIcon,
@@ -24,10 +23,7 @@ import {
import { PlayerStats } from "../types";
import Avatar from "@/components/avatar";
import { useNavigate } from "@tanstack/react-router";
interface PlayerStatsTableProps {
playerStats: PlayerStats[];
}
import { useAllPlayerStats } from "../queries";
type SortKey = keyof PlayerStats | "mmr";
type SortDirection = "asc" | "desc";
@@ -39,33 +35,11 @@ interface SortConfig {
interface PlayerListItemProps {
stat: PlayerStats;
index: number;
onPlayerClick: (playerId: string) => void;
mmr: number;
}
const PlayerListItem = ({ stat, index, onPlayerClick }: PlayerListItemProps) => {
const calculateMMR = (stat: PlayerStats): number => {
if (stat.matches === 0) return 0;
const winScore = stat.win_percentage;
const matchConfidence = Math.min(stat.matches / 15, 1);
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100);
const marginScore = stat.margin_of_victory
? Math.min(stat.margin_of_victory * 20, 50)
: 0;
const volumeBonus = Math.min(stat.matches * 0.5, 10);
const baseMMR =
winScore * 0.5 +
avgCupsScore * 0.25 +
marginScore * 0.15 +
volumeBonus * 0.1;
const finalMMR = baseMMR * matchConfidence;
return Math.round(finalMMR * 10) / 10;
};
const mmr = calculateMMR(stat);
const PlayerListItem = memo(({ stat, onPlayerClick, mmr }: PlayerListItemProps) => {
return (
<>
@@ -165,9 +139,12 @@ const PlayerListItem = ({ stat, index, onPlayerClick }: PlayerListItemProps) =>
</UnstyledButton>
</>
);
};
});
const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
PlayerListItem.displayName = 'PlayerListItem';
const PlayerStatsTable = () => {
const { data: playerStats } = useAllPlayerStats();
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [sortConfig, setSortConfig] = useState<SortConfig>({
@@ -196,8 +173,15 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
return Math.round(finalMMR * 10) / 10;
};
const statsWithMMR = useMemo(() => {
return playerStats.map((stat) => ({
...stat,
mmr: calculateMMR(stat),
}));
}, [playerStats]);
const filteredAndSortedStats = useMemo(() => {
let filtered = playerStats.filter((stat) =>
let filtered = statsWithMMR.filter((stat) =>
stat.player_name.toLowerCase().includes(search.toLowerCase())
);
@@ -206,8 +190,8 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
let bValue: number | string;
if (sortConfig.key === "mmr") {
aValue = calculateMMR(a);
bValue = calculateMMR(b);
aValue = a.mmr;
bValue = b.mmr;
} else {
aValue = a[sortConfig.key];
bValue = b[sortConfig.key];
@@ -227,11 +211,11 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
return 0;
});
}, [playerStats, search, sortConfig]);
}, [statsWithMMR, search, sortConfig]);
const handlePlayerClick = (playerId: string) => {
const handlePlayerClick = useCallback((playerId: string) => {
navigate({ to: `/profile/${playerId}` });
};
}, [navigate]);
const handleSort = (key: SortKey) => {
setSortConfig((prev) => ({
@@ -351,8 +335,8 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
<Box key={stat.id}>
<PlayerListItem
stat={stat}
index={index}
onPlayerClick={handlePlayerClick}
mmr={stat.mmr}
/>
{index < filteredAndSortedStats.length - 1 && <Divider />}
</Box>

View File

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

View File

@@ -33,7 +33,7 @@ const Header = ({ player }: HeaderProps) => {
return (
<>
<Flex h="10vh" px='xl' w='100%' align='self-end' gap='md'>
<Flex h="15dvh" px='xl' w='100%' align='self-end' gap='md'>
<Avatar name={name} size={100} />
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Title ta='center' style={{ fontSize, lineHeight: 1.2 }}>{name}</Title>

View File

@@ -3,7 +3,7 @@ import Header from "./header";
import SwipeableTabs from "@/components/swipeable-tabs";
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
import TeamList from "@/features/teams/components/team-list";
import StatsOverview from "@/shared/components/stats-overview";
import StatsOverview from "@/components/stats-overview";
import MatchList from "@/features/matches/components/match-list";
interface ProfileProps {
@@ -15,8 +15,6 @@ const Profile = ({ id }: ProfileProps) => {
const { data: matches } = usePlayerMatches(id);
const { data: stats, isLoading: statsLoading } = usePlayerStats(id);
console.log(player.teams)
const tabs = [
{
label: "Overview",

View File

@@ -3,7 +3,7 @@ import Header from "./header";
import SwipeableTabs from "@/components/swipeable-tabs";
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
import TeamList from "@/features/teams/components/team-list";
import StatsOverview from "@/shared/components/stats-overview";
import StatsOverview, { StatsSkeleton } from "@/components/stats-overview";
import MatchList from "@/features/matches/components/match-list";
import HeaderSkeleton from "./header-skeleton";
@@ -17,7 +17,7 @@ const ProfileSkeleton = () => {
const tabs = [
{
label: "Overview",
content: <SkeletonLoader />,
content: <StatsSkeleton />,
},
{
label: "Matches",

View File

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

View File

@@ -21,11 +21,18 @@ export const fetchMe = createServerFn()
return {
user: result || undefined,
roles: context.roles,
metadata: context.metadata
metadata: context.metadata,
phone: context.phone
};
} catch (error: any) {
logger.info('fetchMe: Session error', error.message);
return { user: undefined, roles: [], metadata: {} };
logger.info("FetchMe: Session error", error)
if (error?.response?.status === 401) {
const errorData = error?.response?.data;
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
throw error;
}
}
return { user: undefined, roles: [], metadata: {}, phone: undefined };
}
})
);
@@ -146,4 +153,4 @@ export const getUnenrolledPlayers = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: tournamentId }) =>
toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId))
);
);

View File

@@ -2,7 +2,9 @@ import {
Group,
Button,
Text,
Tabs,
Stack,
ScrollArea,
Paper,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useState, useRef, useCallback } from "react";
@@ -27,12 +29,12 @@ const EmojiBar = ({
const toggleReaction = useToggleMatchReaction(matchId);
const [opened, { open, close }] = useDisclosure(false);
const [activeTab, setActiveTab] = useState<string | null>(null);
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
const longPressTimeout = useRef<NodeJS.Timeout | null>(null);
const handleLongPressStart = (emoji: string) => {
longPressTimeout.current = setTimeout(() => {
setActiveTab(emoji);
setSelectedEmoji(emoji);
open();
}, 500);
};
@@ -54,21 +56,29 @@ const EmojiBar = ({
const hasReacted = useCallback((reaction: Reaction) => {
return reaction.players.map(p => p.id).includes(user?.id || "");
}, []);
}, [user?.id]);
const userReactions = reactions?.filter(r => hasReacted(r)).map(r => r.emoji) || [];
if (!reactions) return;
console.log(reactions)
const sortedReactions = [...reactions].sort((a, b) => b.count - a.count);
const visibleReactions = sortedReactions.slice(0, 3);
const groupedReactions = sortedReactions.slice(3);
const hasGrouped = groupedReactions.length > 0;
const groupedCount = groupedReactions.reduce((sum, r) => sum + r.count, 0);
const userHasReactedToGrouped = groupedReactions.some(r => hasReacted(r));
return (
<>
<Group gap="xs" wrap="wrap" justify="space-between">
<Group gap="xs" wrap="wrap">
{reactions.map((reaction) => (
{visibleReactions.map((reaction) => (
<Button
key={reaction.emoji}
variant={hasReacted(reaction) ? "filled" : "light"}
color="gray"
variant={"light"}
bd={hasReacted(reaction) ? "1px solid var(--mantine-primary-color-filled)" : undefined}
size="compact-xs"
radius="xl"
@@ -95,31 +105,99 @@ const EmojiBar = ({
</Group>
</Button>
))}
{hasGrouped && (
<Button
variant={"light"}
bd={userHasReactedToGrouped ? "1px solid var(--mantine-primary-color-filled)" : undefined}
size="compact-xs"
radius="xl"
onMouseDown={() => handleLongPressStart(groupedReactions[0]?.emoji || "")}
onMouseUp={handleLongPressEnd}
onMouseLeave={handleLongPressEnd}
onTouchStart={() => handleLongPressStart(groupedReactions[0]?.emoji || "")}
onTouchEnd={handleLongPressEnd}
onClick={() => {
setSelectedEmoji(groupedReactions[0]?.emoji || "");
open();
}}
style={{
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
position: "relative",
}}
>
<Group gap={2} align="center">
<div style={{
display: "flex",
gap: "1px",
alignItems: "center",
fontSize: "10px",
lineHeight: 1
}}>
{groupedReactions.slice(0, 2).map((reaction) => (
<span key={reaction.emoji}>{reaction.emoji}</span>
))}
{groupedReactions.length > 2 && (
<Text size="8px" fw={600} c="dimmed">
+{groupedReactions.length - 2}
</Text>
)}
</div>
<Text size="xs" fw={600}>
{groupedCount}
</Text>
</Group>
</Button>
)}
</Group>
<EmojiPicker onSelect={onReactionPress || ((emoji) => toggleReaction.mutate({ data: { matchId, emoji } }))} />
<EmojiPicker
onSelect={onReactionPress || ((emoji) => toggleReaction.mutate({ data: { matchId, emoji } }))}
userReactions={userReactions}
/>
</Group>
<Sheet title="Reactions" opened={opened} onChange={() => close()}>
<Tabs value={activeTab || reactions[0]?.emoji} onChange={setActiveTab}>
<Tabs.List grow>
{reactions.map((reaction) => (
<Tabs.Tab key={reaction.emoji} value={reaction.emoji}>
<Group gap="xs" align="center">
<Text size="lg">{reaction.emoji}</Text>
<Text size="xs" c="dimmed">
{reaction.count}
</Text>
</Group>
</Tabs.Tab>
))}
</Tabs.List>
<Stack gap="md">
<ScrollArea w="100%" offsetScrollbars>
<Group gap="xs" wrap="nowrap" px="xs">
{sortedReactions.map((reaction) => (
<Button
key={reaction.emoji}
variant={selectedEmoji === reaction.emoji ? "filled" : "light"}
color="gray"
size="compact-sm"
radius="xl"
onClick={() => setSelectedEmoji(reaction.emoji)}
style={{ flexShrink: 0 }}
>
<Group gap={4} align="center">
<Text size="sm">{reaction.emoji}</Text>
<Text size="xs" fw={600}>
{reaction.count}
</Text>
</Group>
</Button>
))}
</Group>
</ScrollArea>
{reactions.map((reaction) => (
<Tabs.Panel key={reaction.emoji} value={reaction.emoji} pt="md">
<PlayerList players={reaction.players} />
</Tabs.Panel>
))}
</Tabs>
{selectedEmoji && (
<Paper p="md" withBorder radius="md">
<Group gap="sm" mb="md">
<Text size="2xl">{selectedEmoji}</Text>
<div>
<Text size="lg" fw={600}>
{sortedReactions.find(r => r.emoji === selectedEmoji)?.count || 0}
</Text>
</div>
</Group>
<PlayerList players={sortedReactions.find(r => r.emoji === selectedEmoji)?.players || []} />
</Paper>
)}
</Stack>
</Sheet>
</>
);

View File

@@ -5,20 +5,28 @@ import { useState } from "react";
interface EmojiPickerProps {
onSelect: (emoji: string) => void;
disabled?: boolean;
userReactions?: string[];
}
const EMOJIS = [
{ emoji: "😊", label: "smile" },
{ emoji: "😢", label: "cry" },
{ emoji: "🫡", label: "salute" },
{ emoji: "😭", label: "crying" },
{ emoji: "🫦", label: "lip" },
{ emoji: "🏗️", label: "crane" },
{ emoji: "👀", label: "eyes" },
{ emoji: "🔥", label: "fire" },
{ emoji: "❤️", label: "heart" },
{ emoji: "👑", label: "crown" },
{ emoji: "😱", label: "scream" },
{ emoji: "🥹", label: "owo" },
{ emoji: "🤣", label: "rofl" },
{ emoji: "🤪", label: "crazy" },
{ emoji: "🤓", label: "nerd" },
{ emoji: "🥵", label: "hot" },
{ emoji: "🥶", label: "cold" },
];
const EmojiPicker = ({
onSelect,
disabled = false
disabled = false,
userReactions = []
}: EmojiPickerProps) => {
const [opened, setOpened] = useState(false);
@@ -50,36 +58,43 @@ const EmojiPicker = ({
</Popover.Target>
<Popover.Dropdown p="xs">
<SimpleGrid cols={6} spacing={0}>
{EMOJIS.map(({ emoji, label }) => (
<UnstyledButton
key={emoji}
onClick={() => handleEmojiSelect(emoji)}
style={{
borderRadius: "var(--mantine-radius-sm)",
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 36,
minWidth: 36,
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'var(--mantine-color-gray-1)',
<SimpleGrid cols={6} spacing={4}>
{EMOJIS.map(({ emoji, label }) => {
const hasReacted = userReactions.includes(emoji);
return (
<UnstyledButton
key={emoji}
onClick={() => handleEmojiSelect(emoji)}
style={{
borderRadius: "var(--mantine-radius-sm)",
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 36,
minWidth: 36,
backgroundColor: hasReacted ? 'var(--mantine-primary-color-light)' : undefined,
border: hasReacted ? '1px solid var(--mantine-primary-color-filled)' : undefined,
}}
styles={{
root: {
'&:hover': {
backgroundColor: hasReacted
? 'var(--mantine-primary-color-light-hover)'
: 'var(--mantine-color-gray-1)',
},
'&:active': {
transform: 'scale(0.95)',
},
},
'&:active': {
transform: 'scale(0.95)',
},
},
}}
aria-label={label}
>
<Text size="lg" style={{ lineHeight: 1 }}>
{emoji}
</Text>
</UnstyledButton>
))}
}}
aria-label={label}
>
<Text size="lg" style={{ lineHeight: 1 }}>
{emoji}
</Text>
</UnstyledButton>
);
})}
</SimpleGrid>
</Popover.Dropdown>
</Popover>

View File

@@ -1,7 +1,6 @@
import { useServerQuery, useServerMutation } from "@/lib/tanstack-query/hooks";
import { useServerMutation, useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { getMatchReactions, toggleMatchReaction } from "@/features/matches/server";
import { useQueryClient } from "@tanstack/react-query";
import { matchKeys } from "@/features/matches/queries";
export const reactionKeys = {
match: (matchId: string) => ['reactions', 'match', matchId] as const,
@@ -15,7 +14,7 @@ export const reactionQueries = {
};
export const useMatchReactions = (matchId: string) =>
useServerQuery(reactionQueries.match(matchId));
useServerSuspenseQuery(reactionQueries.match(matchId));
export const useToggleMatchReaction = (matchId: string) => {
const queryClient = useQueryClient();
@@ -26,9 +25,6 @@ export const useToggleMatchReaction = (matchId: string) => {
queryClient.invalidateQueries({
queryKey: reactionKeys.match(matchId)
});
queryClient.invalidateQueries({
queryKey: matchKeys.reactions(matchId)
});
},
});
};
};

View File

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

View File

@@ -17,8 +17,6 @@ interface TeamCardProps {
const TeamCard = ({ teamId }: TeamCardProps) => {
const { data: team, error } = useTeam(teamId);
console.log(team)
if (error || !team) {
return (
<Paper p="sm" withBorder radius="md">

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import { Stack, Text, Group, RangeSlider, Divider } from "@mantine/core";
import { Stack, Text, Group, TextInput, Button } from "@mantine/core";
interface DurationPickerProps {
songDurationMs: number;
@@ -9,6 +9,41 @@ interface DurationPickerProps {
disabled?: boolean;
}
interface IncrementButtonsProps {
onAdjust: (seconds: number) => void;
disabled: boolean;
isPositive?: boolean;
}
const IncrementButtons = ({ onAdjust, disabled, isPositive = true }: IncrementButtonsProps) => {
const increments = [1, 5, 30, 60];
const labels = ["1s", "5s", "30s", "1m"];
return (
<Group gap={3} wrap="nowrap" flex={1}>
{increments.map((increment, index) => (
<Button
key={increment}
variant={isPositive ? "light" : "outline"}
color={isPositive ? "blue" : "gray"}
size="xs"
disabled={disabled}
onClick={() => onAdjust(isPositive ? increment : -increment)}
flex={1}
h={24}
style={{
fontSize: '10px',
fontWeight: 500,
minWidth: 0
}}
>
{isPositive ? '+' : '-'}{labels[index]}
</Button>
))}
</Group>
);
};
const DurationPicker = ({
songDurationMs,
initialStart = 0,
@@ -17,11 +52,6 @@ const DurationPicker = ({
disabled = false,
}: DurationPickerProps) => {
const songDurationSeconds = Math.floor(songDurationMs / 1000);
const [range, setRange] = useState<[number, number]>([
initialStart,
initialEnd,
]);
const [isValid, setIsValid] = useState(true);
const formatTime = useCallback((seconds: number) => {
const minutes = Math.floor(seconds / 60);
@@ -29,7 +59,26 @@ const DurationPicker = ({
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
}, []);
const validateRange = useCallback(
const [startTime, setStartTime] = useState(initialStart);
const [endTime, setEndTime] = useState(initialEnd);
const [isValid, setIsValid] = useState(true);
const [startInputValue, setStartInputValue] = useState(formatTime(initialStart));
const [endInputValue, setEndInputValue] = useState(formatTime(initialEnd));
const parseTimeInput = useCallback((input: string): number | null => {
if (input.includes(':')) {
const parts = input.split(':');
if (parts.length === 2) {
const minutes = parseInt(parts[0]) || 0;
const seconds = parseInt(parts[1]) || 0;
return minutes * 60 + seconds;
}
}
const parsed = parseInt(input);
return isNaN(parsed) ? null : parsed;
}, []);
const validateTimes = useCallback(
(start: number, end: number) => {
const duration = end - start;
const withinBounds = start >= 0 && end <= songDurationSeconds;
@@ -53,146 +102,150 @@ const DurationPicker = ({
return null;
}, [songDurationSeconds]);
const handleRangeChange = useCallback(
(newRange: [number, number]) => {
setRange(newRange);
const [start, end] = newRange;
const valid = validateRange(start, end);
setIsValid(valid);
const updateTimes = useCallback((newStart: number, newEnd: number) => {
const clampedStart = Math.max(0, Math.min(newStart, songDurationSeconds - 10));
const clampedEnd = Math.min(songDurationSeconds, Math.max(newEnd, clampedStart + 10));
if (valid) {
onChange(start, end);
}
},
[onChange, validateRange]
);
setStartTime(clampedStart);
setEndTime(clampedEnd);
setStartInputValue(formatTime(clampedStart));
setEndInputValue(formatTime(clampedEnd));
const handleRangeChangeEnd = useCallback(
(newRange: [number, number]) => {
let [start, end] = newRange;
let duration = end - start;
const valid = validateTimes(clampedStart, clampedEnd);
setIsValid(valid);
if (duration < 10) {
if (start < songDurationSeconds / 2) {
end = Math.min(start + 10, songDurationSeconds);
} else {
start = Math.max(end - 10, 0);
}
duration = end - start;
}
if (valid) {
onChange(clampedStart, clampedEnd);
}
}, [songDurationSeconds, validateTimes, onChange, formatTime]);
if (duration > 15) {
const startDiff = Math.abs(start - range[0]);
const endDiff = Math.abs(end - range[1]);
const handleStartInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setStartInputValue(event.target.value);
}, []);
if (startDiff > endDiff) {
end = start + 15;
if (end > songDurationSeconds) {
end = songDurationSeconds;
start = end - 15;
}
} else {
start = end - 15;
if (start < 0) {
start = 0;
end = start + 15;
}
}
}
const handleEndInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setEndInputValue(event.target.value);
}, []);
start = Math.max(0, start);
end = Math.min(songDurationSeconds, end);
const handleStartBlur = useCallback(() => {
const parsed = parseTimeInput(startInputValue);
if (parsed !== null) {
updateTimes(parsed, endTime);
} else {
setStartInputValue(formatTime(startTime));
}
}, [startInputValue, endTime, updateTimes, parseTimeInput, formatTime, startTime]);
const finalRange: [number, number] = [start, end];
setRange(finalRange);
setIsValid(validateRange(start, end));
onChange(start, end);
},
[range, songDurationSeconds, onChange, validateRange]
);
const handleEndBlur = useCallback(() => {
const parsed = parseTimeInput(endInputValue);
if (parsed !== null) {
updateTimes(startTime, parsed);
} else {
setEndInputValue(formatTime(endTime));
}
}, [endInputValue, startTime, updateTimes, parseTimeInput, formatTime, endTime]);
const adjustStartTime = useCallback((seconds: number) => {
updateTimes(startTime + seconds, endTime);
}, [startTime, endTime, updateTimes]);
const adjustEndTime = useCallback((seconds: number) => {
updateTimes(startTime, endTime + seconds);
}, [startTime, endTime, updateTimes]);
useEffect(() => {
if (!validateRange(initialStart, initialEnd)) {
if (!validateTimes(initialStart, initialEnd)) {
const defaultStart = Math.min(30, Math.max(0, songDurationSeconds - 15));
const defaultEnd = Math.min(defaultStart + 15, songDurationSeconds);
const defaultRange: [number, number] = [defaultStart, defaultEnd];
setRange(defaultRange);
onChange(defaultStart, defaultEnd);
updateTimes(defaultStart, defaultEnd);
}
}, [initialStart, initialEnd, songDurationSeconds, validateRange, onChange]);
}, [initialStart, initialEnd, songDurationSeconds, validateTimes, updateTimes]);
const segmentDuration = range[1] - range[0];
const segmentDuration = endTime - startTime;
return (
<Stack gap="md" opacity={disabled ? 0.5 : 1}>
<div>
<Text size="sm" fw={500} mb="xs" c={disabled ? "dimmed" : undefined}>
Start and End
</Text>
<Text size="xs" c="dimmed" mb="md">
{disabled ? "Select a song to choose segment timing" : "Choose a 10-15 second segment for your walkout song"}
</Text>
</div>
<Stack gap="sm" opacity={disabled ? 0.5 : 1}>
<Text size="sm" fw={500} c={disabled ? "dimmed" : undefined} ta="center">
Walkout Segment ({segmentDuration}s)
</Text>
<RangeSlider
min={0}
max={songDurationSeconds}
step={1}
value={range}
onChange={disabled ? undefined : handleRangeChange}
onChangeEnd={disabled ? undefined : handleRangeChangeEnd}
marks={[
{ value: 0, label: "0:00" },
{
value: songDurationSeconds,
label: formatTime(songDurationSeconds),
},
]}
size="lg"
m='xs'
color={disabled ? "gray" : (isValid ? "blue" : "red")}
thumbSize={20}
label={disabled ? undefined : (value) => formatTime(value)}
disabled={disabled}
styles={{
track: { height: 8 },
}}
/>
<Divider />
<Group justify="space-between" align="center">
<Stack gap={2} align="center">
<Text size="xs" c="dimmed">
Start
</Text>
<Text size="sm" fw={500}>
{formatTime(range[0])}
</Text>
<Stack gap="sm">
<Stack gap={4}>
<Group justify="space-between" align="center">
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
Start
</Text>
<TextInput
value={startInputValue}
onChange={handleStartInputChange}
onBlur={handleStartBlur}
disabled={disabled}
size="xs"
w={70}
placeholder="0:00"
ta="center"
styles={{
input: {
fontWeight: 600,
fontSize: '12px'
}
}}
/>
</Group>
<Group gap={4}>
<IncrementButtons
onAdjust={adjustStartTime}
disabled={disabled || startTime <= 0}
isPositive={false}
/>
<IncrementButtons
onAdjust={adjustStartTime}
disabled={disabled || startTime >= songDurationSeconds - 10}
isPositive={true}
/>
</Group>
</Stack>
<Stack gap={2} align="center">
<Text size="xs" c="dimmed">
Duration
</Text>
<Text size="sm" fw={500} c={isValid ? undefined : "red"}>
{segmentDuration}s
</Text>
<Stack gap={4}>
<Group justify="space-between" align="center">
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
End
</Text>
<TextInput
value={endInputValue}
onChange={handleEndInputChange}
onBlur={handleEndBlur}
disabled={disabled}
size="xs"
w={70}
placeholder="0:15"
ta="center"
styles={{
input: {
fontWeight: 600,
fontSize: '12px'
}
}}
/>
</Group>
<Group gap={4}>
<IncrementButtons
onAdjust={adjustEndTime}
disabled={disabled || endTime <= startTime + 10}
isPositive={false}
/>
<IncrementButtons
onAdjust={adjustEndTime}
disabled={disabled || endTime >= songDurationSeconds}
isPositive={true}
/>
</Group>
</Stack>
<Stack gap={2} align="center">
<Text size="xs" c="dimmed">
End
</Text>
<Text size="sm" fw={500}>
{formatTime(range[1])}
</Text>
</Stack>
</Group>
</Stack>
{!isValid && (
<Text size="xs" c="red" ta="center">
{getValidationMessage(range[0], range[1])}
{getValidationMessage(startTime, endTime)}
</Text>
)}
</Stack>

View File

@@ -106,7 +106,6 @@ const TeamForm = ({
mutation(teamData, {
onSuccess: async (team: any) => {
console.log(team)
queryClient.invalidateQueries({ queryKey: teamKeys.list });
queryClient.invalidateQueries({
queryKey: teamKeys.details(team.id),
@@ -130,7 +129,6 @@ const TeamForm = ({
const result = await response.json();
console.log('here for some reason', result)
queryClient.setQueryData(
tournamentKeys.details(result.team!.id),

View File

@@ -8,6 +8,7 @@ import SongSearch from "./song-search";
import DurationPicker from "./duration-picker";
import SongSummary from "./song-summary";
import { MusicNote } from "@phosphor-icons/react/dist/ssr";
import { MusicNoteIcon } from "@phosphor-icons/react";
interface Song {
song_id: string;
@@ -17,6 +18,7 @@ interface Song {
song_start?: number;
song_end?: number;
song_image_url: string;
duration_ms?: number;
}
interface SongPickerProps {
@@ -61,7 +63,7 @@ const SongPicker = ({ form, error }: SongPickerProps) => {
}}
error={error}
Component={SongPickerComponent}
componentProps={{ formValues: form.getValues() }}
componentProps={{}}
title={"Select Song"}
label={"Walkout Song"}
placeholder={"Select your walkout song"}
@@ -72,10 +74,9 @@ const SongPicker = ({ form, error }: SongPickerProps) => {
interface SongPickerComponentProps {
value: Song | undefined;
onChange: (song: Song) => void;
formValues: any;
}
const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerComponentProps) => {
const SongPickerComponent = ({ value: song, onChange }: SongPickerComponentProps) => {
const handleSongSelect = (track: SpotifyTrack) => {
const defaultStart = 0;
const defaultEnd = Math.min(15, Math.floor(track.duration_ms / 1000));
@@ -88,6 +89,7 @@ const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerCo
song_image_url: track.album.images[0]?.url || '',
song_start: defaultStart,
song_end: defaultEnd,
duration_ms: track.duration_ms,
};
onChange(newSong);
@@ -117,7 +119,7 @@ const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerCo
radius="md"
bg="transparent"
>
{!song?.song_image_url && <MusicNote size={24} color="var(--mantine-color-dimmed)" />}
{!song?.song_image_url && <MusicNoteIcon size={24} color="var(--mantine-color-dimmed)" />}
</Avatar>
<div>
<Text size="sm" fw={500} c={song?.song_name ? undefined : "dimmed"}>
@@ -134,7 +136,7 @@ const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerCo
<Stack gap="xs">
<DurationPicker
songDurationMs={180000}
songDurationMs={song?.duration_ms || 180000}
initialStart={song?.song_start || 0}
initialEnd={song?.song_end || 15}
onChange={handleDurationChange}

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { Text, Combobox, InputBase, useCombobox, Group, Avatar, Loader } from "@mantine/core";
import { useState, useRef, useEffect } from "react";
import { Text, TextInput, Group, Avatar, Loader, Paper, Stack, Box } from "@mantine/core";
import { SpotifyTrack } from "@/lib/spotify/types";
import { useDebouncedCallback } from "@mantine/hooks";
@@ -12,10 +12,11 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<SpotifyTrack[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const combobox = useCombobox();
// Standalone search function that doesn't require Spotify context
const searchSpotifyTracks = async (query: string): Promise<SpotifyTrack[]> => {
if (!query.trim()) return [];
@@ -37,6 +38,7 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
const debouncedSearch = useDebouncedCallback(async (query: string) => {
if (!query.trim()) {
setSearchResults([]);
setIsOpen(false);
return;
}
@@ -44,10 +46,12 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
try {
const results = await searchSpotifyTracks(query);
setSearchResults(results);
combobox.openDropdown();
setIsOpen(results.length > 0);
setSelectedIndex(-1);
} catch (error) {
console.error('Search failed:', error);
setSearchResults([]);
setIsOpen(false);
} finally {
setIsLoading(false);
}
@@ -61,60 +65,117 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
const handleSongSelect = (track: SpotifyTrack) => {
onChange(track);
setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`);
combobox.closeDropdown();
setIsOpen(false);
setSelectedIndex(-1);
};
const options = searchResults.map((track) => (
<Combobox.Option value={track.id} key={track.id}>
<Group gap="sm">
{track.album.images[2] && (
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
)}
<div>
<Text size="sm" fw={500}>
{track.name}
</Text>
<Text size="xs" c="dimmed">
{track.artists.map(a => a.name).join(', ')} {track.album.name}
</Text>
</div>
</Group>
</Combobox.Option>
));
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen || searchResults.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev => (prev < searchResults.length - 1 ? prev + 1 : prev));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev));
break;
case 'Enter':
e.preventDefault();
if (selectedIndex >= 0 && searchResults[selectedIndex]) {
handleSongSelect(searchResults[selectedIndex]);
}
break;
case 'Escape':
setIsOpen(false);
setSelectedIndex(-1);
break;
}
};
return (
<Combobox
store={combobox}
onOptionSubmit={(value) => {
const track = searchResults.find(t => t.id === value);
if (track) handleSongSelect(track);
}}
width='100%'
zIndex={9999}
withinPortal={false}
>
<Combobox.Target>
<InputBase
rightSection={isLoading ? <Loader size="xs" /> : <Combobox.Chevron />}
value={searchQuery}
onChange={(event) => handleSearchChange(event.currentTarget.value)}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
placeholder={placeholder}
/>
</Combobox.Target>
<Box ref={containerRef} pos="relative" w="100%">
<TextInput
ref={inputRef}
value={searchQuery}
onChange={(event) => handleSearchChange(event.currentTarget.value)}
onKeyDown={handleKeyDown}
onFocus={() => {
if (searchResults.length > 0) setIsOpen(true);
}}
placeholder={placeholder}
rightSection={isLoading ? <Loader size="xs" /> : null}
/>
<Combobox.Dropdown>
<Combobox.Options>
{options.length > 0 ? options :
<Combobox.Empty>
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
</Combobox.Empty>
}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
{isOpen && (
<Paper
shadow="md"
p={0}
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 9999,
maxHeight: '160px',
overflowY: 'auto',
WebkitOverflowScrolling: 'touch',
touchAction: 'pan-y'
}}
onTouchMove={(e) => e.stopPropagation()}
>
{searchResults.length > 0 ? (
<Stack gap={0}>
{searchResults.map((track, index) => (
<Box
key={track.id}
p="sm"
style={{
cursor: 'pointer',
backgroundColor: selectedIndex === index ? 'var(--mantine-color-gray-1)' : 'transparent',
borderBottom: index < searchResults.length - 1 ? '1px solid var(--mantine-color-gray-3)' : 'none'
}}
onClick={() => handleSongSelect(track)}
onMouseEnter={() => setSelectedIndex(index)}
>
<Group gap="sm">
{track.album.images[2] && (
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
)}
<div>
<Text size="sm" fw={500}>
{track.name}
</Text>
<Text size="xs" c="dimmed">
{track.artists.map(a => a.name).join(', ')} {track.album.name}
</Text>
</div>
</Group>
</Box>
))}
</Stack>
) : (
<Box p="md">
<Text size="sm" c="dimmed" ta="center">
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
</Text>
</Box>
)}
</Paper>
)}
</Box>
);
};

View File

@@ -39,14 +39,21 @@ const TeamListItem = React.memo(({ team }: TeamListItemProps) => {
interface TeamListProps {
teams: TeamInfo[];
loading?: boolean;
onTeamClick?: (teamId: string) => void;
}
const TeamList = ({ teams, loading = false }: TeamListProps) => {
const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
const navigate = useNavigate();
const handleClick = useCallback(
(teamId: string) => navigate({ to: `/teams/${teamId}` }),
[navigate]
(teamId: string) => {
if (onTeamClick) {
onTeamClick(teamId);
} else {
navigate({ to: `/teams/${teamId}` });
}
},
[navigate, onTeamClick]
);
if (loading)
@@ -69,6 +76,7 @@ const TeamList = ({ teams, loading = false }: TeamListProps) => {
{teams?.map((team) => (
<div key={team.id}>
<ListItem
key={`team-list-${team.id}`}
p="xs"
icon={
<Avatar

View File

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

View File

@@ -11,7 +11,7 @@ interface HeaderProps {
const Header = ({ name, logo, id }: HeaderProps) => {
return (
<>
<Flex px="xl" w="100%" align="self-end" gap="md">
<Flex h="20dvh" px="xl" w="100%" align="self-end" gap="md">
<Avatar
radius="sm"
name={name}

View File

@@ -2,7 +2,7 @@ import { Box, Divider, Text, Stack } from "@mantine/core";
import Header from "./header";
import SwipeableTabs from "@/components/swipeable-tabs";
import TournamentList from "@/features/tournaments/components/tournament-list";
import StatsOverview from "@/shared/components/stats-overview";
import StatsOverview from "@/components/stats-overview";
import { useTeam, useTeamMatches, useTeamStats } from "../../queries";
import MatchList from "@/features/matches/components/match-list";
import PlayerList from "@/features/players/components/player-list";

View File

@@ -0,0 +1,37 @@
import { Box, Flex, Loader } from "@mantine/core";
import SwipeableTabs from "@/components/swipeable-tabs";
import HeaderSkeleton from "./header-skeleton";
const SkeletonLoader = () => (
<Flex h="30vh" w="100%" align="center" justify="center">
<Loader />
</Flex>
)
const ProfileSkeleton = () => {
const tabs = [
{
label: "Overview",
content: <SkeletonLoader />,
},
{
label: "Matches",
content: <SkeletonLoader />,
},
{
label: "Tournaments",
content: <SkeletonLoader />,
},
];
return (
<>
<HeaderSkeleton />
<Box mt="lg">
<SwipeableTabs tabs={tabs} />
</Box>
</>
);
};
export default ProfileSkeleton;

View File

@@ -36,6 +36,7 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => {
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
<Avatar
size={32}
radius="sm"
name={team.name}
src={
team.logo

View File

@@ -8,6 +8,7 @@ import {
PencilLineIcon,
TreeStructureIcon,
UsersThreeIcon,
UsersIcon,
} from "@phosphor-icons/react";
import { useSheet } from "@/hooks/use-sheet";
import EditEnrolledTeams from "./edit-enrolled-teams";
@@ -56,10 +57,15 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
onClick={openEditRules}
/>
<ListButton
label="Edit Enrolled Teams"
label="Edit Enrollments"
Icon={UsersThreeIcon}
onClick={openEditTeams}
/>
<ListLink
label="Manage Team Songs/Logos"
Icon={UsersIcon}
to={`/admin/tournaments/${tournamentId}/teams`}
/>
<ListLink
label="Run Tournament"
Icon={TreeStructureIcon}

View File

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

View File

@@ -10,7 +10,7 @@ const Header = ({ tournament }: HeaderProps) => {
return (
<>
<Flex px='xl' w='100%' align='self-end' gap='md'>
<Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'>
<Avatar name={tournament.name} radius={0} withBorder={false} size={125} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Title ta='center' order={2}>{tournament.name}</Title>

View File

@@ -1,9 +1,11 @@
import { Box, Text } from "@mantine/core";
import { useMemo } from "react";
import { Box } from "@mantine/core";
import Header from "./header";
import TeamList from "@/features/teams/components/team-list";
import SwipeableTabs from "@/components/swipeable-tabs";
import { useTournament } from "../../queries";
import MatchList from "@/features/matches/components/match-list";
import { TournamentStats } from "../tournament-stats";
interface ProfileProps {
id: string;
@@ -13,22 +15,22 @@ const Profile = ({ id }: ProfileProps) => {
const { data: tournament } = useTournament(id);
if (!tournament) return null;
const tabs = [
const tabs = useMemo(() => [
{
label: "Overview",
content: <Text p="md">Stats/Badges will go here, bracket link</Text>
content: <TournamentStats tournament={tournament} />
},
{
label: "Matches",
content: <MatchList matches={tournament.matches?.sort((a, b) => b.order - a.order) || []} />
},
{
label: "Teams",
label: "Teams",
content: <>
<TeamList teams={tournament.teams || []} />
</>
}
];
], [tournament]);
return <>
<Header tournament={tournament} />

View File

@@ -0,0 +1,37 @@
import { Box, Flex, Loader } from "@mantine/core";
import SwipeableTabs from "@/components/swipeable-tabs";
import HeaderSkeleton from "./header-skeleton";
const SkeletonLoader = () => (
<Flex h="30vh" w="100%" align="center" justify="center">
<Loader />
</Flex>
)
const ProfileSkeleton = () => {
const tabs = [
{
label: "Overview",
content: <SkeletonLoader />,
},
{
label: "Matches",
content: <SkeletonLoader />,
},
{
label: "Teams",
content: <SkeletonLoader />,
},
];
return (
<>
<HeaderSkeleton />
<Box mt="lg">
<SwipeableTabs tabs={tabs} />
</Box>
</>
);
};
export default ProfileSkeleton;

View File

@@ -15,7 +15,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
);
return (
<Stack align="center" gap={0}>
<Stack px="sm" align="center" gap={0}>
<Avatar
name={tournament.name}
src={

View File

@@ -35,7 +35,7 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
>
{startedMatches.map((match, index) => (
<Carousel.Slide key={match.id}>
<Box pl={index === 0 ? "xl" : undefined } pr={index === startedMatches.length - 1 ? "xl" : undefined}>
<Box pl={index === 0 ? "md" : undefined } pr={index === startedMatches.length - 1 ? "md" : undefined}>
<MatchCard match={match} />
</Box>
</Carousel.Slide>
@@ -69,8 +69,8 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
to={`/tournaments/${tournament.id}/bracket`}
Icon={TreeStructureIcon}
/>
<RulesListButton tournamentId={tournament.id} />
<TeamListButton teams={tournament.teams || []} />
<RulesListButton tournamentId={tournament.id} />
</Box>
</Stack>
);

View File

@@ -0,0 +1,75 @@
import { Box, Card, Center, Divider, Group, Skeleton, Stack } from "@mantine/core";
const StartedTournamentSkeleton = () => {
return (
<Stack gap="lg">
{/* Header skeleton */}
<Stack px="md">
<Group justify="space-between" align="flex-start">
<Box style={{ flex: 1 }}>
<Skeleton height={32} width="60%" mb="xs" />
<Skeleton height={16} width="40%" />
</Box>
<Skeleton height={60} width={60} radius="md" />
</Group>
</Stack>
{/* Match carousel skeleton */}
<Box>
<Group gap="xs" px="xl">
{Array.from({ length: 2 }).map((_, index) => (
<Card
key={index}
withBorder
radius="lg"
p="lg"
style={{ minWidth: "95%", flex: "0 0 auto" }}
>
<Stack gap="md">
{/* Match header */}
<Group justify="space-between">
<Skeleton height={14} width="30%" />
<Skeleton height={20} width={60} radius="xl" />
</Group>
{/* Teams */}
<Stack gap="sm">
<Group>
<Skeleton height={32} width={32} radius="sm" />
<Skeleton height={16} width="40%" />
<Box ml="auto">
<Skeleton height={24} width={30} />
</Box>
</Group>
<Center>
<Skeleton height={14} width={20} />
</Center>
<Group>
<Skeleton height={32} width={32} radius="sm" />
<Skeleton height={16} width="40%" />
<Box ml="auto">
<Skeleton height={24} width={30} />
</Box>
</Group>
</Stack>
</Stack>
</Card>
))}
</Group>
</Box>
{/* Actions section skeleton */}
<Box>
<Divider />
<Stack gap={0}>
<Skeleton height={48} width="100%" />
<Skeleton height={48} width="100%" />
<Skeleton height={48} width="100%" />
<Skeleton height={48} width="100%" />
</Stack>
</Box>
</Stack>
);
};
export default StartedTournamentSkeleton;

View File

@@ -0,0 +1,37 @@
import { useAuth } from "@/contexts/auth-context";
import { useTournaments } from "../queries";
import { useSheet } from "@/hooks/use-sheet";
import { Button, Stack } from "@mantine/core";
import { PlusIcon } from "@phosphor-icons/react";
import Sheet from "@/components/sheet/sheet";
import TournamentForm from "./tournament-form";
import { TournamentCard } from "./tournament-card";
const TournamentCardList = () => {
const { data: tournaments } = useTournaments();
const { roles } = useAuth();
const sheet = useSheet();
return (
<Stack>
{roles?.includes("Admin") ? (
<>
<Button
leftSection={<PlusIcon />}
variant="subtle"
onClick={sheet.open}
>
Create Tournament
</Button>
<Sheet {...sheet.props} title="Create Tournament">
<TournamentForm close={sheet.close} />
</Sheet>
</>
) : null}
{tournaments?.map((tournament: any) => (
<TournamentCard key={tournament.id} tournament={tournament} />
))}
</Stack>
);
};
export default TournamentCardList;

View File

@@ -1,48 +1,27 @@
import {
Badge,
Card,
Text,
Stack,
Group,
Box,
ThemeIcon,
UnstyledButton,
Badge,
} from "@mantine/core";
import { Tournament } from "@/features/tournaments/types";
import { useMemo } from "react";
import { TournamentInfo } from "@/features/tournaments/types";
import {
TrophyIcon,
CalendarIcon,
MapPinIcon,
UsersIcon,
CrownIcon,
MedalIcon,
} from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import Avatar from "@/components/avatar";
interface TournamentCardProps {
tournament: Tournament;
tournament: TournamentInfo;
}
export const TournamentCard = ({ tournament }: TournamentCardProps) => {
const navigate = useNavigate();
const displayDate = useMemo(() => {
if (!tournament.start_time) return null;
const date = new Date(tournament.start_time);
if (isNaN(date.getTime())) return null;
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}, [tournament.start_time]);
const enrollmentDeadline = tournament.enroll_time
? new Date(tournament.enroll_time)
: new Date(tournament.start_time);
const isEnrollmentOpen = enrollmentDeadline > new Date();
const enrolledTeamsCount = tournament.teams?.length || 0;
return (
<UnstyledButton
w="100%"
@@ -78,7 +57,7 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
<Group justify="space-between" align="center">
<Group gap="md" align="center">
<Avatar
size={120}
size={90}
radius="sm"
name={tournament.name}
src={
@@ -93,31 +72,62 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
<Text fw={600} size="lg" lineClamp={2}>
{tournament.name}
</Text>
{displayDate && (
<Group gap="xs">
<ThemeIcon
size="sm"
variant="light"
radius="sm"
color="gray"
>
<CalendarIcon size={12} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{displayDate}
</Text>
</Group>
{(tournament.first_place || tournament.second_place || tournament.third_place) && (
<Stack gap={6} >
{tournament.first_place && (
<Badge
size="md"
radius="md"
variant="filled"
color="yellow"
leftSection={
<CrownIcon size={16} />
}
style={{
textTransform: 'none',
fontWeight: 600,
color: 'white',
}}
>
{tournament.first_place.name}
</Badge>
)}
{tournament.second_place && (
<Badge
size="md"
radius="md"
color="gray"
variant="filled"
leftSection={
<MedalIcon size={16} />
}
style={{
textTransform: 'none',
fontWeight: 500,
}}
>
{tournament.second_place.name}
</Badge>
)}
{tournament.third_place && (
<Badge
size="md"
radius="md"
color="orange"
variant="filled"
leftSection={
<MedalIcon size={16} />
}
style={{
textTransform: 'none',
fontWeight: 500,
}}
>
{tournament.third_place.name}
</Badge>
)}
</Stack>
)}
<Group gap="xs">
<ThemeIcon size="sm" variant="light" radius="sm" color="gray">
<UsersIcon size={12} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{enrolledTeamsCount} team
{enrolledTeamsCount !== 1 ? "s" : ""}
</Text>
</Group>
</Stack>
</Group>
</Group>

View File

@@ -0,0 +1,285 @@
import { useMemo, memo } from "react";
import {
Stack,
Text,
Group,
UnstyledButton,
Container,
Box,
Center,
ThemeIcon,
Divider,
} from "@mantine/core";
import { Tournament } from "@/features/tournaments/types";
import { CrownIcon, MedalIcon, TreeStructureIcon } from "@phosphor-icons/react";
import Avatar from "@/components/avatar";
import ListLink from "@/components/list-link";
interface TournamentStatsProps {
tournament: Tournament;
}
export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
const matches = tournament.matches || [];
const nonByeMatches = useMemo(() =>
matches.filter((match) => !(match.status === 'tbd' && match.bye === true)),
[matches]
);
const isComplete = useMemo(() =>
nonByeMatches.length > 0 && nonByeMatches.every((match) => match.status === 'ended'),
[nonByeMatches]
);
const sortedTeamStats = useMemo(() => {
return [...(tournament.team_stats || [])].sort((a, b) => {
if (b.wins !== a.wins) {
return b.wins - a.wins;
}
return b.total_cups_made - a.total_cups_made;
});
}, [tournament.team_stats]);
const renderPodium = () => {
if (!isComplete || !tournament.first_place) {
return (
<Box p="md">
<Center>
<Text c="dimmed" size="sm">
Podium will appear here when the tournament is over
</Text>
</Center>
</Box>
);
}
return (
<Stack gap="xs" px="md">
{tournament.first_place && (
<Group
gap="md"
p="md"
style={{
backgroundColor: 'var(--mantine-color-yellow-light)',
borderRadius: 'var(--mantine-radius-md)',
border: '3px solid var(--mantine-color-yellow-outline)',
boxShadow: 'var(--mantine-shadow-md)',
}}
>
<ThemeIcon size="xl" color="yellow" variant="light" radius="xl">
<CrownIcon size={24} />
</ThemeIcon>
<Stack gap={4} style={{ flex: 1 }}>
<Text size="md" fw={600}>
{tournament.first_place.name}
</Text>
<Group gap="xs">
{tournament.first_place.players?.map((player) => (
<Text key={player.id} size="sm" c="dimmed">
{player.first_name} {player.last_name}
</Text>
))}
</Group>
</Stack>
</Group>
)}
{tournament.second_place && (
<Group
gap="md"
p="xs"
style={{
backgroundColor: 'var(--mantine-color-default)',
borderRadius: 'var(--mantine-radius-md)',
border: '2px solid var(--mantine-color-default-border)',
boxShadow: 'var(--mantine-shadow-sm)',
}}
>
<ThemeIcon size="lg" color="gray" variant="light" radius="xl">
<MedalIcon size={20} />
</ThemeIcon>
<Stack gap={4} style={{ flex: 1 }}>
<Text size="sm" fw={600}>
{tournament.second_place.name}
</Text>
<Group gap="xs">
{tournament.second_place.players?.map((player) => (
<Text key={player.id} size="xs" c="dimmed">
{player.first_name} {player.last_name}
</Text>
))}
</Group>
</Stack>
</Group>
)}
{tournament.third_place && (
<Group
gap="md"
p="xs"
style={{
backgroundColor: 'var(--mantine-color-orange-light)',
borderRadius: 'var(--mantine-radius-md)',
border: '2px solid var(--mantine-color-orange-outline)',
boxShadow: 'var(--mantine-shadow-sm)',
}}
>
<ThemeIcon size="lg" color="orange" variant="light" radius="xl">
<MedalIcon size={18} />
</ThemeIcon>
<Stack gap={4} style={{ flex: 1 }}>
<Text size="sm" fw={600}>
{tournament.third_place.name}
</Text>
<Group gap="xs">
{tournament.third_place.players?.map((player) => (
<Text key={player.id} size="xs" c="dimmed">
{player.first_name} {player.last_name}
</Text>
))}
</Group>
</Stack>
</Group>
)}
</Stack>
);
};
const teamStatsWithCalculations = useMemo(() => {
return sortedTeamStats.map((stat, index) => ({
...stat,
index,
winPercentage: stat.matches > 0 ? (stat.wins / stat.matches) * 100 : 0,
avgCupsPerMatch: stat.matches > 0 ? stat.total_cups_made / stat.matches : 0,
}));
}, [sortedTeamStats]);
const renderTeamStatsTable = () => {
if (!teamStatsWithCalculations.length) {
return (
<Box p="md">
<Center>
<Text c="dimmed" size="sm">
No stats available yet
</Text>
</Center>
</Box>
);
}
return (
<Stack gap={0}>
<Text px="md" size="lg" fw={600}>Results</Text>
{teamStatsWithCalculations.map((stat) => {
return (
<Box key={stat.id}>
<UnstyledButton
w="100%"
p="md"
style={{
borderRadius: 0,
transition: "background-color 0.15s ease",
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'var(--mantine-color-gray-0)',
},
},
}}
>
<Group justify="space-between" align="center" w="100%">
<Group gap="sm" align="center">
<Avatar name={stat.team_name} size={40} radius="sm" />
<Stack gap={2}>
<Group gap='xs'>
<Text size="xs" c="dimmed">
#{stat.index + 1}
</Text>
<Text size="sm" fw={600}>
{stat.team_name}
</Text>
{stat.index === 0 && isComplete && (
<ThemeIcon size="xs" color="yellow" variant="light" radius="xl">
<CrownIcon size={12} />
</ThemeIcon>
)}
</Group>
<Group gap="md" ta="center">
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
W
</Text>
<Text size="xs" c="dimmed">
{stat.wins}
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
L
</Text>
<Text size="xs" c="dimmed">
{stat.losses}
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
W%
</Text>
<Text size="xs" c="dimmed">
{stat.winPercentage.toFixed(1)}%
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
AVG
</Text>
<Text size="xs" c="dimmed">
{stat.avgCupsPerMatch.toFixed(1)}
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
CF
</Text>
<Text size="xs" c="dimmed">
{stat.total_cups_made}
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
CA
</Text>
<Text size="xs" c="dimmed">
{stat.total_cups_against}
</Text>
</Stack>
</Group>
</Stack>
</Group>
</Group>
</UnstyledButton>
{stat.index < teamStatsWithCalculations.length - 1 && <Divider />}
</Box>
);
})}
</Stack>
);
};
return (
<Container size="100%" px={0}>
<Stack gap="md">
{renderPodium()}
<ListLink
label={`View Bracket`}
to={`/tournaments/${tournament.id}/bracket`}
Icon={TreeStructureIcon}
/>
{renderTeamStatsTable()}
</Stack>
</Container>
);
});
TournamentStats.displayName = 'TournamentStats';

View File

@@ -2,11 +2,23 @@ import Button from "@/components/button";
import Sheet from "@/components/sheet/sheet";
import { useAuth } from "@/contexts/auth-context";
import { useSheet } from "@/hooks/use-sheet";
import { Text } from "@mantine/core";
import { Stack, Text } from "@mantine/core";
import useEnrollFreeAgent from "../../hooks/use-enroll-free-agent";
const EnrollFreeAgent = () => {
const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
const { open, isOpen, toggle } = useSheet();
const { user } = useAuth();
const { user, phone } = useAuth();
const { mutate: enrollFreeAgent, isPending: isEnrolling } = useEnrollFreeAgent();
const handleEnroll = () => {
console.log('enrolling...')
enrollFreeAgent({ playerId: user!.id, tournamentId, phone }, {
onSuccess: () => {
toggle();
}
});
}
return (
<>
<Button variant="subtle" size="sm" onClick={open}>
@@ -14,13 +26,19 @@ const EnrollFreeAgent = () => {
</Button>
<Sheet title="Free Agent Enrollment" opened={isOpen} onChange={toggle}>
<Text size="md" mb="md">
Enrolling as a free agent will enter you in a pool of players wanting to play but don't have a teammate yet.
</Text>
<Text size="sm" mb="md" c='dimmed'>
You will be automatically paired with a partner before the tournament starts, and you will be able to see your new team and set a walkout song in the app.
</Text>
<Button onClick={console.log}>Confirm</Button>
<Stack gap="xs">
<Text size="md">
Enrolling as a free agent will enter you in a pool of players wanting to play but don't have a teammate yet.
</Text>
<Text size="sm" c='dimmed'>
You will be able to see a list of other enrolled free agents, as well as their contact information for organizing your team and walkout song. By enrolling, your phone number will be visible to other free agents.
</Text>
<Text size="xs" c="dimmed">
Note: this does not guarantee you a spot in the tournament. One person from your team must enroll in the app and choose a walkout song in order to secure a spot.
</Text>
<Button onClick={handleEnroll}>Confirm</Button>
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
</Stack>
</Sheet>
</>
);

View File

@@ -0,0 +1,115 @@
import { Group, Stack, Text, Card, Badge, Box, ActionIcon } from "@mantine/core";
import { UserIcon, PhoneIcon } from "@phosphor-icons/react";
import { useFreeAgents } from "../../queries";
import UnenrollFreeAgent from "./unenroll-free-agent";
import toast from "@/lib/sonner";
const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
tournamentId
}) => {
const { data: freeAgents } = useFreeAgents(tournamentId);
const copyToClipboard = async (phone: string) => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(phone);
toast.success("Phone number copied!");
return;
}
const textArea = document.createElement("textarea");
textArea.value = phone;
textArea.style.display = "hidden";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
toast.success("Phone number copied!");
} else {
throw new Error("Copy command failed");
}
} catch (err) {
console.error("Failed to copy:", err);
toast.error("Failed to copy");
}
};
return (
<Stack gap="md">
<Group justify="space-between" align="center">
<Group gap="xs" align="center">
<UserIcon size={16} />
<Text size="sm" fw={500}>
Enrolled as Free Agent
</Text>
</Group>
</Group>
<Text size="xs" c="dimmed">
You're on the free agent list. Other free agents looking for teams:
</Text>
{freeAgents.length > 1 ? (
<Card withBorder radius="md" p="sm">
<Stack gap="xs">
{freeAgents
.filter(agent => agent.player)
.map((agent) => (
<Group key={agent.id} justify="space-between" align="center" wrap="nowrap">
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{agent.player?.first_name} {agent.player?.last_name}
</Text>
</Box>
{agent.phone && (
<Group gap={4} align="center" style={{ flexShrink: 0 }}>
<ActionIcon
variant="subtle"
size="sm"
onClick={() => copyToClipboard(agent.phone!)}
style={{ cursor: 'pointer' }}
>
<PhoneIcon size={12} />
</ActionIcon>
<Text
size="xs"
c="dimmed"
style={{ cursor: 'pointer' }}
onClick={() => copyToClipboard(agent.phone!)}
>
{agent.phone}
</Text>
</Group>
)}
</Group>
))}
{freeAgents.length > 1 && (
<Badge
variant="light"
size="xs"
color="blue"
style={{ alignSelf: 'flex-start', marginTop: '4px' }}
>
{freeAgents.length} free agents total
</Badge>
)}
</Stack>
</Card>
) : (
<Card withBorder radius="md" p="sm">
<Text size="sm" c="dimmed" ta="center">
You're the only free agent so far
</Text>
</Card>
)}
<UnenrollFreeAgent tournamentId={tournamentId} />
</Stack>
);
};
export default EnrolledFreeAgent;

View File

@@ -14,10 +14,6 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
() => new Date(tournament.start_time),
[tournament.start_time]
);
const teamCount = useMemo(
() => tournament.teams?.length || 0,
[tournament.teams]
);
return (
<Stack align="center" gap={0}>
@@ -35,7 +31,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
>
<TrophyIcon size={32} />
</Avatar>
<Flex gap="xs" direction="row" wrap="wrap" justify="space-around">
<Flex gap="xs" direction="column" justify="space-around">
{tournament.location && (
<Group gap="xs">
<ThemeIcon size="sm" variant="light" radius="sm">

View File

@@ -15,8 +15,9 @@ import TeamCard from "@/features/teams/components/team-card";
import UpdateTeam from "./update-team";
import UnenrollTeam from "./unenroll-team";
import { useQueryClient } from "@tanstack/react-query";
import { tournamentKeys } from "../../queries";
import { tournamentKeys, useFreeAgents } from "../../queries";
import RulesListButton from "./rules-list-button";
import EnrolledFreeAgent from "./enrolled-free-agent";
const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
tournament,
@@ -40,57 +41,79 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
const queryClient = useQueryClient();
const handleSubmit = () => {
queryClient.invalidateQueries({ queryKey: tournamentKeys.current })
}
queryClient.invalidateQueries({ queryKey: tournamentKeys.current });
};
const { data: free_agents } = useFreeAgents(tournament.id);
const isFreeAgent = useMemo(() => !isUserEnrolled && free_agents.find(a => a.player?.id === user?.id), [free_agents, isUserEnrolled]);
return (
<Stack gap="lg">
<Header tournament={tournament} />
{tournament.desc && <Text size="sm">{tournament.desc}</Text>}
<Card withBorder radius="lg" p="lg">
<Stack gap="xs">
<Group mb="sm" gap="xs" align="center">
<UsersIcon size={16} />
<Text size="sm" fw={500}>
Enrollment
</Text>
{isEnrollmentOpen && (
<Box ml="auto">
<Countdown
date={enrollmentDeadline}
label="Time left"
color="yellow"
/>
</Box>
<Stack px="xs">
{tournament.desc && <Text px="md" size="sm">{tournament.desc}</Text>}
<Card withBorder radius="lg" p="lg">
<Stack gap="xs">
<Group mb="sm" gap="xs" align="center">
<UsersIcon size={16} />
<Text size="sm" fw={500}>
Enrollment
</Text>
{isEnrollmentOpen && (
<Box ml="auto">
<Countdown
date={enrollmentDeadline}
label="Time left"
color="yellow"
/>
</Box>
)}
</Group>
{!isUserEnrolled && !isEnrollmentOpen && (
<Text fw={600} c="dimmed" size="sm">
Enrollment has been closed for this tournament.
</Text>
)}
</Group>
{!isUserEnrolled &&!isEnrollmentOpen && (
<Text fw={600} c="dimmed" size="sm">
Enrollment has been closed for this tournament.
</Text>
)}
{!isUserEnrolled && isEnrollmentOpen && !isFreeAgent && (
<>
<EnrollTeam
tournamentId={tournament.id}
onSubmit={handleSubmit}
/>
<Divider my={0} label="or" />
<EnrollFreeAgent tournamentId={tournament.id} />
</>
)}
{!isUserEnrolled &&isEnrollmentOpen && (
<>
<EnrollTeam tournamentId={tournament.id} onSubmit={handleSubmit} />
<Divider my={0} label="or" />
<EnrollFreeAgent />
</>
)}
{isUserEnrolled && (
<>
<Suspense fallback={<TeamCardSkeleton />}>
<TeamCard teamId={userTeam.id} />
</Suspense>
<UpdateTeam tournamentId={tournament.id} teamId={userTeam.id} />
{isEnrollmentOpen && (
<UnenrollTeam
tournamentId={tournament.id}
teamId={userTeam.id}
onSubmit={handleSubmit}
/>
)}
</>
)}
{
isUserEnrolled && <>
<Suspense fallback={<TeamCardSkeleton />}>
<TeamCard teamId={userTeam.id} />
</Suspense>
<UpdateTeam tournamentId={tournament.id} teamId={userTeam.id} />
{ isEnrollmentOpen && <UnenrollTeam tournamentId={tournament.id} teamId={userTeam.id} onSubmit={handleSubmit} />}
</>
}
</Stack>
</Card>
{
isFreeAgent && isEnrollmentOpen && (
<EnrolledFreeAgent tournamentId={tournament.id} />
)
}
</Stack>
</Card>
</Stack>
<Box>
<Divider />
@@ -102,12 +125,13 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
/>
)}
<ListLink
label={`View Bracket`}
to={`/tournaments/${tournament.id}/bracket`}
Icon={TreeStructureIcon}
/>
<RulesListButton tournamentId={tournament.id} />
label={`View Bracket`}
to={`/tournaments/${tournament.id}/bracket`}
Icon={TreeStructureIcon}
disabled
/>
<TeamListButton teams={tournament.teams || []} />
<RulesListButton tournamentId={tournament.id} />
</Box>
</Stack>
);

View File

@@ -20,6 +20,7 @@ const RulesListButton: React.FC<RulesListButtonProps> = ({ tournamentId }) => {
extensions: [StarterKit],
content: tournament?.rules || '',
editable: false,
immediatelyRender: false,
});
return (

View File

@@ -0,0 +1,37 @@
import { Box, Card, Divider, Flex, Group, Skeleton, Stack } from "@mantine/core";
const UpcomingTournamentSkeleton = () => {
return (
<Stack gap="lg">
<Flex px="md" justify="center" w="100%">
<Skeleton height={200} width={240} radius="md" />
</Flex>
<Stack align="center" gap={2}>
<Skeleton height={16} w="30%" mb="md" />
<Skeleton height={16} w="30%" />
</Stack>
<Stack px="md">
<Card withBorder radius="lg" p="lg">
<Skeleton height={14} width="80%" mb={16} />
<Group mb="sm" gap="xs" align="center">
<Skeleton height={32} width={16} />
<Skeleton height={32} width="20%" />
<Box ml="auto">
<Skeleton height={32} width={80} radius="sm" />
</Box>
</Group>
<Group mb="sm" gap="xs" align="center">
<Skeleton height={32} width={16} />
<Skeleton height={32} width="20%" />
<Box ml="auto">
<Skeleton height={32} width={80} radius="sm" />
</Box>
</Group>
</Card>
</Stack>
</Stack>
);
};
export default UpcomingTournamentSkeleton;

View File

@@ -0,0 +1,40 @@
import Button from "@/components/button";
import Sheet from "@/components/sheet/sheet";
import { useAuth } from "@/contexts/auth-context";
import { useSheet } from "@/hooks/use-sheet";
import { Stack, Text } from "@mantine/core";
import useUnenrollFreeAgent from "../../hooks/use-unenroll-free-agent";
const UnenrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
const { open, isOpen, toggle } = useSheet();
const { user } = useAuth();
const { mutate: unenrollFreeAgent, isPending: isEnrolling } = useUnenrollFreeAgent();
const handleUnenroll = () => {
unenrollFreeAgent({ playerId: user!.id, tournamentId }, {
onSuccess: () => {
toggle();
}
});
}
return (
<>
<Button variant="subtle" size="sm" onClick={open}>
Unenroll
</Button>
<Sheet title="Are you sure?" opened={isOpen} onChange={toggle}>
<Stack gap="xs">
<Text size="md">
This will remove you from the free agent list.
</Text>
<Button onClick={handleUnenroll}>Confirm</Button>
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
</Stack>
</Sheet>
</>
);
};
export default UnenrollFreeAgent;

View File

@@ -0,0 +1,20 @@
import { useQueryClient } from "@tanstack/react-query";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
import { enrollFreeAgent } from "@/features/tournaments/server";
import { tournamentKeys } from "../queries";
const useEnrollFreeAgent = () => {
const queryClient = useQueryClient();
return useServerMutation({
mutationFn: (data: { tournamentId: string, playerId: string, phone: string }) => {
return enrollFreeAgent({ data });
},
onSuccess: (data, { tournamentId }) => {
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
},
successMessage: 'You\'ve been added as a free agent!',
});
};
export default useEnrollFreeAgent;

View File

@@ -0,0 +1,20 @@
import { useQueryClient } from "@tanstack/react-query";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
import { unenrollFreeAgent } from "@/features/tournaments/server";
import { tournamentKeys } from "../queries";
const useUnenrollFreeAgent = () => {
const queryClient = useQueryClient();
return useServerMutation({
mutationFn: (data: { tournamentId: string, playerId: string }) => {
return unenrollFreeAgent({ data });
},
onSuccess: (data, { tournamentId }) => {
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
},
successMessage: 'You\'ve been removed as a free agent.',
});
};
export default useUnenrollFreeAgent;

View File

@@ -1,11 +1,12 @@
import { getCurrentTournament, getTournament, getUnenrolledTeams, listTournaments } from "./server";
import { getCurrentTournament, getFreeAgents, getTournament, getUnenrolledTeams, listTournaments } from "./server";
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
export const tournamentKeys = {
list: ['tournaments', 'list'] as const,
details: (id: string) => ['tournaments', 'details', id] as const,
current: ['tournaments', 'current'] as const,
unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const
unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const,
free_agents: (id: string) => ['tournaments', 'free_agents', id] as const
};
export const tournamentQueries = {
@@ -24,6 +25,10 @@ export const tournamentQueries = {
unenrolled: (id: string) => ({
queryKey: tournamentKeys.unenrolled(id),
queryFn: () => getUnenrolledTeams({ data: id })
}),
free_agents: (id: string) => ({
queryKey: tournamentKeys.free_agents(id),
queryFn: () => getFreeAgents({ data: id })
})
};
@@ -38,3 +43,6 @@ export const useCurrentTournament = () =>
export const useUnenrolledTeams = (tournamentId: string) =>
useServerSuspenseQuery(tournamentQueries.unenrolled(tournamentId));
export const useFreeAgents = (tournamentId: string) =>
useServerSuspenseQuery(tournamentQueries.free_agents(tournamentId));

View File

@@ -32,9 +32,10 @@ export const updateTournament = createServerFn()
export const getTournament = createServerFn()
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: tournamentId }) =>
toServerResult(() => pbAdmin.getTournament(tournamentId))
);
.handler(async ({ data: tournamentId, context }) => {
const isAdmin = context.roles.includes("Admin");
return toServerResult(() => pbAdmin.getTournament(tournamentId, isAdmin));
});
export const getCurrentTournament = createServerFn()
.middleware([superTokensFunctionMiddleware])
@@ -83,4 +84,39 @@ export const getUnenrolledTeams = createServerFn()
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: tournamentId }) =>
toServerResult(() => pbAdmin.getUnenrolledTeams(tournamentId))
);
);
export const getFreeAgents = createServerFn()
.validator(z.string())
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: tournamentId }) =>
toServerResult(() => pbAdmin.getFreeAgents(tournamentId))
);
export const enrollFreeAgent = createServerFn()
.validator(z.object({ phone: z.string(), tournamentId: z.string() }))
.middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) =>
toServerResult(async () => {
const userAuthId = context.userAuthId;
const player = await pbAdmin.getPlayerByAuthId(userAuthId);
if (!player) throw new Error("Player not found");
await pbAdmin.enrollFreeAgent(player.id, data.phone, data.tournamentId);
logger.info('Player enrolled as free agent', { playerId: player.id, phone: data.phone });
})
);
export const unenrollFreeAgent = createServerFn()
.validator(z.object({ tournamentId: z.string() }))
.middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) =>
toServerResult(async () => {
const userAuthId = context.userAuthId;
const player = await pbAdmin.getPlayerByAuthId(userAuthId);
if (!player) throw new Error("Player not found");
await pbAdmin.unenrollFreeAgent(player.id, data.tournamentId);
logger.info('Player unenrolled as free agent', { playerId: player.id });
})
);

View File

@@ -2,6 +2,22 @@ import { TeamInfo } from "@/features/teams/types";
import { Match } from "@/features/matches/types";
import { z } from "zod";
export interface TournamentTeamStats {
id: string;
team_id: string;
tournament_id: string;
team_name: string;
matches: number;
wins: number;
losses: number;
total_cups_made: number;
total_cups_against: number;
margin_of_victory: number;
margin_of_loss: number;
win_percentage: number;
avg_cups_per_match: number;
}
export interface TournamentInfo {
id: string;
name: string;
@@ -9,6 +25,9 @@ export interface TournamentInfo {
start_time?: string;
end_time?: string;
logo?: string;
first_place?: TeamInfo;
second_place?: TeamInfo;
third_place?: TeamInfo;
}
export interface Tournament {
@@ -25,6 +44,10 @@ export interface Tournament {
updated: string;
teams?: TeamInfo[];
matches?: Match[];
first_place?: TeamInfo;
second_place?: TeamInfo;
third_place?: TeamInfo;
team_stats?: TournamentTeamStats[];
}
export const tournamentInputSchema = z.object({

View File

@@ -2,10 +2,8 @@ import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Logger } from "@/lib/logger";
import { useAuth } from "@/contexts/auth-context";
import { tournamentKeys, tournamentQueries } from "@/features/tournaments/queries";
let newIdeasAvailable = false;
let newIdeasCallbacks: (() => void)[] = [];
import { tournamentQueries } from "@/features/tournaments/queries";
import { reactionKeys, reactionQueries } from "@/features/reactions/queries";
const logger = new Logger('ServerEvents');
@@ -18,42 +16,19 @@ type EventHandler = (event: SSEEvent, queryClient: ReturnType<typeof useQueryCli
const eventHandlers: Record<string, EventHandler> = {
"connected": () => {
logger.info("ServerEvents | New Connection");
logger.info("New Connection");
},
"ping": () => {},
"test": (event, queryClient) => {
},
"match": (event, queryClient) => {
console.log(event);
queryClient.invalidateQueries(tournamentQueries.details(event.tournamentId))
queryClient.invalidateQueries(tournamentQueries.current())
},
"reaction": (event, queryClient) => {
queryClient.invalidateQueries(reactionQueries.match(event.matchId));
queryClient.setQueryData(reactionKeys.match(event.matchId), () => event.reactions);
}
};
export function getNewIdeasAvailable(): boolean {
return newIdeasAvailable;
}
export function clearNewIdeasAvailable(): void {
newIdeasAvailable = false;
}
export function subscribeToNewIdeas(callback: () => void): () => void {
newIdeasCallbacks.push(callback);
return () => {
const index = newIdeasCallbacks.indexOf(callback);
if (index > -1) {
newIdeasCallbacks.splice(index, 1);
}
};
}
export function useServerEvents() {
const queryClient = useQueryClient();
const { user } = useAuth();

View File

@@ -13,4 +13,9 @@ export type MatchEvent = {
tournamentId: string;
}
export type ServerEvent = TestEvent | MatchEvent;
export type ReactionEvent = {
type: "reaction";
matchId: string;
}
export type ServerEvent = TestEvent | MatchEvent | ReactionEvent;

View File

@@ -55,7 +55,7 @@ const MantineProvider = ({ children }: { children: React.ReactNode }) => {
setIsHydrated(true);
}, []);
const colorScheme = isHydrated ? metadata.colorScheme || "auto" : "auto";
const colorScheme = isHydrated ? metadata.colorScheme || "dark" : "dark";
const primaryColor = isHydrated ? metadata.accentColor || "blue" : "blue";
return (

View File

@@ -10,6 +10,11 @@ class PocketBaseAdminClient {
public authPromise: Promise<void>;
constructor() {
console.log('Environment variables loaded:', {
POCKETBASE_URL: process.env.POCKETBASE_URL,
POCKETBASE_ADMIN_EMAIL: process.env.POCKETBASE_ADMIN_EMAIL,
POCKETBASE_ADMIN_PASSWORD: process.env.POCKETBASE_ADMIN_PASSWORD,
});
this.pb = new PocketBase(process.env.POCKETBASE_URL);
this.pb.beforeSend = (url, options) => {

View File

@@ -80,8 +80,6 @@ export function createPlayersService(pb: PocketBase) {
},
async getPlayerMatches(playerId: string): Promise<Match[]> {
console.log('----------------')
console.log(playerId)
const player = await pb.collection("players").getOne(playerId.trim(), {
expand: "teams",
});
@@ -102,18 +100,16 @@ export function createPlayersService(pb: PocketBase) {
expand: "tournament,home,away",
});
return result.map(transformMatch);
return result.map((match) => transformMatch(match));
},
async getUnenrolledPlayers(tournamentId: string): Promise<Player[]> {
try {
// Get the tournament with its enrolled teams
const tournament = await pb.collection("tournaments").getOne(tournamentId, {
fields: "teams",
expand: "teams,teams.players"
});
// Extract player IDs from all enrolled teams
const enrolledPlayerIds: string[] = [];
if (tournament.expand?.teams) {
const teams = Array.isArray(tournament.expand.teams) ? tournament.expand.teams : [tournament.expand.teams];
@@ -127,7 +123,6 @@ export function createPlayersService(pb: PocketBase) {
});
}
// If no players are enrolled, return all players
if (enrolledPlayerIds.length === 0) {
const allPlayers = await pb.collection("players").getFullList<Player>({
fields: "id,first_name,last_name,email",
@@ -135,7 +130,6 @@ export function createPlayersService(pb: PocketBase) {
return allPlayers.map(transformPlayer);
}
// Build filter to exclude enrolled players
const filter = enrolledPlayerIds
.map((playerId: string) => `id != "${playerId}"`)
.join(" && ");
@@ -148,7 +142,6 @@ export function createPlayersService(pb: PocketBase) {
return availablePlayers.map(transformPlayer);
} catch (error) {
console.error("Error getting unenrolled players:", error);
// Fallback to all players if there's an error
const allPlayers = await pb.collection("players").getFullList<Player>({
fields: "id,first_name,last_name,email",
});

View File

@@ -100,7 +100,7 @@ export function createTeamsService(pb: PocketBase) {
expand: "tournament,home,away",
});
return result.map(transformMatch);
return result.map((match) => transformMatch(match));
},
};
}

View File

@@ -7,16 +7,26 @@ import type {
} from "@/features/tournaments/types";
import type { Team } from "@/features/teams/types";
import PocketBase from "pocketbase";
import { transformTournament, transformTournamentInfo } from "@/lib/pocketbase/util/transform-types";
import { transformFreeAgent, transformTournament, transformTournamentInfo } from "@/lib/pocketbase/util/transform-types";
import { transformTeam } from "@/lib/pocketbase/util/transform-types";
import { PlayerInfo } from "@/features/players/types";
export function createTournamentsService(pb: PocketBase) {
return {
async getTournament(id: string): Promise<Tournament> {
const result = await pb.collection("tournaments").getOne(id, {
expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players",
});
return transformTournament(result);
async getTournament(id: string, isAdmin: boolean = false): Promise<Tournament> {
const [tournamentResult, teamStatsResult] = await Promise.all([
pb.collection("tournaments").getOne(id, {
expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players",
}),
pb.collection("team_stats_per_tournament").getFullList({
filter: `tournament_id = "${id}"`,
sort: "-wins,-total_cups_made"
})
]);
tournamentResult.team_stats = teamStatsResult;
return transformTournament(tournamentResult, isAdmin);
},
async getMostRecentTournament(): Promise<Tournament> {
const result = await pb
@@ -27,17 +37,35 @@ export function createTournamentsService(pb: PocketBase) {
sort: "-created",
}
);
const teamStatsResult = await pb.collection("team_stats_per_tournament").getFullList({
filter: `tournament_id = "${result.id}"`,
sort: "-wins,-total_cups_made"
});
result.team_stats = teamStatsResult;
return transformTournament(result);
},
async listTournaments(): Promise<TournamentInfo[]> {
const result = await pb
.collection("tournaments")
.getFullList({
fields: "id,name,location,start_time,end_time,logo",
expand: "teams,teams.players,matches",
sort: "-created",
});
return result.map(transformTournamentInfo);
const tournamentsWithStats = await Promise.all(result.map(async (tournament) => {
const teamStats = await pb.collection("team_stats_per_tournament").getFullList({
filter: `tournament_id = "${tournament.id}"`,
sort: "-wins,-total_cups_made"
});
tournament.team_stats = teamStats;
return tournament;
}));
return tournamentsWithStats.map(transformTournamentInfo);
},
async createTournament(data: TournamentInput): Promise<Tournament> {
const result = await pb
@@ -133,5 +161,33 @@ export function createTournamentsService(pb: PocketBase) {
throw error;
}
},
async enrollFreeAgent(playerId: string, phone: string, tournamentId: string): Promise<void> {
await pb.collection("free_agents").create({
tournament: tournamentId,
player: playerId,
phone: phone
});
},
async unenrollFreeAgent(playerId: string, tournamentId: string): Promise<void> {
const result = await pb.collection("free_agents").getFirstListItem(
`player = "${playerId}" && tournament = "${tournamentId}"`
);
await pb.collection("free_agents").delete(result.id);
},
async getFreeAgents(tournamentId: string): Promise<{ id: string, phone: string, player: PlayerInfo | undefined }[]> {
try {
const free_agents = await pb
.collection("free_agents")
.getFullList({ filter: `tournament = "${tournamentId}"`,
expand: 'player'
});
return free_agents.map(transformFreeAgent);
} catch (error) {
logger.error("PocketBase | Error getting unenrolled teams", error);
throw error;
}
},
};
}

View File

@@ -27,7 +27,7 @@ export function transformTeamInfo(record: any): TeamInfo {
};
}
export const transformMatch = (record: any): Match => {
export const transformMatch = (record: any, isAdmin: boolean = false): Match => {
return {
id: record.id,
order: record.order,
@@ -47,8 +47,8 @@ export const transformMatch = (record: any): Match => {
is_losers_bracket: record.is_losers_bracket,
status: record.status || "tbd",
tournament: record.expand?.tournament ? transformTournamentInfo(record.expand?.tournament) : record.tournament,
home: record.expand?.home ? transformTeamInfo(record.expand.home) : record.home,
away: record.expand?.away ? transformTeamInfo(record.expand.away) : record.away,
home: record.expand?.home ? (isAdmin ? transformTeam(record.expand.home) : transformTeamInfo(record.expand.home)) : record.home,
away: record.expand?.away ? (isAdmin ? transformTeam(record.expand.away) : transformTeamInfo(record.expand.away)) : record.away,
created: record.created,
updated: record.updated,
home_seed: record.home_seed,
@@ -57,12 +57,58 @@ export const transformMatch = (record: any): Match => {
}
export const transformTournamentInfo = (record: any): TournamentInfo => {
// Check if tournament is complete by looking at matches
const matches = record.expand?.matches || [];
// Filter out bye matches (tbd status with bye=true) when checking completion
const nonByeMatches = matches.filter((match: any) => !(match.status === 'tbd' && match.bye === true));
const isComplete = nonByeMatches.length > 0 && nonByeMatches.every((match: any) => match.status === 'ended');
let first_place: TeamInfo | undefined = undefined;
let second_place: TeamInfo | undefined = undefined;
let third_place: TeamInfo | undefined = undefined;
if (isComplete) {
const teams = record.expand?.teams || [];
const teamMap = new Map<string, TeamInfo>(teams.map((team: any) => [team.id, transformTeamInfo(team)]));
const winnersMatches = matches.filter((match: any) => !match.is_losers_bracket);
const finalsMatch = winnersMatches.reduce((highest: any, current: any) =>
(!highest || current.lid > highest.lid) ? current : highest, null);
const losersMatches = matches.filter((match: any) => match.is_losers_bracket);
const losersFinale = losersMatches.reduce((highest: any, current: any) =>
(!highest || current.lid > highest.lid) ? current : highest, null);
if (finalsMatch && finalsMatch.status === 'ended') {
const finalsWinner = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.home : finalsMatch.away;
const finalsLoser = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.away : finalsMatch.home;
const finalsWinnerId = typeof finalsWinner === 'string' ? finalsWinner : finalsWinner?.id;
const finalsLoserId = typeof finalsLoser === 'string' ? finalsLoser : finalsLoser?.id;
first_place = finalsWinnerId ? teamMap.get(finalsWinnerId) || undefined : undefined;
second_place = finalsLoserId ? teamMap.get(finalsLoserId) || undefined : undefined;
}
if (losersFinale && losersFinale.status === 'ended') {
const losersFinaleLoser = (losersFinale.home_cups > losersFinale.away_cups) ? losersFinale.away : losersFinale.home;
const losersFinaleloserId = typeof losersFinaleLoser === 'string' ? losersFinaleLoser : losersFinaleLoser?.id;
third_place = losersFinaleloserId ? teamMap.get(losersFinaleloserId) || undefined : undefined;
}
}
return {
id: record.id,
name: record.name,
location: record.location,
start_time: record.start_time,
end_time: record.end_time,
logo: record.logo,
first_place,
second_place,
third_place,
};
}
@@ -85,6 +131,22 @@ export function transformPlayer(record: any): Player {
};
}
export function transformFreeAgent(record: any) {
const player = record.expand?.player ? transformPlayerInfo(record.expand.player) : undefined;
const tournaments =
record.expand?.tournaments
?.sort((a: any, b: any) =>
new Date(a.created!) < new Date(b.created!) ? -1 : 0
)
?.map(transformTournamentInfo) ?? [];
return {
id: record.id as string,
phone: record.phone as string,
player
};
}
export function transformTeam(record: any): Team {
const players =
record.expand?.players
@@ -119,21 +181,74 @@ export function transformTeam(record: any): Team {
};
}
export function transformTournament(record: any): Tournament {
export function transformTournament(record: any, isAdmin: boolean = false): Tournament {
const teams =
record.expand?.teams
?.sort((a: any, b: any) =>
new Date(a.created) < new Date(b.created) ? -1 : 0
)
?.map(transformTeamInfo) ?? [];
?.map(isAdmin ? transformTeam : transformTeamInfo) ?? [];
const matches =
record.expand?.matches
?.sort((a: any, b: any) =>
a.lid - b.lid ? -1 : 0
)
?.map(transformMatch) ?? [];
?.map((match: any) => transformMatch(match, isAdmin)) ?? [];
const team_stats = record.team_stats?.map((stat: any) => ({
id: stat.id,
team_id: stat.team_id,
tournament_id: stat.tournament_id,
team_name: stat.team_name,
matches: stat.matches,
wins: stat.wins,
losses: stat.losses,
total_cups_made: stat.total_cups_made,
total_cups_against: stat.total_cups_against,
margin_of_victory: stat.margin_of_victory,
margin_of_loss: stat.margin_of_loss,
win_percentage: (stat.wins / stat.matches) * 100,
avg_cups_per_match: stat.total_cups_made / stat.matches,
})) ?? [];
const nonByeMatches = matches.filter((match: any) => !(match.status === 'tbd' && match.bye === true));
const isComplete = nonByeMatches.length > 0 && nonByeMatches.every((match: any) => match.status === 'ended');
let first_place: TeamInfo | undefined = undefined;
let second_place: TeamInfo | undefined = undefined;
let third_place: TeamInfo | undefined = undefined;
if (isComplete) {
const teamMap = new Map<string, TeamInfo>(teams.map((team: any) => [team.id, team]));
const winnersMatches = matches.filter((match: any) => !match.is_losers_bracket);
const finalsMatch = winnersMatches.reduce((highest: any, current: any) =>
(!highest || current.lid > highest.lid) ? current : highest, null);
const losersMatches = matches.filter((match: any) => match.is_losers_bracket);
const losersFinale = losersMatches.reduce((highest: any, current: any) =>
(!highest || current.lid > highest.lid) ? current : highest, null);
if (finalsMatch && finalsMatch.status === 'ended') {
const finalsWinner = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.home : finalsMatch.away;
const finalsLoser = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.away : finalsMatch.home;
const finalsWinnerId = typeof finalsWinner === 'string' ? finalsWinner : finalsWinner?.id;
const finalsLoserId = typeof finalsLoser === 'string' ? finalsLoser : finalsLoser?.id;
first_place = finalsWinnerId ? teamMap.get(finalsWinnerId) || undefined : undefined;
second_place = finalsLoserId ? teamMap.get(finalsLoserId) || undefined : undefined;
}
if (losersFinale && losersFinale.status === 'ended') {
const losersFinaleLoser = (losersFinale.home_cups > losersFinale.away_cups) ? losersFinale.away : losersFinale.home;
const losersFinaleloserId = typeof losersFinaleLoser === 'string' ? losersFinaleLoser : losersFinaleLoser?.id;
third_place = losersFinaleloserId ? teamMap.get(losersFinaleloserId) || undefined : undefined;
}
}
return {
id: record.id,
name: record.name,
@@ -147,7 +262,11 @@ export function transformTournament(record: any): Tournament {
created: record.created,
updated: record.updated,
teams,
matches
matches,
first_place,
second_place,
third_place,
team_stats,
};
}

View File

@@ -96,6 +96,17 @@ export class SpotifyWebApiClient {
});
}
async playTrack(trackId: string, deviceId?: string, positionMs?: number): Promise<void> {
const endpoint = deviceId ? `/me/player/play?device_id=${deviceId}` : '/me/player/play';
await this.request(endpoint, {
method: 'PUT',
body: JSON.stringify({
uris: [`spotify:track:${trackId}`],
position_ms: positionMs || 0,
}),
});
}
async pause(): Promise<void> {
await this.request('/me/player/pause', {
method: 'PUT',

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