This commit is contained in:
yohlo
2025-08-20 22:35:40 -05:00
commit f51c278cd3
169 changed files with 8173 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
import { Stack, TextInput, Textarea } from "@mantine/core";
import { useForm, UseFormInput } from "@mantine/form";
import { LinkIcon } from "@phosphor-icons/react";
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
import { TournamentFormInput } from "@/features/tournaments/types";
import { DateTimePicker } from "./date-time-picker";
import { isNotEmpty } from "@mantine/form";
import useCreateTournament from "../hooks/use-create-tournament";
const CreateTournament = ({ close }: { close: () => void }) => {
const config: UseFormInput<TournamentFormInput> = {
initialValues: { // TODO : Remove fake initial values
name: 'Test Tournament',
location: 'Test Location',
desc: 'Test Description',
logo_url: 'https://en.wikipedia.org/wiki/Trophy#/media/File:1934_Melbourne_Cup,_National_Museum_of_Australia.jpg',
start_time: '2025-01-01T00:00:00Z',
enroll_time: '2025-01-01T00:00:00Z',
},
onSubmitPreventDefault: 'always',
validate: {
name: isNotEmpty('Name is required'),
location: isNotEmpty('Location is required'),
start_time: isNotEmpty('Start time is required'),
enroll_time: isNotEmpty('Enrollment time is required'),
}
}
const form = useForm(config);
const { mutate: createTournament, isPending } = useCreateTournament();
const handleSubmit = async (values: TournamentFormInput) => {
createTournament(values, {
onSuccess: () => {
close();
}
});
}
return (
<SlidePanel
onSubmit={form.onSubmit(handleSubmit)}
onCancel={close}
submitText="Create Tournament"
cancelText="Cancel"
loading={isPending}
>
<Stack>
<TextInput
label="Name"
withAsterisk
key={form.key('name')}
{...form.getInputProps('name')}
/>
<TextInput
label="Location"
withAsterisk
key={form.key('location')}
{...form.getInputProps('location')}
/>
<TextInput
label="Short Description"
key={form.key('desc')}
{...form.getInputProps('desc')}
/>
<TextInput
key={form.key('logo_url')}
accept="image/*"
label="Logo"
leftSection={<LinkIcon size={16} />}
{...form.getInputProps('logo_url')}
/>
<SlidePanelField
key={form.key('start_time')}
{...form.getInputProps('start_time')}
Component={DateTimePicker}
title="Select Start Date"
label="Start Date"
withAsterisk
formatValue={(date) => new Date(date).toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
})}
/>
<SlidePanelField
key={form.key('enroll_time')}
{...form.getInputProps('enroll_time')}
Component={DateTimePicker}
title="Select Enrollment Due Date"
label="Enrollment Due"
withAsterisk
formatValue={(date) => new Date(date).toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
})}
/>
</Stack>
</SlidePanel>
);
};
export default CreateTournament;

View File

@@ -0,0 +1,90 @@
import { DatePicker, TimeInput } from "@mantine/dates";
import { ActionIcon, Stack } from "@mantine/core";
import { useRef } from "react";
import { ClockIcon } from "@phosphor-icons/react";
interface DateTimePickerProps {
value: Date | null;
onChange: (date: string | null) => void;
label?: string;
[key: string]: any;
}
const DateTimePicker = ({ value, onChange, label, ...rest }: DateTimePickerProps) => {
const timeRef = useRef<HTMLInputElement>(null);
const currentDate = value ? new Date(value) : null;
const formatDate = (date: Date | null): string => {
if (!date) return "";
return date.toISOString().split('T')[0];
};
const formatTime = (date: Date | null): string => {
if (!date) return "";
return date.toTimeString().slice(0, 5);
};
const handleDateChange = (dateString: string | null) => {
if (!dateString) {
onChange('');
return;
}
const newDate = new Date(dateString + 'T00:00:00');
if (currentDate) {
newDate.setHours(currentDate.getHours());
newDate.setMinutes(currentDate.getMinutes());
}
onChange(newDate.toISOString());
};
const handleTimeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const timeValue = event.target.value;
if (!timeValue) return;
const [hours, minutes] = timeValue.split(':').map(Number);
if (isNaN(hours) || isNaN(minutes)) return;
const baseDate = currentDate || new Date();
const newDate = new Date(baseDate);
newDate.setHours(hours);
newDate.setMinutes(minutes);
newDate.setSeconds(0);
newDate.setMilliseconds(0);
onChange(newDate.toISOString());
};
return (
<Stack>
<DatePicker
size="md"
value={formatDate(currentDate)}
onChange={handleDateChange}
{...rest}
/>
<TimeInput
ref={timeRef}
label="Time"
size="md"
value={formatTime(currentDate)}
onChange={handleTimeChange}
rightSection={
<ActionIcon
variant="subtle"
color="gray"
onClick={() => timeRef.current?.showPicker()}
>
<ClockIcon size={16} />
</ActionIcon>
}
{...rest}
/>
</Stack>
);
};
export { DateTimePicker };

View File

@@ -0,0 +1,37 @@
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { createTournament } from "@/features/tournaments/server";
import toast from '@/lib/sonner';
import { TournamentInput } from "@/features/tournaments/types";
import { logger } from "../";
const useCreateTournament = () => {
const navigate = useNavigate();
return useMutation({
mutationFn: (data: TournamentInput) => createTournament({ data }),
onMutate: (data) => {
logger.info('Creating tournament', data);
},
onSuccess: (data) => {
if (!data) {
toast.error('There was an issue creating your tournament. Please try again later.');
logger.error('Error creating tournament', data);
} else {
toast.success('Tournament created successfully!');
logger.info('Tournament created successfully', data);
navigate({ to: '/tournaments' });
}
},
onError: (error: any) => {
logger.error('Error creating tournament', error);
if (error.message) {
toast.error(error.message);
} else {
toast.error('An unexpected error occurred when trying to create a tournament. Please try again later.');
}
},
});
};
export default useCreateTournament;

View File

@@ -0,0 +1,3 @@
import { Logger } from "@/lib/logger";
export const logger = new Logger('Admin');

View File

