init
This commit is contained in:
118
src/features/admin/components/create-tournament.tsx
Normal file
118
src/features/admin/components/create-tournament.tsx
Normal 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;
|
||||
90
src/features/admin/components/date-time-picker.tsx
Normal file
90
src/features/admin/components/date-time-picker.tsx
Normal 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 };
|
||||
37
src/features/admin/hooks/use-create-tournament.ts
Normal file
37
src/features/admin/hooks/use-create-tournament.ts
Normal 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;
|
||||
3
src/features/admin/index.ts
Normal file
3
src/features/admin/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Logger } from "@/lib/logger";
|
||||
|
||||
export const logger = new Logger('Admin');
|
||||
412
src/features/bracket/bracket.ts
Normal file
412
src/features/bracket/bracket.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
146
src/features/bracket/components/bracket-page.tsx
Normal file
146
src/features/bracket/components/bracket-page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
119
src/features/bracket/components/bracket-view.tsx
Normal file
119
src/features/bracket/components/bracket-view.tsx
Normal 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;
|
||||
49
src/features/bracket/components/seed-list.tsx
Normal file
49
src/features/bracket/components/seed-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/features/bracket/components/styles.module.css
Normal file
35
src/features/bracket/components/styles.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
13
src/features/bracket/queries.ts
Normal file
13
src/features/bracket/queries.ts
Normal 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 }),
|
||||
}),
|
||||
};
|
||||
30
src/features/bracket/server.ts
Normal file
30
src/features/bracket/server.ts
Normal 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]);
|
||||
});
|
||||
24
src/features/bracket/utils/index.ts
Normal file
24
src/features/bracket/utils/index.ts
Normal 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,
|
||||
}
|
||||
31
src/features/core/components/animated-outlet.tsx
Normal file
31
src/features/core/components/animated-outlet.tsx
Normal 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;
|
||||
25
src/features/core/components/back-button.tsx
Normal file
25
src/features/core/components/back-button.tsx
Normal 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;
|
||||
26
src/features/core/components/header.tsx
Normal file
26
src/features/core/components/header.tsx
Normal 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;
|
||||
53
src/features/core/components/layout.tsx
Normal file
53
src/features/core/components/layout.tsx
Normal 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;
|
||||
1
src/features/core/components/nav-link/index.ts
Normal file
1
src/features/core/components/nav-link/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './nav-link';
|
||||
28
src/features/core/components/nav-link/nav-link.tsx
Normal file
28
src/features/core/components/nav-link/nav-link.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
src/features/core/components/nav-link/styles.module.css
Normal file
6
src/features/core/components/nav-link/styles.module.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.navLinkBox {
|
||||
text-decoration: none;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
color: unset;
|
||||
width: fit-content;
|
||||
}
|
||||
39
src/features/core/components/navbar.tsx
Normal file
39
src/features/core/components/navbar.tsx
Normal 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;
|
||||
124
src/features/core/components/pullable.tsx
Normal file
124
src/features/core/components/pullable.tsx
Normal 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;
|
||||
27
src/features/core/hooks/use-header-config.ts
Normal file
27
src/features/core/hooks/use-header-config.ts
Normal 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;
|
||||
38
src/features/core/hooks/use-links.ts
Normal file
38
src/features/core/hooks/use-links.ts
Normal 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]);
|
||||
24
src/features/core/hooks/use-refresh-config.ts
Normal file
24
src/features/core/hooks/use-refresh-config.ts
Normal 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;
|
||||
31
src/features/core/hooks/use-visual-viewport-size.ts
Normal file
31
src/features/core/hooks/use-visual-viewport-size.ts
Normal 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;
|
||||
7
src/features/core/types/header-config.ts
Normal file
7
src/features/core/types/header-config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
interface HeaderConfig {
|
||||
title?: string;
|
||||
withBackButton?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export type { HeaderConfig };
|
||||
9
src/features/core/types/route-config.ts
Normal file
9
src/features/core/types/route-config.ts
Normal 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 };
|
||||
11
src/features/core/utils/fake-scheme-manager.ts
Normal file
11
src/features/core/utils/fake-scheme-manager.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import {
|
||||
MantineColorSchemeManager,
|
||||
} from '@mantine/core';
|
||||
|
||||
export const fakeColorSchemeManager: MantineColorSchemeManager = {
|
||||
get: (defaultValue) => defaultValue,
|
||||
set: (value) => { },
|
||||
subscribe: (onUpdate) => { },
|
||||
unsubscribe: () => { },
|
||||
clear: () => { },
|
||||
}
|
||||
47
src/features/login/components/code-prompt.tsx
Normal file
47
src/features/login/components/code-prompt.tsx
Normal 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;
|
||||
23
src/features/login/components/error.tsx
Normal file
23
src/features/login/components/error.tsx
Normal 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;
|
||||
45
src/features/login/components/layout.tsx
Normal file
45
src/features/login/components/layout.tsx
Normal 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;
|
||||
23
src/features/login/components/login-flow.tsx
Normal file
23
src/features/login/components/login-flow.tsx
Normal 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;
|
||||
12
src/features/login/components/login-page.tsx
Normal file
12
src/features/login/components/login-page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import Layout from './layout';
|
||||
import LoginFlow from './login-flow';
|
||||
|
||||
const LoginPage = () => {
|
||||
return (
|
||||
<Layout>
|
||||
<LoginFlow />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
49
src/features/login/components/name-prompt.tsx
Normal file
49
src/features/login/components/name-prompt.tsx
Normal 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;
|
||||
39
src/features/login/components/phone-prompt.tsx
Normal file
39
src/features/login/components/phone-prompt.tsx
Normal 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;
|
||||
@@ -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;
|
||||
130
src/features/login/components/player-prompt/index.tsx
Normal file
130
src/features/login/components/player-prompt/index.tsx
Normal 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;
|
||||
@@ -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;
|
||||
47
src/features/login/hooks/use-consume-code.ts
Normal file
47
src/features/login/hooks/use-consume-code.ts
Normal 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;
|
||||
29
src/features/login/hooks/use-create-code.ts
Normal file
29
src/features/login/hooks/use-create-code.ts
Normal 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;
|
||||
42
src/features/login/hooks/use-create-user.ts
Normal file
42
src/features/login/hooks/use-create-user.ts
Normal 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;
|
||||
57
src/features/matches/types.ts
Normal file
57
src/features/matches/types.ts
Normal 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>;
|
||||
40
src/features/players/components/player-list.tsx
Normal file
40
src/features/players/components/player-list.tsx
Normal 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;
|
||||
42
src/features/players/components/profile/header.tsx
Normal file
42
src/features/players/components/profile/header.tsx
Normal 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;
|
||||
22
src/features/players/components/profile/index.tsx
Normal file
22
src/features/players/components/profile/index.tsx
Normal 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;
|
||||
60
src/features/players/components/profile/name-form.tsx
Normal file
60
src/features/players/components/profile/name-form.tsx
Normal 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;
|
||||
3
src/features/players/index.ts
Normal file
3
src/features/players/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Logger } from "@/lib/logger";
|
||||
|
||||
export const logger = new Logger('Players');
|
||||
23
src/features/players/queries.ts
Normal file
23
src/features/players/queries.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
143
src/features/players/server.ts
Normal file
143
src/features/players/server.ts
Normal 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 [];
|
||||
}
|
||||
});
|
||||
23
src/features/players/types.ts
Normal file
23
src/features/players/types.ts
Normal 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>;
|
||||
61
src/features/settings/components/accent-color-picker.tsx
Normal file
61
src/features/settings/components/accent-color-picker.tsx
Normal 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;
|
||||
51
src/features/settings/components/color-scheme-picker.tsx
Normal file
51
src/features/settings/components/color-scheme-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
src/features/teams/components/team-list.tsx
Normal file
42
src/features/teams/components/team-list.tsx
Normal 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;
|
||||
22
src/features/teams/components/team-profile/header.tsx
Normal file
22
src/features/teams/components/team-profile/header.tsx
Normal 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;
|
||||
21
src/features/teams/components/team-profile/index.tsx
Normal file
21
src/features/teams/components/team-profile/index.tsx
Normal 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;
|
||||
3
src/features/teams/index.tsx
Normal file
3
src/features/teams/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Logger } from "@/lib/logger";
|
||||
|
||||
export const logger = new Logger("Teams");
|
||||
13
src/features/teams/queries.ts
Normal file
13
src/features/teams/queries.ts
Normal 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 }),
|
||||
}),
|
||||
};
|
||||
13
src/features/teams/server.ts
Normal file
13
src/features/teams/server.ts
Normal 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);
|
||||
});
|
||||
47
src/features/teams/types.ts
Normal file
47
src/features/teams/types.ts
Normal 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>;
|
||||
44
src/features/tournaments/components/tournament-card.tsx
Normal file
44
src/features/tournaments/components/tournament-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
3
src/features/tournaments/index.ts
Normal file
3
src/features/tournaments/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Logger } from "@/lib/logger";
|
||||
|
||||
export const logger = new Logger('Tournaments');
|
||||
18
src/features/tournaments/queries.ts
Normal file
18
src/features/tournaments/queries.ts
Normal 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 }),
|
||||
}),
|
||||
};
|
||||
43
src/features/tournaments/server.ts
Normal file
43
src/features/tournaments/server.ts
Normal 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;
|
||||
});
|
||||
45
src/features/tournaments/types.ts
Normal file
45
src/features/tournaments/types.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user