Compare commits
3 Commits
75479be334
...
b7de2e7af3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7de2e7af3 | ||
|
|
e5f3bbe095 | ||
|
|
1eb621dd34 |
@@ -1,21 +1,28 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { tournamentQueries } from '@/features/tournaments/queries'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import EditTournament from '@/features/admin/components/edit-tournament'
|
||||
import Page from '@/components/page'
|
||||
import { Loader } from '@mantine/core'
|
||||
import { List } from '@mantine/core'
|
||||
import ListButton from '@/components/list-button'
|
||||
import { HardDrivesIcon, PencilLineIcon, UsersThreeIcon } from '@phosphor-icons/react'
|
||||
import { useSheet } from '@/hooks/use-sheet'
|
||||
import Sheet from '@/components/sheet/sheet'
|
||||
import TournamentForm from '@/features/tournaments/components/tournament-form'
|
||||
|
||||
export const Route = createFileRoute('/_authed/admin/tournaments/$id')({
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
const { queryClient } = context;
|
||||
await queryClient.ensureQueryData(tournamentQueries.details(params.id))
|
||||
const tournament = await queryClient.ensureQueryData(tournamentQueries.details(params.id))
|
||||
if (!tournament) throw redirect({ to: '/admin/tournaments' });
|
||||
return {
|
||||
tournament
|
||||
}
|
||||
},
|
||||
loader: () => ({
|
||||
loader: ({ context }) => ({
|
||||
header: {
|
||||
withBackButton: true,
|
||||
title: 'Edit Tournament',
|
||||
title: `Manage ${context.tournament.name}`,
|
||||
},
|
||||
withPadding: false
|
||||
}),
|
||||
component: RouteComponent,
|
||||
})
|
||||
@@ -25,9 +32,30 @@ function RouteComponent() {
|
||||
const { data: tournament } = useQuery(tournamentQueries.details(id))
|
||||
if (!tournament) throw new Error("Tournament not found.")
|
||||
|
||||
const { isOpen: editTournamentOpened, open: openEditTournament, close: closeEditTournament } = useSheet();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<EditTournament tournament={tournament} />
|
||||
</Page>
|
||||
<>
|
||||
<List>
|
||||
<ListButton label="Edit Tournament" Icon={HardDrivesIcon} onClick={openEditTournament} />
|
||||
<ListButton label="Edit Rules" Icon={PencilLineIcon} onClick={console.log} />
|
||||
<ListButton label="Edit Enrolled Teams" Icon={UsersThreeIcon} onClick={console.log} />
|
||||
</List>
|
||||
|
||||
<Sheet opened={editTournamentOpened} onChange={closeEditTournament}>
|
||||
<TournamentForm
|
||||
tournamentId={tournament.id}
|
||||
initialValues={{
|
||||
name: tournament.name,
|
||||
location: tournament.location,
|
||||
desc: tournament.desc,
|
||||
start_time: tournament.start_time,
|
||||
enroll_time: tournament.enroll_time,
|
||||
end_time: tournament.end_time,
|
||||
}}
|
||||
close={closeEditTournament}
|
||||
/>
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import { useSheet } from '@/hooks/use-sheet'
|
||||
import Sheet from '@/components/sheet/sheet'
|
||||
import CreateTournament from '@/features/admin/components/create-tournament'
|
||||
import TournamentForm from '@/features/tournaments/components/tournament-form'
|
||||
import { PlusIcon } from '@phosphor-icons/react'
|
||||
import Button from '@/components/button'
|
||||
|
||||
@@ -38,7 +38,7 @@ function RouteComponent() {
|
||||
<>
|
||||
<Button leftSection={<PlusIcon />} variant='subtle' onClick={sheet.open}>Create Tournament</Button>
|
||||
<Sheet {...sheet.props} title='Create Tournament'>
|
||||
<CreateTournament close={sheet.close} />
|
||||
<TournamentForm close={sheet.close} />
|
||||
</Sheet>
|
||||
</>
|
||||
) : null
|
||||
|
||||
@@ -5,7 +5,7 @@ import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||
|
||||
export const ServerRoute = createServerFileRoute("/api/events/$").middleware([superTokensRequestMiddleware]).methods({
|
||||
GET: ({ request, context }) => {
|
||||
logger.info('ServerEvents | New connection', (context as any)?.userAuthId);
|
||||
logger.info('ServerEvents | New connection', context?.userAuthId);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
@@ -41,7 +41,7 @@ export const ServerRoute = createServerFileRoute("/api/events/$").middleware([su
|
||||
serverEvents.off("test", handleEvent);
|
||||
clearInterval(pingInterval);
|
||||
try {
|
||||
logger.info('ServerEvents | Closing connection', (context as any)?.userAuthId);
|
||||
logger.info('ServerEvents | Closing connection', context?.userAuthId);
|
||||
controller.close();
|
||||
} catch (e) {
|
||||
logger.error('ServerEvents | Error closing controller', e);
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { TextInput, Flex, Box, Button } from "@mantine/core";
|
||||
import { CalendarIcon } from "@phosphor-icons/react";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
import { DateTimePicker } from "../features/admin/components/date-time-picker";
|
||||
|
||||
interface DateInputProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
withAsterisk?: boolean;
|
||||
error?: React.ReactNode;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
// Date input that opens a sheet with mantine date time picker when clicked
|
||||
export const DateInputSheet = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
withAsterisk,
|
||||
error,
|
||||
placeholder
|
||||
}: DateInputProps) => {
|
||||
const sheet = useSheet();
|
||||
|
||||
const formatDisplayValue = (dateString: string) => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true
|
||||
});
|
||||
};
|
||||
|
||||
const handleDateChange = (newValue: string | null) => {
|
||||
onChange(newValue || '');
|
||||
sheet.close();
|
||||
};
|
||||
|
||||
const dateValue = value ? new Date(value) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextInput
|
||||
label={label}
|
||||
value={formatDisplayValue(value)}
|
||||
onClick={sheet.open}
|
||||
readOnly
|
||||
withAsterisk={withAsterisk}
|
||||
error={error}
|
||||
placeholder={placeholder || "Click to select date"}
|
||||
rightSection={<CalendarIcon size={16} />}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
|
||||
<Sheet {...sheet.props} title={`Select ${label}`}>
|
||||
<Flex gap='xs' direction='column' justify='center' p="md">
|
||||
<Box mx='auto'>
|
||||
<DateTimePicker
|
||||
value={dateValue}
|
||||
onChange={handleDateChange}
|
||||
/>
|
||||
</Box>
|
||||
<Button onClick={sheet.close} variant="subtle">
|
||||
Close
|
||||
</Button>
|
||||
</Flex>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -4,17 +4,17 @@ import { CaretRightIcon, Icon } from "@phosphor-icons/react";
|
||||
interface ListButtonProps {
|
||||
label: string;
|
||||
Icon: Icon;
|
||||
handleClick: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ListButton = ({ label, handleClick, Icon }: ListButtonProps) => {
|
||||
const ListButton = ({ label, onClick, Icon }: ListButtonProps) => {
|
||||
return (
|
||||
<>
|
||||
<UnstyledButton
|
||||
w='100%'
|
||||
p='md'
|
||||
component={'button'}
|
||||
onClick={handleClick}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Group>
|
||||
<Icon weight='bold' size={20} />
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Link, useNavigate } from "@tanstack/react-router";
|
||||
interface ListLinkProps {
|
||||
label: string;
|
||||
to: string;
|
||||
Icon: Icon;
|
||||
Icon?: Icon;
|
||||
}
|
||||
|
||||
const ListLink = ({ label, to, Icon }: ListLinkProps) => {
|
||||
@@ -19,7 +19,7 @@ const ListLink = ({ label, to, Icon }: ListLinkProps) => {
|
||||
component={'button'}
|
||||
onClick={() => navigate({ to })}
|
||||
label={<Text fw={500} size='md'>{label}</Text>}
|
||||
leftSection={<Icon weight='bold' size={20} />}
|
||||
leftSection={Icon && <Icon weight='bold' size={20} />}
|
||||
rightSection={<CaretRightIcon size={20} />}
|
||||
/>
|
||||
<Divider />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FloatingIndicator, UnstyledButton, Box, Text } from "@mantine/core";
|
||||
import { Carousel } from "@mantine/carousel";
|
||||
import { useState, useEffect, ReactNode, useRef } from "react";
|
||||
import { useState, useEffect, ReactNode, useRef, useCallback, useMemo } from "react";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
|
||||
interface TabItem {
|
||||
@@ -19,22 +19,22 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
|
||||
const search = router.state.location.search as any;
|
||||
const [embla, setEmbla] = useState<any>(null);
|
||||
|
||||
const getActiveTabFromUrl = () => {
|
||||
const getActiveTabFromUrl = useCallback(() => {
|
||||
const urlTab = search?.tab;
|
||||
if (typeof urlTab === 'string') {
|
||||
const tabIndex = tabs.findIndex(tab => tab.label.toLowerCase() === urlTab.toLowerCase());
|
||||
return tabIndex !== -1 ? tabIndex : defaultTab;
|
||||
}
|
||||
return defaultTab;
|
||||
};
|
||||
}, [search?.tab, tabs, defaultTab]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getActiveTabFromUrl);
|
||||
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
|
||||
const [controlsRefs, setControlsRefs] = useState<Record<number, HTMLSpanElement | null>>({});
|
||||
const controlsRefs = useRef<Record<number, HTMLSpanElement | null>>({});
|
||||
const slideRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||
const [carouselHeight, setCarouselHeight] = useState<number | 'auto'>('auto');
|
||||
|
||||
const changeTab = (index: number) => {
|
||||
const changeTab = useCallback((index: number) => {
|
||||
if (index === activeTab || index < 0 || index >= tabs.length) return;
|
||||
|
||||
setActiveTab(index);
|
||||
@@ -47,19 +47,20 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
|
||||
url.searchParams.set('tab', tabLabel);
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
}
|
||||
};
|
||||
}, [activeTab, tabs, embla, onTabChange]);
|
||||
|
||||
const handleEmblaSelect = useCallback(() => {
|
||||
if (!embla) return;
|
||||
const newIndex = embla.selectedScrollSnap();
|
||||
changeTab(newIndex);
|
||||
}, [embla, changeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!embla) return;
|
||||
|
||||
const onSelect = () => {
|
||||
const newIndex = embla.selectedScrollSnap();
|
||||
changeTab(newIndex);
|
||||
};
|
||||
|
||||
embla.on("select", onSelect);
|
||||
return () => embla.off("select", onSelect);
|
||||
}, [embla, activeTab, tabs]);
|
||||
embla.on("select", handleEmblaSelect);
|
||||
return () => embla.off("select", handleEmblaSelect);
|
||||
}, [embla, handleEmblaSelect]);
|
||||
|
||||
useEffect(() => {
|
||||
const newActiveTab = getActiveTabFromUrl();
|
||||
@@ -77,14 +78,13 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const setControlRef = (index: number) => (node: HTMLSpanElement | null) => {
|
||||
controlsRefs[index] = node;
|
||||
setControlsRefs(controlsRefs);
|
||||
};
|
||||
const setControlRef = useCallback((index: number) => (node: HTMLSpanElement | null) => {
|
||||
controlsRefs.current[index] = node;
|
||||
}, []);
|
||||
|
||||
const setSlideRef = (index: number) => (node: HTMLDivElement | null) => {
|
||||
const setSlideRef = useCallback((index: number) => (node: HTMLDivElement | null) => {
|
||||
slideRefs.current[index] = node;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -100,7 +100,7 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
|
||||
}}
|
||||
>
|
||||
<FloatingIndicator
|
||||
target={controlsRefs[activeTab]}
|
||||
target={controlsRefs.current[activeTab]}
|
||||
parent={rootRef}
|
||||
styles={{
|
||||
root: {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createContext, PropsWithChildren, useCallback, useContext, useMemo } fr
|
||||
import { MantineColor, MantineColorScheme } from "@mantine/core";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchMe } from "@/features/players/server";
|
||||
import { Player } from "@/features/players/types";
|
||||
|
||||
const queryKey = ['auth'];
|
||||
export const authQueryConfig = {
|
||||
@@ -10,7 +11,7 @@ export const authQueryConfig = {
|
||||
}
|
||||
|
||||
interface AuthData {
|
||||
user: any;
|
||||
user: Player | undefined;
|
||||
metadata: { accentColor: MantineColor; colorScheme: MantineColorScheme };
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Title, List, Divider } from "@mantine/core";
|
||||
import ListLink from "@/components/list-link";
|
||||
import Page from "@/components/page";
|
||||
import { TrophyIcon, UsersFourIcon, UsersThreeIcon } from "@phosphor-icons/react";
|
||||
import { DatabaseIcon, TrophyIcon, UsersFourIcon, UsersThreeIcon } from "@phosphor-icons/react";
|
||||
import ListButton from "@/components/list-button";
|
||||
|
||||
const AdminPage = () => {
|
||||
console.log(process.env.VITE_POCKETBASE_URL!)
|
||||
return (
|
||||
<Page noPadding>
|
||||
<Title pl='sm' order={2} mb="md">Admin</Title>
|
||||
<List>
|
||||
<ListLink label="Manage Tournaments" Icon={TrophyIcon} to="/admin/tournaments" />
|
||||
<ListButton label="Open Pocketbase" Icon={DatabaseIcon} onClick={() => window.location.replace(import.meta.env.VITE_POCKETBASE_URL! + "/_/")} />
|
||||
</List>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import { Box, FileInput, Group, Stack, TextInput, Textarea, Title } from "@mantine/core";
|
||||
import { useForm, UseFormInput } from "@mantine/form";
|
||||
import { LinkIcon } from "@phosphor-icons/react";
|
||||
import { Tournament, TournamentFormInput } from "@/features/tournaments/types";
|
||||
import { DateInputSheet } from "../../../components/date-input";
|
||||
import { isNotEmpty } from "@mantine/form";
|
||||
import toast from '@/lib/sonner';
|
||||
import { logger } from "..";
|
||||
import { useQueryClient, useMutation } from "@tanstack/react-query";
|
||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||
import { updateTournament } from "@/features/tournaments/server";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import Button from "@/components/button";
|
||||
|
||||
interface EditTournamentProps {
|
||||
tournament: Tournament;
|
||||
}
|
||||
|
||||
const EditTournament = ({ tournament }: EditTournamentProps) => {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const config: UseFormInput<TournamentFormInput> = {
|
||||
initialValues: {
|
||||
name: tournament.name || '',
|
||||
location: tournament.location || '',
|
||||
desc: tournament.desc || '',
|
||||
start_time: tournament.start_time || '',
|
||||
enroll_time: tournament.enroll_time || '',
|
||||
end_time: tournament.end_time || '',
|
||||
rules: tournament.rules || '',
|
||||
logo: undefined, // Always start with no file selected
|
||||
},
|
||||
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: editTournament, isPending } = useMutation({
|
||||
mutationFn: (data: Partial<TournamentFormInput>) =>
|
||||
updateTournament({ data: { id: tournament.id, updates: data } }),
|
||||
onSuccess: (updatedTournament) => {
|
||||
if (updatedTournament) {
|
||||
queryClient.invalidateQueries({ queryKey: tournamentQueries.list().queryKey });
|
||||
queryClient.setQueryData(
|
||||
tournamentQueries.details(updatedTournament.id).queryKey,
|
||||
updatedTournament
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: TournamentFormInput) => {
|
||||
const { logo, ...tournamentData } = values;
|
||||
|
||||
editTournament(tournamentData, {
|
||||
onSuccess: async (updatedTournament) => {
|
||||
if (logo && updatedTournament) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('tournamentId', updatedTournament.id);
|
||||
formData.append('logo', logo);
|
||||
|
||||
const response = await fetch('/api/tournaments/upload-logo', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to upload logo');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
queryClient.setQueryData(
|
||||
tournamentQueries.details(result.tournament!.id).queryKey,
|
||||
result.tournament
|
||||
);
|
||||
|
||||
toast.success('Tournament updated successfully!');
|
||||
} catch (error: any) {
|
||||
toast.error(`Tournament updated but logo upload failed: ${error.message}`);
|
||||
logger.error('Tournament logo upload error', error);
|
||||
}
|
||||
} else {
|
||||
toast.success('Tournament updated successfully!');
|
||||
}
|
||||
|
||||
router.history.back();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Failed to update tournament: ${error.message}`);
|
||||
logger.error('Tournament update error', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box w="100%" maw={600} mx="auto" p="lg">
|
||||
<Stack gap="lg">
|
||||
<Title order={2}>Edit Tournament</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Name"
|
||||
withAsterisk
|
||||
key={form.key('name')}
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Location"
|
||||
withAsterisk
|
||||
key={form.key('location')}
|
||||
{...form.getInputProps('location')}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Description"
|
||||
key={form.key('desc')}
|
||||
{...form.getInputProps('desc')}
|
||||
minRows={3}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Rules"
|
||||
key={form.key('rules')}
|
||||
{...form.getInputProps('rules')}
|
||||
minRows={4}
|
||||
/>
|
||||
|
||||
<FileInput
|
||||
key={form.key('logo')}
|
||||
accept="image/png,image/jpeg,image/gif,image/jpg"
|
||||
label="Change Logo"
|
||||
leftSection={<LinkIcon size={16} />}
|
||||
{...form.getInputProps('logo')}
|
||||
/>
|
||||
|
||||
<DateInputSheet
|
||||
label="Start Date"
|
||||
withAsterisk
|
||||
value={form.values.start_time}
|
||||
onChange={(value) => form.setFieldValue('start_time', value)}
|
||||
error={form.errors.start_time}
|
||||
/>
|
||||
|
||||
<DateInputSheet
|
||||
label="Enrollment Due"
|
||||
withAsterisk
|
||||
value={form.values.enroll_time}
|
||||
onChange={(value) => form.setFieldValue('enroll_time', value)}
|
||||
error={form.errors.enroll_time}
|
||||
/>
|
||||
|
||||
<DateInputSheet
|
||||
label="End Date (Optional)"
|
||||
value={form.values.end_time || ''}
|
||||
onChange={(value) => form.setFieldValue('end_time', value)}
|
||||
error={form.errors.end_time}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="lg">
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => router.history.back()}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isPending}
|
||||
>
|
||||
Update Tournament
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditTournament;
|
||||
@@ -1,21 +1,14 @@
|
||||
import { List } from "@mantine/core";
|
||||
import Page from "@/components/page";
|
||||
import { TrophyIcon, UsersThreeIcon } from "@phosphor-icons/react";
|
||||
import ListButton from "@/components/list-button";
|
||||
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||
import ListLink from "@/components/list-link";
|
||||
|
||||
const ManageTournaments = () => {
|
||||
const { data: tournaments } = useSuspenseQuery(tournamentQueries.list());
|
||||
return (
|
||||
<List>
|
||||
<ListButton
|
||||
label="Edit Enrolled Teams"
|
||||
Icon={UsersThreeIcon}
|
||||
handleClick={console.log}
|
||||
/>
|
||||
{tournaments.map(t => (
|
||||
<ListButton label={t.name} Icon={TrophyIcon} handleClick={console.log} />
|
||||
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} />
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,4 @@ import { Logger } from "@/lib/logger";
|
||||
|
||||
export const logger = new Logger('Admin');
|
||||
|
||||
export { default as CreateTournament } from './components/create-tournament';
|
||||
export { default as EditTournament } from './components/edit-tournament';
|
||||
export { default as AdminPage } from './components/admin-page';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Flex } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { BracketMaps } from '../utils/bracket-maps';
|
||||
import { BracketRound } from './bracket-round';
|
||||
import { Match } from '../types';
|
||||
@@ -16,7 +16,7 @@ const BracketView: React.FC<BracketViewProps> = ({
|
||||
onAnnounce,
|
||||
}) => {
|
||||
|
||||
const getParentMatchOrder = (parentLid: number): number | string => {
|
||||
const getParentMatchOrder = useCallback((parentLid: number): number | string => {
|
||||
const parentMatch = bracketMaps.matchByLid.get(parentLid);
|
||||
if (
|
||||
parentMatch &&
|
||||
@@ -26,7 +26,7 @@ const BracketView: React.FC<BracketViewProps> = ({
|
||||
return parentMatch.order;
|
||||
}
|
||||
return `Match ${parentLid}`;
|
||||
};
|
||||
}, [bracketMaps]);
|
||||
|
||||
return (
|
||||
<Flex direction="row" gap={24} justify="left" pos="relative" p="xl">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ActionIcon, Card, Text } from '@mantine/core';
|
||||
import { PlayIcon } from '@phosphor-icons/react';
|
||||
import React from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { MatchSlot } from './match-slot';
|
||||
import { Match } from '../types';
|
||||
|
||||
@@ -15,6 +15,14 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
getParentMatchOrder,
|
||||
onAnnounce
|
||||
}) => {
|
||||
|
||||
const showAnnounce = useMemo(() =>
|
||||
onAnnounce && match.home.team && match.away.team,
|
||||
[onAnnounce, match.home.team, match.away.team]);
|
||||
|
||||
const handleAnnounce = useCallback(() =>
|
||||
onAnnounce?.(match.home.team, match.away.team), [match.home.team, match.away.team]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
@@ -44,16 +52,14 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{onAnnounce && match.home?.team && match.away?.team && (
|
||||
{showAnnounce && (
|
||||
<ActionIcon
|
||||
pos="absolute"
|
||||
variant="filled"
|
||||
color="green"
|
||||
top={-20}
|
||||
right={-12}
|
||||
onClick={() => {
|
||||
onAnnounce(match.home.team, match.away.team);
|
||||
}}
|
||||
onClick={handleAnnounce}
|
||||
bd="none"
|
||||
style={{ boxShadow: 'none' }}
|
||||
size="xs"
|
||||
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
import { useCallback } from "react";
|
||||
|
||||
interface PlayerListProps {
|
||||
players: Player[];
|
||||
@@ -11,6 +12,9 @@ interface PlayerListProps {
|
||||
const PlayerList = ({ players, loading = false }: PlayerListProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = useCallback((playerId: string) =>
|
||||
navigate({ to: `/profile/${playerId} `}), [navigate]);
|
||||
|
||||
if (loading) return <List>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<ListItem py='xs' key={`skeleton-${i}`}
|
||||
@@ -27,9 +31,7 @@ const PlayerList = ({ players, loading = false }: PlayerListProps) => {
|
||||
py='xs'
|
||||
icon={<Avatar size={40} name={`${player.first_name} ${player.last_name}`} />}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
navigate({ to: `/profile/${player.id}` });
|
||||
}}
|
||||
onClick={() => handleClick(player.id)}
|
||||
>
|
||||
<Text fw={500}>{`${player.first_name} ${player.last_name}`}</Text>
|
||||
</ListItem>
|
||||
|
||||
@@ -45,7 +45,7 @@ export const updatePlayer = createServerFn()
|
||||
.validator(playerUpdateSchema)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const userAuthId = (context as any).userAuthId;
|
||||
const userAuthId = context.userAuthId;
|
||||
if (!userAuthId) return;
|
||||
|
||||
try {
|
||||
@@ -78,7 +78,7 @@ export const createPlayer = createServerFn()
|
||||
.validator(playerInputSchema)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const userAuthId = (context as any).userAuthId;
|
||||
const userAuthId = context.userAuthId;
|
||||
if (!userAuthId) return;
|
||||
|
||||
try {
|
||||
@@ -106,7 +106,7 @@ export const associatePlayer = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const userAuthId = (context as any).userAuthId;
|
||||
const userAuthId = context.userAuthId;
|
||||
if (!userAuthId) return;
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Team } from "@/features/teams/types";
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface Player {
|
||||
id?: string;
|
||||
id: string;
|
||||
auth_id?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
|
||||
@@ -2,6 +2,22 @@ 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";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import React from "react";
|
||||
|
||||
interface TeamListItemProps { team: Team }
|
||||
const TeamListItem = React.memo(({ team }: TeamListItemProps) => {
|
||||
const playerNames = useMemo(() => team.players?.map(p => `${p.first_name} ${p.last_name}`) || [], [team.players]);
|
||||
|
||||
return <>
|
||||
<Stack gap={0}>
|
||||
<Text fw={500}>{`${team.name}`}</Text>
|
||||
{
|
||||
playerNames.map(name => <Text size='xs' c='dimmed'>{name}</Text>)
|
||||
}
|
||||
</Stack>
|
||||
</>
|
||||
})
|
||||
|
||||
interface TeamListProps {
|
||||
teams: Team[];
|
||||
@@ -11,6 +27,9 @@ interface TeamListProps {
|
||||
const TeamList = ({ teams, loading = false }: TeamListProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = useCallback((teamId: string) =>
|
||||
navigate({ to: `/teams/${teamId}` }), [navigate]);
|
||||
|
||||
if (loading) return <List>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<ListItem key={`skeleton-${i}`} py='xs' icon={<Skeleton height={40} width={40} />}
|
||||
@@ -26,13 +45,9 @@ const TeamList = ({ teams, loading = false }: TeamListProps) => {
|
||||
py='xs'
|
||||
icon={<Avatar radius='sm' size={40} name={`${team.name}`} />}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate({ to: `/teams/${team.id}` })}
|
||||
onClick={() => handleClick(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>
|
||||
|
||||
<TeamListItem team={team} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
@@ -3,23 +3,34 @@ 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";
|
||||
import useUpdateTournament from "../hooks/use-update-tournament";
|
||||
import toast from '@/lib/sonner';
|
||||
import { logger } from "..";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useCallback } from "react";
|
||||
|
||||
const CreateTournament = ({ close }: { close: () => void }) => {
|
||||
interface TournamentFormProps {
|
||||
close: () => void;
|
||||
initialValues?: Partial<TournamentFormInput>;
|
||||
tournamentId?: string;
|
||||
}
|
||||
|
||||
const TournamentForm = ({ close, initialValues, tournamentId }: TournamentFormProps) => {
|
||||
const isEditMode = !!tournamentId;
|
||||
|
||||
const config: UseFormInput<TournamentFormInput> = {
|
||||
initialValues: { // TODO : Remove fake initial values
|
||||
name: 'Test Tournament',
|
||||
location: 'Test Location',
|
||||
desc: 'Test Description',
|
||||
start_time: '2025-01-01T00:00:00Z',
|
||||
enroll_time: '2025-01-01T00:00:00Z',
|
||||
initialValues: {
|
||||
name: initialValues?.name || '',
|
||||
location: initialValues?.location || '',
|
||||
desc: initialValues?.desc || '',
|
||||
start_time: initialValues?.start_time || '',
|
||||
enroll_time: initialValues?.enroll_time || '',
|
||||
end_time: initialValues?.end_time || '',
|
||||
logo: undefined,
|
||||
},
|
||||
onSubmitPreventDefault: 'always',
|
||||
validate: {
|
||||
@@ -33,12 +44,19 @@ const CreateTournament = ({ close }: { close: () => void }) => {
|
||||
const form = useForm(config);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: createTournament, isPending } = useCreateTournament();
|
||||
const { mutate: createTournament, isPending: createPending } = useCreateTournament();
|
||||
const { mutate: updateTournament, isPending: updatePending } = useUpdateTournament(tournamentId || '');
|
||||
|
||||
const handleSubmit = async (values: TournamentFormInput) => {
|
||||
const isPending = createPending || updatePending;
|
||||
|
||||
const handleSubmit = useCallback(async (values: TournamentFormInput) => {
|
||||
const { logo, ...tournamentData } = values;
|
||||
|
||||
createTournament(tournamentData, {
|
||||
const mutation = isEditMode ? updateTournament : createTournament;
|
||||
const successMessage = isEditMode ? 'Tournament updated successfully!' : 'Tournament created successfully!';
|
||||
const errorMessage = isEditMode ? 'Failed to update tournament' : 'Failed to create tournament';
|
||||
|
||||
mutation(tournamentData, {
|
||||
onSuccess: async (tournament) => {
|
||||
if (logo && tournament) {
|
||||
try {
|
||||
@@ -59,27 +77,37 @@ const CreateTournament = ({ close }: { close: () => void }) => {
|
||||
const result = await response.json();
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: tournamentQueries.list().queryKey });
|
||||
queryClient.invalidateQueries({ queryKey: tournamentQueries.details(result.tournament!.id).queryKey });
|
||||
queryClient.setQueryData(
|
||||
tournamentQueries.details(result.tournament!.id).queryKey,
|
||||
result.tournament
|
||||
);
|
||||
|
||||
toast.success('Tournament created successfully!');
|
||||
toast.success(successMessage);
|
||||
} catch (error: any) {
|
||||
toast.error(`Tournament created but logo upload failed: ${error.message}`);
|
||||
const logoErrorMessage = isEditMode
|
||||
? `Tournament updated but logo upload failed: ${error.message}`
|
||||
: `Tournament created but logo upload failed: ${error.message}`;
|
||||
toast.error(logoErrorMessage);
|
||||
logger.error('Tournament logo upload error', error);
|
||||
}
|
||||
} else {
|
||||
toast.success(successMessage);
|
||||
}
|
||||
close();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`${errorMessage}: ${error.message}`);
|
||||
logger.error(`Tournament ${isEditMode ? 'update' : 'create'} error`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isEditMode, createTournament, updateTournament, queryClient]);
|
||||
|
||||
return (
|
||||
<SlidePanel
|
||||
onSubmit={form.onSubmit(handleSubmit)}
|
||||
onCancel={close}
|
||||
submitText="Create Tournament"
|
||||
submitText={isEditMode ? "Update Tournament" : "Create Tournament"}
|
||||
cancelText="Cancel"
|
||||
loading={isPending}
|
||||
>
|
||||
@@ -97,16 +125,17 @@ const CreateTournament = ({ close }: { close: () => void }) => {
|
||||
{...form.getInputProps('location')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Short Description"
|
||||
<Textarea
|
||||
label="Description"
|
||||
key={form.key('desc')}
|
||||
{...form.getInputProps('desc')}
|
||||
minRows={3}
|
||||
/>
|
||||
|
||||
<FileInput
|
||||
key={form.key('logo')}
|
||||
accept="image/png,image/jpeg,image/gif,image/jpg"
|
||||
label="Logo"
|
||||
label={isEditMode ? "Change Logo" : "Logo"}
|
||||
leftSection={<LinkIcon size={16} />}
|
||||
{...form.getInputProps('logo')}
|
||||
/>
|
||||
@@ -146,9 +175,28 @@ const CreateTournament = ({ close }: { close: () => void }) => {
|
||||
hour12: true
|
||||
})}
|
||||
/>
|
||||
|
||||
{isEditMode && (
|
||||
<SlidePanelField
|
||||
key={form.key('end_time')}
|
||||
{...form.getInputProps('end_time')}
|
||||
Component={DateTimePicker}
|
||||
title="Select End Date"
|
||||
label="End Date (Optional)"
|
||||
formatValue={(date) => date ? new Date(date).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true
|
||||
}) : 'Not set'}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</SlidePanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTournament;
|
||||
export default TournamentForm;
|
||||
@@ -2,6 +2,7 @@ import { List, ListItem, Skeleton, Text } from "@mantine/core";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { Tournament } from "../types";
|
||||
import { useCallback } from "react";
|
||||
|
||||
interface TournamentListProps {
|
||||
tournaments: Tournament[];
|
||||
@@ -11,6 +12,9 @@ interface TournamentListProps {
|
||||
const TournamentList = ({ tournaments, loading = false }: TournamentListProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = useCallback((tournamentId: string) =>
|
||||
navigate({ to: `/tournaments/${tournamentId}` }), [navigate]);
|
||||
|
||||
if (loading) return <List>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<ListItem py='xs' key={`skeleton-${i}`}
|
||||
@@ -27,9 +31,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
|
||||
py='xs'
|
||||
icon={<Avatar radius='xs' size={40} name={`${tournament.name}`} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
navigate({ to: `/tournaments/${tournament.id}` });
|
||||
}}
|
||||
onClick={() => handleClick(tournament.id)}
|
||||
>
|
||||
<Text fw={500}>{`${tournament.name}`}</Text>
|
||||
</ListItem>
|
||||
|
||||
12
src/features/tournaments/hooks/use-update-tournament.ts
Normal file
12
src/features/tournaments/hooks/use-update-tournament.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { updateTournament } from "@/features/tournaments/server";
|
||||
import { TournamentFormInput } from "@/features/tournaments/types";
|
||||
|
||||
const useUpdateTournament = (tournamentId: string) => {
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<TournamentFormInput>) =>
|
||||
updateTournament({ data: { id: tournamentId, updates: data } }),
|
||||
});
|
||||
};
|
||||
|
||||
export default useUpdateTournament;
|
||||
@@ -114,7 +114,7 @@ export const setUserMetadata = createServerFn({ method: 'POST' })
|
||||
}).partial())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const { userAuthId, metadata } = context as any;
|
||||
const { userAuthId, metadata } = context;
|
||||
if (!userAuthId) return;
|
||||
|
||||
await UserMetadata.updateUserMetadata(userAuthId, {
|
||||
@@ -135,7 +135,7 @@ export const updateUserColorScheme = createServerFn({ method: 'POST' })
|
||||
.validator((data: string) => data)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const { userAuthId, metadata } = context as any;
|
||||
const { userAuthId, metadata } = context;
|
||||
if (!userAuthId) return;
|
||||
|
||||
await UserMetadata.updateUserMetadata(userAuthId, {
|
||||
@@ -154,7 +154,7 @@ export const updateUserAccentColor = createServerFn({ method: 'POST' })
|
||||
.validator((data: string) => data)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const { userAuthId, metadata } = context as any;
|
||||
const { userAuthId, metadata } = context;
|
||||
if (!userAuthId) return;
|
||||
|
||||
await UserMetadata.updateUserMetadata(userAuthId, {
|
||||
|
||||
@@ -7,6 +7,6 @@ export const testEvent = createServerFn()
|
||||
.handler(async ({ context }) => {
|
||||
serverEvents.emit('test', {
|
||||
type: 'test',
|
||||
userId: (context as any).userAuthId,
|
||||
userId: context.userAuthId,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"strict": false,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
Reference in New Issue
Block a user