@@ -0,0 +1,412 @@
// Type definitions
interface TMatchSlot {}
interface Seed extends TMatchSlot {
seed: number;
}
interface TBD extends TMatchSlot {
parent: TMatchBase;
loser: boolean;
ifNecessary?: boolean;
}
interface TMatchBase {
lid: number; // local id
round: number;
order?: number | null;
}
interface TMatch extends TMatchBase {
home: Seed | TBD;
away: Seed | TBD;
reset?: boolean;
}
interface TBye extends TMatchBase {
home: Seed | TBD;
}
type MatchType = TMatch | TBye;
// Utility functions
function innerOuter<T>(ls: T[]): T[] {
if (ls.length === 2) return ls;
const size = Math.floor(ls.length / 4);
const innerPart = [ls.slice(size, 2 * size), ls.slice(2 * size, 3 * size)];
const outerPart = [ls.slice(0, size), ls.slice(3 * size)];
const inner = (part: T[][]): T[] => [part[0].pop()!, part[1].shift()!];
const outer = (part: T[][]): T[] => [part[0].shift()!, part[1].pop()!];
const quads: T[][] = Array(Math.floor(size / 2)).fill(null).map(() => []);
const push = (part: T[][], method: (p: T[][]) => T[], arr: T[]) => {
if (part[0].length && part[1].length) {
arr.push(...method(part));
}
};
for (let i = 0; i < Math.floor(size / 2); i++) {
push(outerPart, outer, quads[i]);
push(innerPart, inner, quads[i]);
push(outerPart, inner, quads[i]);
push(innerPart, outer, quads[i]);
}
const result: T[] = [];
for (let i = 0; i < quads.length; i++) {
const curr = i % 2 === 0 ? quads.shift()! : quads.pop()!;
result.push(...curr);
}
return result;
}
function reverseHalfShift<T>(ls: T[]): T[] {
const halfLength = Math.floor(ls.length / 2);
return [...ls.slice(-halfLength), ...ls.slice(0, -halfLength)];
}
export class BracketGenerator {
private _bracket: MatchType[][] = [];
private _losersBracket: MatchType[][] = [];
private _order: number = 0;
private _floatingLosers: TBD[] = [];
private _lid: number = 0;
private _matches: Map<number, MatchType> = new Map();
public n: number;
public doubleElim: boolean;
private _nearestPowerOf2: number;
private _m: number;
private _byes: number;
constructor(n: number, doubleElim: boolean = false) {
if (n < 8 || n > 64) {
throw new Error("The number of teams must be greater than or equal to 8 and less than or equal to 64");
}
this.n = n;
this.doubleElim = doubleElim;
this._nearestPowerOf2 = Math.pow(2, Math.ceil(Math.log2(n)));
this._m = this._nearestPowerOf2;
this._byes = this._m - n;
this._generateSingleElim();
}
private _makeMatch(round: number, home: Seed | TBD, away: Seed | TBD, order: number): TMatch {
const match: TMatch = {
lid: this._lid,
round: round,
home: home,
away: away,
order: order
};
this._matches.set(this._lid, match);
this._lid += 1;
return match;
}
private _makeBye(round: number, home: Seed | TBD): TBye {
const bye: TBye = {
lid: this._lid,
round: round,
home: home
};
this._matches.set(this._lid, bye);
this._lid += 1;
return bye;
}
private _makeTBD(parent: TMatchBase, loser: boolean = false): TBD {
return {
parent: parent,
loser: loser
};
}
private _makeSeed(seed: number): Seed {
return { seed: seed };
}
private _parseQuad(quad: MatchType[]): MatchType[] {
// Used to generate losers bracket by iterating over the first round of the bracket, 4 matches/byes at a time
const pop = (): TBye => this._makeBye(0, this._floatingLosers.pop()!);
const popAt = (i: number) => (): TBye => this._makeBye(0, this._floatingLosers.splice(i, 1)[0]);
const shift = (): TBye => this._makeBye(0, this._floatingLosers.shift()!);
const popShift = (): TMatch => this._makeMatch(0, this._floatingLosers.pop()!, this._floatingLosers.shift()!, this._orderIncrement());
const pairShift = (): TMatch => this._makeMatch(0, this._floatingLosers.shift()!, this._floatingLosers.shift()!, this._orderIncrement());
// Actions to perform based on number of byes in the winners bracket quad
const actions: { [key: number]: (() => MatchType)[] } = {
0: [pop, pairShift, pop, pairShift],
1: [pop, shift, pop, pairShift],
2: [pop, shift, pop, shift],
3: [popAt(-2), popShift],
4: [pop, pop]
};
// Count the number of byes in the quad
const b = quad.filter(m => 'home' in m && !('away' in m)).length;
const result = actions[b].map(action => action());
return result;
}
private _flattenRound(round: MatchType[], roundNumber: number = 0): MatchType[] {
// Check if all matches are byes
if (round.every(m => 'home' in m && !('away' in m))) {
const result: MatchType[] = [];
for (let i = 0; i < round.length; i += 2) {
result.push(this._makeMatch(
roundNumber,
(round[i] as TBye).home,
(round[i + 1] as TBye).home,
this._orderIncrement()
));
}
return result;
}
return round;
}
private _startsWithBringInRound(): boolean {
// Start at 8, first block of size 4 returns 0
let start = 8;
const blockSizes = [4, 5, 7, 9, 15, 17]; // Sizes of blocks
let result = 0; // First block returns 0
// Loop through predefined block sizes
for (const blockSize of blockSizes) {
const end = start + blockSize - 1;
if (start <= this.n && this.n <= end) {
return result === 0;
}
start = end + 1;
result = 1 - result; // Alternate between 0 and 1
}
return false;
}
private _generateStartingRounds(): void {
this._floatingLosers = [];
// Generate Pairings based on seeding
const seeds: (Seed | null)[] = [];
for (let i = 1; i <= this.n; i++) {
seeds.push(this._makeSeed(i));
}
for (let i = 0; i < this._byes; i++) {
seeds.push(null);
}
const pairings: [Seed | null, Seed | null][] = [];
const innerOuterResult = innerOuter(seeds);
for (let i = 0; i < innerOuterResult.length; i += 2) {
pairings.push([innerOuterResult[i], innerOuterResult[i + 1]]);
}
// First Round
let round: MatchType[] = [];
for (const [home, away] of pairings) {
if (away === null) {
round.push(this._makeBye(0, home!));
} else {
const match = this._makeMatch(0, home!, away, this._orderIncrement());
round.push(match);
this._floatingLosers.push(this._makeTBD(match, true));
}
}
this._bracket = [round];
// Second Round
const prev = round;
round = [];
const getSlot = (m: MatchType): Seed | TBD => {
return 'away' in m ? this._makeTBD(m) : (m as TBye).home;
};
const startOrder = this._orderIncrement();
const orderDelta = Math.abs(this._byes - (this._m / 4));
const orderSplit = [startOrder + orderDelta, startOrder];
for (let i = 0; i < prev.length; i += 2) {
const home = getSlot(prev[i]);
const away = getSlot(prev[i + 1]);
let order: number;
if ('parent' in away) {
order = orderSplit[0];
orderSplit[0] += 1;
} else {
order = orderSplit[1];
orderSplit[1] += 1;
}
const match = this._makeMatch(1, home, away, order);
round.push(match);
this._floatingLosers.push(this._makeTBD(match, true));
}
this._bracket.push(round);
this._order = orderSplit[0] - 1;
// Generate losers bracket if double elim
if (this.doubleElim) {
// Round one
this._floatingLosers = innerOuter(this._floatingLosers);
this._losersBracket = [];
let roundOne: MatchType[] = [];
for (let i = 0; i < prev.length; i += 4) {
roundOne.push(...this._parseQuad(prev.slice(i, i + 4)));
}
this._losersBracket.push(this._flattenRound(roundOne));
// Round two
const roundTwo: MatchType[] = [];
for (let i = 0; i < roundOne.length; i += 2) {
roundTwo.push(this._makeMatch(
1,
getSlot(roundOne[i]),
getSlot(roundOne[i + 1]),
this._orderIncrement()
));
}
this._losersBracket.push(roundTwo);
}
}
private _orderIncrement(): number {
this._order += 1;
return this._order;
}
private _generateBringInRound(roundNumber: number): void {
console.log('generating bring in round', roundNumber);
const bringIns = reverseHalfShift(this._floatingLosers);
this._floatingLosers = [];
const round: MatchType[] = [];
const prev = this._losersBracket[this._losersBracket.length - 1];
for (const match of prev) {
const bringIn = bringIns.pop()!;
const newMatch = this._makeMatch(
roundNumber,
bringIn,
this._makeTBD(match),
this._orderIncrement()
);
round.push(newMatch);
}
this._losersBracket.push(round);
}
private _generateLosersRound(roundNumber: number): void {
console.log('generating losers round', roundNumber);
const round: MatchType[] = [];
const prev = this._losersBracket[this._losersBracket.length - 1];
if (prev.length < 2) return;
for (let i = 0; i < prev.length; i += 2) {
const newMatch = this._makeMatch(
roundNumber,
this._makeTBD(prev[i]),
this._makeTBD(prev[i + 1]),
this._orderIncrement()
);
round.push(newMatch);
}
this._losersBracket.push(round);
}
private _generateSingleElim(): void {
this._generateStartingRounds();
let prev = this._bracket[this._bracket.length - 1];
const add = (
round: MatchType[],
prevSlot: TBD | null,
match: MatchType
): [MatchType[], TBD | null] => {
if (prevSlot === null) return [round, this._makeTBD(match)];
const newMatch = this._makeMatch(
this._bracket.length,
prevSlot,
this._makeTBD(match),
this._orderIncrement()
);
this._floatingLosers.push(this._makeTBD(newMatch, true));
return [[...round, newMatch], null];
};
while (prev.length > 1) {
let round: MatchType[] = [];
let prevSlot: TBD | null = null;
for (const match of prev) {
[round, prevSlot] = add(round, prevSlot, match);
}
this._bracket.push(round);
prev = round;
if (this.doubleElim) {
const r = this._losersBracket.length;
if (this._startsWithBringInRound()) {
this._generateBringInRound(r);
this._generateLosersRound(r + 1);
} else {
this._generateLosersRound(r);
this._generateBringInRound(r + 1);
}
}
}
// Grand Finals and bracket reset
if (this.doubleElim) {
const winnersFinal = this._bracket[this._bracket.length - 1][this._bracket[this._bracket.length - 1].length - 1];
const losersFinal = this._losersBracket[this._losersBracket.length - 1][this._losersBracket[this._losersBracket.length - 1].length - 1];
const grandFinal = this._makeMatch(
this._bracket.length,
this._makeTBD(winnersFinal),
this._makeTBD(losersFinal),
this._orderIncrement()
);
const resetMatch = this._makeMatch(
this._bracket.length + 1,
this._makeTBD(grandFinal),
this._makeTBD(grandFinal, true),
this._orderIncrement()
);
resetMatch.reset = true;
this._bracket.push([grandFinal], [resetMatch]);
}
}
// Public getters for accessing the brackets
get bracket(): MatchType[][] {
return this._bracket;
}
get losersBracket(): MatchType[][] {
return this._losersBracket;
}
get matches(): Map<number, MatchType> {
return this._matches;
}
}

View File

@@ -0,0 +1,146 @@
import { Text, Container, Flex, ScrollArea } from "@mantine/core";
import { SeedList } from "./seed-list";
import BracketView from "./bracket-view";
import { MutableRefObject, RefObject, useEffect, useRef, useState } from "react";
import { bracketQueries } from "../queries";
import { useQuery } from "@tanstack/react-query";
import { useDraggable } from "react-use-draggable-scroll";
import { ref } from "process";
import './styles.module.css';
import { useIsMobile } from "@/hooks/use-is-mobile";
import useAppShellHeight from "@/hooks/use-appshell-height";
interface Team {
id: string;
name: string;
}
interface BracketData {
n: number;
doubleElim: boolean;
matches: { [key: string]: any };
winnersBracket: number[][];
losersBracket: number[][];
}
export const PreviewBracketPage: React.FC = () => {
const isMobile = useIsMobile();
const height = useAppShellHeight();
const refDraggable = useRef<HTMLDivElement>(null);
const { events } = useDraggable(refDraggable as RefObject<HTMLDivElement>, { isMounted: !!refDraggable.current });
const teamCount = 20;
const { data, isLoading, error } = useQuery<BracketData>(bracketQueries.preview(teamCount));
// Create teams with proper structure
const [teams, setTeams] = useState<Team[]>(
Array.from({ length: teamCount }, (_, i) => ({
id: `team-${i + 1}`,
name: `Team ${i + 1}`
}))
);
const [seededWinnersBracket, setSeededWinnersBracket] = useState<any[][]>([]);
const [seededLosersBracket, setSeededLosersBracket] = useState<any[][]>([]);
useEffect(() => {
if (!data) return;
// Map match IDs to actual match objects with team names
const mapBracket = (bracketIds: number[][]) => {
return bracketIds.map(roundIds =>
roundIds.map(lid => {
const match = data.matches[lid];
if (!match) return null;
const mappedMatch = { ...match };
// Map home slot - handle both uppercase and lowercase type names
if (match.home?.type?.toLowerCase() === 'seed') {
mappedMatch.home = {
...match.home,
team: teams[match.home.seed - 1]
};
}
// Map away slot if it exists - handle both uppercase and lowercase type names
if (match.away?.type?.toLowerCase() === 'seed') {
mappedMatch.away = {
...match.away,
team: teams[match.away.seed - 1]
};
}
return mappedMatch;
}).filter(m => m !== null)
);
};
setSeededWinnersBracket(mapBracket(data.winnersBracket));
setSeededLosersBracket(mapBracket(data.losersBracket));
}, [teams, data]);
const handleSeedChange = (teamIndex: number, newSeedIndex: number) => {
const newTeams = [...teams];
const movingTeam = newTeams[teamIndex];
// Remove the team from its current position
newTeams.splice(teamIndex, 1);
// Insert it at the new position
newTeams.splice(newSeedIndex, 0, movingTeam);
setTeams(newTeams);
};
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error loading bracket</p>;
if (!data) return <p>No data available</p>;
return (
<Container p={0} w="100%" style={{ userSelect: "none" }}>
<Flex w="100%" justify="space-between" h='3rem'>
<Text fw={600} size="lg" mb={16}>
Preview Bracket ({data.n} teams, {data.doubleElim ? 'Double' : 'Single'} Elimination)
</Text>
</Flex>
<Flex w="100%" gap={24}>
<div style={{ minWidth: 250, display: 'none' }}>
<Text fw={600} pb={16}>
Seed Teams
</Text>
<SeedList teams={teams} onSeedChange={handleSeedChange} />
</div>
<ScrollArea
px='xs'
viewportRef={refDraggable}
viewportProps={events}
h={`calc(${height} - 4rem)`}
className="bracket-container"
styles={{
root: { overflow: "auto", flex: 1, gap: 24, display: 'flex', flexDirection: 'column' }
}}
>
<div>
<Text fw={600} size="md" mb={16}>
Winners Bracket
</Text>
<BracketView
bracket={seededWinnersBracket}
matches={data.matches}
/>
</div>
<div>
<Text fw={600} size="md" mb={16}>
Losers Bracket
</Text>
<BracketView
bracket={seededLosersBracket}
matches={data.matches}
/>
</div>
</ScrollArea>
</Flex>
</Container>
);
};

