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