diff --git a/pb_migrations/1756595097_created_matches.js b/pb_migrations/1756595097_created_matches.js new file mode 100644 index 0000000..4b76632 --- /dev/null +++ b/pb_migrations/1756595097_created_matches.js @@ -0,0 +1,247 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1080860409", + "max": null, + "min": null, + "name": "lid", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "bool1352515405", + "name": "reset", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "number2650326517", + "max": null, + "min": null, + "name": "home_cups", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number766636452", + "max": null, + "min": null, + "name": "away_cups", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3404566555", + "max": null, + "min": null, + "name": "ot_count", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "date1345189255", + "max": "", + "min": "", + "name": "start_time", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "date1096160257", + "max": "", + "min": "", + "name": "end_time", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "bool2000130356", + "name": "bye", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "number3642169398", + "max": null, + "min": null, + "name": "home_from_lid", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1662941821", + "max": null, + "min": null, + "name": "away_from_lid", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "bool1093314320", + "name": "home_from_loser", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "bool4045114275", + "name": "away_from_loser", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "bool1628031220", + "name": "is_losers_bracket", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_340646327", + "hidden": false, + "id": "relation3177167065", + "maxSelect": 1, + "minSelect": 0, + "name": "tournament", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1568971955", + "hidden": false, + "id": "relation1909853392", + "maxSelect": 1, + "minSelect": 0, + "name": "home", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1568971955", + "hidden": false, + "id": "relation2791285457", + "maxSelect": 1, + "minSelect": 0, + "name": "away", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_2541054544", + "indexes": [], + "listRule": null, + "name": "matches", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_2541054544"); + + return app.delete(collection); +}) diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index cf8736a..043e7ca 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -25,6 +25,7 @@ import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/prof import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview' import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index' import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id' +import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id' import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo' import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$' import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$' @@ -104,6 +105,12 @@ const AuthedAdminTournamentsIdRoute = path: '/tournaments/$id', getParentRoute: () => AuthedAdminRoute, } as any) +const AuthedAdminTournamentsRunIdRoute = + AuthedAdminTournamentsRunIdRouteImport.update({ + id: '/tournaments/run/$id', + path: '/tournaments/run/$id', + getParentRoute: () => AuthedAdminRoute, + } as any) const ApiTournamentsUploadLogoServerRoute = ApiTournamentsUploadLogoServerRouteImport.update({ id: '/api/tournaments/upload-logo', @@ -141,6 +148,7 @@ export interface FileRoutesByFullPath { '/tournaments': typeof AuthedTournamentsIndexRoute '/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute + '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute } export interface FileRoutesByTo { '/login': typeof LoginRoute @@ -155,6 +163,7 @@ export interface FileRoutesByTo { '/tournaments': typeof AuthedTournamentsIndexRoute '/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute + '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -172,6 +181,7 @@ export interface FileRoutesById { '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute '/_authed/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute '/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute + '/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -189,6 +199,7 @@ export interface FileRouteTypes { | '/tournaments' | '/admin/tournaments/$id' | '/admin/tournaments' + | '/admin/tournaments/run/$id' fileRoutesByTo: FileRoutesByTo to: | '/login' @@ -203,6 +214,7 @@ export interface FileRouteTypes { | '/tournaments' | '/admin/tournaments/$id' | '/admin/tournaments' + | '/admin/tournaments/run/$id' id: | '__root__' | '/_authed' @@ -219,6 +231,7 @@ export interface FileRouteTypes { | '/_authed/tournaments/' | '/_authed/admin/tournaments/$id' | '/_authed/admin/tournaments/' + | '/_authed/admin/tournaments/run/$id' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -373,6 +386,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminTournamentsIdRouteImport parentRoute: typeof AuthedAdminRoute } + '/_authed/admin/tournaments/run/$id': { + id: '/_authed/admin/tournaments/run/$id' + path: '/tournaments/run/$id' + fullPath: '/admin/tournaments/run/$id' + preLoaderRoute: typeof AuthedAdminTournamentsRunIdRouteImport + parentRoute: typeof AuthedAdminRoute + } } } declare module '@tanstack/react-start/server' { @@ -413,6 +433,7 @@ interface AuthedAdminRouteChildren { AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute AuthedAdminTournamentsIdRoute: typeof AuthedAdminTournamentsIdRoute AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute + AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute } const AuthedAdminRouteChildren: AuthedAdminRouteChildren = { @@ -420,6 +441,7 @@ const AuthedAdminRouteChildren: AuthedAdminRouteChildren = { AuthedAdminIndexRoute: AuthedAdminIndexRoute, AuthedAdminTournamentsIdRoute: AuthedAdminTournamentsIdRoute, AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute, + AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute, } const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren( diff --git a/src/app/router.tsx b/src/app/router.tsx index 02fe954..7517a7d 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -27,6 +27,7 @@ export function createRouter() { header: defaultHeaderConfig, refresh: [], withPadding: true, + fullWidth: false, }, defaultPreload: "intent", defaultErrorComponent: DefaultCatchBoundary, diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx index baa2b63..0207b7d 100644 --- a/src/app/routes/__root.tsx +++ b/src/app/routes/__root.tsx @@ -26,6 +26,7 @@ export const Route = createRootRouteWithContext<{ header: HeaderConfig; refresh: string[]; withPadding: boolean; + fullWidth: boolean; }>()({ head: () => ({ meta: [ diff --git a/src/app/routes/_authed/admin/tournaments/run.$id.tsx b/src/app/routes/_authed/admin/tournaments/run.$id.tsx new file mode 100644 index 0000000..dbb007c --- /dev/null +++ b/src/app/routes/_authed/admin/tournaments/run.$id.tsx @@ -0,0 +1,31 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' +import { tournamentQueries } from '@/features/tournaments/queries' +import { ensureServerQueryData } from '@/lib/tanstack-query/utils/ensure' +import RunTournament from '@/features/tournaments/components/run-tournament' + +export const Route = createFileRoute('/_authed/admin/tournaments/run/$id')({ + beforeLoad: async ({ context, params }) => { + const { queryClient } = context + const tournament = await ensureServerQueryData( + queryClient, + tournamentQueries.details(params.id) + ) + if (!tournament) throw redirect({ to: '/admin/tournaments' }) + return { + tournament, + } + }, + loader: ({ context }) => ({ + fullWidth: true, + header: { + withBackButton: true, + title: `Run ${context.tournament.name}`, + }, + }), + component: RouteComponent, +}) + +function RouteComponent() { + const { id } = Route.useParams() + return +} diff --git a/src/components/page.tsx b/src/components/page.tsx index 3b77c86..e78071b 100644 --- a/src/components/page.tsx +++ b/src/components/page.tsx @@ -3,9 +3,10 @@ import useRouterConfig from "@/features/core/hooks/use-router-config"; interface PageProps extends ContainerProps, React.PropsWithChildren { noPadding?: boolean; + fullWidth?: boolean; } -const Page = ({ children, noPadding, ...props }: PageProps) => { +const Page = ({ children, noPadding, fullWidth, ...props }: PageProps) => { const { header } = useRouterConfig(); return ( { pt={header.collapsed ? 60 : 0} pb={20} m={0} - maw={600} + maw={fullWidth ? '100%' : 600} mx="auto" {...props} > diff --git a/src/features/core/components/layout.tsx b/src/features/core/components/layout.tsx index 0a6640d..ddc0ae3 100644 --- a/src/features/core/components/layout.tsx +++ b/src/features/core/components/layout.tsx @@ -11,7 +11,7 @@ const Layout: React.FC = ({ children }) => { const { header } = useRouterConfig(); const viewport = useVisualViewportSize(); const [scrollPosition, setScrollPosition] = useState({ x: 0, y: 0 }); - const { withPadding } = useRouterConfig(); + const { withPadding, fullWidth } = useRouterConfig(); return ( = ({ children }) => { style={{ transition: 'none' }} > - + {children} diff --git a/src/features/core/hooks/use-router-config.ts b/src/features/core/hooks/use-router-config.ts index e05cc79..40ea237 100644 --- a/src/features/core/hooks/use-router-config.ts +++ b/src/features/core/hooks/use-router-config.ts @@ -33,7 +33,8 @@ const useRouterConfig = () => { return { header: headerConfig, refresh: current && typeof current === 'object' && 'refresh' in current ? current.refresh : [], - withPadding: current && typeof current === 'object' && 'withPadding' in current ? current.withPadding : true + withPadding: current && typeof current === 'object' && 'withPadding' in current ? current.withPadding : true, + fullWidth: current && typeof current === 'object' && 'fullWidth' in current ? current.fullWidth : false, }; } diff --git a/src/features/matches/types.ts b/src/features/matches/types.ts index cb5511d..dd63688 100644 --- a/src/features/matches/types.ts +++ b/src/features/matches/types.ts @@ -16,7 +16,7 @@ export interface Match { away_from_lid: number; home_from_loser: boolean; away_from_loser: boolean; - bracket_type: 'winners' | 'losers'; + is_losers_bracket: 'winners' | 'losers'; tournament_id: string; home_id: string; away_id: string; @@ -39,7 +39,7 @@ export const matchInputSchema = z.object({ away_from_lid: z.number().int().min(1).optional(), home_from_loser: z.boolean().optional().default(false), away_from_loser: z.boolean().optional().default(false), - losers_bracket: z.boolean().optional().default(false), + is_losers_bracket: z.boolean().optional().default(false), tournament_id: z.string().min(1), home_id: z.string().min(1).optional(), away_id: z.string().min(1).optional(), diff --git a/src/features/tournaments/components/manage-tournament.tsx b/src/features/tournaments/components/manage-tournament.tsx index 544dbb2..f7a424e 100644 --- a/src/features/tournaments/components/manage-tournament.tsx +++ b/src/features/tournaments/components/manage-tournament.tsx @@ -6,10 +6,12 @@ import TournamentForm from "./tournament-form"; import { HardDrivesIcon, PencilLineIcon, + TreeStructureIcon, UsersThreeIcon, } from "@phosphor-icons/react"; import { useSheet } from "@/hooks/use-sheet"; import EditEnrolledTeams from "./edit-enrolled-teams"; +import ListLink from "@/components/list-link"; interface ManageTournamentProps { tournamentId: string; @@ -53,6 +55,11 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => { Icon={UsersThreeIcon} onClick={openEditTeams} /> + { + const { data: tournament } = useTournament(tournamentId) + const teamCount = tournament?.teams?.length || 0 + const { data: bracketData, isLoading } = useBracketPreview(teamCount) + + const [teamSeeds, setTeamSeeds] = useState>(() => { + if (!tournament?.teams) return {} + return tournament.teams.reduce((acc, team, index) => { + acc[team.id] = index + 1 + return acc + }, {} as Record) + }) + + const [seededWinnersBracket, setSeededWinnersBracket] = useState([]) + const [seededLosersBracket, setSeededLosersBracket] = useState([]) + const [bracketMaps, setBracketMaps] = useState(null) + + const sortedTeams = useMemo(() => { + if (!tournament?.teams) return [] + + return tournament.teams + .map(team => ({ + ...team, + seed: teamSeeds[team.id] || 1, + })) + .sort((a, b) => a.seed - b.seed) + }, [tournament?.teams, teamSeeds]) + + const handleSeedChange = (teamId: string, newSeed: number) => { + if (newSeed < 1 || !tournament?.teams) return + + setTeamSeeds(prev => { + const currSeed = prev[teamId] + const newSeeds = { ...prev } + + const otherTeams = tournament.teams!.filter(team => team.id !== teamId) + + if (newSeed !== currSeed) { + const currTeam = otherTeams.find(team => prev[team.id] === newSeed) + + if (currTeam) { + const affectedTeams = otherTeams.filter(team => { + const teamSeed = prev[team.id] + return newSeed < currSeed + ? teamSeed >= newSeed && teamSeed < currSeed + : teamSeed > currSeed && teamSeed <= newSeed + }) + + affectedTeams.forEach(team => { + const teamSeed = prev[team.id] + if (newSeed < currSeed) { + newSeeds[team.id] = teamSeed + 1 + } else { + newSeeds[team.id] = teamSeed - 1 + } + }) + } + + newSeeds[teamId] = newSeed + } + + return newSeeds + }) + } + + useEffect(() => { + if (!bracketData || !tournament?.teams || sortedTeams.length === 0) return + + const maps = createBracketMaps(bracketData) + setBracketMaps(maps) + + const mapBracket = (bracket: Match[][]) => { + return bracket.map((round) => + round.map((match) => { + const mappedMatch = { ...match } + + if (match.home?.seed && match.home.seed > 0) { + const team = sortedTeams.find(t => t.seed === match.home.seed) + if (team) { + mappedMatch.home = { + ...match.home, + team: team, + } + } + } + + if (match.away?.seed && match.away.seed > 0) { + const team = sortedTeams.find(t => t.seed === match.away.seed) + if (team) { + mappedMatch.away = { + ...match.away, + team: team, + } + } + } + + return mappedMatch + }) + ) + } + + setSeededWinnersBracket(mapBracket(bracketData.winners)) + setSeededLosersBracket(mapBracket(bracketData.losers)) + }, [bracketData, sortedTeams]) + + if (!tournament) throw new Error('Tournament not found.') + + return ( + + + + Team Seeds + {sortedTeams.map((team) => ( + <> + + + + {team.name} + + handleSeedChange(team.id, Number(value) || 1)} + min={1} + max={tournament.teams?.length || 1} + size="xs" + w={50} + styles={{ input: { textAlign: 'center' } }} + step={-1} + /> + + + + ))} + + + + + + + Tournament Bracket + {isLoading ? ( + + Loading bracket... + + ) : ( + + )} + + + + ) +} + +export default RunTournament \ No newline at end of file