View File

@@ -0,0 +1,119 @@
import { ActionIcon, Card, Container, Flex, Text } from '@mantine/core';
import { PlayIcon } from '@phosphor-icons/react';
import React from 'react';
interface BracketViewProps {
bracket: any[][];
matches: { [key: string]: any };
onAnnounce?: (teamOne: any, teamTwo: any) => void;
}
const BracketView: React.FC<BracketViewProps> = ({ bracket, matches, onAnnounce }) => {
// Helper to check match type (handle both uppercase and lowercase)
const isMatchType = (type: string, expected: string) => {
return type?.toLowerCase() === expected.toLowerCase();
};
// Helper to check slot type (handle both uppercase and lowercase)
const isSlotType = (type: string, expected: string) => {
return type?.toLowerCase() === expected.toLowerCase();
};
// Helper to get parent match order number
const getParentMatchOrder = (parentId: number): number | string => {
const parentMatch = matches[parentId];
if (parentMatch && parentMatch.order !== null && parentMatch.order !== undefined) {
return parentMatch.order;
}
// If no order (like for byes), return the parentId with a different prefix
return `Match ${parentId}`;
};
return (
<Flex direction='row' gap={24} justify='left' pos='relative' p='xl'>
{bracket.map((round, roundIndex) => (
<Flex direction='column' key={roundIndex} gap={24} justify='space-around'>
{round.map((match, matchIndex) => {
if (!match) return null;
// Handle bye matches (no away slot) - check both 'TBye' and 'bye'
if (isMatchType(match.type, 'bye') || isMatchType(match.type, 'tbye')) {
return (
<Flex key={matchIndex}>
</Flex>
);
}
// Regular matches with both home and away
return (
<Flex direction='row' key={matchIndex} align='center' justify='end' gap={8}>
<Text c='dimmed' fw='bolder'>{match.order}</Text>
<Card withBorder pos='relative' w={200} style={{ overflow: 'visible' }}>
<Card.Section withBorder p={4}>
{isSlotType(match.home?.type, 'seed') && (
<>
<Text c='dimmed' size='xs'>Seed {match.home.seed}</Text>
{match.home.team && <Text size='xs'>{match.home.team.name}</Text>}
</>
)}
{isSlotType(match.home?.type, 'tbd') && (
<Text c='dimmed' size='xs'>
{match.home.loser ? 'Loser' : 'Winner'} of Match {getParentMatchOrder(match.home.parentId || match.home.parent)}
</Text>
)}
{!match.home && <Text c='dimmed' size='xs' fs='italic'>TBD</Text>}
</Card.Section>
<Card.Section p={4} mb={-16}>
{isSlotType(match.away?.type, 'seed') && (
<>
<Text c='dimmed' size='xs'>Seed {match.away.seed}</Text>
{match.away.team && <Text size='xs'>{match.away.team.name}</Text>}
</>
)}
{isSlotType(match.away?.type, 'tbd') && (
<Text c='dimmed' size='xs'>
{match.away.loser ? 'Loser' : 'Winner'} of Match {getParentMatchOrder(match.away.parentId || match.away.parent)}
</Text>
)}
{!match.away && <Text c='dimmed' size='xs' fs='italic'>TBD</Text>}
</Card.Section>
{match.reset && (
<Text
pos='absolute'
top={-8}
left={8}
size='xs'
c='orange'
fw='bold'
>
IF NECESSARY
</Text>
)}
{onAnnounce && match.home?.team && match.away?.team && (
<ActionIcon
pos='absolute'
variant='filled'
color='green'
top={-20}
right={-12}
onClick={() => {
onAnnounce(match.home.team, match.away.team);
}}
bd='none'
style={{ boxShadow: 'none' }}
size='xs'
>
<PlayIcon size={12} />
</ActionIcon>
)}
</Card>
</Flex>
);
})}
</Flex>
))}
</Flex>
);
};
export default BracketView;

View File

@@ -0,0 +1,49 @@
import { Flex, Text, Select, Card } from '@mantine/core';
import React from 'react';
interface Team {
id: string;
name: string;
}
interface SeedListProps {
teams: Team[];
onSeedChange: (currentIndex: number, newIndex: number) => void;
}
export function SeedList({ teams, onSeedChange }: SeedListProps) {
const seedOptions = teams.map((_, index) => ({
value: index.toString(),
label: `Seed ${index + 1}`
}));
return (
<Flex direction='column' gap={8}>
{teams.map((team, index) => (
<Card key={team.id} withBorder p="xs">
<Flex align="center" gap="xs" justify="space-between">
<Flex align="center" gap="xs">
<Select
value={index.toString()}
data={seedOptions}
onChange={(value) => {
if (value !== null) {
const newIndex = parseInt(value);
if (newIndex !== index) {
onSeedChange(index, newIndex);
}
}
}}
size="xs"
w={100}
/>
<Text size="sm" fw={500}>
{team.name}
</Text>
</Flex>
</Flex>
</Card>
))}
</Flex>
);
}

View File

@@ -0,0 +1,35 @@
/* Hide scrollbars but keep functionality */
.bracket-container::-webkit-scrollbar {
display: none;
}
.bracket-container {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Cursor states for draggable area */
.bracket-container:active {
cursor: grabbing;
}
/* Smooth scrolling on mobile */
.bracket-container {
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
}
/* Prevent text selection while dragging */
.bracket-container * {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Optional: Add subtle shadows for depth on desktop */
@media (min-width: 768px) {
.bracket-container {
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
}
}

View File

@@ -0,0 +1,13 @@
import { queryOptions } from "@tanstack/react-query";
import { previewBracket } from "./server";
const bracketKeys = {
preview: (teams: number) => ['bracket-preview', teams] as const,
};
export const bracketQueries = {
preview: (teams: number) => queryOptions({
queryKey: bracketKeys.preview(teams),
queryFn: () => previewBracket({ data: teams }),
}),
};

View File

@@ -0,0 +1,30 @@
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { Logger } from "@/lib/logger";
import brackets from './utils';
const logger = new Logger("Bracket Generation")
// Transform the imported JSON to match the expected format
function transformBracketData(bracketData: any) {
return {
n: bracketData.config.teams,
doubleElim: bracketData.config.doubleElimination,
matches: bracketData.matches,
winnersBracket: bracketData.structure.winners,
losersBracket: bracketData.structure.losers
};
}
export const previewBracket = createServerFn()
.validator(z.number())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teams }) => {
logger.info('Generating bracket', teams);
if (!Object.keys(brackets).includes(teams.toString()))
throw Error("Bracket not available")
// Transform the imported data to match expected format
return transformBracketData(brackets[teams]);
});

View File

@@ -0,0 +1,24 @@
/**
* Imports saved json dumps of bracket generation from a python script that I didn't prioritize converting to TS
*/
import b12 from '../../../../scripts/brackets/12.json';
import b13 from '../../../../scripts/brackets/13.json';
import b14 from '../../../../scripts/brackets/14.json';
import b15 from '../../../../scripts/brackets/15.json';
import b16 from '../../../../scripts/brackets/16.json';
import b17 from '../../../../scripts/brackets/17.json';
import b18 from '../../../../scripts/brackets/18.json';
import b19 from '../../../../scripts/brackets/19.json';
import b20 from '../../../../scripts/brackets/20.json';
export default {
12: b12,
13: b13,
14: b14,
15: b15,
16: b16,
17: b17,
18: b18,
19: b19,
20: b20,
}

View File

@@ -0,0 +1,31 @@
import { Outlet, useRouter } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
const AnimatedOutlet = () => {
const router = useRouter();
return (
<AnimatePresence mode="wait">
<motion.div
key={router.state.location.pathname}
initial={{ x: '100%', opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: '-100%', opacity: 0 }}
transition={{
type: 'tween',
duration: 0.3,
ease: 'easeInOut'
}}
style={{
position: 'absolute',
width: '100%',
height: '100%'
}}
>
<Outlet />
</motion.div>
</AnimatePresence>
);
}
export default AnimatedOutlet;

View File

@@ -0,0 +1,25 @@
import { Box } from "@mantine/core"
import { ArrowLeftIcon } from "@phosphor-icons/react"
import { useRouter } from "@tanstack/react-router"
interface BackButtonProps {
offsetY: number;
}
const BackButton = ({ offsetY }: BackButtonProps) => {
const router = useRouter()
return (
<Box
style={{ cursor: 'pointer', zIndex: 1000, transform: `translateY(-${offsetY}px)` }}
onClick={() => router.history.back()}
pos='absolute'
left={{ base: 0, sm: 100, md: 200, lg: 300 }}
m={20}
>
<ArrowLeftIcon weight='bold' size={20} />
</Box>
);
}
export default BackButton;

