Compare commits

3 Commits

Author SHA1 Message Date
yohlo
b7de2e7af3 more optimizations 2025-08-27 11:04:04 -05:00
yohlo
e5f3bbe095 optimize swipeable tabs 2025-08-27 09:58:55 -05:00
yohlo
1eb621dd34 several changes 2025-08-27 09:29:42 -05:00
26 changed files with 210 additions and 374 deletions

View File

@@ -1,21 +1,28 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute, redirect } from '@tanstack/react-router'
import { tournamentQueries } from '@/features/tournaments/queries' import { tournamentQueries } from '@/features/tournaments/queries'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useAuth } from '@/contexts/auth-context' import { List } from '@mantine/core'
import EditTournament from '@/features/admin/components/edit-tournament' import ListButton from '@/components/list-button'
import Page from '@/components/page' import { HardDrivesIcon, PencilLineIcon, UsersThreeIcon } from '@phosphor-icons/react'
import { Loader } from '@mantine/core' 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')({ export const Route = createFileRoute('/_authed/admin/tournaments/$id')({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
const { queryClient } = context; 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: { header: {
withBackButton: true, withBackButton: true,
title: 'Edit Tournament', title: `Manage ${context.tournament.name}`,
}, },
withPadding: false
}), }),
component: RouteComponent, component: RouteComponent,
}) })
@@ -25,9 +32,30 @@ function RouteComponent() {
const { data: tournament } = useQuery(tournamentQueries.details(id)) const { data: tournament } = useQuery(tournamentQueries.details(id))
if (!tournament) throw new Error("Tournament not found.") if (!tournament) throw new Error("Tournament not found.")
const { isOpen: editTournamentOpened, open: openEditTournament, close: closeEditTournament } = useSheet();
return ( return (
<Page> <>
<EditTournament tournament={tournament} /> <List>
</Page> <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>
</>
) )
} }

View File

@@ -7,7 +7,7 @@ import { useQuery } from '@tanstack/react-query'
import { useAuth } from '@/contexts/auth-context' import { useAuth } from '@/contexts/auth-context'
import { useSheet } from '@/hooks/use-sheet' import { useSheet } from '@/hooks/use-sheet'
import Sheet from '@/components/sheet/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 { PlusIcon } from '@phosphor-icons/react'
import Button from '@/components/button' import Button from '@/components/button'
@@ -38,7 +38,7 @@ function RouteComponent() {
<> <>
<Button leftSection={<PlusIcon />} variant='subtle' onClick={sheet.open}>Create Tournament</Button> <Button leftSection={<PlusIcon />} variant='subtle' onClick={sheet.open}>Create Tournament</Button>
<Sheet {...sheet.props} title='Create Tournament'> <Sheet {...sheet.props} title='Create Tournament'>
<CreateTournament close={sheet.close} /> <TournamentForm close={sheet.close} />
</Sheet> </Sheet>
</> </>
) : null ) : null

View File

