From 7441d1ac5800718da1f30b558073220cc547adee Mon Sep 17 00:00:00 2001 From: yohlo Date: Tue, 23 Sep 2025 14:48:04 -0500 Subject: [PATCH] skeletons, tournament stats, polish, bug fixes --- docker-compose.yml | 22 +- .../_authed/admin/tournaments/$id/teams.tsx | 2 +- src/app/routes/_authed/index.tsx | 1 - src/app/routes/_authed/teams.$teamId.tsx | 6 +- .../_authed/tournaments/$tournamentId.tsx | 10 +- src/app/routes/_authed/tournaments/index.tsx | 46 +-- src/components/DefaultCatchBoundary.tsx | 191 +++++------ src/components/stats-overview.tsx | 10 +- src/components/swipeable-tabs.tsx | 7 +- src/features/core/components/layout.tsx | 8 +- src/features/core/components/navbar.tsx | 2 +- src/features/core/components/pullable.tsx | 19 ++ src/features/login/components/error.tsx | 23 -- .../matches/components/match-card.tsx | 75 +++-- .../matches/components/match-list.tsx | 21 +- .../components/profile/header-skeleton.tsx | 2 +- .../players/components/profile/header.tsx | 2 +- .../components/team-form/duration-picker.tsx | 303 ++++++++++-------- .../components/team-form/song-picker.tsx | 9 +- .../components/team-form/song-search.tsx | 171 ++++++---- .../team-profile/header-skeleton.tsx | 14 + .../teams/components/team-profile/header.tsx | 2 +- .../components/team-profile/skeleton.tsx | 37 +++ .../components/manage-tournament.tsx | 4 +- .../components/profile/header-skeleton.tsx | 14 + .../tournaments/components/profile/header.tsx | 2 +- .../tournaments/components/profile/index.tsx | 12 +- .../components/profile/skeleton.tsx | 37 +++ .../components/started-tournament/index.tsx | 2 +- .../components/tournament-card-list.tsx | 37 +++ .../components/tournament-card.tsx | 2 +- .../components/tournament-stats.tsx | 285 ++++++++++++++++ .../enrolled-free-agent.tsx | 26 +- .../components/upcoming-tournament/header.tsx | 2 +- .../components/upcoming-tournament/index.tsx | 4 +- .../upcoming-tournament/skeleton.tsx | 37 +-- 36 files changed, 990 insertions(+), 457 deletions(-) delete mode 100644 src/features/login/components/error.tsx create mode 100644 src/features/teams/components/team-profile/header-skeleton.tsx create mode 100644 src/features/teams/components/team-profile/skeleton.tsx create mode 100644 src/features/tournaments/components/profile/header-skeleton.tsx create mode 100644 src/features/tournaments/components/profile/skeleton.tsx create mode 100644 src/features/tournaments/components/tournament-card-list.tsx create mode 100644 src/features/tournaments/components/tournament-stats.tsx diff --git a/docker-compose.yml b/docker-compose.yml index 7b7ab84..c898ee5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,17 +32,17 @@ services: - app-network restart: unless-stopped - redis: - image: redis:7-alpine - container_name: redis-cache - ports: - - "6379:6379" - command: redis-server --appendonly yes - volumes: - - redis-data:/data - networks: - - app-network - restart: unless-stopped + #redis: + # image: redis:7-alpine + # container_name: redis-cache + # ports: + # - "6379:6379" + # command: redis-server --appendonly yes + # volumes: + # - redis-data:/data + # networks: + # - app-network + # restart: unless-stopped supertokens: image: registry.supertokens.io/supertokens/supertokens-postgresql diff --git a/src/app/routes/_authed/admin/tournaments/$id/teams.tsx b/src/app/routes/_authed/admin/tournaments/$id/teams.tsx index ed034e1..f1b98ad 100644 --- a/src/app/routes/_authed/admin/tournaments/$id/teams.tsx +++ b/src/app/routes/_authed/admin/tournaments/$id/teams.tsx @@ -18,7 +18,7 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({ loader: ({ context }) => ({ header: { withBackButton: true, - title: `Manage Teams - ${context.tournament.name}`, + title: `${context.tournament.name} Teams`, }, withPadding: false, }), diff --git a/src/app/routes/_authed/index.tsx b/src/app/routes/_authed/index.tsx index debcd79..1faae50 100644 --- a/src/app/routes/_authed/index.tsx +++ b/src/app/routes/_authed/index.tsx @@ -27,7 +27,6 @@ export const Route = createFileRoute("/_authed/")({ function Home() { const { data: tournament } = useCurrentTournament(); - if (!tournament.matches || tournament.matches.length === 0) { return ; } diff --git a/src/app/routes/_authed/teams.$teamId.tsx b/src/app/routes/_authed/teams.$teamId.tsx index f758276..5751be8 100644 --- a/src/app/routes/_authed/teams.$teamId.tsx +++ b/src/app/routes/_authed/teams.$teamId.tsx @@ -1,7 +1,9 @@ import TeamProfile from "@/features/teams/components/team-profile"; +import ProfileSkeleton from "@/features/teams/components/team-profile/skeleton"; import { teamKeys, teamQueries } from "@/features/teams/queries"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { createFileRoute } from "@tanstack/react-router"; +import { Suspense } from "react"; import { z } from "zod"; const searchSchema = z.object({ @@ -24,6 +26,8 @@ export const Route = createFileRoute("/_authed/teams/$teamId")({ }), component: () => { const { teamId } = Route.useParams(); - return ; + return }> + + ; }, }); diff --git a/src/app/routes/_authed/tournaments/$tournamentId.tsx b/src/app/routes/_authed/tournaments/$tournamentId.tsx index 6c3bb3e..9ed811c 100644 --- a/src/app/routes/_authed/tournaments/$tournamentId.tsx +++ b/src/app/routes/_authed/tournaments/$tournamentId.tsx @@ -3,6 +3,8 @@ import { tournamentQueries } from '@/features/tournaments/queries'; import Profile from '@/features/tournaments/components/profile'; import { z } from "zod"; import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch'; +import { Suspense } from 'react'; +import ProfileSkeleton from '@/features/tournaments/components/profile/skeleton'; const searchSchema = z.object({ tab: z.string().optional(), @@ -10,9 +12,9 @@ const searchSchema = z.object({ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({ validateSearch: searchSchema, - beforeLoad: async ({ context, params }) => { + beforeLoad: ({ context, params }) => { const { queryClient } = context; - await prefetchServerQuery(queryClient, tournamentQueries.details(params.tournamentId)) + prefetchServerQuery(queryClient, tournamentQueries.details(params.tournamentId)) }, loader: ({ params, context }) => ({ header: { @@ -28,5 +30,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({ function RouteComponent() { const tournamentId = Route.useParams().tournamentId; - return + return }> + + } diff --git a/src/app/routes/_authed/tournaments/index.tsx b/src/app/routes/_authed/tournaments/index.tsx index 05ca697..d250f0d 100644 --- a/src/app/routes/_authed/tournaments/index.tsx +++ b/src/app/routes/_authed/tournaments/index.tsx @@ -1,20 +1,14 @@ -import Page from '@/components/page' -import { Stack } from '@mantine/core' import { createFileRoute } from '@tanstack/react-router' -import { TournamentCard } from '@/features/tournaments/components/tournament-card' -import { tournamentQueries, useTournaments } from '@/features/tournaments/queries' -import { useAuth } from '@/contexts/auth-context' -import { useSheet } from '@/hooks/use-sheet' -import Sheet from '@/components/sheet/sheet' -import TournamentForm from '@/features/tournaments/components/tournament-form' -import { PlusIcon } from '@phosphor-icons/react' -import Button from '@/components/button' +import { tournamentQueries } from '@/features/tournaments/queries' import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch' +import { Suspense } from 'react' +import TournamentCardList from '@/features/tournaments/components/tournament-card-list' +import { Skeleton, Stack } from '@mantine/core' export const Route = createFileRoute('/_authed/tournaments/')({ beforeLoad: async ({ context }) => { const { queryClient } = context; - await prefetchServerQuery(queryClient, tournamentQueries.list()) + prefetchServerQuery(queryClient, tournamentQueries.list()) }, loader: () => ({ header: { @@ -27,27 +21,11 @@ export const Route = createFileRoute('/_authed/tournaments/')({ }) function RouteComponent() { - const { data: tournaments } = useTournaments(); - const { roles } = useAuth(); - const sheet = useSheet(); - - return ( - - { - roles?.includes("Admin") ? ( - <> - - - - - - ) : null - } - { - tournaments?.map((tournament: any) => ( - - )) - } - - ) + return + {Array(10).fill(null).map((_, index) => ( + + ))} + }> + + } diff --git a/src/components/DefaultCatchBoundary.tsx b/src/components/DefaultCatchBoundary.tsx index d5b80b7..828fd54 100644 --- a/src/components/DefaultCatchBoundary.tsx +++ b/src/components/DefaultCatchBoundary.tsx @@ -7,23 +7,22 @@ import { redirect, } from '@tanstack/react-router' import type { ErrorComponentProps } from '@tanstack/react-router' -import { - Box, +import { + Box, Button as MantineButton, - Text, - Title, - Stack, - Group, - Alert, + Text, + Stack, + Group, Collapse, Code, - ThemeIcon + Container, + Center } from '@mantine/core' import { useDisclosure } from '@mantine/hooks' import { useEffect } from 'react' import toast from '@/lib/sonner' import { logger } from '@/lib/logger' -import { ExclamationMarkIcon, XCircleIcon } from '@phosphor-icons/react' +import { XCircleIcon, WarningIcon } from '@phosphor-icons/react' import Button from './button' export function DefaultCatchBoundary({ error }: ErrorComponentProps) { @@ -50,112 +49,90 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) { if (errorMessage.toLowerCase().includes('unauthorized')) { return ( - - - - - - Access Denied - - You don't have permission to access this. - - - - - Home - - - - + +
+ + + Access Denied + + You don't have permission to access this page. + + + + + Home + + + +
+
) } return ( - - - - - - - Something went wrong - - - There was an unexpected error. Please try again later. - + +
+ + - - {errorMessage} - - - - {errorStack} - - - + Something went wrong - - - {isRoot ? ( - - Home - - ) : ( + + An error occurred while loading this page. + + + + Error: {errorMessage} - )} - - - + + + {errorStack} + + + + + + + {isRoot ? ( + + Home + + ) : ( + + )} + + +
+
) } diff --git a/src/components/stats-overview.tsx b/src/components/stats-overview.tsx index 22a9349..5ca7688 100644 --- a/src/components/stats-overview.tsx +++ b/src/components/stats-overview.tsx @@ -50,9 +50,13 @@ const StatItem = ({ {label} - - {value !== null ? `${value}${suffix}` : } - + {value !== null ? ( + + {`${value}${suffix}`} + + ) : ( + + )} ); }; diff --git a/src/components/swipeable-tabs.tsx b/src/components/swipeable-tabs.tsx index d64edc0..292e446 100644 --- a/src/components/swipeable-tabs.tsx +++ b/src/components/swipeable-tabs.tsx @@ -101,20 +101,23 @@ function SwipeableTabs({ useEffect(() => { const timeoutId = setTimeout(updateHeight, 0); return () => clearTimeout(timeoutId); - }); + }, [updateHeight]); useEffect(() => { const activeSlideRef = slideRefs.current[activeTab]; if (!activeSlideRef) return; + let timeoutId: number; const resizeObserver = new ResizeObserver(() => { - updateHeight(); + clearTimeout(timeoutId); + timeoutId = setTimeout(updateHeight, 16); }); resizeObserver.observe(activeSlideRef); return () => { resizeObserver.disconnect(); + clearTimeout(timeoutId); }; }, [activeTab, updateHeight]); diff --git a/src/features/core/components/layout.tsx b/src/features/core/components/layout.tsx index 3d1cfcd..10e474e 100644 --- a/src/features/core/components/layout.tsx +++ b/src/features/core/components/layout.tsx @@ -31,14 +31,18 @@ const Layout: React.FC = ({ children }) => { pos='relative' h='100dvh' mah='100dvh' - style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }} + style={{ + top: 0, + minHeight: '100dvh', + maxHeight: '100dvh' + }} >
{ const links = useLinks(user?.id, roles); if (isMobile) return ( - + {links.map((link) => ( diff --git a/src/features/core/components/pullable.tsx b/src/features/core/components/pullable.tsx index e8ec509..7fcbd7c 100644 --- a/src/features/core/components/pullable.tsx +++ b/src/features/core/components/pullable.tsx @@ -4,6 +4,7 @@ import useAppShellHeight from "@/hooks/use-appshell-height"; import { ArrowClockwiseIcon, SpinnerIcon } from "@phosphor-icons/react"; import { useQueryClient } from "@tanstack/react-query"; import useRouterConfig from "../hooks/use-router-config"; +import { useLocation } from "@tanstack/react-router"; const THRESHOLD = 80; @@ -21,6 +22,8 @@ const Pullable: React.FC = ({ children, scrollPosition, onScrollP const [scrolling, setScrolling] = useState(false); const { refresh } = useRouterConfig(); const queryClient = useQueryClient(); + const location = useLocation(); + const scrollAreaRef = useRef(null); const scrollY = useMemo(() => scrollPosition.y < 0 && scrolling ? Math.abs(scrollPosition.y) : 0, [scrollPosition.y, scrolling]); @@ -79,6 +82,21 @@ const Pullable: React.FC = ({ children, scrollPosition, onScrollP return () => void ac.abort(); }, []); + useEffect(() => { + const timeoutId = setTimeout(() => { + if (scrollAreaRef.current) { + const viewport = scrollAreaRef.current.querySelector('.mantine-ScrollArea-viewport') as HTMLElement; + if (viewport) { + viewport.scrollTop = 0; + viewport.scrollLeft = 0; + } + } + onScrollPositionChange({ x: 0, y: 0 }); + }, 10); + + return () => clearTimeout(timeoutId); + }, [location.pathname, onScrollPositionChange]); + return ( <> @@ -103,6 +121,7 @@ const Pullable: React.FC = ({ children, scrollPosition, onScrollP /> { - const show = useMemo(() => (error ? error.length > 0 : false), [error]); - - return ( - - {(styles) => ( - } style={styles}>{error} - )} - - ) -} - -export default Error; diff --git a/src/features/matches/components/match-card.tsx b/src/features/matches/components/match-card.tsx index e56e452..37cfc16 100644 --- a/src/features/matches/components/match-card.tsx +++ b/src/features/matches/components/match-card.tsx @@ -1,9 +1,10 @@ -import { Text, Group, Stack, Paper, Indicator, Box } from "@mantine/core"; +import { Text, Group, Stack, Paper, Indicator, Box, Tooltip } from "@mantine/core"; import { CrownIcon } from "@phosphor-icons/react"; import { useNavigate } from "@tanstack/react-router"; import { Match } from "../types"; import Avatar from "@/components/avatar"; import EmojiBar from "@/features/reactions/components/emoji-bar"; +import { Suspense } from "react"; interface MatchCardProps { match: Match; @@ -88,15 +89,28 @@ const MatchCard = ({ match }: MatchCardProps) => { )} - - {match.home?.name!} - + + {match.home?.name!} + + + + {match.home?.players.map((p) => ( + + {p.first_name} {p.last_name} + + ))} + { > {match.home_cups} - - {match.home?.players.map((p) => ( - - {p.first_name} {p.last_name} - - ))} - @@ -144,15 +151,28 @@ const MatchCard = ({ match }: MatchCardProps) => { )} - - {match.away?.name} - + + {match.away?.name} + + + + {match.away?.players.map((p) => ( + + {p.first_name} {p.last_name} + + ))} + { > {match.away_cups} - - {match.away?.players.map((p) => ( - - {p.first_name} {p.last_name} - - ))} - @@ -187,7 +200,9 @@ const MatchCard = ({ match }: MatchCardProps) => { border: "1px solid var(--mantine-color-default-border)", }} > - + + + diff --git a/src/features/matches/components/match-list.tsx b/src/features/matches/components/match-list.tsx index c81cb0a..e883303 100644 --- a/src/features/matches/components/match-list.tsx +++ b/src/features/matches/components/match-list.tsx @@ -1,5 +1,4 @@ import { Stack } from "@mantine/core"; -import { motion, AnimatePresence } from "framer-motion"; import { Match } from "../types"; import MatchCard from "./match-card"; @@ -18,19 +17,13 @@ const MatchList = ({ matches }: MatchListProps) => { return ( - - {filteredMatches.map((match, index) => ( - - - - ))} - + {filteredMatches.map((match, index) => ( +
+ +
+ ))}
); }; diff --git a/src/features/players/components/profile/header-skeleton.tsx b/src/features/players/components/profile/header-skeleton.tsx index 4989839..eac77de 100644 --- a/src/features/players/components/profile/header-skeleton.tsx +++ b/src/features/players/components/profile/header-skeleton.tsx @@ -2,7 +2,7 @@ import { Flex, Skeleton } from "@mantine/core"; const HeaderSkeleton = () => { return ( - + diff --git a/src/features/players/components/profile/header.tsx b/src/features/players/components/profile/header.tsx index c51cc90..4771acb 100644 --- a/src/features/players/components/profile/header.tsx +++ b/src/features/players/components/profile/header.tsx @@ -33,7 +33,7 @@ const Header = ({ player }: HeaderProps) => { return ( <> - + {name} diff --git a/src/features/teams/components/team-form/duration-picker.tsx b/src/features/teams/components/team-form/duration-picker.tsx index f2a6f88..16cec2a 100644 --- a/src/features/teams/components/team-form/duration-picker.tsx +++ b/src/features/teams/components/team-form/duration-picker.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from "react"; -import { Stack, Text, Group, RangeSlider, Divider } from "@mantine/core"; +import { Stack, Text, Group, TextInput, Button } from "@mantine/core"; interface DurationPickerProps { songDurationMs: number; @@ -9,6 +9,41 @@ interface DurationPickerProps { disabled?: boolean; } +interface IncrementButtonsProps { + onAdjust: (seconds: number) => void; + disabled: boolean; + isPositive?: boolean; +} + +const IncrementButtons = ({ onAdjust, disabled, isPositive = true }: IncrementButtonsProps) => { + const increments = [1, 5, 30, 60]; + const labels = ["1s", "5s", "30s", "1m"]; + + return ( + + {increments.map((increment, index) => ( + + ))} + + ); +}; + const DurationPicker = ({ songDurationMs, initialStart = 0, @@ -17,11 +52,6 @@ const DurationPicker = ({ disabled = false, }: DurationPickerProps) => { const songDurationSeconds = Math.floor(songDurationMs / 1000); - const [range, setRange] = useState<[number, number]>([ - initialStart, - initialEnd, - ]); - const [isValid, setIsValid] = useState(true); const formatTime = useCallback((seconds: number) => { const minutes = Math.floor(seconds / 60); @@ -29,7 +59,26 @@ const DurationPicker = ({ return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`; }, []); - const validateRange = useCallback( + const [startTime, setStartTime] = useState(initialStart); + const [endTime, setEndTime] = useState(initialEnd); + const [isValid, setIsValid] = useState(true); + const [startInputValue, setStartInputValue] = useState(formatTime(initialStart)); + const [endInputValue, setEndInputValue] = useState(formatTime(initialEnd)); + + const parseTimeInput = useCallback((input: string): number | null => { + if (input.includes(':')) { + const parts = input.split(':'); + if (parts.length === 2) { + const minutes = parseInt(parts[0]) || 0; + const seconds = parseInt(parts[1]) || 0; + return minutes * 60 + seconds; + } + } + const parsed = parseInt(input); + return isNaN(parsed) ? null : parsed; + }, []); + + const validateTimes = useCallback( (start: number, end: number) => { const duration = end - start; const withinBounds = start >= 0 && end <= songDurationSeconds; @@ -53,146 +102,150 @@ const DurationPicker = ({ return null; }, [songDurationSeconds]); - const handleRangeChange = useCallback( - (newRange: [number, number]) => { - setRange(newRange); - const [start, end] = newRange; - const valid = validateRange(start, end); - setIsValid(valid); + const updateTimes = useCallback((newStart: number, newEnd: number) => { + const clampedStart = Math.max(0, Math.min(newStart, songDurationSeconds - 10)); + const clampedEnd = Math.min(songDurationSeconds, Math.max(newEnd, clampedStart + 10)); - if (valid) { - onChange(start, end); - } - }, - [onChange, validateRange] - ); + setStartTime(clampedStart); + setEndTime(clampedEnd); + setStartInputValue(formatTime(clampedStart)); + setEndInputValue(formatTime(clampedEnd)); - const handleRangeChangeEnd = useCallback( - (newRange: [number, number]) => { - let [start, end] = newRange; - let duration = end - start; + const valid = validateTimes(clampedStart, clampedEnd); + setIsValid(valid); - if (duration < 10) { - if (start < songDurationSeconds / 2) { - end = Math.min(start + 10, songDurationSeconds); - } else { - start = Math.max(end - 10, 0); - } - duration = end - start; - } + if (valid) { + onChange(clampedStart, clampedEnd); + } + }, [songDurationSeconds, validateTimes, onChange, formatTime]); - if (duration > 15) { - const startDiff = Math.abs(start - range[0]); - const endDiff = Math.abs(end - range[1]); + const handleStartInputChange = useCallback((event: React.ChangeEvent) => { + setStartInputValue(event.target.value); + }, []); - if (startDiff > endDiff) { - end = start + 15; - if (end > songDurationSeconds) { - end = songDurationSeconds; - start = end - 15; - } - } else { - start = end - 15; - if (start < 0) { - start = 0; - end = start + 15; - } - } - } + const handleEndInputChange = useCallback((event: React.ChangeEvent) => { + setEndInputValue(event.target.value); + }, []); - start = Math.max(0, start); - end = Math.min(songDurationSeconds, end); + const handleStartBlur = useCallback(() => { + const parsed = parseTimeInput(startInputValue); + if (parsed !== null) { + updateTimes(parsed, endTime); + } else { + setStartInputValue(formatTime(startTime)); + } + }, [startInputValue, endTime, updateTimes, parseTimeInput, formatTime, startTime]); - const finalRange: [number, number] = [start, end]; - setRange(finalRange); - setIsValid(validateRange(start, end)); - onChange(start, end); - }, - [range, songDurationSeconds, onChange, validateRange] - ); + const handleEndBlur = useCallback(() => { + const parsed = parseTimeInput(endInputValue); + if (parsed !== null) { + updateTimes(startTime, parsed); + } else { + setEndInputValue(formatTime(endTime)); + } + }, [endInputValue, startTime, updateTimes, parseTimeInput, formatTime, endTime]); + + const adjustStartTime = useCallback((seconds: number) => { + updateTimes(startTime + seconds, endTime); + }, [startTime, endTime, updateTimes]); + + const adjustEndTime = useCallback((seconds: number) => { + updateTimes(startTime, endTime + seconds); + }, [startTime, endTime, updateTimes]); useEffect(() => { - if (!validateRange(initialStart, initialEnd)) { + if (!validateTimes(initialStart, initialEnd)) { const defaultStart = Math.min(30, Math.max(0, songDurationSeconds - 15)); const defaultEnd = Math.min(defaultStart + 15, songDurationSeconds); - const defaultRange: [number, number] = [defaultStart, defaultEnd]; - setRange(defaultRange); - onChange(defaultStart, defaultEnd); + updateTimes(defaultStart, defaultEnd); } - }, [initialStart, initialEnd, songDurationSeconds, validateRange, onChange]); + }, [initialStart, initialEnd, songDurationSeconds, validateTimes, updateTimes]); - const segmentDuration = range[1] - range[0]; + const segmentDuration = endTime - startTime; return ( - -
- - Start and End - - - {disabled ? "Select a song to choose segment timing" : "Choose a 10-15 second segment for your walkout song"} - -
+ + + Walkout Segment ({segmentDuration}s) + - formatTime(value)} - disabled={disabled} - styles={{ - track: { height: 8 }, - }} - /> - - - - - - - Start - - - {formatTime(range[0])} - + + + + + Start + + + + + + = songDurationSeconds - 10} + isPositive={true} + /> + - - - Duration - - - {segmentDuration}s - + + + + End + + + + + + = songDurationSeconds} + isPositive={true} + /> + - - - - End - - - {formatTime(range[1])} - - - + {!isValid && ( - {getValidationMessage(range[0], range[1])} + {getValidationMessage(startTime, endTime)} )}
diff --git a/src/features/teams/components/team-form/song-picker.tsx b/src/features/teams/components/team-form/song-picker.tsx index edcb7f6..1048145 100644 --- a/src/features/teams/components/team-form/song-picker.tsx +++ b/src/features/teams/components/team-form/song-picker.tsx @@ -18,6 +18,7 @@ interface Song { song_start?: number; song_end?: number; song_image_url: string; + duration_ms?: number; } interface SongPickerProps { @@ -62,7 +63,7 @@ const SongPicker = ({ form, error }: SongPickerProps) => { }} error={error} Component={SongPickerComponent} - componentProps={{ formValues: form.getValues() }} + componentProps={{}} title={"Select Song"} label={"Walkout Song"} placeholder={"Select your walkout song"} @@ -73,10 +74,9 @@ const SongPicker = ({ form, error }: SongPickerProps) => { interface SongPickerComponentProps { value: Song | undefined; onChange: (song: Song) => void; - formValues: any; } -const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerComponentProps) => { +const SongPickerComponent = ({ value: song, onChange }: SongPickerComponentProps) => { const handleSongSelect = (track: SpotifyTrack) => { const defaultStart = 0; const defaultEnd = Math.min(15, Math.floor(track.duration_ms / 1000)); @@ -89,6 +89,7 @@ const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerCo song_image_url: track.album.images[0]?.url || '', song_start: defaultStart, song_end: defaultEnd, + duration_ms: track.duration_ms, }; onChange(newSong); @@ -135,7 +136,7 @@ const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerCo ([]); const [isLoading, setIsLoading] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const containerRef = useRef(null); + const inputRef = useRef(null); - const combobox = useCombobox(); - - // Standalone search function that doesn't require Spotify context const searchSpotifyTracks = async (query: string): Promise => { if (!query.trim()) return []; @@ -37,6 +38,7 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc const debouncedSearch = useDebouncedCallback(async (query: string) => { if (!query.trim()) { setSearchResults([]); + setIsOpen(false); return; } @@ -44,10 +46,12 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc try { const results = await searchSpotifyTracks(query); setSearchResults(results); - combobox.openDropdown(); + setIsOpen(results.length > 0); + setSelectedIndex(-1); } catch (error) { console.error('Search failed:', error); setSearchResults([]); + setIsOpen(false); } finally { setIsLoading(false); } @@ -61,60 +65,117 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc const handleSongSelect = (track: SpotifyTrack) => { onChange(track); setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`); - combobox.closeDropdown(); + setIsOpen(false); + setSelectedIndex(-1); }; - const options = searchResults.map((track) => ( - - - {track.album.images[2] && ( - - )} -
- - {track.name} - - - {track.artists.map(a => a.name).join(', ')} • {track.album.name} - -
-
-
- )); + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen || searchResults.length === 0) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex(prev => (prev < searchResults.length - 1 ? prev + 1 : prev)); + break; + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev)); + break; + case 'Enter': + e.preventDefault(); + if (selectedIndex >= 0 && searchResults[selectedIndex]) { + handleSongSelect(searchResults[selectedIndex]); + } + break; + case 'Escape': + setIsOpen(false); + setSelectedIndex(-1); + break; + } + }; return ( - { - const track = searchResults.find(t => t.id === value); - if (track) handleSongSelect(track); - }} - width='100%' - zIndex={9999} - withinPortal={false} - > - - : } - value={searchQuery} - onChange={(event) => handleSearchChange(event.currentTarget.value)} - onClick={() => combobox.openDropdown()} - onFocus={() => combobox.openDropdown()} - onBlur={() => combobox.closeDropdown()} - placeholder={placeholder} - /> - + + handleSearchChange(event.currentTarget.value)} + onKeyDown={handleKeyDown} + onFocus={() => { + if (searchResults.length > 0) setIsOpen(true); + }} + placeholder={placeholder} + rightSection={isLoading ? : null} + /> - - - {options.length > 0 ? options : - - {searchQuery.trim() ? 'No songs found' : 'Start typing to search...'} - - } - - - + {isOpen && ( + e.stopPropagation()} + > + {searchResults.length > 0 ? ( + + {searchResults.map((track, index) => ( + handleSongSelect(track)} + onMouseEnter={() => setSelectedIndex(index)} + > + + {track.album.images[2] && ( + + )} +
+ + {track.name} + + + {track.artists.map(a => a.name).join(', ')} • {track.album.name} + +
+
+
+ ))} +
+ ) : ( + + + {searchQuery.trim() ? 'No songs found' : 'Start typing to search...'} + + + )} +
+ )} + ); }; diff --git a/src/features/teams/components/team-profile/header-skeleton.tsx b/src/features/teams/components/team-profile/header-skeleton.tsx new file mode 100644 index 0000000..82e8e12 --- /dev/null +++ b/src/features/teams/components/team-profile/header-skeleton.tsx @@ -0,0 +1,14 @@ +import { Flex, Skeleton } from "@mantine/core"; + +const HeaderSkeleton = () => { + return ( + + + + + + + ); +}; + +export default HeaderSkeleton; \ No newline at end of file diff --git a/src/features/teams/components/team-profile/header.tsx b/src/features/teams/components/team-profile/header.tsx index 6d08bfb..c242a0e 100644 --- a/src/features/teams/components/team-profile/header.tsx +++ b/src/features/teams/components/team-profile/header.tsx @@ -11,7 +11,7 @@ interface HeaderProps { const Header = ({ name, logo, id }: HeaderProps) => { return ( <> - + ( + + + +) + +const ProfileSkeleton = () => { + const tabs = [ + { + label: "Overview", + content: , + }, + { + label: "Matches", + content: , + }, + { + label: "Tournaments", + content: , + }, + ]; + + return ( + <> + + + + + + ); +}; + +export default ProfileSkeleton; diff --git a/src/features/tournaments/components/manage-tournament.tsx b/src/features/tournaments/components/manage-tournament.tsx index e4b0e57..ae7f794 100644 --- a/src/features/tournaments/components/manage-tournament.tsx +++ b/src/features/tournaments/components/manage-tournament.tsx @@ -57,12 +57,12 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => { onClick={openEditRules} /> diff --git a/src/features/tournaments/components/profile/header-skeleton.tsx b/src/features/tournaments/components/profile/header-skeleton.tsx new file mode 100644 index 0000000..be761ba --- /dev/null +++ b/src/features/tournaments/components/profile/header-skeleton.tsx @@ -0,0 +1,14 @@ +import { Flex, Skeleton } from "@mantine/core"; + +const HeaderSkeleton = () => { + return ( + + + + + + + ); +}; + +export default HeaderSkeleton; \ No newline at end of file diff --git a/src/features/tournaments/components/profile/header.tsx b/src/features/tournaments/components/profile/header.tsx index 1a24e8b..05e4a15 100644 --- a/src/features/tournaments/components/profile/header.tsx +++ b/src/features/tournaments/components/profile/header.tsx @@ -10,7 +10,7 @@ const Header = ({ tournament }: HeaderProps) => { return ( <> - + {tournament.name} diff --git a/src/features/tournaments/components/profile/index.tsx b/src/features/tournaments/components/profile/index.tsx index 167575e..a1b1553 100644 --- a/src/features/tournaments/components/profile/index.tsx +++ b/src/features/tournaments/components/profile/index.tsx @@ -1,9 +1,11 @@ -import { Box, Text } from "@mantine/core"; +import { useMemo } from "react"; +import { Box } from "@mantine/core"; import Header from "./header"; import TeamList from "@/features/teams/components/team-list"; import SwipeableTabs from "@/components/swipeable-tabs"; import { useTournament } from "../../queries"; import MatchList from "@/features/matches/components/match-list"; +import { TournamentStats } from "../tournament-stats"; interface ProfileProps { id: string; @@ -13,22 +15,22 @@ const Profile = ({ id }: ProfileProps) => { const { data: tournament } = useTournament(id); if (!tournament) return null; - const tabs = [ + const tabs = useMemo(() => [ { label: "Overview", - content: Stats/Badges will go here, bracket link + content: }, { label: "Matches", content: b.order - a.order) || []} /> }, { - label: "Teams", + label: "Teams", content: <> } - ]; + ], [tournament]); return <>
diff --git a/src/features/tournaments/components/profile/skeleton.tsx b/src/features/tournaments/components/profile/skeleton.tsx new file mode 100644 index 0000000..afee881 --- /dev/null +++ b/src/features/tournaments/components/profile/skeleton.tsx @@ -0,0 +1,37 @@ +import { Box, Flex, Loader } from "@mantine/core"; +import SwipeableTabs from "@/components/swipeable-tabs"; +import HeaderSkeleton from "./header-skeleton"; + +const SkeletonLoader = () => ( + + + +) + +const ProfileSkeleton = () => { + const tabs = [ + { + label: "Overview", + content: , + }, + { + label: "Matches", + content: , + }, + { + label: "Teams", + content: , + }, + ]; + + return ( + <> + + + + + + ); +}; + +export default ProfileSkeleton; diff --git a/src/features/tournaments/components/started-tournament/index.tsx b/src/features/tournaments/components/started-tournament/index.tsx index 04d082f..768b0e7 100644 --- a/src/features/tournaments/components/started-tournament/index.tsx +++ b/src/features/tournaments/components/started-tournament/index.tsx @@ -35,7 +35,7 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({ > {startedMatches.map((match, index) => ( - + diff --git a/src/features/tournaments/components/tournament-card-list.tsx b/src/features/tournaments/components/tournament-card-list.tsx new file mode 100644 index 0000000..2975f6a --- /dev/null +++ b/src/features/tournaments/components/tournament-card-list.tsx @@ -0,0 +1,37 @@ +import { useAuth } from "@/contexts/auth-context"; +import { useTournaments } from "../queries"; +import { useSheet } from "@/hooks/use-sheet"; +import { Button, Stack } from "@mantine/core"; +import { PlusIcon } from "@phosphor-icons/react"; +import Sheet from "@/components/sheet/sheet"; +import TournamentForm from "./tournament-form"; +import { TournamentCard } from "./tournament-card"; + +const TournamentCardList = () => { + const { data: tournaments } = useTournaments(); + const { roles } = useAuth(); + const sheet = useSheet(); + return ( + + {roles?.includes("Admin") ? ( + <> + + + + + + ) : null} + {tournaments?.map((tournament: any) => ( + + ))} + + ); +}; + +export default TournamentCardList; diff --git a/src/features/tournaments/components/tournament-card.tsx b/src/features/tournaments/components/tournament-card.tsx index 4f4ce2d..a88855c 100644 --- a/src/features/tournaments/components/tournament-card.tsx +++ b/src/features/tournaments/components/tournament-card.tsx @@ -57,7 +57,7 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => { { + + const matches = tournament.matches || []; + const nonByeMatches = useMemo(() => + matches.filter((match) => !(match.status === 'tbd' && match.bye === true)), + [matches] + ); + const isComplete = useMemo(() => + nonByeMatches.length > 0 && nonByeMatches.every((match) => match.status === 'ended'), + [nonByeMatches] + ); + + const sortedTeamStats = useMemo(() => { + return [...(tournament.team_stats || [])].sort((a, b) => { + if (b.wins !== a.wins) { + return b.wins - a.wins; + } + return b.total_cups_made - a.total_cups_made; + }); + }, [tournament.team_stats]); + + const renderPodium = () => { + if (!isComplete || !tournament.first_place) { + return ( + +
+ + Podium will appear here when the tournament is over + +
+
+ ); + } + + return ( + + {tournament.first_place && ( + + + + + + + {tournament.first_place.name} + + + {tournament.first_place.players?.map((player) => ( + + {player.first_name} {player.last_name} + + ))} + + + + )} + + {tournament.second_place && ( + + + + + + + {tournament.second_place.name} + + + {tournament.second_place.players?.map((player) => ( + + {player.first_name} {player.last_name} + + ))} + + + + )} + + {tournament.third_place && ( + + + + + + + {tournament.third_place.name} + + + {tournament.third_place.players?.map((player) => ( + + {player.first_name} {player.last_name} + + ))} + + + + )} + + ); + }; + + const teamStatsWithCalculations = useMemo(() => { + return sortedTeamStats.map((stat, index) => ({ + ...stat, + index, + winPercentage: stat.matches > 0 ? (stat.wins / stat.matches) * 100 : 0, + avgCupsPerMatch: stat.matches > 0 ? stat.total_cups_made / stat.matches : 0, + })); + }, [sortedTeamStats]); + + const renderTeamStatsTable = () => { + if (!teamStatsWithCalculations.length) { + return ( + +
+ + No stats available yet + +
+
+ ); + } + + return ( + + Results + {teamStatsWithCalculations.map((stat) => { + return ( + + + + + + + + + #{stat.index + 1} + + + {stat.team_name} + + {stat.index === 0 && isComplete && ( + + + + )} + + + + + W + + + {stat.wins} + + + + + L + + + {stat.losses} + + + + + W% + + + {stat.winPercentage.toFixed(1)}% + + + + + AVG + + + {stat.avgCupsPerMatch.toFixed(1)} + + + + + CF + + + {stat.total_cups_made} + + + + + CA + + + {stat.total_cups_against} + + + + + + + + {stat.index < teamStatsWithCalculations.length - 1 && } + + ); + })} + + ); + }; + + return ( + + + {renderPodium()} + + {renderTeamStatsTable()} + + + ); +}); + +TournamentStats.displayName = 'TournamentStats'; \ No newline at end of file diff --git a/src/features/tournaments/components/upcoming-tournament/enrolled-free-agent.tsx b/src/features/tournaments/components/upcoming-tournament/enrolled-free-agent.tsx index e643940..7b2283b 100644 --- a/src/features/tournaments/components/upcoming-tournament/enrolled-free-agent.tsx +++ b/src/features/tournaments/components/upcoming-tournament/enrolled-free-agent.tsx @@ -11,10 +11,30 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({ const copyToClipboard = async (phone: string) => { try { - await navigator.clipboard.writeText(phone); - toast.success("Phone number copied!"); + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(phone); + toast.success("Phone number copied!"); + return; + } + + const textArea = document.createElement("textarea"); + textArea.value = phone; + textArea.style.display = "hidden"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + const successful = document.execCommand('copy'); + document.body.removeChild(textArea); + + if (successful) { + toast.success("Phone number copied!"); + } else { + throw new Error("Copy command failed"); + } } catch (err) { - toast.success("Failed to copy"); + console.error("Failed to copy:", err); + toast.error("Failed to copy"); } }; diff --git a/src/features/tournaments/components/upcoming-tournament/header.tsx b/src/features/tournaments/components/upcoming-tournament/header.tsx index 95cf2d7..764f2f1 100644 --- a/src/features/tournaments/components/upcoming-tournament/header.tsx +++ b/src/features/tournaments/components/upcoming-tournament/header.tsx @@ -31,7 +31,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => { >
- + {tournament.location && ( diff --git a/src/features/tournaments/components/upcoming-tournament/index.tsx b/src/features/tournaments/components/upcoming-tournament/index.tsx index cc18195..c308e45 100644 --- a/src/features/tournaments/components/upcoming-tournament/index.tsx +++ b/src/features/tournaments/components/upcoming-tournament/index.tsx @@ -51,8 +51,8 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
- - {tournament.desc && {tournament.desc}} + + {tournament.desc && {tournament.desc}} diff --git a/src/features/tournaments/components/upcoming-tournament/skeleton.tsx b/src/features/tournaments/components/upcoming-tournament/skeleton.tsx index 751078c..05d6e16 100644 --- a/src/features/tournaments/components/upcoming-tournament/skeleton.tsx +++ b/src/features/tournaments/components/upcoming-tournament/skeleton.tsx @@ -4,37 +4,32 @@ const UpcomingTournamentSkeleton = () => { return ( - - - - - - - + + + + + - - + - - + + - + + + + + + + + - - - - - - - - - ); };