View File

@@ -0,0 +1,26 @@
import { Title, AppShell, Flex } from "@mantine/core";
import { HeaderConfig } from "../types/header-config";
import BackButton from "./back-button";
import { useMemo } from "react";
interface HeaderProps extends HeaderConfig {
scrollPosition: { x: number, y: number };
}
const Header = ({ withBackButton, collapsed, title, scrollPosition }: HeaderProps) => {
const offsetY = useMemo(() => {
return collapsed ? scrollPosition.y : 0;
}, [collapsed, scrollPosition.y]);
return (
<>
{withBackButton && <BackButton offsetY={offsetY} />}
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
<Flex justify='center' align='center' h='100%' px='md'>
<Title order={2}>{title}</Title>
</Flex>
</AppShell.Header>
</>
);
}
export default Header;

View File

@@ -0,0 +1,53 @@
import { AppShell } from '@mantine/core';
import { PropsWithChildren, useState } from 'react';
import Header from './header';
import Navbar from './navbar';
import useHeaderConfig from '../hooks/use-header-config';
import Pullable from './pullable';
import useVisualViewportSize from '../hooks/use-visual-viewport-size';
const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const headerConfig = useHeaderConfig();
const viewport = useVisualViewportSize();
const [scrollPosition, setScrollPosition] = useState({ x: 0, y: 0 });
return (
<AppShell
id='app-shell'
layout='alt'
header={{ height: 60, collapsed: headerConfig.collapsed }}
navbar={{
width: { base: 0, sm: 100, md: 200, lg: 300 },
breakpoint: 'sm',
collapsed: { mobile: true },
}}
aside={{
width: { base: 0, sm: 100, md: 200, lg: 300 },
breakpoint: 'sm',
collapsed: { desktop: false, mobile: true }
}}
pos='relative'
h='100dvh'
mah='100dvh'
style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
>
<Header scrollPosition={scrollPosition} {...headerConfig} />
<AppShell.Main
pos='relative'
h='100%'
mah='100%'
pb={{ base: 70, md: 0 }}
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
style={{ transition: 'none' }}
>
<Pullable scrollPosition={scrollPosition} onScrollPositionChange={setScrollPosition}>
{children}
</Pullable>
</AppShell.Main>
<Navbar />
<AppShell.Aside withBorder />
</AppShell>
);
}
export default Layout;

View File

@@ -0,0 +1 @@
export * from './nav-link';

View File

@@ -0,0 +1,28 @@
import { Flex, Box, Text } from "@mantine/core";
import { Link, useRouterState } from "@tanstack/react-router";
import styles from './styles.module.css';
import { Icon } from "@phosphor-icons/react";
import { useMemo } from "react";
interface NavLinkProps {
href: string;
label: string;
Icon: Icon;
}
export const NavLink = ({ href, label, Icon }: NavLinkProps) => {
const router = useRouterState();
const isActive = useMemo(() => router.location.pathname === href || (router.location.pathname.includes(href) && href !== '/'), [router.location.pathname, href]);
return (
<Box component={Link} to={href}
className={styles.navLinkBox}
p={{ base: 0, sm: 8 }}
>
<Flex direction={{ base: 'column', md: 'row' }} align='center' gap={{ base: 0, md: 'xs' }}>
<Icon weight={isActive ? 'fill' : 'regular'} size={28} style={{ color: isActive ? 'var(--mantine-primary-color-filled)' : undefined }} />
<Text visibleFrom='md' ta='center' size='md' fw={isActive ? 800 : 500} c={isActive ? 'var(--mantine-primary-color-filled)' : undefined}>{label}</Text>
</Flex>
</Box>
)
}

View File

@@ -0,0 +1,6 @@
.navLinkBox {
text-decoration: none;
border-radius: var(--mantine-radius-md);
color: unset;
width: fit-content;
}

View File

@@ -0,0 +1,39 @@
import { AppShell, ScrollArea, Stack, Group, Paper } from "@mantine/core";
import { Link } from "@tanstack/react-router";
import { NavLink } from "./nav-link";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useAuth } from "@/contexts/auth-context";
import { useLinks } from "../hooks/use-links";
const Navbar = () => {
const { user, roles } = useAuth()
const isMobile = useIsMobile();
const links = useLinks(user?.id, roles);
console.log('rendered')
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 }}>
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
{links.map((link) => (
<NavLink key={link.href} {...link} />
))}
</Group>
</Paper>
)
return <AppShell.Navbar p="xs" role='navigation'>
<AppShell.Section grow component={ScrollArea}>
<Stack gap='xs' mx='auto' w='fit-content' justify='end' mt='md'>
{links.map((link) => (
<NavLink key={link.href} {...link} />
))}
</Stack>
</AppShell.Section>
<AppShell.Section>
<Link to="/logout">Logout</Link>
</AppShell.Section>
</AppShell.Navbar>
}
export default Navbar;

View File

@@ -0,0 +1,124 @@
import { ActionIcon, Box, Button, Flex, ScrollArea } from "@mantine/core";
import { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from "react";
import useAppShellHeight from "@/hooks/use-appshell-height";
import useRefreshConfig from "@/features/core/hooks/use-refresh-config";
import { ArrowClockwiseIcon, SpinnerIcon } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
const THRESHOLD = 80;
interface PullableProps extends PropsWithChildren {
scrollPosition: { x: number, y: number };
onScrollPositionChange: (position: { x: number, y: number }) => void;
}
/**
* Pullable is a component that allows the user to pull down to refresh the page
* TODO: Need to figure out why it isn't disabled when onRefresh is undefined
*/
const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollPositionChange }) => {
const height = useAppShellHeight();
const [isRefreshing, setIsRefreshing] = useState(false);
const [scrolling, setScrolling] = useState(false);
const { toRefresh } = useRefreshConfig();
const queryClient = useQueryClient();
const scrollY = useMemo(() => scrollPosition.y < 0 && scrolling ? Math.abs(scrollPosition.y) : 0, [scrollPosition.y, scrolling]);
const onTrigger = useCallback(async () => {
setIsRefreshing(true);
if (toRefresh.length > 0) {
// TODO: Remove this after testing - or does the delay help ux?
await new Promise(resolve => setTimeout(resolve, 1000));
await queryClient.refetchQueries({ queryKey: toRefresh, exact: true});
}
setIsRefreshing(false);
}, [toRefresh]);
useEffect(() => {
if (!isRefreshing && scrollY > THRESHOLD) {
onTrigger();
}
}, [scrollY, isRefreshing, onTrigger]);
const iconOpacity = useMemo(() => {
if (isRefreshing) return 1;
if (toRefresh.length === 0) return 0;
const clampedValue = Math.max(5, Math.min(THRESHOLD, scrollY));
const min = 5;
const max = THRESHOLD;
const range = max - min;
return (clampedValue - min) / range;
}, [scrollY, isRefreshing])
useEffect(() => {
const scrollWrapper = document.getElementById('scroll-wrapper');
if (scrollWrapper) {
scrollWrapper.addEventListener('touchstart', () => {
setScrolling(true);
});
scrollWrapper.addEventListener('touchend', () => {
setScrolling(false);
});
}
}, []);
useEffect(() => {
if (typeof window === 'undefined') return;
const ac = new AbortController();
const options = {
passive: true,
signal: ac.signal
};
window.addEventListener('touchstart', () => setScrolling(true), options);
window.addEventListener('touchend', () => setScrolling(false), options);
return () => void ac.abort();
}, []);
return (
<>
<Flex
pos='absolute'
justify='center'
align='center'
w='100%'
display={scrollY > 20 || isRefreshing ? 'flex' : 'none'}
opacity={iconOpacity}
style={{ zIndex: 10 }}
>
<SpinnerIcon
weight="bold"
size={iconOpacity * 28}
color='var(--mantine-color-dimmed)'
style={{
marginTop: 8,
transform: iconOpacity === 1 ? undefined : `rotate(${iconOpacity * 360}deg)`,
animation: iconOpacity === 1 ? 'spin 1s linear infinite' : undefined,
}}
/>
</Flex>
<ScrollArea
id='scroll-wrapper'
onScrollPositionChange={onScrollPositionChange}
type='never' mah='100%' h='100%'
pt={(scrolling || scrollY > 40) || !isRefreshing ? 0 : 40 - scrollY}
>
<Box pt='1rem'pb='0.285rem' mih={height} style={{ boxSizing: 'content-box' }}>
{ /* TODO: Remove this debug button */}
<ActionIcon style={{ zIndex: 1000 }} pos='absolute' top={8} left='calc(50% - 24px)' onClick={onTrigger} variant='filled' color='var(--mantine-color-dimmed)'>
<ArrowClockwiseIcon />
</ActionIcon>
{children}
</Box>
</ScrollArea>
</>
)
}
export default Pullable;

View File

@@ -0,0 +1,27 @@
import { isMatch, useMatches } from "@tanstack/react-router";
import { HeaderConfig } from "../types/header-config";
export const defaultHeaderConfig: HeaderConfig = {
title: 'Starter App',
withBackButton: false,
collapsed: false,
}
const useHeaderConfig = () => {
const matches = useMatches();
const matchesWithHeader = matches.filter((match) =>
isMatch(match, 'loaderData.header'),
)
const config = matchesWithHeader.reduce((acc, match) => {
return {
...acc,
...match?.loaderData?.header,
}
}, defaultHeaderConfig) as HeaderConfig;
return config;
}
export default useHeaderConfig;

View File

