diff --git a/pb_migrations/1757386414_updated_players.js b/pb_migrations/1757386414_updated_players.js
new file mode 100644
index 0000000..0225fb1
--- /dev/null
+++ b/pb_migrations/1757386414_updated_players.js
@@ -0,0 +1,28 @@
+///
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_3072146508")
+
+ // update collection data
+ unmarshal({
+ "createRule": "",
+ "deleteRule": "",
+ "listRule": "",
+ "updateRule": "",
+ "viewRule": ""
+ }, collection)
+
+ return app.save(collection)
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_3072146508")
+
+ // update collection data
+ unmarshal({
+ "createRule": null,
+ "deleteRule": null,
+ "listRule": null,
+ "updateRule": null,
+ "viewRule": null
+ }, collection)
+
+ return app.save(collection)
+})
diff --git a/pb_migrations/1757386423_updated_matches.js b/pb_migrations/1757386423_updated_matches.js
new file mode 100644
index 0000000..7cac184
--- /dev/null
+++ b/pb_migrations/1757386423_updated_matches.js
@@ -0,0 +1,28 @@
+///
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_2541054544")
+
+ // update collection data
+ unmarshal({
+ "createRule": "",
+ "deleteRule": "",
+ "listRule": "",
+ "updateRule": "",
+ "viewRule": ""
+ }, collection)
+
+ return app.save(collection)
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_2541054544")
+
+ // update collection data
+ unmarshal({
+ "createRule": null,
+ "deleteRule": null,
+ "listRule": null,
+ "updateRule": null,
+ "viewRule": null
+ }, collection)
+
+ return app.save(collection)
+})
diff --git a/pb_migrations/1757386431_updated_teams.js b/pb_migrations/1757386431_updated_teams.js
new file mode 100644
index 0000000..51e2332
--- /dev/null
+++ b/pb_migrations/1757386431_updated_teams.js
@@ -0,0 +1,28 @@
+///
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_1568971955")
+
+ // update collection data
+ unmarshal({
+ "createRule": "",
+ "deleteRule": "",
+ "listRule": "",
+ "updateRule": "",
+ "viewRule": ""
+ }, collection)
+
+ return app.save(collection)
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_1568971955")
+
+ // update collection data
+ unmarshal({
+ "createRule": null,
+ "deleteRule": null,
+ "listRule": null,
+ "updateRule": null,
+ "viewRule": null
+ }, collection)
+
+ return app.save(collection)
+})
diff --git a/pb_migrations/1757386438_updated_tournaments.js b/pb_migrations/1757386438_updated_tournaments.js
new file mode 100644
index 0000000..630969b
--- /dev/null
+++ b/pb_migrations/1757386438_updated_tournaments.js
@@ -0,0 +1,28 @@
+///
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_340646327")
+
+ // update collection data
+ unmarshal({
+ "createRule": "",
+ "deleteRule": "",
+ "listRule": "",
+ "updateRule": "",
+ "viewRule": ""
+ }, collection)
+
+ return app.save(collection)
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_340646327")
+
+ // update collection data
+ unmarshal({
+ "createRule": null,
+ "deleteRule": null,
+ "listRule": null,
+ "updateRule": null,
+ "viewRule": null
+ }, collection)
+
+ return app.save(collection)
+})
diff --git a/src/app/routes/_authed.tsx b/src/app/routes/_authed.tsx
index 55c176f..bc951b2 100644
--- a/src/app/routes/_authed.tsx
+++ b/src/app/routes/_authed.tsx
@@ -2,6 +2,7 @@ import { redirect, createFileRoute, Outlet } from "@tanstack/react-router";
import Layout from "@/features/core/components/layout";
import { useServerEvents } from "@/hooks/use-server-events";
import { Loader } from "@mantine/core";
+import FullScreenLoader from "@/components/full-screen-loader";
export const Route = createFileRoute("/_authed")({
beforeLoad: ({ context }) => {
@@ -26,7 +27,7 @@ export const Route = createFileRoute("/_authed")({
},
pendingComponent: () => (
-
+
),
});
diff --git a/src/app/routes/_authed/index.tsx b/src/app/routes/_authed/index.tsx
index be521c3..49cb954 100644
--- a/src/app/routes/_authed/index.tsx
+++ b/src/app/routes/_authed/index.tsx
@@ -1,27 +1,21 @@
import { createFileRoute } from "@tanstack/react-router";
-import { TrophyIcon } from "@phosphor-icons/react";
-import ListLink from "@/components/list-link";
-import { Box, Divider, Text } from "@mantine/core";
+import { useCurrentTournament } from "@/features/tournaments/queries";
+import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament";
export const Route = createFileRoute("/_authed/")({
component: Home,
loader: () => ({
- withPadding: false
- })
+ withPadding: true,
+ }),
});
function Home() {
- return (
- <>
-
- Some Content Here
-
+ const { data: tournament } = useCurrentTournament();
+ const now = new Date();
-
- Quick Links
-
-
-
- >
- );
+ if (new Date(tournament.start_time) > now) {
+ return ;
+ }
+
+ return
Started Tournament
}
diff --git a/src/components/countdown.tsx b/src/components/countdown.tsx
new file mode 100644
index 0000000..c75ddf5
--- /dev/null
+++ b/src/components/countdown.tsx
@@ -0,0 +1,57 @@
+import useNow from '@/hooks/use-now';
+import { Text, Group } from '@mantine/core';
+import { useMemo } from 'react';
+
+interface CountdownProps {
+ date: Date;
+ label?: string;
+ color?: string;
+}
+
+interface TimeLeft {
+ days: number;
+ hours: number;
+ minutes: number;
+ seconds: number;
+}
+
+function calculateTimeLeft(targetDate: Date, currentTime = new Date()): TimeLeft {
+ const difference = targetDate.getTime() - currentTime.getTime();
+
+ if (difference <= 0) {
+ return { days: 0, hours: 0, minutes: 0, seconds: 0 };
+ }
+
+ return {
+ days: Math.floor(difference / (1000 * 60 * 60 * 24)),
+ hours: Math.floor((difference / (1000 * 60 * 60)) % 24),
+ minutes: Math.floor((difference / 1000 / 60) % 60),
+ seconds: Math.floor((difference / 1000) % 60)
+ };
+}
+
+export function Countdown({ date, label, color }: CountdownProps) {
+ const now = useNow();
+ const timeLeft = useMemo(() => calculateTimeLeft(date, now), [date, now]);
+
+ const formatTime = () => {
+ const pad = (num: number) => num.toString().padStart(2, '0');
+
+ if (timeLeft.days > 0) {
+ return `${timeLeft.days}d ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:${pad(timeLeft.seconds)}`;
+ } else {
+ return `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:${pad(timeLeft.seconds)}`;
+ }
+ };
+
+ return (
+
+ {label && {label}:}
+
+ {formatTime()}
+
+
+ );
+}
+
+export default Countdown;
\ No newline at end of file
diff --git a/src/components/sheet/drawer.tsx b/src/components/sheet/drawer.tsx
index 123c891..aa169c2 100644
--- a/src/components/sheet/drawer.tsx
+++ b/src/components/sheet/drawer.tsx
@@ -1,8 +1,8 @@
-import { Box, Container, useComputedColorScheme } from "@mantine/core";
-import { PropsWithChildren, useEffect } from "react";
+import { Box, Container, Flex, Loader, useComputedColorScheme } from "@mantine/core";
+import { PropsWithChildren, Suspense, useEffect } from "react";
import { Drawer as VaulDrawer } from "vaul";
-import { useMantineColorScheme } from "@mantine/core";
import styles from "./styles.module.css";
+import FullScreenLoader from "../full-screen-loader";
interface DrawerProps extends PropsWithChildren {
title?: string;
@@ -76,7 +76,13 @@ const Drawer: React.FC = ({
/>
{title}
- {children}
+
+
+
+ }>
+ {children}
+
diff --git a/src/components/sheet/modal.tsx b/src/components/sheet/modal.tsx
index 7633973..19c3ff5 100644
--- a/src/components/sheet/modal.tsx
+++ b/src/components/sheet/modal.tsx
@@ -1,5 +1,5 @@
-import { Modal as MantineModal, Title } from "@mantine/core";
-import { PropsWithChildren } from "react";
+import { Flex, Loader, Modal as MantineModal, Title } from "@mantine/core";
+import { PropsWithChildren, Suspense } from "react";
interface ModalProps extends PropsWithChildren {
title?: string;
@@ -13,7 +13,15 @@ const Modal: React.FC = ({ title, children, opened, onClose }) => (
onClose={onClose}
title={{title}}
>
- {children}
+
+
+
+ }
+ >
+ {children}
+
);
diff --git a/src/components/sheet/slide-panel/slide-panel.tsx b/src/components/sheet/slide-panel/slide-panel.tsx
index 2507278..2f7749a 100644
--- a/src/components/sheet/slide-panel/slide-panel.tsx
+++ b/src/components/sheet/slide-panel/slide-panel.tsx
@@ -17,6 +17,7 @@ interface SlidePanelProps {
onCancel?: () => void;
submitText?: string;
cancelText?: string;
+ cancelColor?: string;
maxHeight?: string;
formProps?: Record;
loading?: boolean;
@@ -28,6 +29,7 @@ const SlidePanel = ({
onCancel,
submitText = "Submit",
cancelText = "Cancel",
+ cancelColor = "red",
maxHeight = "70vh",
formProps = {},
loading = false,
@@ -114,7 +116,7 @@ const SlidePanel = ({
{onCancel && (
+
+
+
+
+ option.label)}
+ comboboxProps={{ withinPortal: false }}
+ />
+
+
+ Enroll Selected Team
+
+
+
+ );
+});
+
+TeamSelectionView.displayName = 'TeamSelectionView';
+
+export default TeamSelectionView;
\ No newline at end of file
diff --git a/src/features/tournaments/components/tournament-form.tsx b/src/features/tournaments/components/tournament-form.tsx
index cefdbf8..8a7df2d 100644
--- a/src/features/tournaments/components/tournament-form.tsx
+++ b/src/features/tournaments/components/tournament-form.tsx
@@ -10,8 +10,8 @@ import toast from "@/lib/sonner";
import { logger } from "..";
import { useQueryClient } from "@tanstack/react-query";
import { tournamentKeys } from "@/features/tournaments/queries";
-import { DateTimePicker } from "@mantine/dates";
import { useCallback } from "react";
+import { DateTimePicker } from "@/components/date-time-picker";
interface TournamentFormProps {
close: () => void;
@@ -166,6 +166,7 @@ const TournamentForm = ({
label="Start Date"
withAsterisk
formatValue={(date) =>
+ !date ? 'Select a time' :
new Date(date).toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
@@ -186,6 +187,7 @@ const TournamentForm = ({
label="Enrollment Due"
withAsterisk
formatValue={(date) =>
+ !date ? 'Select a time' :
new Date(date).toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
@@ -206,17 +208,16 @@ const TournamentForm = ({
title="Select End Date"
label="End Date (Optional)"
formatValue={(date) =>
- date
- ? new Date(date).toLocaleDateString("en-US", {
- weekday: "short",
- year: "numeric",
- month: "short",
- day: "numeric",
- hour: "numeric",
- minute: "numeric",
- hour12: true,
- })
- : "Not set"
+ !date ? 'Select a time' :
+ new Date(date).toLocaleDateString("en-US", {
+ weekday: "short",
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "numeric",
+ hour12: true,
+ })
}
/>
)}
diff --git a/src/features/tournaments/components/upcoming-tournament.tsx b/src/features/tournaments/components/upcoming-tournament.tsx
new file mode 100644
index 0000000..5cdcda1
--- /dev/null
+++ b/src/features/tournaments/components/upcoming-tournament.tsx
@@ -0,0 +1,146 @@
+import { useEffect, useMemo, useState } from "react";
+import { Tournament } from "../types";
+import { useAuth } from "@/contexts/auth-context";
+import {
+ Box,
+ Button,
+ Card,
+ Divider,
+ Group,
+ Stack,
+ Title,
+ Text,
+} from "@mantine/core";
+import Avatar from "@/components/avatar";
+import Countdown from "@/components/countdown";
+import ListLink from "@/components/list-link";
+import ListButton from "@/components/list-button";
+import {
+ TrophyIcon,
+ CalendarIcon,
+ MapPinIcon,
+ UsersIcon,
+ ListIcon,
+} from "@phosphor-icons/react";
+import EnrollTeam from "./enroll-team";
+import EnrollFreeAgent from "./enroll-free-agent";
+
+const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
+ tournament,
+}) => {
+ const { user, roles } = useAuth();
+
+ const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
+ const userTeam = useMemo(
+ () =>
+ tournament.teams?.find((team) =>
+ team.players?.some((player) => player.id === user?.id)
+ ),
+ [tournament.teams, user?.id]
+ );
+ const isUserEnrolled = !!userTeam;
+
+ const enrollmentDeadline = tournament.enroll_time
+ ? new Date(tournament.enroll_time)
+ : new Date(tournament.start_time);
+ const tournamentStart = new Date(tournament.start_time);
+ const isEnrollmentOpen = enrollmentDeadline > new Date();
+
+ return (
+
+
+
+
+
+
+
+
+ {tournament.name}
+
+ {tournament.location && (
+
+
+
+ {tournament.location}
+
+
+ )}
+
+
+
+ {tournamentStart.toLocaleDateString()} at{" "}
+ {tournamentStart.toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+
+
+
+
+
+
+ {tournament.desc && (
+
+ {tournament.desc}
+
+ )}
+
+
+
+
+
+
+ Enrollment
+
+ {isEnrollmentOpen && (
+
+
+
+ )}
+
+
+ {!isUserEnrolled && isEnrollmentOpen && (
+ <>
+
+
+
+ >
+ )}
+
+
+
+
+
+ {isAdmin && (
+
+ )}
+ {}} />
+ {}}
+ />
+
+
+ );
+};
+
+export default UpcomingTournament;
diff --git a/src/features/tournaments/queries.ts b/src/features/tournaments/queries.ts
index 3229b3b..8e00914 100644
--- a/src/features/tournaments/queries.ts
+++ b/src/features/tournaments/queries.ts
@@ -1,9 +1,10 @@
-import { getTournament, getUnenrolledTeams, listTournaments } from "./server";
+import { getCurrentTournament, getTournament, getUnenrolledTeams, listTournaments } from "./server";
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
export const tournamentKeys = {
list: ['tournaments', 'list'] as const,
details: (id: string) => ['tournaments', 'details', id] as const,
+ current: ['tournaments', 'current'] as const,
unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const
};
@@ -16,6 +17,10 @@ export const tournamentQueries = {
queryKey: tournamentKeys.details(id),
queryFn: () => getTournament({ data: id })
}),
+ current: () => ({
+ queryKey: tournamentKeys.current,
+ queryFn: getCurrentTournament
+ }),
unenrolled: (id: string) => ({
queryKey: tournamentKeys.unenrolled(id),
queryFn: () => getUnenrolledTeams({ data: id })
@@ -28,5 +33,8 @@ export const useTournaments = () =>
export const useTournament = (id: string) =>
useServerSuspenseQuery(tournamentQueries.details(id));
+export const useCurrentTournament = () =>
+ useServerSuspenseQuery(tournamentQueries.current());
+
export const useUnenrolledTeams = (tournamentId: string) =>
useServerSuspenseQuery(tournamentQueries.unenrolled(tournamentId));
diff --git a/src/features/tournaments/server.ts b/src/features/tournaments/server.ts
index 3b46ae9..8013567 100644
--- a/src/features/tournaments/server.ts
+++ b/src/features/tournaments/server.ts
@@ -36,6 +36,12 @@ export const getTournament = createServerFn()
toServerResult(() => pbAdmin.getTournament(tournamentId))
);
+export const getCurrentTournament = createServerFn()
+ .middleware([superTokensFunctionMiddleware])
+ .handler(async () =>
+ toServerResult(() => pbAdmin.getMostRecentTournament())
+ );
+
export const enrollTeam = createServerFn()
.validator(z.object({
tournamentId: z.string(),
diff --git a/src/hooks/use-now.ts b/src/hooks/use-now.ts
new file mode 100644
index 0000000..984505a
--- /dev/null
+++ b/src/hooks/use-now.ts
@@ -0,0 +1,18 @@
+import { useEffect, useState } from "react";
+
+const useNow = () => {
+ const [now, setNow] = useState(new Date());
+
+ useEffect(() => {
+ const intervalId = setInterval(() => {
+ setNow(new Date());
+ }, 1000);
+
+ return () => clearInterval(intervalId);
+ }, []);
+
+ return now;
+}
+
+
+export default useNow;
\ No newline at end of file
diff --git a/src/lib/pocketbase/services/players.ts b/src/lib/pocketbase/services/players.ts
index 690eda1..9f4f7ba 100644
--- a/src/lib/pocketbase/services/players.ts
+++ b/src/lib/pocketbase/services/players.ts
@@ -26,6 +26,7 @@ export function createPlayersService(pb: PocketBase) {
async getPlayerByAuthId(authId: string): Promise {
const result = await pb.collection("players").getList(1, 1, {
filter: `auth_id = "${authId}"`,
+ expand: 'teams'
});
return result.items[0] ? transformPlayer(result.items[0]) : null;
},
diff --git a/src/lib/pocketbase/services/teams.ts b/src/lib/pocketbase/services/teams.ts
index 434af2a..eeea79b 100644
--- a/src/lib/pocketbase/services/teams.ts
+++ b/src/lib/pocketbase/services/teams.ts
@@ -1,15 +1,15 @@
import { logger } from "@/lib/logger";
import PocketBase from "pocketbase";
import { transformTeam, transformTeamInfo } from "@/lib/pocketbase/util/transform-types";
-import { Team, TeamInfo } from "@/features/teams/types";
-import { DataFetchOptions } from "./base";
+import { Team, TeamInfo, TeamInput, TeamUpdateInput } from "@/features/teams/types";
export function createTeamsService(pb: PocketBase) {
return {
async getTeamInfo(id: string): Promise {
logger.info("PocketBase | Getting team info", id);
const result = await pb.collection("teams").getOne(id, {
- fields: "id,name,primary_color,accent_color"
+ fields: "id,name,primary_color,accent_color,logo",
+ expand: "players"
});
return transformTeamInfo(result);
},
@@ -17,10 +17,12 @@ export function createTeamsService(pb: PocketBase) {
async listTeamInfos(): Promise {
logger.info("PocketBase | Listing team infos");
const result = await pb.collection("teams").getFullList({
- fields: "id,name,primary_color,accent_color"
+ fields: "id,name,primary_color,accent_color,logo",
+ expand: "players"
});
return result.map(transformTeamInfo);
},
+
async getTeam(id: string): Promise {
logger.info("PocketBase | Getting team", id);
const result = await pb.collection("teams").getOne(id, {
@@ -28,5 +30,56 @@ export function createTeamsService(pb: PocketBase) {
});
return transformTeam(result);
},
+
+ async createTeam(data: TeamInput): Promise {
+ logger.info("PocketBase | Creating team", data);
+
+ try {
+ for (const playerId of data.players) {
+ const playerExists = await pb.collection("players").getOne(playerId).catch(() => null);
+ if (!playerExists) {
+ throw new Error(`Player with ID ${playerId} not found`);
+ }
+ }
+
+ const result = await pb.collection("teams").create({
+ ...data,
+ players: data.players
+ });
+
+ for (const playerId of data.players) {
+ await pb.collection("players").update(playerId, {
+ "teams+": result.id
+ });
+ }
+
+ return transformTeam(await pb.collection("teams").getOne(result.id, {
+ expand: "players, tournaments"
+ }));
+ } catch (error) {
+ logger.error("PocketBase | Error creating team", error);
+ throw error;
+ }
+ },
+
+ async updateTeam(id: string, data: TeamUpdateInput): Promise {
+ logger.info("PocketBase | Updating team", { id, updates: Object.keys(data) });
+
+ try {
+ const existingTeam = await pb.collection("teams").getOne(id).catch(() => null);
+ if (!existingTeam) {
+ throw new Error(`Team with ID ${id} not found`);
+ }
+
+ const result = await pb.collection("teams").update(id, data);
+
+ return transformTeam(await pb.collection("teams").getOne(result.id, {
+ expand: "players, tournaments"
+ }));
+ } catch (error) {
+ logger.error("PocketBase | Error updating team", error);
+ throw error;
+ }
+ },
};
}
diff --git a/src/lib/pocketbase/services/tournaments.ts b/src/lib/pocketbase/services/tournaments.ts
index 6b58074..6513d36 100644
--- a/src/lib/pocketbase/services/tournaments.ts
+++ b/src/lib/pocketbase/services/tournaments.ts
@@ -18,6 +18,17 @@ export function createTournamentsService(pb: PocketBase) {
});
return transformTournament(result);
},
+ async getMostRecentTournament(): Promise {
+ const result = await pb
+ .collection("tournaments")
+ .getFirstListItem('',
+ {
+ expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away",
+ sort: "-created",
+ }
+ );
+ return transformTournament(result);
+ },
async listTournaments(): Promise {
const result = await pb
.collection("tournaments")