Compare commits
4 Commits
cae5fa1c71
...
7441d1ac58
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7441d1ac58 | ||
|
|
7ff26229d9 | ||
|
|
b93ce38d48 | ||
|
|
ae934e77f4 |
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { tournamentQueries } from "@/features/tournaments/queries";
|
|||||||
import ManageTournament from "@/features/tournaments/components/manage-tournament";
|
import ManageTournament from "@/features/tournaments/components/manage-tournament";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/tournaments/$id")({
|
export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
|
||||||
beforeLoad: async ({ context, params }) => {
|
beforeLoad: async ({ context, params }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
const tournament = await ensureServerQueryData(
|
const tournament = await ensureServerQueryData(
|
||||||
32
src/app/routes/_authed/admin/tournaments/$id/teams.tsx
Normal file
32
src/app/routes/_authed/admin/tournaments/$id/teams.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||||
|
import ManageTeams from "@/features/teams/components/manage-teams";
|
||||||
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
|
||||||
|
beforeLoad: async ({ context, params }) => {
|
||||||
|
const { queryClient } = context;
|
||||||
|
const tournament = await ensureServerQueryData(
|
||||||
|
queryClient,
|
||||||
|
tournamentQueries.details(params.id)
|
||||||
|
);
|
||||||
|
if (!tournament) throw redirect({ to: "/admin/tournaments" });
|
||||||
|
return {
|
||||||
|
tournament,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
loader: ({ context }) => ({
|
||||||
|
header: {
|
||||||
|
withBackButton: true,
|
||||||
|
title: `${context.tournament.name} Teams`,
|
||||||
|
},
|
||||||
|
withPadding: false,
|
||||||
|
}),
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { id } = Route.useParams();
|
||||||
|
const { tournament } = Route.useRouteContext();
|
||||||
|
return <ManageTeams tournament={tournament} />;
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,25 +49,15 @@ 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)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack align="center" gap="lg">
|
|
||||||
<ThemeIcon color="red" size={80} radius="xl">
|
|
||||||
<XCircleIcon size={48} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Title order={2} ta="center">Access Denied</Title>
|
|
||||||
<Text size="lg" c="dimmed" ta="center">
|
|
||||||
You don't have permission to access this.
|
|
||||||
</Text>
|
</Text>
|
||||||
<Group>
|
<Group gap="sm" mt="md">
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
onClick={() => window.history.back()}
|
onClick={() => window.history.back()}
|
||||||
@@ -84,59 +73,46 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
|||||||
</MantineButton>
|
</MantineButton>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Center>
|
||||||
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
onClick={toggleDetails}
|
onClick={toggleDetails}
|
||||||
|
fullWidth
|
||||||
>
|
>
|
||||||
{detailsOpened ? 'Hide' : 'Show'} stack trace
|
{detailsOpened ? 'Hide' : 'Show'} details
|
||||||
</Button>
|
</Button>
|
||||||
<Collapse in={detailsOpened}>
|
<Collapse in={detailsOpened}>
|
||||||
<Code block mt="md" p="md">
|
<Code block mt="sm" p="sm" style={{ fontSize: '11px' }}>
|
||||||
{errorStack}
|
{errorStack}
|
||||||
</Code>
|
</Code>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Alert>
|
</Box>
|
||||||
|
|
||||||
<Group>
|
<Group gap="sm" mt="lg">
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
onClick={() => router.invalidate()}
|
onClick={() => router.invalidate()}
|
||||||
>
|
>
|
||||||
Try Again
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
{isRoot ? (
|
{isRoot ? (
|
||||||
<MantineButton
|
<MantineButton
|
||||||
@@ -156,6 +132,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Center>
|
||||||
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,13 @@ const StatItem = ({
|
|||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
{value !== null ? (
|
||||||
<Text size="sm" fw={700} c="dimmed">
|
<Text size="sm" fw={700} c="dimmed">
|
||||||
{value !== null ? `${value}${suffix}` : <Skeleton width={20} height={20} />}
|
{`${value}${suffix}`}
|
||||||
</Text>
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Skeleton width={20} height={20} />
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: number;
|
||||||
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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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: ""
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -422,6 +445,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
play,
|
play,
|
||||||
|
playTrack,
|
||||||
pause,
|
pause,
|
||||||
skipNext,
|
skipNext,
|
||||||
skipPrevious,
|
skipPrevious,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' }}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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%'
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Alert } from "@mantine/core";
|
|
||||||
import { Info } from "@phosphor-icons/react";
|
|
||||||
import { Transition } from "@mantine/core";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
const Error = ({ error }: { error?: string }) => {
|
|
||||||
const show = useMemo(() => (error ? error.length > 0 : false), [error]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Transition
|
|
||||||
mounted={show}
|
|
||||||
transition="slide-up"
|
|
||||||
duration={400}
|
|
||||||
timingFunction="ease"
|
|
||||||
>
|
|
||||||
{(styles) => (
|
|
||||||
<Alert w='95%' color="red" icon={<Info />} style={styles}>{error}</Alert>
|
|
||||||
)}
|
|
||||||
</Transition>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Error;
|
|
||||||
@@ -1,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>
|
||||||
|
<Tooltip
|
||||||
|
label={match.home?.name!}
|
||||||
|
disabled={!match.home?.name}
|
||||||
|
events={{ hover: true, focus: true, touch: true }}
|
||||||
|
>
|
||||||
<Text
|
<Text
|
||||||
size="sm"
|
size="sm"
|
||||||
fw={600}
|
fw={600}
|
||||||
lineClamp={1}
|
lineClamp={1}
|
||||||
style={{ minWidth: 0, flex: 1 }}
|
style={{ minWidth: 0, flex: 1, cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
{match.home?.name!}
|
{match.home?.name!}
|
||||||
</Text>
|
</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>
|
||||||
|
<Tooltip
|
||||||
|
label={match.away?.name}
|
||||||
|
disabled={!match.away?.name}
|
||||||
|
events={{ hover: true, focus: true, touch: true }}
|
||||||
|
>
|
||||||
<Text
|
<Text
|
||||||
size="sm"
|
size="sm"
|
||||||
fw={600}
|
fw={600}
|
||||||
lineClamp={1}
|
lineClamp={1}
|
||||||
style={{ minWidth: 0, flex: 1 }}
|
style={{ minWidth: 0, flex: 1, cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
{match.away?.name}
|
{match.away?.name}
|
||||||
</Text>
|
</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)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Suspense>
|
||||||
<EmojiBar matchId={match.id} />
|
<EmojiBar matchId={match.id} />
|
||||||
|
</Suspense>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
</Indicator>
|
</Indicator>
|
||||||
|
|||||||
@@ -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) => (
|
||||||
<motion.div
|
<div
|
||||||
key={`match-${match.id}-${index}`}
|
key={`match-${match.id}-${index}`}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
transition={{ duration: 0.2, delay: index * 0.01 }}
|
|
||||||
>
|
>
|
||||||
<MatchCard match={match} />
|
<MatchCard match={match} />
|
||||||
</motion.div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
165
src/features/teams/components/manage-teams.tsx
Normal file
165
src/features/teams/components/manage-teams.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Stack,
|
||||||
|
Container,
|
||||||
|
Box,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import { Tournament } from "@/features/tournaments/types";
|
||||||
|
import TeamList from "./team-list";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import TeamForm from "./team-form";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
import { useTeam } from "../queries";
|
||||||
|
|
||||||
|
interface TeamEditSheetProps {
|
||||||
|
teamId: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamEditSheet = ({ teamId, isOpen, onClose }: TeamEditSheetProps) => {
|
||||||
|
const { data: team } = useTeam(teamId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet
|
||||||
|
title={team ? `Edit ${team.name}` : "Edit Team"}
|
||||||
|
opened={isOpen}
|
||||||
|
onChange={onClose}
|
||||||
|
>
|
||||||
|
{team && (
|
||||||
|
<TeamForm
|
||||||
|
teamId={team.id}
|
||||||
|
initialValues={{
|
||||||
|
...team,
|
||||||
|
players: team.players ? team.players.map((p) => p.id) : [],
|
||||||
|
logo: typeof team.logo === "string" ? undefined : team.logo,
|
||||||
|
}}
|
||||||
|
close={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ManageTeamsProps {
|
||||||
|
tournament: Tournament;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ManageTeams = ({ tournament }: ManageTeamsProps) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isOpen: editTeamOpened,
|
||||||
|
open: openEditTeam,
|
||||||
|
close: closeEditTeam,
|
||||||
|
} = useSheet();
|
||||||
|
|
||||||
|
const teams = tournament.teams || [];
|
||||||
|
|
||||||
|
const filteredTeams = useMemo(() => {
|
||||||
|
if (!search.trim()) return teams;
|
||||||
|
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
|
||||||
|
return teams.filter((team) => {
|
||||||
|
if (team.name.toLowerCase().includes(searchLower)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (team.players) {
|
||||||
|
return team.players.some((player) => {
|
||||||
|
const firstName = player.first_name?.toLowerCase() || "";
|
||||||
|
const lastName = player.last_name?.toLowerCase() || "";
|
||||||
|
const fullName = `${firstName} ${lastName}`.toLowerCase();
|
||||||
|
|
||||||
|
return fullName.includes(searchLower) ||
|
||||||
|
firstName.includes(searchLower) ||
|
||||||
|
lastName.includes(searchLower);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}, [teams, search]);
|
||||||
|
|
||||||
|
const handleTeamClick = (teamId: string) => {
|
||||||
|
setSelectedTeamId(teamId);
|
||||||
|
openEditTeam();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseEditTeam = () => {
|
||||||
|
setSelectedTeamId(null);
|
||||||
|
closeEditTeam();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!teams.length) {
|
||||||
|
return (
|
||||||
|
<Container px={0} size="md">
|
||||||
|
<Stack align="center" gap="md" py="xl">
|
||||||
|
<ThemeIcon size="xl" variant="light" radius="md">
|
||||||
|
<UsersIcon size={32} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Title order={3} c="dimmed">
|
||||||
|
No Teams Enrolled
|
||||||
|
</Title>
|
||||||
|
<Text c="dimmed" ta="center">
|
||||||
|
This tournament has no enrolled teams yet.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Container size="100%" px={0}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search teams by name or player..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
leftSection={<MagnifyingGlassIcon size={16} />}
|
||||||
|
size="md"
|
||||||
|
px="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box px="md">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{filteredTeams.length} of {teams.length} teams
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TeamList
|
||||||
|
teams={filteredTeams}
|
||||||
|
onTeamClick={handleTeamClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{filteredTeams.length === 0 && search && (
|
||||||
|
<Text ta="center" c="dimmed" py="xl">
|
||||||
|
No teams found matching "{search}"
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{selectedTeamId && (
|
||||||
|
<TeamEditSheet
|
||||||
|
teamId={selectedTeamId}
|
||||||
|
isOpen={editTeamOpened}
|
||||||
|
onClose={handleCloseEditTeam}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ManageTeams;
|
||||||
@@ -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);
|
setStartTime(clampedStart);
|
||||||
|
setEndTime(clampedEnd);
|
||||||
|
setStartInputValue(formatTime(clampedStart));
|
||||||
|
setEndInputValue(formatTime(clampedEnd));
|
||||||
|
|
||||||
|
const valid = validateTimes(clampedStart, clampedEnd);
|
||||||
setIsValid(valid);
|
setIsValid(valid);
|
||||||
|
|
||||||
if (valid) {
|
if (valid) {
|
||||||
onChange(start, end);
|
onChange(clampedStart, clampedEnd);
|
||||||
}
|
}
|
||||||
},
|
}, [songDurationSeconds, validateTimes, onChange, formatTime]);
|
||||||
[onChange, validateRange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRangeChangeEnd = useCallback(
|
const handleStartInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
(newRange: [number, number]) => {
|
setStartInputValue(event.target.value);
|
||||||
let [start, end] = newRange;
|
}, []);
|
||||||
let duration = end - start;
|
|
||||||
|
|
||||||
if (duration < 10) {
|
const handleEndInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (start < songDurationSeconds / 2) {
|
setEndInputValue(event.target.value);
|
||||||
end = Math.min(start + 10, songDurationSeconds);
|
}, []);
|
||||||
|
|
||||||
|
const handleStartBlur = useCallback(() => {
|
||||||
|
const parsed = parseTimeInput(startInputValue);
|
||||||
|
if (parsed !== null) {
|
||||||
|
updateTimes(parsed, endTime);
|
||||||
} else {
|
} else {
|
||||||
start = Math.max(end - 10, 0);
|
setStartInputValue(formatTime(startTime));
|
||||||
}
|
|
||||||
duration = end - start;
|
|
||||||
}
|
}
|
||||||
|
}, [startInputValue, endTime, updateTimes, parseTimeInput, formatTime, startTime]);
|
||||||
|
|
||||||
if (duration > 15) {
|
const handleEndBlur = useCallback(() => {
|
||||||
const startDiff = Math.abs(start - range[0]);
|
const parsed = parseTimeInput(endInputValue);
|
||||||
const endDiff = Math.abs(end - range[1]);
|
if (parsed !== null) {
|
||||||
|
updateTimes(startTime, parsed);
|
||||||
if (startDiff > endDiff) {
|
|
||||||
end = start + 15;
|
|
||||||
if (end > songDurationSeconds) {
|
|
||||||
end = songDurationSeconds;
|
|
||||||
start = end - 15;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
start = end - 15;
|
setEndInputValue(formatTime(endTime));
|
||||||
if (start < 0) {
|
|
||||||
start = 0;
|
|
||||||
end = start + 15;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}, [endInputValue, startTime, updateTimes, parseTimeInput, formatTime, endTime]);
|
||||||
|
|
||||||
start = Math.max(0, start);
|
const adjustStartTime = useCallback((seconds: number) => {
|
||||||
end = Math.min(songDurationSeconds, end);
|
updateTimes(startTime + seconds, endTime);
|
||||||
|
}, [startTime, endTime, updateTimes]);
|
||||||
|
|
||||||
const finalRange: [number, number] = [start, end];
|
const adjustEndTime = useCallback((seconds: number) => {
|
||||||
setRange(finalRange);
|
updateTimes(startTime, endTime + seconds);
|
||||||
setIsValid(validateRange(start, end));
|
}, [startTime, endTime, updateTimes]);
|
||||||
onChange(start, end);
|
|
||||||
},
|
|
||||||
[range, songDurationSeconds, onChange, validateRange]
|
|
||||||
);
|
|
||||||
|
|
||||||
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
|
|
||||||
min={0}
|
|
||||||
max={songDurationSeconds}
|
|
||||||
step={1}
|
|
||||||
value={range}
|
|
||||||
onChange={disabled ? undefined : handleRangeChange}
|
|
||||||
onChangeEnd={disabled ? undefined : handleRangeChangeEnd}
|
|
||||||
marks={[
|
|
||||||
{ value: 0, label: "0:00" },
|
|
||||||
{
|
|
||||||
value: songDurationSeconds,
|
|
||||||
label: formatTime(songDurationSeconds),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
size="lg"
|
|
||||||
m='xs'
|
|
||||||
color={disabled ? "gray" : (isValid ? "blue" : "red")}
|
|
||||||
thumbSize={20}
|
|
||||||
label={disabled ? undefined : (value) => formatTime(value)}
|
|
||||||
disabled={disabled}
|
|
||||||
styles={{
|
|
||||||
track: { height: 8 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Stack gap={4}>
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Stack gap={2} align="center">
|
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
Start
|
Start
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={500}>
|
<TextInput
|
||||||
{formatTime(range[0])}
|
value={startInputValue}
|
||||||
</Text>
|
onChange={handleStartInputChange}
|
||||||
|
onBlur={handleStartBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
size="xs"
|
||||||
|
w={70}
|
||||||
|
placeholder="0:00"
|
||||||
|
ta="center"
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '12px'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group gap={4}>
|
||||||
|
<IncrementButtons
|
||||||
|
onAdjust={adjustStartTime}
|
||||||
|
disabled={disabled || startTime <= 0}
|
||||||
|
isPositive={false}
|
||||||
|
/>
|
||||||
|
<IncrementButtons
|
||||||
|
onAdjust={adjustStartTime}
|
||||||
|
disabled={disabled || startTime >= songDurationSeconds - 10}
|
||||||
|
isPositive={true}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<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>
|
|
||||||
<Text size="sm" fw={500} c={isValid ? undefined : "red"}>
|
|
||||||
{segmentDuration}s
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack gap={2} align="center">
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
End
|
End
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={500}>
|
<TextInput
|
||||||
{formatTime(range[1])}
|
value={endInputValue}
|
||||||
</Text>
|
onChange={handleEndInputChange}
|
||||||
</Stack>
|
onBlur={handleEndBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
size="xs"
|
||||||
|
w={70}
|
||||||
|
placeholder="0:15"
|
||||||
|
ta="center"
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '12px'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
<Group gap={4}>
|
||||||
|
<IncrementButtons
|
||||||
|
onAdjust={adjustEndTime}
|
||||||
|
disabled={disabled || endTime <= startTime + 10}
|
||||||
|
isPositive={false}
|
||||||
|
/>
|
||||||
|
<IncrementButtons
|
||||||
|
onAdjust={adjustEndTime}
|
||||||
|
disabled={disabled || endTime >= songDurationSeconds}
|
||||||
|
isPositive={true}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
{!isValid && (
|
{!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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,11 +65,91 @@ 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) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!isOpen || searchResults.length === 0) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => (prev < searchResults.length - 1 ? prev + 1 : prev));
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev));
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedIndex >= 0 && searchResults[selectedIndex]) {
|
||||||
|
handleSongSelect(searchResults[selectedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
setIsOpen(false);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box ref={containerRef} pos="relative" w="100%">
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => handleSearchChange(event.currentTarget.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => {
|
||||||
|
if (searchResults.length > 0) setIsOpen(true);
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rightSection={isLoading ? <Loader size="xs" /> : null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<Paper
|
||||||
|
shadow="md"
|
||||||
|
p={0}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
maxHeight: '160px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
touchAction: 'pan-y'
|
||||||
|
}}
|
||||||
|
onTouchMove={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{searchResults.length > 0 ? (
|
||||||
|
<Stack gap={0}>
|
||||||
|
{searchResults.map((track, index) => (
|
||||||
|
<Box
|
||||||
|
key={track.id}
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: selectedIndex === index ? 'var(--mantine-color-gray-1)' : 'transparent',
|
||||||
|
borderBottom: index < searchResults.length - 1 ? '1px solid var(--mantine-color-gray-3)' : 'none'
|
||||||
|
}}
|
||||||
|
onClick={() => handleSongSelect(track)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
{track.album.images[2] && (
|
{track.album.images[2] && (
|
||||||
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
|
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
|
||||||
@@ -79,42 +163,19 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
</Combobox.Option>
|
</Box>
|
||||||
));
|
))}
|
||||||
|
</Stack>
|
||||||
return (
|
) : (
|
||||||
<Combobox
|
<Box p="md">
|
||||||
store={combobox}
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
onOptionSubmit={(value) => {
|
|
||||||
const track = searchResults.find(t => t.id === value);
|
|
||||||
if (track) handleSongSelect(track);
|
|
||||||
}}
|
|
||||||
width='100%'
|
|
||||||
zIndex={9999}
|
|
||||||
withinPortal={false}
|
|
||||||
>
|
|
||||||
<Combobox.Target>
|
|
||||||
<InputBase
|
|
||||||
rightSection={isLoading ? <Loader size="xs" /> : <Combobox.Chevron />}
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(event) => handleSearchChange(event.currentTarget.value)}
|
|
||||||
onClick={() => combobox.openDropdown()}
|
|
||||||
onFocus={() => combobox.openDropdown()}
|
|
||||||
onBlur={() => combobox.closeDropdown()}
|
|
||||||
placeholder={placeholder}
|
|
||||||
/>
|
|
||||||
</Combobox.Target>
|
|
||||||
|
|
||||||
<Combobox.Dropdown>
|
|
||||||
<Combobox.Options>
|
|
||||||
{options.length > 0 ? options :
|
|
||||||
<Combobox.Empty>
|
|
||||||
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
|
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
|
||||||
</Combobox.Empty>
|
</Text>
|
||||||
}
|
</Box>
|
||||||
</Combobox.Options>
|
)}
|
||||||
</Combobox.Dropdown>
|
</Paper>
|
||||||
</Combobox>
|
)}
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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}
|
||||||
|
|||||||
37
src/features/teams/components/team-profile/skeleton.tsx
Normal file
37
src/features/teams/components/team-profile/skeleton.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Box, Flex, Loader } from "@mantine/core";
|
||||||
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
|
import HeaderSkeleton from "./header-skeleton";
|
||||||
|
|
||||||
|
const SkeletonLoader = () => (
|
||||||
|
<Flex h="30vh" w="100%" align="center" justify="center">
|
||||||
|
<Loader />
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ProfileSkeleton = () => {
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
label: "Overview",
|
||||||
|
content: <SkeletonLoader />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Matches",
|
||||||
|
content: <SkeletonLoader />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Tournaments",
|
||||||
|
content: <SkeletonLoader />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeaderSkeleton />
|
||||||
|
<Box mt="lg">
|
||||||
|
<SwipeableTabs tabs={tabs} />
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileSkeleton;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
37
src/features/tournaments/components/profile/skeleton.tsx
Normal file
37
src/features/tournaments/components/profile/skeleton.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Box, Flex, Loader } from "@mantine/core";
|
||||||
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
|
import HeaderSkeleton from "./header-skeleton";
|
||||||
|
|
||||||
|
const SkeletonLoader = () => (
|
||||||
|
<Flex h="30vh" w="100%" align="center" justify="center">
|
||||||
|
<Loader />
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ProfileSkeleton = () => {
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
label: "Overview",
|
||||||
|
content: <SkeletonLoader />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Matches",
|
||||||
|
content: <SkeletonLoader />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Teams",
|
||||||
|
content: <SkeletonLoader />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeaderSkeleton />
|
||||||
|
<Box mt="lg">
|
||||||
|
<SwipeableTabs tabs={tabs} />
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileSkeleton;
|
||||||
@@ -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>
|
||||||
|
|||||||
37
src/features/tournaments/components/tournament-card-list.tsx
Normal file
37
src/features/tournaments/components/tournament-card-list.tsx
Normal 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;
|
||||||
@@ -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"
|
||||||
|
leftSection={
|
||||||
|
<CrownIcon size={16} />
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<CalendarIcon size={12} />
|
{tournament.first_place.name}
|
||||||
</ThemeIcon>
|
</Badge>
|
||||||
<Text size="sm" c="dimmed">
|
)}
|
||||||
{displayDate}
|
{tournament.second_place && (
|
||||||
</Text>
|
<Badge
|
||||||
</Group>
|
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>
|
||||||
|
|||||||
285
src/features/tournaments/components/tournament-stats.tsx
Normal file
285
src/features/tournaments/components/tournament-stats.tsx
Normal 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';
|
||||||
@@ -11,10 +11,30 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
|
|||||||
|
|
||||||
const copyToClipboard = async (phone: string) => {
|
const copyToClipboard = async (phone: string) => {
|
||||||
try {
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
await navigator.clipboard.writeText(phone);
|
await navigator.clipboard.writeText(phone);
|
||||||
toast.success("Phone number copied!");
|
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");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
@@ -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));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([
|
||||||
|
pb.collection("tournaments").getOne(id, {
|
||||||
expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players",
|
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
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user