@@ -0,0 +1,38 @@
import { GearIcon, HouseIcon, QuestionIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
export const useLinks = (userId: number, roles: string[]) =>
useMemo(() => {
const links = [
{
label: 'Home',
href: '/',
Icon: HouseIcon
},
{
label: 'Tournaments',
href: '/tournaments',
Icon: TrophyIcon
},
{
label: 'Profile',
href: `/profile/${userId}`,
Icon: UserCircleIcon
},
{
label: 'Settings',
href: '/settings',
Icon: GearIcon
}
]
if (roles.includes('Admin')) {
links.push({
label: 'Admin',
href: '/admin',
Icon: ShieldIcon
})
}
return links;
}, [userId, roles]);

View File

@@ -0,0 +1,24 @@
import { isMatch, useMatches } from "@tanstack/react-router";
export const defaultRefreshConfig: { toRefresh: string[] } = {
toRefresh: [],
}
const useRefreshConfig = () => {
const matches = useMatches();
const matchesWithRefresh = matches.filter((match) =>
isMatch(match, 'loaderData.refresh'),
)
const config = matchesWithRefresh.reduce((acc, match) => {
return {
...acc,
...match?.loaderData?.refresh,
}
}, defaultRefreshConfig) as { toRefresh: string[] };
return config;
}
export default useRefreshConfig;

View File

@@ -0,0 +1,31 @@
import { useCallback, useEffect, useState } from 'react';
const eventListerOptions = {
passive: true,
};
const useVisualViewportSize = () => {
const windowExists = typeof window !== 'undefined';
const [windowSize, setWindowSize] = useState({
width: windowExists ? window.visualViewport?.width || 0 : 0,
height: windowExists ? window.visualViewport?.height || 0 : 0,
top: windowExists ? window.visualViewport?.offsetTop || 0 : 0,
});
const setSize = useCallback(() => {
if (!windowExists) return;
setWindowSize({ width: window.visualViewport?.width || 0, height: window.visualViewport?.height || 0, top: window.visualViewport?.offsetTop || 0 });
}, []);
useEffect(() => {
if (!windowExists) return;
window.visualViewport?.addEventListener('resize', setSize, eventListerOptions);
return () => {
window.visualViewport?.removeEventListener('resize', setSize);
}
}, []);
return windowSize;
}
export default useVisualViewportSize;

View File

@@ -0,0 +1,7 @@
interface HeaderConfig {
title?: string;
withBackButton?: boolean;
collapsed?: boolean;
}
export type { HeaderConfig };

View File

@@ -0,0 +1,9 @@
import { HeaderConfig } from "./header-config";
interface RouteConfig {
header?: HeaderConfig;
refreshQueryKeys?: string[];
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | number;
}
export type { RouteConfig };

View File

@@ -0,0 +1,11 @@
import {
MantineColorSchemeManager,
} from '@mantine/core';
export const fakeColorSchemeManager: MantineColorSchemeManager = {
get: (defaultValue) => defaultValue,
set: (value) => { },
subscribe: (onUpdate) => { },
unsubscribe: () => { },
clear: () => { },
}

View File

@@ -0,0 +1,47 @@
import { useState } from 'react';
import { Flex, PinInput, Title, Text, Stack, LoadingOverlay } from '@mantine/core';
import useConsumeCode from '../hooks/use-consume-code';
import { useSearch } from '@tanstack/react-router';
const CodePrompt = () => {
const { number } = useSearch({ from: '/login' });
const [isWrong, setIsWrong] = useState(false);
const [code, setCode] = useState('');
const handleWrongCode = () => setIsWrong(true);
const { mutate: consumeCode, isPending } = useConsumeCode(handleWrongCode);
const handleChange = (value: string) => {
if (value.length === 0) return;
if (isWrong) setIsWrong(false);
setCode(value);
if (value.length === 6) {
consumeCode(value);
}
}
return (
<Flex direction="column" p={10} w='max-content' m='auto'>
<Title order={4}>Enter Verification Code</Title>
<Text size='xs'c="dimmed" mb={5}>A code was sent to +1 ({number?.slice(0, 3)}) {number?.slice(3, 6)}-{number?.slice(6)}</Text>
<Stack justify='center' p={10} gap={2} pos='relative'>
<PinInput aria-label="One time code"
value={code}
error={isWrong}
onChange={handleChange}
autoFocus={true}
oneTimeCode
length={6}
disabled={isPending}
type='number'
/>
<LoadingOverlay visible={isPending} overlayProps={{ blur: 0.375, radius: 'md', backgroundOpacity: 0.35 }} />
{isWrong && <Text c='red' size='xs'>Incorrect code</Text>}
</Stack>
</Flex>
)
};
export default CodePrompt;

View File

@@ -0,0 +1,23 @@
import { Alert } from "@mantine/core";
import { Info } from "@phosphor-icons/react";
import { Transition } from "@mantine/core";
import { useMemo } from "react";
const Error = ({ error }: { error?: string }) => {
const show = useMemo(() => (error ? error.length > 0 : false), [error]);
return (
<Transition
mounted={show}
transition="slide-up"
duration={400}
timingFunction="ease"
>
{(styles) => (
<Alert w='95%' color="red" icon={<Info />} style={styles}>{error}</Alert>
)}
</Transition>
)
}
export default Error;

View File

@@ -0,0 +1,45 @@
import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size';
import { AppShell, Flex, Paper, em, Title, Stack } from '@mantine/core';
import { useMediaQuery, useViewportSize } from '@mantine/hooks';
import { TrophyIcon } from '@phosphor-icons/react';
import { PropsWithChildren } from 'react';
const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const isMobile = useMediaQuery(`(max-width: ${em(450)})`);
const visualViewport = useVisualViewportSize();
const viewport = useViewportSize();
return (
<AppShell>
<AppShell.Main h='100%' style={{ overflow: 'scroll' }}>
<Flex
w={isMobile ? '100vw' : em(450)}
justify='center'
align='center'
h='auto'
direction='column'
gap='md'
mx='auto'
pt={viewport.height === visualViewport.height ? '5rem' : '12.5rem'}
style={{ transition: 'padding-top 0.1s ease' }}
>
<Paper
shadow='none'
p='md'
w='100%'
maw='375px'
radius='md'
>
<Stack align='center' gap='xs' mb='md'>
<TrophyIcon size={150} />
<Title order={2} ta='center'>Welcome to FLXN</Title>
</Stack>
{children}
</Paper>
</Flex>
</AppShell.Main>
</AppShell>
);
};
export default Layout;

View File

@@ -0,0 +1,23 @@
import { Center, Loader } from "@mantine/core";
import PhonePrompt from "./phone-prompt";
import CodePrompt from "./code-prompt";
import { useSearch } from "@tanstack/react-router";
import PlayerPrompt from "./player-prompt";
const LoginFlow = () => {
const { stage } = useSearch({ from: '/login' });
if (!stage) {
return <PhonePrompt />;
} else if (stage === 'code') {
return <CodePrompt />;
} else if (stage === 'name') {
return <PlayerPrompt />;
}
return <Center>
<Loader color="blue" size="xl" type="dots" />
</Center>;
};
export default LoginFlow;

View File

@@ -0,0 +1,12 @@
import Layout from './layout';
import LoginFlow from './login-flow';
const LoginPage = () => {
return (
<Layout>
<LoginFlow />
</Layout>
);
};
export default LoginPage;

View File

@@ -0,0 +1,49 @@
import { Button, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import useCreateUser from "../hooks/use-create-user";
const NamePrompt = () => {
const form = useForm({
initialValues: {
first_name: '',
last_name: ''
},
validate: {
first_name: (value) => {
if (value.length === 0) return 'First name is required'
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return 'First name must be 3-20 characters long and contain only letters'
},
last_name: (value) => {
if (value.length === 0) return 'Last name is required'
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return 'Last name must be 3-20 characters long and contain only letters'
}
}
})
const { mutate: createUser, isPending } = useCreateUser();
const handleSubmit = (data: { first_name: string, last_name: string }) => {
form.reset();
return createUser(data);
}
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
id="first_name"
label='First Name'
key={form.key('first_name')}
{...form.getInputProps('first_name')}
/>
<TextInput
id="last_name"
label='Last Name'
key={form.key('last_name')}
{...form.getInputProps('last_name')}
/>
<Button loading={isPending} type='submit' w='100%' mt='10px' variant='filled'>Create Account</Button>
</form>
)
}
export default NamePrompt;

View File

@@ -0,0 +1,39 @@
import { Button } from "@mantine/core";
import PhoneNumberInput from "@/components/phone-number-input";
import { useForm } from "@mantine/form";
import useCreateCode from "../hooks/use-create-code";
const PhonePrompt = () => {
const form = useForm({
initialValues: {
number: ''
},
validate: {
number: (value) => {
if (value.length === 0) return 'Phone number is required'
if (value.length !== 10) return 'Phone number must be 10 digits'
}
}
})
const { mutate: createCode, isPending } = useCreateCode();
const handleSubmit = (data: { number: string }) => {
form.reset();
return createCode(data.number);
}
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<PhoneNumberInput
id="number"
label='Enter your phone number'
key={form.key('number')}
{...form.getInputProps('number')}
/>
<Button type='submit' w='100%' mt='10px' variant='filled' loading={isPending}>Send Code</Button>
</form>
)
}
export default PhonePrompt;

View File

@@ -0,0 +1,21 @@
import { Button, Center, ElementProps, SimpleGrid, Text } from "@mantine/core";
import { ChalkboardTeacherIcon } from "@phosphor-icons/react";
const ExistingPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) => {
return <Button
p='xs'
w='45%'
h='fit-content'
onClick={onClick}
variant='outline'
>
<SimpleGrid style={{ overflow: 'hidden' }}>
<Center>
<ChalkboardTeacherIcon size='3rem' />
</Center>
<Text size='md' fw={600}>Returning Player</Text>
</SimpleGrid>
</Button>
};
export default ExistingPlayerButton;

View File