@@ -5,7 +5,7 @@ import { superTokensRequestMiddleware } from "@/utils/supertokens";
export const ServerRoute = createServerFileRoute("/api/events/$").middleware([superTokensRequestMiddleware]).methods({ export const ServerRoute = createServerFileRoute("/api/events/$").middleware([superTokensRequestMiddleware]).methods({
GET: ({ request, context }) => { GET: ({ request, context }) => {
logger.info('ServerEvents | New connection', (context as any)?.userAuthId); logger.info('ServerEvents | New connection', context?.userAuthId);
const stream = new ReadableStream({ const stream = new ReadableStream({
start(controller) { start(controller) {
@@ -41,7 +41,7 @@ export const ServerRoute = createServerFileRoute("/api/events/$").middleware([su
serverEvents.off("test", handleEvent); serverEvents.off("test", handleEvent);
clearInterval(pingInterval); clearInterval(pingInterval);
try { try {
logger.info('ServerEvents | Closing connection', (context as any)?.userAuthId); logger.info('ServerEvents | Closing connection', context?.userAuthId);
controller.close(); controller.close();
} catch (e) { } catch (e) {
logger.error('ServerEvents | Error closing controller', e); logger.error('ServerEvents | Error closing controller', e);

View File

@@ -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>
</>
);
};

View File

@@ -4,17 +4,17 @@ import { CaretRightIcon, Icon } from "@phosphor-icons/react";
interface ListButtonProps { interface ListButtonProps {
label: string; label: string;
Icon: Icon; Icon: Icon;
handleClick: () => void; onClick: () => void;
} }
const ListButton = ({ label, handleClick, Icon }: ListButtonProps) => { const ListButton = ({ label, onClick, Icon }: ListButtonProps) => {
return ( return (
<> <>
<UnstyledButton <UnstyledButton
w='100%' w='100%'
p='md' p='md'
component={'button'} component={'button'}
onClick={handleClick} onClick={onClick}
> >
<Group> <Group>
<Icon weight='bold' size={20} /> <Icon weight='bold' size={20} />

View File

@@ -5,7 +5,7 @@ import { Link, useNavigate } from "@tanstack/react-router";
interface ListLinkProps { interface ListLinkProps {
label: string; label: string;
to: string; to: string;
Icon: Icon; Icon?: Icon;
} }
const ListLink = ({ label, to, Icon }: ListLinkProps) => { const ListLink = ({ label, to, Icon }: ListLinkProps) => {
@@ -19,7 +19,7 @@ const ListLink = ({ label, to, Icon }: ListLinkProps) => {
component={'button'} component={'button'}
onClick={() => navigate({ to })} onClick={() => navigate({ to })}
label={<Text fw={500} size='md'>{label}</Text>} 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} />} rightSection={<CaretRightIcon size={20} />}
/> />
<Divider /> <Divider />

View File

@@ -1,6 +1,6 @@
import { FloatingIndicator, UnstyledButton, Box, Text } from "@mantine/core"; import { FloatingIndicator, UnstyledButton, Box, Text } from "@mantine/core";
import { Carousel } from "@mantine/carousel"; 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"; import { useRouter } from "@tanstack/react-router";
interface TabItem { interface TabItem {
@@ -19,22 +19,22 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
const search = router.state.location.search as any; const search = router.state.location.search as any;
const [embla, setEmbla] = useState<any>(null); const [embla, setEmbla] = useState<any>(null);
const getActiveTabFromUrl = () => { const getActiveTabFromUrl = useCallback(() => {
const urlTab = search?.tab; const urlTab = search?.tab;
if (typeof urlTab === 'string') { if (typeof urlTab === 'string') {
const tabIndex = tabs.findIndex(tab => tab.label.toLowerCase() === urlTab.toLowerCase()); const tabIndex = tabs.findIndex(tab => tab.label.toLowerCase() === urlTab.toLowerCase());
return tabIndex !== -1 ? tabIndex : defaultTab; return tabIndex !== -1 ? tabIndex : defaultTab;
} }
return defaultTab; return defaultTab;
}; }, [search?.tab, tabs, defaultTab]);
const [activeTab, setActiveTab] = useState(getActiveTabFromUrl); const [activeTab, setActiveTab] = useState(getActiveTabFromUrl);
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null); 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 slideRefs = useRef<Record<number, HTMLDivElement | null>>({});
const [carouselHeight, setCarouselHeight] = useState<number | 'auto'>('auto'); 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; if (index === activeTab || index < 0 || index >= tabs.length) return;
setActiveTab(index); setActiveTab(index);
@@ -47,19 +47,20 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
url.searchParams.set('tab', tabLabel); url.searchParams.set('tab', tabLabel);
window.history.replaceState(null, '', url.toString()); 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(() => { useEffect(() => {
if (!embla) return; if (!embla) return;
const onSelect = () => { embla.on("select", handleEmblaSelect);
const newIndex = embla.selectedScrollSnap(); return () => embla.off("select", handleEmblaSelect);
changeTab(newIndex); }, [embla, handleEmblaSelect]);
};
embla.on("select", onSelect);
return () => embla.off("select", onSelect);
}, [embla, activeTab, tabs]);
useEffect(() => { useEffect(() => {
const newActiveTab = getActiveTabFromUrl(); const newActiveTab = getActiveTabFromUrl();
@@ -77,14 +78,13 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
} }
}, [activeTab]); }, [activeTab]);
const setControlRef = (index: number) => (node: HTMLSpanElement | null) => { const setControlRef = useCallback((index: number) => (node: HTMLSpanElement | null) => {
controlsRefs[index] = node; controlsRefs.current[index] = node;
setControlsRefs(controlsRefs); }, []);
};
const setSlideRef = (index: number) => (node: HTMLDivElement | null) => { const setSlideRef = useCallback((index: number) => (node: HTMLDivElement | null) => {
slideRefs.current[index] = node; slideRefs.current[index] = node;
}; }, []);
return ( return (
<Box> <Box>
@@ -100,7 +100,7 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
}} }}
> >
<FloatingIndicator <FloatingIndicator
target={controlsRefs[activeTab]} target={controlsRefs.current[activeTab]}
parent={rootRef} parent={rootRef}
styles={{ styles={{
root: { root: {

View File

@@ -2,6 +2,7 @@ import { createContext, PropsWithChildren, useCallback, useContext, useMemo } fr
import { MantineColor, MantineColorScheme } from "@mantine/core"; import { MantineColor, MantineColorScheme } from "@mantine/core";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchMe } from "@/features/players/server"; import { fetchMe } from "@/features/players/server";
import { Player } from "@/features/players/types";
const queryKey = ['auth']; const queryKey = ['auth'];
export const authQueryConfig = { export const authQueryConfig = {
@@ -10,7 +11,7 @@ export const authQueryConfig = {
} }
interface AuthData { interface AuthData {
user: any; user: Player | undefined;
metadata: { accentColor: MantineColor; colorScheme: MantineColorScheme }; metadata: { accentColor: MantineColor; colorScheme: MantineColorScheme };
roles: string[]; roles: string[];
} }

View File

@@ -1,15 +1,17 @@
import { Title, List, Divider } from "@mantine/core"; import { Title, List, Divider } from "@mantine/core";
import ListLink from "@/components/list-link"; import ListLink from "@/components/list-link";
import Page from "@/components/page"; 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"; import ListButton from "@/components/list-button";
const AdminPage = () => { const AdminPage = () => {
console.log(process.env.VITE_POCKETBASE_URL!)
return ( return (
<Page noPadding> <Page noPadding>
<Title pl='sm' order={2} mb="md">Admin</Title> <Title pl='sm' order={2} mb="md">Admin</Title>
<List> <List>
<ListLink label="Manage Tournaments" Icon={TrophyIcon} to="/admin/tournaments" /> <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> </List>
</Page> </Page>
); );

View File

@@ -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;

View File

@@ -1,21 +1,14 @@
import { List } from "@mantine/core"; import { List } from "@mantine/core";
import Page from "@/components/page"; import { useSuspenseQuery } from "@tanstack/react-query";
import { TrophyIcon, UsersThreeIcon } from "@phosphor-icons/react";
import ListButton from "@/components/list-button";
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { tournamentQueries } from "@/features/tournaments/queries"; import { tournamentQueries } from "@/features/tournaments/queries";
import ListLink from "@/components/list-link";
const ManageTournaments = () => { const ManageTournaments = () => {
const { data: tournaments } = useSuspenseQuery(tournamentQueries.list()); const { data: tournaments } = useSuspenseQuery(tournamentQueries.list());
return ( return (
<List> <List>
<ListButton
label="Edit Enrolled Teams"
Icon={UsersThreeIcon}
handleClick={console.log}
/>
{tournaments.map(t => ( {tournaments.map(t => (
<ListButton label={t.name} Icon={TrophyIcon} handleClick={console.log} /> <ListLink label={t.name} to={`/admin/tournaments/${t.id}`} />
))} ))}
</List> </List>
); );

View File

@@ -2,6 +2,4 @@ import { Logger } from "@/lib/logger";
export const logger = new Logger('Admin'); 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'; export { default as AdminPage } from './components/admin-page';

View File

@@ -1,5 +1,5 @@
import { Flex } from '@mantine/core'; import { Flex } from '@mantine/core';
import React from 'react'; import React, { useCallback } from 'react';
import { BracketMaps } from '../utils/bracket-maps'; import { BracketMaps } from '../utils/bracket-maps';
import { BracketRound } from './bracket-round'; import { BracketRound } from './bracket-round';
import { Match } from '../types'; import { Match } from '../types';
@@ -16,7 +16,7 @@ const BracketView: React.FC<BracketViewProps> = ({
onAnnounce, onAnnounce,
}) => { }) => {
const getParentMatchOrder = (parentLid: number): number | string => { const getParentMatchOrder = useCallback((parentLid: number): number | string => {
const parentMatch = bracketMaps.matchByLid.get(parentLid); const parentMatch = bracketMaps.matchByLid.get(parentLid);
if ( if (
parentMatch && parentMatch &&
@@ -26,7 +26,7 @@ const BracketView: React.FC<BracketViewProps> = ({
return parentMatch.order; return parentMatch.order;
} }
return `Match ${parentLid}`; return `Match ${parentLid}`;
}; }, [bracketMaps]);
return ( return (
<Flex direction="row" gap={24} justify="left" pos="relative" p="xl"> <Flex direction="row" gap={24} justify="left" pos="relative" p="xl">

View File

@@ -1,6 +1,6 @@
import { ActionIcon, Card, Text } from '@mantine/core'; import { ActionIcon, Card, Text } from '@mantine/core';
import { PlayIcon } from '@phosphor-icons/react'; import { PlayIcon } from '@phosphor-icons/react';
import React from 'react'; import React, { useCallback, useMemo } from 'react';
import { MatchSlot } from './match-slot'; import { MatchSlot } from './match-slot';
import { Match } from '../types'; import { Match } from '../types';
@@ -15,6 +15,14 @@ export const MatchCard: React.FC<MatchCardProps> = ({
getParentMatchOrder, getParentMatchOrder,
onAnnounce 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 ( return (
<Card <Card
withBorder withBorder
@@ -44,16 +52,14 @@ export const MatchCard: React.FC<MatchCardProps> = ({
</Text> </Text>
)} )}
{onAnnounce && match.home?.team && match.away?.team && ( {showAnnounce && (
<ActionIcon <ActionIcon
pos="absolute" pos="absolute"
variant="filled" variant="filled"
color="green" color="green"
top={-20} top={-20}
right={-12} right={-12}
onClick={() => { onClick={handleAnnounce}
onAnnounce(match.home.team, match.away.team);
}}
bd="none" bd="none"
style={{ boxShadow: 'none' }} style={{ boxShadow: 'none' }}
size="xs" size="xs"

View File

@@ -2,6 +2,7 @@ import { List, ListItem, Skeleton, Text } from "@mantine/core";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { Player } from "@/features/players/types"; import { Player } from "@/features/players/types";
import { useCallback } from "react";
interface PlayerListProps { interface PlayerListProps {
players: Player[]; players: Player[];
@@ -10,6 +11,9 @@ interface PlayerListProps {
const PlayerList = ({ players, loading = false }: PlayerListProps) => { const PlayerList = ({ players, loading = false }: PlayerListProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const handleClick = useCallback((playerId: string) =>
navigate({ to: `/profile/${playerId} `}), [navigate]);
if (loading) return <List> if (loading) return <List>
{Array.from({ length: 10 }).map((_, i) => ( {Array.from({ length: 10 }).map((_, i) => (
@@ -27,9 +31,7 @@ const PlayerList = ({ players, loading = false }: PlayerListProps) => {
py='xs' py='xs'
icon={<Avatar size={40} name={`${player.first_name} ${player.last_name}`} />} icon={<Avatar size={40} name={`${player.first_name} ${player.last_name}`} />}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => { onClick={() => handleClick(player.id)}
navigate({ to: `/profile/${player.id}` });
}}
> >
<Text fw={500}>{`${player.first_name} ${player.last_name}`}</Text> <Text fw={500}>{`${player.first_name} ${player.last_name}`}</Text>
</ListItem> </ListItem>

View File

@@ -45,7 +45,7 @@ export const updatePlayer = createServerFn()
.validator(playerUpdateSchema) .validator(playerUpdateSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => { .handler(async ({ context, data }) => {
const userAuthId = (context as any).userAuthId; const userAuthId = context.userAuthId;
if (!userAuthId) return; if (!userAuthId) return;
try { try {
@@ -78,7 +78,7 @@ export const createPlayer = createServerFn()
.validator(playerInputSchema) .validator(playerInputSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => { .handler(async ({ context, data }) => {
const userAuthId = (context as any).userAuthId; const userAuthId = context.userAuthId;
if (!userAuthId) return; if (!userAuthId) return;
try { try {
@@ -106,7 +106,7 @@ export const associatePlayer = createServerFn()
.validator(z.string()) .validator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => { .handler(async ({ context, data }) => {
const userAuthId = (context as any).userAuthId; const userAuthId = context.userAuthId;
if (!userAuthId) return; if (!userAuthId) return;
try { try {

View File

@@ -2,7 +2,7 @@ import { Team } from "@/features/teams/types";
import { z } from 'zod'; import { z } from 'zod';
export interface Player { export interface Player {
id?: string; id: string;
auth_id?: string; auth_id?: string;
first_name?: string; first_name?: string;
last_name?: string; last_name?: string;

View File

@@ -2,6 +2,22 @@ import { Group, List, ListItem, Skeleton, Stack, Text } from "@mantine/core";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { Team } from "@/features/teams/types"; import { Team } from "@/features/teams/types";
import { useNavigate } from "@tanstack/react-router"; 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 { interface TeamListProps {
teams: Team[]; teams: Team[];
@@ -11,6 +27,9 @@ interface TeamListProps {
const TeamList = ({ teams, loading = false }: TeamListProps) => { const TeamList = ({ teams, loading = false }: TeamListProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const handleClick = useCallback((teamId: string) =>
navigate({ to: `/teams/${teamId}` }), [navigate]);
if (loading) return <List> if (loading) return <List>
{Array.from({ length: 10 }).map((_, i) => ( {Array.from({ length: 10 }).map((_, i) => (
<ListItem key={`skeleton-${i}`} py='xs' icon={<Skeleton height={40} width={40} />} <ListItem key={`skeleton-${i}`} py='xs' icon={<Skeleton height={40} width={40} />}
@@ -26,13 +45,9 @@ const TeamList = ({ teams, loading = false }: TeamListProps) => {
py='xs' py='xs'
icon={<Avatar radius='sm' size={40} name={`${team.name}`} />} icon={<Avatar radius='sm' size={40} name={`${team.name}`} />}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => navigate({ to: `/teams/${team.id}` })} onClick={() => handleClick(team.id)}
> >
<Stack gap={0}> <TeamListItem team={team} />
<Text fw={500}>{`${team.name}`}</Text>
{team.players?.map(p => <Text size='xs' c='dimmed'>{p.first_name} {p.last_name}</Text>)}
</Stack>
</ListItem> </ListItem>
))} ))}
</List> </List>

View File

@@ -3,23 +3,34 @@ import { useForm, UseFormInput } from "@mantine/form";
import { LinkIcon } from "@phosphor-icons/react"; import { LinkIcon } from "@phosphor-icons/react";
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel"; import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
import { TournamentFormInput } from "@/features/tournaments/types"; import { TournamentFormInput } from "@/features/tournaments/types";
import { DateTimePicker } from "./date-time-picker";
import { isNotEmpty } from "@mantine/form"; import { isNotEmpty } from "@mantine/form";
import useCreateTournament from "../hooks/use-create-tournament"; import useCreateTournament from "../hooks/use-create-tournament";
import useUpdateTournament from "../hooks/use-update-tournament";
import toast from '@/lib/sonner'; import toast from '@/lib/sonner';
import { logger } from ".."; import { logger } from "..";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { tournamentQueries } from "@/features/tournaments/queries"; 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> = { const config: UseFormInput<TournamentFormInput> = {
initialValues: { // TODO : Remove fake initial values initialValues: {
name: 'Test Tournament', name: initialValues?.name || '',
location: 'Test Location', location: initialValues?.location || '',
desc: 'Test Description', desc: initialValues?.desc || '',
start_time: '2025-01-01T00:00:00Z', start_time: initialValues?.start_time || '',
enroll_time: '2025-01-01T00:00:00Z', enroll_time: initialValues?.enroll_time || '',
end_time: initialValues?.end_time || '',
logo: undefined,
}, },
onSubmitPreventDefault: 'always', onSubmitPreventDefault: 'always',
validate: { validate: {
@@ -33,12 +44,19 @@ const CreateTournament = ({ close }: { close: () => void }) => {
const form = useForm(config); const form = useForm(config);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { mutate: createTournament, isPending } = useCreateTournament(); const { mutate: createTournament, isPending: createPending } = useCreateTournament();
const { mutate: updateTournament, isPending: updatePending } = useUpdateTournament(tournamentId || '');
const isPending = createPending || updatePending;
const handleSubmit = async (values: TournamentFormInput) => { const handleSubmit = useCallback(async (values: TournamentFormInput) => {
const { logo, ...tournamentData } = values; 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) => { onSuccess: async (tournament) => {
if (logo && tournament) { if (logo && tournament) {
try { try {
@@ -59,27 +77,37 @@ const CreateTournament = ({ close }: { close: () => void }) => {
const result = await response.json(); const result = await response.json();
queryClient.invalidateQueries({ queryKey: tournamentQueries.list().queryKey }); queryClient.invalidateQueries({ queryKey: tournamentQueries.list().queryKey });
queryClient.invalidateQueries({ queryKey: tournamentQueries.details(result.tournament!.id).queryKey });
queryClient.setQueryData( queryClient.setQueryData(
tournamentQueries.details(result.tournament!.id).queryKey, tournamentQueries.details(result.tournament!.id).queryKey,
result.tournament result.tournament
); );
toast.success('Tournament created successfully!'); toast.success(successMessage);
} catch (error: any) { } 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); logger.error('Tournament logo upload error', error);
} }
} else {
toast.success(successMessage);
} }
close(); close();
},
onError: (error: any) => {
toast.error(`${errorMessage}: ${error.message}`);
logger.error(`Tournament ${isEditMode ? 'update' : 'create'} error`, error);
} }
}); });
} }, [isEditMode, createTournament, updateTournament, queryClient]);
return ( return (
<SlidePanel <SlidePanel
onSubmit={form.onSubmit(handleSubmit)} onSubmit={form.onSubmit(handleSubmit)}
onCancel={close} onCancel={close}
submitText="Create Tournament" submitText={isEditMode ? "Update Tournament" : "Create Tournament"}
cancelText="Cancel" cancelText="Cancel"
loading={isPending} loading={isPending}
> >
@@ -97,16 +125,17 @@ const CreateTournament = ({ close }: { close: () => void }) => {
{...form.getInputProps('location')} {...form.getInputProps('location')}
/> />
<TextInput <Textarea
label="Short Description" label="Description"
key={form.key('desc')} key={form.key('desc')}
{...form.getInputProps('desc')} {...form.getInputProps('desc')}
minRows={3}
/> />
<FileInput <FileInput
key={form.key('logo')} key={form.key('logo')}
accept="image/png,image/jpeg,image/gif,image/jpg" accept="image/png,image/jpeg,image/gif,image/jpg"
label="Logo" label={isEditMode ? "Change Logo" : "Logo"}
leftSection={<LinkIcon size={16} />} leftSection={<LinkIcon size={16} />}
{...form.getInputProps('logo')} {...form.getInputProps('logo')}
/> />
@@ -146,9 +175,28 @@ const CreateTournament = ({ close }: { close: () => void }) => {
hour12: true 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> </Stack>
</SlidePanel> </SlidePanel>
); );
}; };
export default CreateTournament; export default TournamentForm;

View File

@@ -2,6 +2,7 @@ import { List, ListItem, Skeleton, Text } from "@mantine/core";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { Tournament } from "../types"; import { Tournament } from "../types";
import { useCallback } from "react";
interface TournamentListProps { interface TournamentListProps {
tournaments: Tournament[]; tournaments: Tournament[];
@@ -10,6 +11,9 @@ interface TournamentListProps {
const TournamentList = ({ tournaments, loading = false }: TournamentListProps) => { const TournamentList = ({ tournaments, loading = false }: TournamentListProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const handleClick = useCallback((tournamentId: string) =>
navigate({ to: `/tournaments/${tournamentId}` }), [navigate]);
if (loading) return <List> if (loading) return <List>
{Array.from({ length: 10 }).map((_, i) => ( {Array.from({ length: 10 }).map((_, i) => (
@@ -27,9 +31,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
py='xs' py='xs'
icon={<Avatar radius='xs' size={40} name={`${tournament.name}`} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />} icon={<Avatar radius='xs' size={40} name={`${tournament.name}`} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => { onClick={() => handleClick(tournament.id)}
navigate({ to: `/tournaments/${tournament.id}` });
}}
> >
<Text fw={500}>{`${tournament.name}`}</Text> <Text fw={500}>{`${tournament.name}`}</Text>
</ListItem> </ListItem>

View 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;

View File

@@ -114,7 +114,7 @@ export const setUserMetadata = createServerFn({ method: 'POST' })
}).partial()) }).partial())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => { .handler(async ({ context, data }) => {
const { userAuthId, metadata } = context as any; const { userAuthId, metadata } = context;
if (!userAuthId) return; if (!userAuthId) return;
await UserMetadata.updateUserMetadata(userAuthId, { await UserMetadata.updateUserMetadata(userAuthId, {
@@ -135,7 +135,7 @@ export const updateUserColorScheme = createServerFn({ method: 'POST' })
.validator((data: string) => data) .validator((data: string) => data)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => { .handler(async ({ context, data }) => {
const { userAuthId, metadata } = context as any; const { userAuthId, metadata } = context;
if (!userAuthId) return; if (!userAuthId) return;
await UserMetadata.updateUserMetadata(userAuthId, { await UserMetadata.updateUserMetadata(userAuthId, {
@@ -154,7 +154,7 @@ export const updateUserAccentColor = createServerFn({ method: 'POST' })
.validator((data: string) => data) .validator((data: string) => data)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => { .handler(async ({ context, data }) => {
const { userAuthId, metadata } = context as any; const { userAuthId, metadata } = context;
if (!userAuthId) return; if (!userAuthId) return;
await UserMetadata.updateUserMetadata(userAuthId, { await UserMetadata.updateUserMetadata(userAuthId, {

View File

@@ -7,6 +7,6 @@ export const testEvent = createServerFn()
.handler(async ({ context }) => { .handler(async ({ context }) => {
serverEvents.emit('test', { serverEvents.emit('test', {
type: 'test', type: 'test',
userId: (context as any).userAuthId, userId: context.userAuthId,
}); });
}); });

View File

@@ -1,7 +1,7 @@
{ {
"include": ["**/*.ts", "**/*.tsx"], "include": ["**/*.ts", "**/*.tsx"],
"compilerOptions": { "compilerOptions": {
"strict": false, "strict": true,
"strictNullChecks": true, "strictNullChecks": true,
"esModuleInterop": true, "esModuleInterop": true,
"jsx": "react-jsx", "jsx": "react-jsx",