7 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
80 changed files with 1867 additions and 672 deletions

View File

@@ -32,17 +32,17 @@ services:
- app-network - app-network
restart: unless-stopped restart: unless-stopped
redis: #redis:
image: redis:7-alpine # image: redis:7-alpine
container_name: redis-cache # container_name: redis-cache
ports: # ports:
- "6379:6379" # - "6379:6379"
command: redis-server --appendonly yes # command: redis-server --appendonly yes
volumes: # volumes:
- redis-data:/data # - redis-data:/data
networks: # networks:
- app-network # - app-network
restart: unless-stopped # restart: unless-stopped
supertokens: supertokens:
image: registry.supertokens.io/supertokens/supertokens-postgresql image: registry.supertokens.io/supertokens/supertokens-postgresql

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,6 @@ export const Route = createFileRoute("/_authed/")({
function Home() { function Home() {
const { data: tournament } = useCurrentTournament(); const { data: tournament } = useCurrentTournament();
if (!tournament.matches || tournament.matches.length === 0) { if (!tournament.matches || tournament.matches.length === 0) {
return <UpcomingTournament tournament={tournament} />; return <UpcomingTournament tournament={tournament} />;
} }

View File

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

View File

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

View File

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

View File

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

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

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 }); return new Response('Unauthorized', { status: 403 });
logger.info('Uploading team logo', { logger.info('Uploading team logo', {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ interface AuthData {
export const defaultAuthData: AuthData = { export const defaultAuthData: AuthData = {
user: undefined, user: undefined,
metadata: { accentColor: "blue", colorScheme: "auto" }, metadata: { accentColor: "blue", colorScheme: "dark" },
roles: [], roles: [],
phone: "" phone: ""
}; };

View File

@@ -186,6 +186,29 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
} }
}, [authState.isAuthenticated]); }, [authState.isAuthenticated]);
const playTrack = useCallback(async (trackId: string, deviceId?: string, positionMs?: number) => {
if (!authState.isAuthenticated) return;
setIsLoading(true);
setError(null);
try {
await makeSpotifyRequest('playback', {
method: 'POST',
body: JSON.stringify({ action: 'playTrack', trackId, deviceId, positionMs }),
});
setTimeout(refreshPlaybackState, 500);
} catch (error) {
if (error instanceof Error && !error.message.includes('JSON')) {
setError(error.message);
}
console.warn('Track playback action completed with warning:', error);
} finally {
setIsLoading(false);
}
}, [authState.isAuthenticated]);
const pause = useCallback(async () => { const pause = useCallback(async () => {
if (!authState.isAuthenticated) return; if (!authState.isAuthenticated) return;
@@ -415,13 +438,13 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
activeDevice, activeDevice,
isLoading, isLoading,
error, error,
// Capture/Resume state
capturedState, capturedState,
isCaptureLoading, isCaptureLoading,
isResumeLoading, isResumeLoading,
login, login,
logout, logout,
play, play,
playTrack,
pause, pause,
skipNext, skipNext,
skipPrevious, skipPrevious,
@@ -429,11 +452,9 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
getDevices, getDevices,
setActiveDevice, setActiveDevice,
refreshPlaybackState, refreshPlaybackState,
// Capture/Resume methods
capturePlaybackState, capturePlaybackState,
resumePlaybackState, resumePlaybackState,
clearCapturedState, clearCapturedState,
// Search
searchTracks, 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 { PlayIcon, PencilIcon, SpeakerHighIcon } from "@phosphor-icons/react";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { MatchSlot } from "./match-slot"; import { MatchSlot } from "./match-slot";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
import { Team } from "@/features/teams/types";
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
import { MatchForm } from "./match-form"; import { MatchForm } from "./match-form";
import Sheet from "@/components/sheet/sheet"; import Sheet from "@/components/sheet/sheet";
@@ -10,6 +11,7 @@ import { useServerMutation } from "@/lib/tanstack-query/hooks";
import { endMatch, startMatch } from "@/features/matches/server"; import { endMatch, startMatch } from "@/features/matches/server";
import { tournamentKeys } from "@/features/tournaments/queries"; import { tournamentKeys } from "@/features/tournaments/queries";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useSpotifyPlayback } from "@/lib/spotify/hooks";
interface MatchCardProps { interface MatchCardProps {
match: Match; match: Match;
@@ -24,6 +26,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
}) => { }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const editSheet = useSheet(); const editSheet = useSheet();
const { playTrack, pause } = useSpotifyPlayback();
const homeSlot = useMemo( const homeSlot = useMemo(
() => ({ () => ({
from: orders[match.home_from_lid], from: orders[match.home_from_lid],
@@ -65,6 +68,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
[showControls, match.status] [showControls, match.status]
); );
const hasWalkoutData = showControls && match.home && match.away && 'song_id' in match.home && 'song_id' in match.away;
const start = useServerMutation({ const start = useServerMutation({
mutationFn: startMatch, mutationFn: startMatch,
successMessage: "Match started!", successMessage: "Match started!",
@@ -84,19 +89,13 @@ export const MatchCard: React.FC<MatchCardProps> = ({
}, },
}); });
const handleStart = useCallback(async () => {
await start.mutate({
data: match.id,
});
}, [match]);
const handleFormSubmit = useCallback( const handleFormSubmit = useCallback(
async (data: { async (data: {
home_cups: number; home_cups: number;
away_cups: number; away_cups: number;
ot_count: number; ot_count: number;
}) => { }) => {
await end.mutate({ end.mutate({
data: { data: {
...data, ...data,
matchId: match.id, matchId: match.id,
@@ -107,12 +106,14 @@ export const MatchCard: React.FC<MatchCardProps> = ({
[match.id, editSheet] [match.id, editSheet]
); );
const handleSpeakerClick = useCallback(() => { const speak = useCallback((text: string): Promise<void> => {
if ("speechSynthesis" in window && match.home?.name && match.away?.name) { return new Promise((resolve) => {
const utterance = new SpeechSynthesisUtterance( if (!("speechSynthesis" in window)) {
`${match.home.name} vs. ${match.away.name}` resolve();
); return;
}
const utterance = new SpeechSynthesisUtterance(text);
const voices = window.speechSynthesis.getVoices(); const voices = window.speechSynthesis.getVoices();
const preferredVoice = const preferredVoice =
@@ -130,9 +131,71 @@ export const MatchCard: React.FC<MatchCardProps> = ({
utterance.volume = 0.8; utterance.volume = 0.8;
utterance.pitch = 1.0; utterance.pitch = 1.0;
utterance.onend = () => resolve();
utterance.onerror = () => resolve();
window.speechSynthesis.speak(utterance); window.speechSynthesis.speak(utterance);
});
}, []);
const playTeamWalkout = useCallback((team: Team): Promise<void> => {
return new Promise((resolve) => {
const songDuration = (team.song_end - team.song_start) * 1000;
playTrack(team.song_id, undefined, team.song_start * 1000);
setTimeout(async () => {
await pause();
resolve();
}, songDuration);
});
}, [playTrack, pause]);
const handleSpeakerClick = useCallback(async () => {
if (!hasWalkoutData || !match.home?.name || !match.away?.name) {
await speak(`${match.home?.name || "Home"} vs. ${match.away?.name || "Away"}`);
return;
} }
}, [match.home?.name, match.away?.name]);
try {
const homeTeam = match.home as Team;
const awayTeam = match.away as Team;
await playTeamWalkout(homeTeam);
await speak(homeTeam.name);
await speak("versus");
await playTeamWalkout(awayTeam);
await speak(awayTeam.name);
await speak("have fun, good luck!");
} catch (error) {
console.warn('Walkout sequence error:', error);
await speak(`${match.home.name} vs. ${match.away.name}`);
}
}, [hasWalkoutData, match.home, match.away, speak, playTeamWalkout]);
const handleStart = useCallback(async () => {
start.mutate({
data: match.id,
});
// Play walkout sequence after starting the match
if (hasWalkoutData && match.home?.name && match.away?.name) {
try {
const homeTeam = match.home as Team;
const awayTeam = match.away as Team;
await playTeamWalkout(homeTeam);
await speak(homeTeam.name);
await speak("versus");
await playTeamWalkout(awayTeam);
await speak(awayTeam.name);
await speak("have fun, good luck!");
} catch (error) {
console.warn('Auto-walkout sequence error:', error);
}
}
}, [match, start, hasWalkoutData, playTeamWalkout, speak]);
return ( return (
<Flex direction="row" align="center" justify="end" gap={8}> <Flex direction="row" align="center" justify="end" gap={8}>
@@ -175,7 +238,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
</Text> </Text>
)} )}
{showControls && ( {showControls && match.status !== "tbd" && (
<ActionIcon <ActionIcon
pos="absolute" pos="absolute"
bottom={-2} bottom={-2}
@@ -210,6 +273,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
</Flex> </Flex>
)} )}
{showEditButton && ( {showEditButton && (
<Flex direction="column" justify="center" align="center"> <Flex direction="column" justify="center" align="center">
<ActionIcon <ActionIcon

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { Flex, Skeleton } from "@mantine/core";
const HeaderSkeleton = () => { const HeaderSkeleton = () => {
return ( 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%" /> <Skeleton opacity={0} height={100} width={100} radius="50%" />
<Flex align='center' justify='center' gap={4} pb={20} w='100%'> <Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Skeleton height={24} width={200} /> <Skeleton height={24} width={200} />

View File

@@ -33,7 +33,7 @@ const Header = ({ player }: HeaderProps) => {
return ( 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} /> <Avatar name={name} size={100} />
<Flex align='center' justify='center' gap={4} pb={20} w='100%'> <Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Title ta='center' style={{ fontSize, lineHeight: 1.2 }}>{name}</Title> <Title ta='center' style={{ fontSize, lineHeight: 1.2 }}>{name}</Title>

View File

@@ -3,7 +3,7 @@ import Header from "./header";
import SwipeableTabs from "@/components/swipeable-tabs"; import SwipeableTabs from "@/components/swipeable-tabs";
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries"; import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
import TeamList from "@/features/teams/components/team-list"; import TeamList from "@/features/teams/components/team-list";
import StatsOverview from "@/shared/components/stats-overview"; import StatsOverview from "@/components/stats-overview";
import MatchList from "@/features/matches/components/match-list"; import MatchList from "@/features/matches/components/match-list";
interface ProfileProps { interface ProfileProps {

View File

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

View File

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

View File

@@ -78,8 +78,7 @@ const EmojiBar = ({
{visibleReactions.map((reaction) => ( {visibleReactions.map((reaction) => (
<Button <Button
key={reaction.emoji} key={reaction.emoji}
variant={hasReacted(reaction) ? "filled" : "light"} variant={"light"}
color="gray"
bd={hasReacted(reaction) ? "1px solid var(--mantine-primary-color-filled)" : undefined} bd={hasReacted(reaction) ? "1px solid var(--mantine-primary-color-filled)" : undefined}
size="compact-xs" size="compact-xs"
radius="xl" radius="xl"
@@ -109,8 +108,7 @@ const EmojiBar = ({
{hasGrouped && ( {hasGrouped && (
<Button <Button
variant={userHasReactedToGrouped ? "filled" : "light"} variant={"light"}
color="gray"
bd={userHasReactedToGrouped ? "1px solid var(--mantine-primary-color-filled)" : undefined} bd={userHasReactedToGrouped ? "1px solid var(--mantine-primary-color-filled)" : undefined}
size="compact-xs" size="compact-xs"
radius="xl" radius="xl"

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

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

View File

@@ -18,6 +18,7 @@ interface Song {
song_start?: number; song_start?: number;
song_end?: number; song_end?: number;
song_image_url: string; song_image_url: string;
duration_ms?: number;
} }
interface SongPickerProps { interface SongPickerProps {
@@ -62,7 +63,7 @@ const SongPicker = ({ form, error }: SongPickerProps) => {
}} }}
error={error} error={error}
Component={SongPickerComponent} Component={SongPickerComponent}
componentProps={{ formValues: form.getValues() }} componentProps={{}}
title={"Select Song"} title={"Select Song"}
label={"Walkout Song"} label={"Walkout Song"}
placeholder={"Select your walkout song"} placeholder={"Select your walkout song"}
@@ -73,10 +74,9 @@ const SongPicker = ({ form, error }: SongPickerProps) => {
interface SongPickerComponentProps { interface SongPickerComponentProps {
value: Song | undefined; value: Song | undefined;
onChange: (song: Song) => void; onChange: (song: Song) => void;
formValues: any;
} }
const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerComponentProps) => { const SongPickerComponent = ({ value: song, onChange }: SongPickerComponentProps) => {
const handleSongSelect = (track: SpotifyTrack) => { const handleSongSelect = (track: SpotifyTrack) => {
const defaultStart = 0; const defaultStart = 0;
const defaultEnd = Math.min(15, Math.floor(track.duration_ms / 1000)); const defaultEnd = Math.min(15, Math.floor(track.duration_ms / 1000));
@@ -89,6 +89,7 @@ const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerCo
song_image_url: track.album.images[0]?.url || '', song_image_url: track.album.images[0]?.url || '',
song_start: defaultStart, song_start: defaultStart,
song_end: defaultEnd, song_end: defaultEnd,
duration_ms: track.duration_ms,
}; };
onChange(newSong); onChange(newSong);
@@ -135,7 +136,7 @@ const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerCo
<Stack gap="xs"> <Stack gap="xs">
<DurationPicker <DurationPicker
songDurationMs={180000} songDurationMs={song?.duration_ms || 180000}
initialStart={song?.song_start || 0} initialStart={song?.song_start || 0}
initialEnd={song?.song_end || 15} initialEnd={song?.song_end || 15}
onChange={handleDurationChange} onChange={handleDurationChange}

View File

@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState, useRef, useEffect } from "react";
import { Text, Combobox, InputBase, useCombobox, Group, Avatar, Loader } from "@mantine/core"; import { Text, TextInput, Group, Avatar, Loader, Paper, Stack, Box } from "@mantine/core";
import { SpotifyTrack } from "@/lib/spotify/types"; import { SpotifyTrack } from "@/lib/spotify/types";
import { useDebouncedCallback } from "@mantine/hooks"; import { useDebouncedCallback } from "@mantine/hooks";
@@ -12,10 +12,11 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<SpotifyTrack[]>([]); const [searchResults, setSearchResults] = useState<SpotifyTrack[]>([]);
const [isLoading, setIsLoading] = useState(false); 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[]> => { const searchSpotifyTracks = async (query: string): Promise<SpotifyTrack[]> => {
if (!query.trim()) return []; if (!query.trim()) return [];
@@ -37,6 +38,7 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
const debouncedSearch = useDebouncedCallback(async (query: string) => { const debouncedSearch = useDebouncedCallback(async (query: string) => {
if (!query.trim()) { if (!query.trim()) {
setSearchResults([]); setSearchResults([]);
setIsOpen(false);
return; return;
} }
@@ -44,10 +46,12 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
try { try {
const results = await searchSpotifyTracks(query); const results = await searchSpotifyTracks(query);
setSearchResults(results); setSearchResults(results);
combobox.openDropdown(); setIsOpen(results.length > 0);
setSelectedIndex(-1);
} catch (error) { } catch (error) {
console.error('Search failed:', error); console.error('Search failed:', error);
setSearchResults([]); setSearchResults([]);
setIsOpen(false);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -61,60 +65,117 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
const handleSongSelect = (track: SpotifyTrack) => { const handleSongSelect = (track: SpotifyTrack) => {
onChange(track); onChange(track);
setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`); setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`);
combobox.closeDropdown(); setIsOpen(false);
setSelectedIndex(-1);
}; };
const options = searchResults.map((track) => ( useEffect(() => {
<Combobox.Option value={track.id} key={track.id}> const handleClickOutside = (event: MouseEvent) => {
<Group gap="sm"> if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
{track.album.images[2] && ( setIsOpen(false);
<Avatar src={track.album.images[2].url} size={40} radius="sm" /> }
)} };
<div>
<Text size="sm" fw={500}> document.addEventListener('mousedown', handleClickOutside);
{track.name} return () => document.removeEventListener('mousedown', handleClickOutside);
</Text> }, []);
<Text size="xs" c="dimmed">
{track.artists.map(a => a.name).join(', ')} {track.album.name} const handleKeyDown = (e: React.KeyboardEvent) => {
</Text> if (!isOpen || searchResults.length === 0) return;
</div>
</Group> switch (e.key) {
</Combobox.Option> 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 ( return (
<Combobox <Box ref={containerRef} pos="relative" w="100%">
store={combobox} <TextInput
onOptionSubmit={(value) => { ref={inputRef}
const track = searchResults.find(t => t.id === value); value={searchQuery}
if (track) handleSongSelect(track); onChange={(event) => handleSearchChange(event.currentTarget.value)}
}} onKeyDown={handleKeyDown}
width='100%' onFocus={() => {
zIndex={9999} if (searchResults.length > 0) setIsOpen(true);
withinPortal={false} }}
> placeholder={placeholder}
<Combobox.Target> rightSection={isLoading ? <Loader size="xs" /> : null}
<InputBase />
rightSection={isLoading ? <Loader size="xs" /> : <Combobox.Chevron />}
value={searchQuery}
onChange={(event) => handleSearchChange(event.currentTarget.value)}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
placeholder={placeholder}
/>
</Combobox.Target>
<Combobox.Dropdown> {isOpen && (
<Combobox.Options> <Paper
{options.length > 0 ? options : shadow="md"
<Combobox.Empty> p={0}
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'} style={{
</Combobox.Empty> position: 'absolute',
} top: '100%',
</Combobox.Options> left: 0,
</Combobox.Dropdown> right: 0,
</Combobox> 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 { interface TeamListProps {
teams: TeamInfo[]; teams: TeamInfo[];
loading?: boolean; loading?: boolean;
onTeamClick?: (teamId: string) => void;
} }
const TeamList = ({ teams, loading = false }: TeamListProps) => { const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const handleClick = useCallback( const handleClick = useCallback(
(teamId: string) => navigate({ to: `/teams/${teamId}` }), (teamId: string) => {
[navigate] if (onTeamClick) {
onTeamClick(teamId);
} else {
navigate({ to: `/teams/${teamId}` });
}
},
[navigate, onTeamClick]
); );
if (loading) if (loading)

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

View File

@@ -2,7 +2,7 @@ import { Box, Divider, Text, Stack } from "@mantine/core";
import Header from "./header"; import Header from "./header";
import SwipeableTabs from "@/components/swipeable-tabs"; import SwipeableTabs from "@/components/swipeable-tabs";
import TournamentList from "@/features/tournaments/components/tournament-list"; import TournamentList from "@/features/tournaments/components/tournament-list";
import StatsOverview from "@/shared/components/stats-overview"; import StatsOverview from "@/components/stats-overview";
import { useTeam, useTeamMatches, useTeamStats } from "../../queries"; import { useTeam, useTeamMatches, useTeamStats } from "../../queries";
import MatchList from "@/features/matches/components/match-list"; import MatchList from "@/features/matches/components/match-list";
import PlayerList from "@/features/players/components/player-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"> <Group py="xs" px="sm" w="100%" gap="sm" align="center">
<Avatar <Avatar
size={32} size={32}
radius="sm"
name={team.name} name={team.name}
src={ src={
team.logo team.logo

View File

@@ -8,6 +8,7 @@ import {
PencilLineIcon, PencilLineIcon,
TreeStructureIcon, TreeStructureIcon,
UsersThreeIcon, UsersThreeIcon,
UsersIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
import EditEnrolledTeams from "./edit-enrolled-teams"; import EditEnrolledTeams from "./edit-enrolled-teams";
@@ -56,10 +57,15 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
onClick={openEditRules} onClick={openEditRules}
/> />
<ListButton <ListButton
label="Edit Enrolled Teams" label="Edit Enrollments"
Icon={UsersThreeIcon} Icon={UsersThreeIcon}
onClick={openEditTeams} onClick={openEditTeams}
/> />
<ListLink
label="Manage Team Songs/Logos"
Icon={UsersIcon}
to={`/admin/tournaments/${tournamentId}/teams`}
/>
<ListLink <ListLink
label="Run Tournament" label="Run Tournament"
Icon={TreeStructureIcon} 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 ( 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}`} /> <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%'> <Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Title ta='center' order={2}>{tournament.name}</Title> <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 Header from "./header";
import TeamList from "@/features/teams/components/team-list"; import TeamList from "@/features/teams/components/team-list";
import SwipeableTabs from "@/components/swipeable-tabs"; import SwipeableTabs from "@/components/swipeable-tabs";
import { useTournament } from "../../queries"; import { useTournament } from "../../queries";
import MatchList from "@/features/matches/components/match-list"; import MatchList from "@/features/matches/components/match-list";
import { TournamentStats } from "../tournament-stats";
interface ProfileProps { interface ProfileProps {
id: string; id: string;
@@ -13,10 +15,10 @@ const Profile = ({ id }: ProfileProps) => {
const { data: tournament } = useTournament(id); const { data: tournament } = useTournament(id);
if (!tournament) return null; if (!tournament) return null;
const tabs = [ const tabs = useMemo(() => [
{ {
label: "Overview", label: "Overview",
content: <Text p="md">Stats/Badges will go here, bracket link</Text> content: <TournamentStats tournament={tournament} />
}, },
{ {
label: "Matches", label: "Matches",
@@ -28,7 +30,7 @@ const Profile = ({ id }: ProfileProps) => {
<TeamList teams={tournament.teams || []} /> <TeamList teams={tournament.teams || []} />
</> </>
} }
]; ], [tournament]);
return <> return <>
<Header tournament={tournament} /> <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

@@ -35,7 +35,7 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
> >
{startedMatches.map((match, index) => ( {startedMatches.map((match, index) => (
<Carousel.Slide key={match.id}> <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} /> <MatchCard match={match} />
</Box> </Box>
</Carousel.Slide> </Carousel.Slide>

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 { import {
Badge,
Card, Card,
Text, Text,
Stack, Stack,
Group, Group,
Box,
ThemeIcon,
UnstyledButton, UnstyledButton,
Badge,
} from "@mantine/core"; } from "@mantine/core";
import { Tournament } from "@/features/tournaments/types"; import { TournamentInfo } from "@/features/tournaments/types";
import { useMemo } from "react";
import { import {
TrophyIcon, TrophyIcon,
CalendarIcon, CrownIcon,
MapPinIcon, MedalIcon,
UsersIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
interface TournamentCardProps { interface TournamentCardProps {
tournament: Tournament; tournament: TournamentInfo;
} }
export const TournamentCard = ({ tournament }: TournamentCardProps) => { export const TournamentCard = ({ tournament }: TournamentCardProps) => {
const navigate = useNavigate(); 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 ( return (
<UnstyledButton <UnstyledButton
w="100%" w="100%"
@@ -78,7 +57,7 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Group gap="md" align="center"> <Group gap="md" align="center">
<Avatar <Avatar
size={120} size={90}
radius="sm" radius="sm"
name={tournament.name} name={tournament.name}
src={ src={
@@ -93,31 +72,62 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
<Text fw={600} size="lg" lineClamp={2}> <Text fw={600} size="lg" lineClamp={2}>
{tournament.name} {tournament.name}
</Text> </Text>
{displayDate && ( {(tournament.first_place || tournament.second_place || tournament.third_place) && (
<Group gap="xs"> <Stack gap={6} >
<ThemeIcon {tournament.first_place && (
size="sm" <Badge
variant="light" size="md"
radius="sm" radius="md"
color="gray" variant="filled"
> color="yellow"
<CalendarIcon size={12} /> leftSection={
</ThemeIcon> <CrownIcon size={16} />
<Text size="sm" c="dimmed"> }
{displayDate} style={{
</Text> textTransform: 'none',
</Group> 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> </Stack>
</Group> </Group>
</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

@@ -11,10 +11,30 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
const copyToClipboard = async (phone: string) => { const copyToClipboard = async (phone: string) => {
try { try {
await navigator.clipboard.writeText(phone); if (navigator.clipboard && window.isSecureContext) {
toast.success("Phone number copied!"); 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) { } catch (err) {
toast.success("Failed to copy"); console.error("Failed to copy:", err);
toast.error("Failed to copy");
} }
}; };

View File

@@ -31,7 +31,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
> >
<TrophyIcon size={32} /> <TrophyIcon size={32} />
</Avatar> </Avatar>
<Flex gap="xs" direction="row" wrap="wrap" justify="space-around"> <Flex gap="xs" direction="column" justify="space-around">
{tournament.location && ( {tournament.location && (
<Group gap="xs"> <Group gap="xs">
<ThemeIcon size="sm" variant="light" radius="sm"> <ThemeIcon size="sm" variant="light" radius="sm">

View File

@@ -51,8 +51,8 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
<Stack gap="lg"> <Stack gap="lg">
<Header tournament={tournament} /> <Header tournament={tournament} />
<Stack px="md"> <Stack px="xs">
{tournament.desc && <Text size="sm">{tournament.desc}</Text>} {tournament.desc && <Text px="md" size="sm">{tournament.desc}</Text>}
<Card withBorder radius="lg" p="lg"> <Card withBorder radius="lg" p="lg">
<Stack gap="xs"> <Stack gap="xs">

View File

@@ -4,37 +4,32 @@ const UpcomingTournamentSkeleton = () => {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<Flex px="md" justify="center" w="100%"> <Flex px="md" justify="center" w="100%">
<Skeleton height={240} width={240} radius="md" /> <Skeleton height={200} width={240} radius="md" />
<Stack justify="space-between" align="flex-start">
<Box style={{ flex: 1 }}>
<Skeleton height={32} mb="xs" />
<Skeleton height={16} />
</Box>
</Stack>
</Flex> </Flex>
<Stack align="center" gap={2}>
<Skeleton height={16} w="30%" mb="md" />
<Skeleton height={16} w="30%" />
</Stack>
<Stack px="md"> <Stack px="md">
<Skeleton height={14} width="80%" />
<Card withBorder radius="lg" p="lg"> <Card withBorder radius="lg" p="lg">
<Skeleton height={14} width="80%" mb={16} />
<Group mb="sm" gap="xs" align="center"> <Group mb="sm" gap="xs" align="center">
<Skeleton height={16} width={16} /> <Skeleton height={32} width={16} />
<Skeleton height={14} width="20%" /> <Skeleton height={32} width="20%" />
<Box ml="auto"> <Box ml="auto">
<Skeleton height={20} width={80} radius="sm" /> <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> </Box>
</Group> </Group>
</Card> </Card>
</Stack> </Stack>
<Box>
<Divider />
<Stack gap={0}>
<Skeleton height={48} width="100%" />
<Skeleton height={48} width="100%" />
<Skeleton height={48} width="100%" />
</Stack>
</Box>
</Stack> </Stack>
); );
}; };

View File

@@ -32,9 +32,10 @@ export const updateTournament = createServerFn()
export const getTournament = createServerFn() export const getTournament = createServerFn()
.validator(z.string()) .validator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: tournamentId }) => .handler(async ({ data: tournamentId, context }) => {
toServerResult(() => pbAdmin.getTournament(tournamentId)) const isAdmin = context.roles.includes("Admin");
); return toServerResult(() => pbAdmin.getTournament(tournamentId, isAdmin));
});
export const getCurrentTournament = createServerFn() export const getCurrentTournament = createServerFn()
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])

View File

@@ -2,6 +2,22 @@ import { TeamInfo } from "@/features/teams/types";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
import { z } from "zod"; 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 { export interface TournamentInfo {
id: string; id: string;
name: string; name: string;
@@ -9,6 +25,9 @@ export interface TournamentInfo {
start_time?: string; start_time?: string;
end_time?: string; end_time?: string;
logo?: string; logo?: string;
first_place?: TeamInfo;
second_place?: TeamInfo;
third_place?: TeamInfo;
} }
export interface Tournament { export interface Tournament {
@@ -25,6 +44,10 @@ export interface Tournament {
updated: string; updated: string;
teams?: TeamInfo[]; teams?: TeamInfo[];
matches?: Match[]; matches?: Match[];
first_place?: TeamInfo;
second_place?: TeamInfo;
third_place?: TeamInfo;
team_stats?: TournamentTeamStats[];
} }
export const tournamentInputSchema = z.object({ export const tournamentInputSchema = z.object({

View File

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

View File

@@ -10,6 +10,11 @@ class PocketBaseAdminClient {
public authPromise: Promise<void>; public authPromise: Promise<void>;
constructor() { 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 = new PocketBase(process.env.POCKETBASE_URL);
this.pb.beforeSend = (url, options) => { this.pb.beforeSend = (url, options) => {

View File

@@ -100,7 +100,7 @@ export function createPlayersService(pb: PocketBase) {
expand: "tournament,home,away", expand: "tournament,home,away",
}); });
return result.map(transformMatch); return result.map((match) => transformMatch(match));
}, },
async getUnenrolledPlayers(tournamentId: string): Promise<Player[]> { async getUnenrolledPlayers(tournamentId: string): Promise<Player[]> {

View File

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

View File

@@ -9,16 +9,24 @@ import type { Team } from "@/features/teams/types";
import PocketBase from "pocketbase"; import PocketBase from "pocketbase";
import { transformFreeAgent, 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 { transformTeam } from "@/lib/pocketbase/util/transform-types";
import { getFreeAgents } from "@/features/tournaments/server";
import { PlayerInfo } from "@/features/players/types"; import { PlayerInfo } from "@/features/players/types";
export function createTournamentsService(pb: PocketBase) { export function createTournamentsService(pb: PocketBase) {
return { return {
async getTournament(id: string): Promise<Tournament> { async getTournament(id: string, isAdmin: boolean = false): Promise<Tournament> {
const result = await pb.collection("tournaments").getOne(id, { const [tournamentResult, teamStatsResult] = await Promise.all([
expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players", 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); }),
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> { async getMostRecentTournament(): Promise<Tournament> {
const result = await pb const result = await pb
@@ -29,17 +37,35 @@ export function createTournamentsService(pb: PocketBase) {
sort: "-created", 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); return transformTournament(result);
}, },
async listTournaments(): Promise<TournamentInfo[]> { async listTournaments(): Promise<TournamentInfo[]> {
const result = await pb const result = await pb
.collection("tournaments") .collection("tournaments")
.getFullList({ .getFullList({
fields: "id,name,location,start_time,end_time,logo", expand: "teams,teams.players,matches",
sort: "-created", 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> { async createTournament(data: TournamentInput): Promise<Tournament> {
const result = await pb const result = await pb

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 { return {
id: record.id, id: record.id,
order: record.order, order: record.order,
@@ -47,8 +47,8 @@ export const transformMatch = (record: any): Match => {
is_losers_bracket: record.is_losers_bracket, is_losers_bracket: record.is_losers_bracket,
status: record.status || "tbd", status: record.status || "tbd",
tournament: record.expand?.tournament ? transformTournamentInfo(record.expand?.tournament) : record.tournament, tournament: record.expand?.tournament ? transformTournamentInfo(record.expand?.tournament) : record.tournament,
home: record.expand?.home ? transformTeamInfo(record.expand.home) : record.home, home: record.expand?.home ? (isAdmin ? transformTeam(record.expand.home) : transformTeamInfo(record.expand.home)) : record.home,
away: record.expand?.away ? transformTeamInfo(record.expand.away) : record.away, away: record.expand?.away ? (isAdmin ? transformTeam(record.expand.away) : transformTeamInfo(record.expand.away)) : record.away,
created: record.created, created: record.created,
updated: record.updated, updated: record.updated,
home_seed: record.home_seed, home_seed: record.home_seed,
@@ -57,12 +57,58 @@ export const transformMatch = (record: any): Match => {
} }
export const transformTournamentInfo = (record: any): TournamentInfo => { 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 { return {
id: record.id, id: record.id,
name: record.name, name: record.name,
location: record.location, location: record.location,
start_time: record.start_time, start_time: record.start_time,
end_time: record.end_time,
logo: record.logo, logo: record.logo,
first_place,
second_place,
third_place,
}; };
} }
@@ -135,20 +181,73 @@ export function transformTeam(record: any): Team {
}; };
} }
export function transformTournament(record: any): Tournament { export function transformTournament(record: any, isAdmin: boolean = false): Tournament {
const teams = const teams =
record.expand?.teams record.expand?.teams
?.sort((a: any, b: any) => ?.sort((a: any, b: any) =>
new Date(a.created) < new Date(b.created) ? -1 : 0 new Date(a.created) < new Date(b.created) ? -1 : 0
) )
?.map(transformTeamInfo) ?? []; ?.map(isAdmin ? transformTeam : transformTeamInfo) ?? [];
const matches = const matches =
record.expand?.matches record.expand?.matches
?.sort((a: any, b: any) => ?.sort((a: any, b: any) =>
a.lid - b.lid ? -1 : 0 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 { return {
id: record.id, id: record.id,
@@ -163,7 +262,11 @@ export function transformTournament(record: any): Tournament {
created: record.created, created: record.created,
updated: record.updated, updated: record.updated,
teams, 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> { async pause(): Promise<void> {
await this.request('/me/player/pause', { await this.request('/me/player/pause', {
method: 'PUT', method: 'PUT',

View File

@@ -22,6 +22,7 @@ export const useSpotifyPlayback = () => {
playbackState, playbackState,
currentTrack, currentTrack,
play, play,
playTrack,
pause, pause,
skipNext, skipNext,
skipPrevious, skipPrevious,
@@ -34,6 +35,7 @@ export const useSpotifyPlayback = () => {
playbackState, playbackState,
currentTrack, currentTrack,
play, play,
playTrack,
pause, pause,
skipNext, skipNext,
skipPrevious, skipPrevious,

View File

@@ -90,6 +90,7 @@ export interface SpotifyContextType extends SpotifyAuthState {
logout: () => void; logout: () => void;
play: (deviceId?: string) => Promise<void>; play: (deviceId?: string) => Promise<void>;
playTrack: (trackId: string, deviceId?: string, positionMs?: number) => Promise<void>;
pause: () => Promise<void>; pause: () => Promise<void>;
skipNext: () => Promise<void>; skipNext: () => Promise<void>;
skipPrevious: () => Promise<void>; skipPrevious: () => Promise<void>;

View File

@@ -23,15 +23,15 @@ export function useOptimisticMutation<TData, TVariables = unknown>(
return { previousData }; return { previousData };
}, },
onError: (error, variables, context) => { onError: (error, variables, onMutateResult, context) => {
if (context && typeof context === 'object' && 'previousData' in context && context.previousData) { if (context && typeof context === 'object' && 'previousData' in context && context.previousData) {
queryClient.setQueryData(queryKey, context.previousData); queryClient.setQueryData(queryKey, context.previousData);
} }
mutationOptions.onError?.(error, variables, context); mutationOptions.onError?.(error, variables, onMutateResult, context);
}, },
onSettled: (data, error, variables, context) => { onSettled: (data, error, variables, onMutateResult, context) => {
queryClient.invalidateQueries({ queryKey }); queryClient.invalidateQueries({ queryKey });
mutationOptions.onSettled?.(data, error, variables, context); mutationOptions.onSettled?.(data, error, variables, onMutateResult, context);
} }
}); });
} }

View File

@@ -44,6 +44,7 @@ export function useServerMutation<TData, TVariables = unknown>(
if (errorData?.error === "SESSION_REFRESH_REQUIRED") { if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
const currentUrl = window.location.pathname + window.location.search; const currentUrl = window.location.pathname + window.location.search;
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`; window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
console.log('redirecting 2')
throw new Error("SESSION_REFRESH_REQUIRED"); throw new Error("SESSION_REFRESH_REQUIRED");
} }
} catch (parseError) {} } catch (parseError) {}
@@ -52,14 +53,14 @@ export function useServerMutation<TData, TVariables = unknown>(
throw error; throw error;
} }
}, },
onSuccess: (data, variables, context) => { onSuccess: (data, variables, onMutateResult, context) => {
if (showSuccessToast && successMessage) { if (showSuccessToast && successMessage) {
toast.success(successMessage); toast.success(successMessage);
} }
onSuccess?.(data, variables, context); onSuccess?.(data, variables, onMutateResult, context);
}, },
onError: (error, variables, context) => { onError: (error, variables, onMutateResult, context) => {
onError?.(error, variables, context); onError?.(error, variables, onMutateResult, context);
} }
}); });
} }

View File

@@ -79,6 +79,7 @@ export const getSessionContext = async (request: Request, options?: { isServerFu
const url = new URL(request.url); const url = new URL(request.url);
const from = encodeURIComponent(url.pathname + url.search); const from = encodeURIComponent(url.pathname + url.search);
console.log('redirecting')
throw redirect({ throw redirect({
to: "/refresh-session", to: "/refresh-session",
search: { redirect: from } search: { redirect: from }

View File

@@ -18,5 +18,11 @@ export default defineConfig({
}, },
}), }),
react() react()
] ],
build: {
target: 'esnext',
},
ssr: {
target: 'node',
}
}) })