@@ -0,0 +1,130 @@
import { useState, FormEventHandler, useMemo } from 'react';
import { ArrowLeftIcon } from '@phosphor-icons/react';
import { useQuery } from '@tanstack/react-query';
import { Autocomplete, Button, Divider, Flex, Text, TextInput, Title, UnstyledButton } from '@mantine/core';
import ExistingPlayerButton from './existing-player-button';
import NewPlayerButton from './new-player-button';
import { Player } from '@/features/players/types';
import { toast } from 'sonner';
import { playerQueries } from '@/features/players/queries';
import useCreateUser from '../../hooks/use-create-user';
enum PlayerPromptStage {
returning = 'returning',
new = 'new'
}
const PlayerPrompt = () => {
const [stage, setStage] = useState<PlayerPromptStage>();
const playersQuery = useQuery(playerQueries.unassociated());
const { mutate: createUser, isPending } = useCreateUser();
const players = playersQuery.data;
const [player, setPlayer] = useState<Player>();
const [value, setValue] = useState('');
const [error, setError] = useState('');
const parsedPlayers = useMemo(() => players?.map(p => ({ label: `${p.first_name} ${p.last_name}`, value: p})), [players])
const autocompleteOptions = [...new Set(parsedPlayers?.map(p => p.label))]
const formSubmitHandler = (callback: () => void): FormEventHandler<HTMLFormElement> => {
return async (event) => {
event.preventDefault();
await callback();
}
}
const handleNewPlayerSubmit = () => {
const first_name = value.split(' ').slice(0, -1).join(' ');
const last_name = value.split(' ').slice(-1).join(' ');
// check if player already exists
if (!!parsedPlayers?.find(p => p.label === value)) {
toast.error("Player already exists");
return;
}
createUser({
first_name,
last_name
});
}
const handlePlayerSubmit = () => {
if (player) {
setError('');
createUser(player.id!);
} else {
setError('You must select a player from the dropdown. If you don\'t see yourself, please go back and select \'New Player\'');
}
}
const handleNewPlayerChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value);
}
const handleReturningPlayerChange = (player: string) => {
const selected = parsedPlayers?.find(p => p.label === player);
if (selected) {
setError('');
setPlayer(selected.value);
} else {
setPlayer(undefined);
}
}
if (!stage) {
return <>
<Title order={3}>Have you played before?</Title>
<Text size='xs' mb='sm'>If this is your first time participating, please select <i>New Player</i>, otherwise select <i>Returning Player</i></Text>
<Flex justify='space-around'>
<ExistingPlayerButton onClick={() => setStage(PlayerPromptStage.returning)} />
<Divider orientation='vertical' variant="dashed" />
<NewPlayerButton onClick={() => setStage(PlayerPromptStage.new)} />
</Flex>
</>
}
return <>
<UnstyledButton
onClick={() => setStage(undefined)}
style={{
position: 'absolute',
top: 24,
left: 24,
}}
>
<Flex align='center' gap='xs'>
<ArrowLeftIcon size={24} />
</Flex>
</UnstyledButton>
{
stage === PlayerPromptStage.new ?
<>
<form onSubmit={formSubmitHandler(handleNewPlayerSubmit)}>
<TextInput
label='Enter your name'
placeholder='Salah Atiyeh'
value={value}
onChange={handleNewPlayerChange}
/>
<Button type='submit' w='100%' mt='10px' color='green' variant='filled'>Submit</Button>
</form>
</> :
<form onSubmit={formSubmitHandler(handlePlayerSubmit)}>
<Autocomplete
label='Enter your name'
placeholder='Salah Atiyeh'
data={autocompleteOptions}
onChange={handleReturningPlayerChange}
error={error}
/>
<Button type='submit' w='100%' mt='10px' color='green' variant='filled'>Submit</Button>
</form>
}
</>
};
export default PlayerPrompt;

View File

@@ -0,0 +1,21 @@
import { Button, Center, ElementProps, SimpleGrid, Text } from "@mantine/core";
import { UserPlusIcon } from "@phosphor-icons/react";
const NewPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) => {
return <Button
p='xs'
w='45%'
h='fit-content'
onClick={onClick}
variant='outline'
>
<SimpleGrid>
<Center>
<UserPlusIcon size='3rem' />
</Center>
<Text size='md' fw={600}>New Player</Text>
</SimpleGrid>
</Button>
};
export default NewPlayerButton;

View File

@@ -0,0 +1,47 @@
import { consumeCode } from "supertokens-web-js/recipe/passwordless";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { fetchMe } from "@/features/players/server";
import { useNavigate } from "@tanstack/react-router";
import { authQueryConfig } from "@/contexts/auth-context";
import toast from '@/lib/sonner'
const useConsumeCode = (onWrongCode: () => void) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (code: string) => consumeCode({ userInputCode: code }),
onSuccess: async (data) => {
if (data.status === 'OK') {
const data = await fetchMe();
queryClient.setQueryData(authQueryConfig.queryKey, data);
if (!data || !data.user) {
navigate({ to: '/login', search: { stage: 'name' } });
} else {
toast.success('Successfully logged in. Welcome back!');
navigate({ to: '/' })
}
} else if (data.status === 'INCORRECT_USER_INPUT_CODE_ERROR') {
onWrongCode();
} else if (data.status === 'EXPIRED_USER_INPUT_CODE_ERROR') {
toast.error('Code has expired. Please request a new code.');
} else if (data.status === "RESTART_FLOW_ERROR") {
toast.error('Too many failed attempts. Please try again.');
navigate({ to: '/login', search: { stage: undefined, number: undefined } });
} else {
toast.error('Unknown error. Please try again later.');
}
return data;
},
onError: (error: any) => {
if (error.isSuperTokensGeneralError === true) {
toast.error(error.message);
} else {
toast.error("Unknown error. Please try again later.");
}
},
});
};
export default useConsumeCode;

View File

@@ -0,0 +1,29 @@
import { createCode } from "supertokens-web-js/recipe/passwordless";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import toast from '@/lib/sonner'
const useCreateCode = () => {
const navigate = useNavigate();
return useMutation({
mutationFn: (phoneNumber: string) => createCode({ phoneNumber: '+1' + phoneNumber }),
onSuccess: (data, phoneNumber) => {
if (data.status === 'OK') {
toast.success('Code sent successfully');
navigate({ to: '/login', search: { stage: 'code', number: phoneNumber } });
} else {
toast.error(data.reason);
}
},
onError: (error: any) => {
if (error.isSuperTokensGeneralError === true) {
toast.error(error.message);
} else {
toast.error('An unexpected error occurred when trying to send a one-time passcode. Please try again later.');
}
},
});
};
export default useCreateCode;

View File

@@ -0,0 +1,42 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { authQueryConfig } from "@/contexts/auth-context";
import { useNavigate } from "@tanstack/react-router";
import { associatePlayer, createPlayer } from "@/features/players/server";
import toast from '@/lib/sonner';
const useCreateUser = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { first_name: string, last_name: string } | string) => {
if (typeof data === 'string') {
return associatePlayer({ data });
} else {
return createPlayer({ data });
}
},
onSuccess: (data) => {
if (!data) {
toast.error('There was an issue creating your account. Please try again later.');
navigate({ to: '/login' });
} else {
queryClient.setQueryData(authQueryConfig.queryKey, (old: any) => ({
...old,
user: data
}));
toast.success('Account created successfully!');
navigate({ to: '/' });
}
},
onError: (error: any) => {
if (error.message) {
toast.error(error.message);
} else {
toast.error('An unexpected error occurred when trying to create an account. Please try again later.');
}
},
});
};
export default useCreateUser;

View File

@@ -0,0 +1,57 @@
import { z } from "zod";
export interface Match {
id: string;
order: number;
lid: number;
reset: boolean;
round: number;
home_cups: number;
away_cups: number;
ot_count: number;
start_time: string;
end_time: string;
bye: boolean;
home_from_lid: number;
away_from_lid: number;
home_from_loser: boolean;
away_from_loser: boolean;
bracket_type: 'winners' | 'losers';
tournament_id: string;
home_id: string;
away_id: string;
created: string;
updated: string;
}
export const matchInputSchema = z.object({
order: z.number().int().min(1).optional(),
lid: z.number().int().min(1),
reset: z.boolean().optional().default(false),
round: z.number().int().min(1),
home_cups: z.number().int().min(0).optional().default(0),
away_cups: z.number().int().min(0).optional().default(0),
ot_count: z.number().int().min(0).optional().default(0),
start_time: z.iso.datetime("Invalid start time format").optional(),
end_time: z.iso.datetime("Invalid end time format").optional(),
bye: z.boolean().optional().default(false),
home_from_lid: z.number().int().min(1).optional(),
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),
tournament_id: z.string().min(1),
home_id: z.string().min(1).optional(),
away_id: z.string().min(1).optional(),
}).refine(
(data) => {
if (data.start_time && data.end_time) {
return new Date(data.start_time) < new Date(data.end_time);
}
return true;
},
{ message: "End time must be after start time", path: ["end_time"] }
);
export type MatchInput = z.infer<typeof matchInputSchema>;
export type MatchUpdateInput = Partial<MatchInput>;

View File

@@ -0,0 +1,40 @@
import { List, ListItem, Skeleton, Text } from "@mantine/core";
import { useNavigate } from "@tanstack/react-router";
import Avatar from "@/components/avatar";
import { Player } from "@/features/players/types";
interface PlayerListProps {
players: Player[];
loading?: boolean;
}
const PlayerList = ({ players, loading = false }: PlayerListProps) => {
const navigate = useNavigate();
if (loading) return <List>
{Array.from({ length: 10 }).map((_, i) => (
<ListItem py='xs'
icon={<Skeleton circle height={40} width={40} />}
>
<Skeleton height={20} width={200} />
</ListItem>
))}
</List>
return <List>
{players?.map((player) => (
<ListItem key={player.id}
py='xs'
icon={<Avatar size={40} name={`${player.first_name} ${player.last_name}`} />}
style={{ cursor: 'pointer' }}
onClick={() => {
navigate({ to: `/profile/${player.id}` });
}}
>
<Text fw={500}>{`${player.first_name} ${player.last_name}`}</Text>
</ListItem>
))}
</List>
}
export default PlayerList;

View File

@@ -0,0 +1,42 @@
import Sheet from "@/components/sheet/sheet";
import { useAuth } from "@/contexts/auth-context";
import { Flex, Title, ActionIcon } from "@mantine/core";
import { PencilIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import NameUpdateForm from "./name-form";
import Avatar from "@/components/avatar";
import { useSheet } from "@/hooks/use-sheet";
import { Player } from "../../types";
interface HeaderProps {
player: Player;
}
const Header = ({ player }: HeaderProps) => {
const sheet = useSheet();
const { user: authUser } = useAuth();
const owner = useMemo(() => authUser?.id === player.id, [authUser?.id, player.id]);
const name = useMemo(() => `${player.first_name} ${player.last_name}`, [player.first_name, player.last_name]);
return (
<>
<Flex px='xl' w='100%' align='self-end' gap='md'>
<Avatar name={name} size={125} />
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Title ta='center' order={2}>{name}</Title>
<ActionIcon display={owner ? 'block' : 'none'} radius='xl' variant='subtle' onClick={sheet.open}>
<PencilIcon size={20} />
</ActionIcon>
</Flex>
</Flex>
<Sheet title='Update Name' {...sheet.props}>
<NameUpdateForm player={player} toggle={sheet.toggle} />
</Sheet>
</>
)
};
export default Header;

View File

@@ -0,0 +1,22 @@
import { Box, Button, Text } from "@mantine/core";
import Header from "./header";
import { testEvent } from "@/utils/test-event";
import { Player } from "@/features/players/types";
import TeamList from "@/features/teams/components/team-list";
interface ProfileProps {
player: Player;
}
const Profile = ({ player }: ProfileProps) => {
return <>
<Header player={player} />
<Box m='sm' mt='lg'>
<Text size='xl' fw={600}>Teams</Text>
<TeamList teams={player.teams ?? []} />
</Box>
</>;
};
export default Profile;

View File

@@ -0,0 +1,60 @@
import { updatePlayer } from "@/features/players/server";
import { useMutation } from "@tanstack/react-query";
import { Button, Stack, TextInput } from "@mantine/core"
import { useForm } from "@mantine/form";
import toast from "@/lib/sonner";
import { useRouter } from "@tanstack/react-router";
import { Player } from "../../types";
interface NameUpdateFormProps {
player: Player;
toggle: () => void;
}
const NameUpdateForm = ({ player, toggle }: NameUpdateFormProps) => {
const router = useRouter();
const form = useForm({
initialValues: {
first_name: player.first_name,
last_name: player.last_name
},
validate: {
first_name: (value: string) => {
if (value.length === 0) return 'First name is required'
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return 'First name must be 3-20 characters long and contain only letters'
},
last_name: (value: string) => {
if (value.length === 0) return 'Last name is required'
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return 'Last name must be 3-20 characters long and contain only letters'
}
}
})
const { mutate: updateName, isPending } = useMutation({
mutationFn: async (data: { first_name: string, last_name: string }) => await updatePlayer({ data }),
onSuccess: () => {
toggle();
toast.success('Name updated successfully!');
router.invalidate();
},
onError: () => {
toast.error('There was an issue updating your name. Please try again later.');
}
});
const handleSubmit = async (data: { first_name: string, last_name: string }) => await updateName(data)
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap='xs'>
<TextInput label='First Name' {...form.getInputProps('first_name')} />
<TextInput label='Last Name' {...form.getInputProps('last_name')} />
<Button fullWidth loading={isPending} type='submit'>Save</Button>
<Button fullWidth variant='subtle' color='red' onClick={toggle}>Cancel</Button>
</Stack>
</form>
)
}
export default NameUpdateForm;

View File

@@ -0,0 +1,3 @@
import { Logger } from "@/lib/logger";
export const logger = new Logger('Players');

View File

@@ -0,0 +1,23 @@
import { queryOptions } from "@tanstack/react-query";
import { listPlayers, getPlayer, getUnassociatedPlayers } from "./server";
const playerKeys = {
list: ['players', 'list'] as const,
details: (id: string) => ['players', 'details', id] as const,
unassociated: ['players','unassociated'] as const,
};
export const playerQueries = {
list: () => queryOptions({
queryKey: playerKeys.list,
queryFn: listPlayers,
}),
details: (id: string) => queryOptions({
queryKey: playerKeys.details(id),
queryFn: () => getPlayer({ data: id }),
}),
unassociated: () => queryOptions({
queryKey: playerKeys.unassociated,
queryFn: getUnassociatedPlayers,
}),
};

View File

@@ -0,0 +1,143 @@
import { fetchSuperTokensAuth, setUserMetadata, superTokensFunctionMiddleware, superTokensRoleFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";
import { playerInputSchema, playerUpdateSchema } from "@/features/players/types";
import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod";
import { logger } from ".";
export const fetchMe = createServerFn().handler(async () => {
const data = await fetchSuperTokensAuth();
if (!data || !data.userAuthId) return { user: undefined, roles: [], metadata: {} };
try {
const result = await pbAdmin.getPlayerByAuthId(data.userAuthId);
logger.info('Fetched player', result);
return {
user: result || undefined,
roles: data.roles,
metadata: data.metadata
};
} catch (error) {
logger.error('Error fetching player:', error);
return { user: undefined, roles: data.roles, metadata: data.metadata };
}
});
export const getPlayer = createServerFn()
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) => {
try {
const player = await pbAdmin.getPlayer(data);
return player;
} catch (error) {
logger.error('Error getting player', error);
return undefined;
}
});
export const updatePlayer = createServerFn()
.validator(playerUpdateSchema)
.middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => {
const userAuthId = (context as any).userAuthId;
if (!userAuthId) return;
try {
// Find the player by authId first
const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
if (!existing) return;
// Update the player
const updatedPlayer = await pbAdmin.updatePlayer(
existing.id!,
{
first_name: data.first_name,
last_name: data.last_name
}
);
logger.info('Updated player name', updatedPlayer);
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name } });
return updatedPlayer;
} catch (error) {
logger.error('Error updating player name', error);
return undefined;
}
});
export const createPlayer = createServerFn()
.validator(playerInputSchema)
.middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => {
const userAuthId = (context as any).userAuthId;
if (!userAuthId) return;
try {
const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
if (existing) return;
logger.info('Creating player', data, userAuthId);
const newPlayer = await pbAdmin.createPlayer({
auth_id: userAuthId,
first_name: data.first_name,
last_name: data.last_name
});
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name, player_id: newPlayer?.id?.toString() } });
logger.info('Created player', newPlayer);
return newPlayer;
} catch (error) {
logger.error('Error creating player', error);
return undefined;
}
});
export const associatePlayer = createServerFn()
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => {
const userAuthId = (context as any).userAuthId;
if (!userAuthId) return;
try {
await pbAdmin.updatePlayer(data, {
auth_id: userAuthId
});
await setUserMetadata({ data: { player_id: data } });
const player = await pbAdmin.getPlayer(data);
logger.info('Associated player', player);
return player;
} catch (error) {
logger.error('Error associating player', error);
return undefined;
}
});
export const listPlayers = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () => {
try {
return await pbAdmin.listPlayers();
} catch (error) {
logger.error('Error listing players', error);
return [];
}
});
export const getUnassociatedPlayers = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () => {
try {
return await pbAdmin.getUnassociatedPlayers();
} catch (error) {
logger.error('Error getting unassociated players', error);
return [];
}
});

View File

@@ -0,0 +1,23 @@
import { Team } from "@/features/teams/types";
import { z } from 'zod';
export interface Player {
id?: string;
auth_id?: string;
first_name?: string;
last_name?: string;
created?: string;
updated?: string;
teams?: Team[];
}
export const playerInputSchema = z.object({
auth_id: z.string().optional(),
first_name: z.string().min(3).max(20).regex(/^[a-zA-Z0-9\s]+$/, "First name must be 3-20 characters long and contain only letters and spaces"),
last_name: z.string().min(3).max(20).regex(/^[a-zA-Z0-9\s]+$/, "Last name must be 3-20 characters long and contain only letters and spaces"),
});
export const playerUpdateSchema = playerInputSchema.partial();
export type PlayerInput = z.infer<typeof playerInputSchema>;
export type PlayerUpdateInput = z.infer<typeof playerUpdateSchema>;

View File

@@ -0,0 +1,61 @@
import { Box, ColorSwatch, Group, Text } from '@mantine/core';
import { updateUserAccentColor } from '@/utils/supertokens';
import { useAuth } from '@/contexts/auth-context';
const colors = ['blue', 'red', 'green', 'yellow', 'grape', 'orange', 'pink', 'lime'];
interface ColorButtonProps {
color: string;
handleClick: (color: string) => void;
isSelected: boolean;
}
const ColorButton = ({ color, handleClick, isSelected }: ColorButtonProps) => {
return (
<Box
m={isSelected ? 0 :'0.125rem'}
bd={isSelected ? '0.125rem solid var(--mantine-color-bright)' : 'none'}
style={{ borderRadius: '50%' }}
>
<ColorSwatch
component='button'
color={`var(--mantine-color-${color}-6)`}
onClick={() => handleClick(color)}
size={28}
m={2}
style={{
color: '#fff',
cursor: 'pointer',
}}
/>
</Box>
);
};
const AccentColorPicker = () => {
const { metadata, user, set } = useAuth()
const handleClick = async (color: string) => {
if (user) {
await updateUserAccentColor({ data: color });
set({ metadata: { ...metadata, accentColor: color } })
}
}
return (
<Box>
<Text fw={500} size='sm' mb='xs'>Accent Color</Text>
<Group gap='xs' w='100%' justify='space-between'>
{colors.map((color) => (
<ColorButton
key={color}
color={color}
handleClick={handleClick}
isSelected={metadata.accentColor === color}
/>
))}
</Group>
</Box>
);
}
export default AccentColorPicker;

View File

@@ -0,0 +1,51 @@
import { Center, Box, Text, SegmentedControl, MantineColorScheme } from '@mantine/core';
import { SunIcon, MoonIcon, Icon, MonitorIcon } from '@phosphor-icons/react'
import { updateUserColorScheme } from '@/utils/supertokens';
import { useAuth } from '@/contexts/auth-context';
interface ColorSchemeLabelProps {
colorScheme: string;
Icon: Icon;
}
const ColorSchemeLabel: React.FC<ColorSchemeLabelProps> = ({ colorScheme, Icon }) => {
return (<Center style={{ gap: 10 }}>
<Icon size={16} />
<span>{colorScheme}</span>
</Center>)
}
export function ColorSchemePicker() {
const { metadata, user, set } = useAuth()
const handleClick = async (value: string) => {
if (user) {
await updateUserColorScheme({ data: value });
set({ metadata: { ...metadata, colorScheme: value as MantineColorScheme } })
}
}
return (
<Box>
<Text fw={500} size='sm' mb='xs'>Color Scheme</Text>
<SegmentedControl
w='100%'
value={metadata.colorScheme}
onChange={handleClick}
data={[
{
value: 'dark',
label: <ColorSchemeLabel colorScheme='Dark' Icon={MoonIcon} />
},
{
value: 'light',
label: <ColorSchemeLabel colorScheme='Light' Icon={SunIcon} />
},
{
value: 'auto',
label: <ColorSchemeLabel colorScheme='System' Icon={MonitorIcon} />
},
]}
/>
</Box>
)
}

View File

@@ -0,0 +1,42 @@
import { Group, List, ListItem, Skeleton, Stack, Text } from "@mantine/core";
import Avatar from "@/components/avatar";
import { Team } from "@/features/teams/types";
import { useNavigate } from "@tanstack/react-router";
interface TeamListProps {
teams: Team[];
loading?: boolean;
}
const TeamList = ({ teams, loading = false }: TeamListProps) => {
const navigate = useNavigate();
if (loading) return <List>
{Array.from({ length: 10 }).map((_, i) => (
<ListItem py='xs'
icon={<Skeleton height={40} width={40} />}
>
<Skeleton height={35} width={200} />
</ListItem>
))}
</List>
return <List>
{teams?.map((team) => (
<ListItem key={team.id}
py='xs'
icon={<Avatar radius='sm' size={40} name={`${team.name}`} />}
style={{ cursor: 'pointer' }}
onClick={() => navigate({ to: `/teams/${team.id}` })}
>
<Stack gap={0}>
<Text fw={500}>{`${team.name}`}</Text>
{team.players?.map(p => <Text size='xs' c='dimmed'>{p.first_name} {p.last_name}</Text>)}
</Stack>
</ListItem>
))}
</List>
}
export default TeamList;

View File

@@ -0,0 +1,22 @@
import { Flex, Title } from "@mantine/core";
import Avatar from "@/components/avatar";
import { Team } from "../../types";
interface HeaderProps {
team: Team;
}
const Header = ({ team }: HeaderProps) => {
return (
<>
<Flex px='xl' w='100%' align='self-end' gap='md'>
<Avatar radius='sm' name={team.name} size={125} />
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Title ta='center' order={2}>{team.name}</Title>
</Flex>
</Flex>
</>
)
};
export default Header;

View File

@@ -0,0 +1,21 @@
import { Box, Text } from "@mantine/core";
import Header from "./header";
import TeamList from "@/features/teams/components/team-list";
import { Team } from "../../types";
import PlayerList from "@/features/players/components/player-list";
interface ProfileProps {
team: Team;
}
const TeamProfile = ({ team }: ProfileProps) => {
return <>
<Header team={team} />
<Box m='sm' mt='lg'>
<Text size='xl' fw={600}>Players</Text>
<PlayerList players={team.players} />
</Box>
</>;
};
export default TeamProfile;

View File

@@ -0,0 +1,3 @@
import { Logger } from "@/lib/logger";
export const logger = new Logger("Teams");

View File

@@ -0,0 +1,13 @@
import { queryOptions } from "@tanstack/react-query";
import { getTeam } from "./server";
const teamKeys = {
details: (id: string) => ['teams', id] as const,
};
export const teamQueries = {
details: (id: string) => queryOptions({
queryKey: teamKeys.details(id),
queryFn: () => getTeam({ data: id }),
}),
};

View File

@@ -0,0 +1,13 @@
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from ".";
import { z } from "zod";
export const getTeam = createServerFn()
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teamId }) => {
logger.info('Getting team', teamId);
return await pbAdmin.getTeam(teamId);
});

View File

@@ -0,0 +1,47 @@
import { Player } from "@/features/players/types";
import { z } from 'zod';
export interface Team {
id: string;
name: string;
logo_url: string;
primary_color: string;
accent_color: string;
song_id: string;
song_name: string;
song_artist: string;
song_album: string;
song_year: number;
song_start: number;
song_end: number;
song_image_url: string;
created: string;
updated: string;
players: Player[];
}
export const teamInputSchema = z.object({
name: z.string().min(1, "Team name is required").max(100, "Name too long"),
logo_url: z.url("Invalid logo URL").optional(),
primary_color: z.string().regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)").optional(),
accent_color: z.string().regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)").optional(),
song_id: z.string().max(255).optional(),
song_name: z.string().max(255).optional(),
song_artist: z.string().max(255).optional(),
song_album: z.string().max(255).optional(),
song_year: z.number().int().optional(),
song_start: z.number().int().optional(),
song_end: z.number().int().optional(),
song_image_url: z.url("Invalid song image URL").optional(),
}).refine(
(data) => {
if (data.song_start && data.song_end) {
return data.song_end > data.song_start;
}
return true;
},
{ message: "Song end time must be after start time", path: ["song_end"] }
);
export type TeamInput = z.infer<typeof teamInputSchema>;
export type TeamUpdateInput = Partial<TeamInput>;

View File

@@ -0,0 +1,44 @@
import { Badge, Card, Text, Image, Stack, Flex } from "@mantine/core"
import { Tournament } from "@/features/tournaments/types"
import { useMemo } from "react"
import { CaretRightIcon } from "@phosphor-icons/react"
import { useNavigate } from "@tanstack/react-router"
interface TournamentCardProps {
tournament: Tournament
}
export const TournamentCard = ({ tournament }: TournamentCardProps) => {
const navigate = useNavigate({ from: '/tournaments/$tournamentId' })
const date = useMemo(() => new Date(tournament.start_time), [tournament?.start_time])
const year = useMemo(() => date.getFullYear(), [date])
const month = useMemo(() => date.getMonth(), [date])
const monthName = useMemo(() => new Date(date.getFullYear(), month, 1).toLocaleString('default', { month: 'long' }), [date])
const day = useMemo(() => date.getDate(), [date])
return (
<Card shadow="sm" padding="lg" radius="md" withBorder style={{ cursor: 'pointer' }} onClick={() => navigate({ to: `/tournaments/${tournament.id}` })}>
<Stack>
<Flex align='center' gap='md'>
<Image
src={tournament.logo_url}
maw={100}
mah={100}
fit='contain'
alt={tournament.name}
/>
<Stack ta='center' mx='auto' gap='0'>
<Text size='lg' fw={800}>{tournament.name} <CaretRightIcon size={12} weight='bold' /></Text>
<Text c='dimmed' size='xs' fw={600}>{monthName} {day}, {year}</Text>
<Stack gap={4} mt={4}>
{ /* TODO: Add medalists when data is available */}
<Badge variant='dot' color='gold'>Longer Team Name Goes Here</Badge>
<Badge variant='dot' color='silver'>Some Team</Badge>
<Badge variant='dot' color='orange'>Medium Team Name</Badge>
</Stack>
</Stack>
</Flex>
</Stack>
</Card>
)
}

View File

@@ -0,0 +1,3 @@
import { Logger } from "@/lib/logger";
export const logger = new Logger('Tournaments');

View File

@@ -0,0 +1,18 @@
import { queryOptions, useQuery } from "@tanstack/react-query";
import { getTournament, listTournaments } from "./server";
const tournamentKeys = {
list: ['tournaments'] as const,
details: (id: string) => [...tournamentKeys.list, id] as const,
};
export const tournamentQueries = {
list: () => queryOptions({
queryKey: tournamentKeys.list,
queryFn: listTournaments,
}),
details: (id: string) => queryOptions({
queryKey: tournamentKeys.details(id),
queryFn: () => getTournament({ data: id }),
}),
};

View File

@@ -0,0 +1,43 @@
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client";
import { tournamentInputSchema } from "@/features/tournaments/types";
import { logger } from ".";
import { z } from "zod";
export const listTournaments = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () => {
try {
const result = await pbAdmin.listTournaments();
return result;
} catch (error) {
logger.error('Error fetching tournaments', error);
return [];
}
});
export const createTournament = createServerFn()
.validator(tournamentInputSchema)
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) => {
try {
logger.info('Creating tournament', data);
const tournament = await pbAdmin.createTournament(data);
return tournament;
} catch (error) {
logger.error('Error creating tournament', error);
return null;
}
});
export const getTournament = createServerFn()
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: tournamentId }) => {
logger.info('Getting tournament', tournamentId);
const tournament = await pbAdmin.getTournament(tournamentId);
return tournament;
});

View File

@@ -0,0 +1,45 @@
import { Team } from "@/features/teams/types";
import { z } from "zod";
export interface Tournament {
id: string;
name: string;
location?: string;
desc?: string;
rules?: string;
logo_url?: string;
enroll_time?: string;
start_time: string;
end_time?: string;
created: string;
updated: string;
teams?: Team[];
}
// Schema for the form (client-side)
export const tournamentFormSchema = z.object({
name: z.string(),
location: z.string().optional(),
desc: z.string().optional(),
rules: z.string().optional(),
logo_url: z.string().optional(),
enroll_time: z.string(),
start_time: z.string(),
end_time: z.string().optional(),
});
// Schema for the server input (with base64 logo)
export const tournamentInputSchema = z.object({
name: z.string(),
location: z.string().optional(),
desc: z.string().optional(),
rules: z.string().optional(),
logo_url: z.string().optional(),
enroll_time: z.string(),
start_time: z.string(),
end_time: z.string().optional(),
});
export type TournamentFormInput = z.infer<typeof tournamentFormSchema>;
export type TournamentInput = z.infer<typeof tournamentInputSchema>;
export type TournamentUpdateInput = Partial<TournamentInput>;