skeletons, tournament stats, polish, bug fixes

This commit is contained in:
yohlo
2025-09-23 14:48:04 -05:00
parent 7ff26229d9
commit 7441d1ac58
36 changed files with 990 additions and 457 deletions

View File

@@ -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 (
<Group gap={3} wrap="nowrap" flex={1}>
{increments.map((increment, index) => (
<Button
key={increment}
variant={isPositive ? "light" : "outline"}
color={isPositive ? "blue" : "gray"}
size="xs"
disabled={disabled}
onClick={() => onAdjust(isPositive ? increment : -increment)}
flex={1}
h={24}
style={{
fontSize: '10px',
fontWeight: 500,
minWidth: 0
}}
>
{isPositive ? '+' : '-'}{labels[index]}
</Button>
))}
</Group>
);
};
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<Stack gap="md" opacity={disabled ? 0.5 : 1}>
<div>
<Text size="sm" fw={500} mb="xs" c={disabled ? "dimmed" : undefined}>
Start and End
</Text>
<Text size="xs" c="dimmed" mb="md">
{disabled ? "Select a song to choose segment timing" : "Choose a 10-15 second segment for your walkout song"}
</Text>
</div>
<Stack gap="sm" opacity={disabled ? 0.5 : 1}>
<Text size="sm" fw={500} c={disabled ? "dimmed" : undefined} ta="center">
Walkout Segment ({segmentDuration}s)
</Text>
<RangeSlider
min={0}
max={songDurationSeconds}
step={1}
value={range}
onChange={disabled ? undefined : handleRangeChange}
onChangeEnd={disabled ? undefined : handleRangeChangeEnd}
marks={[
{ value: 0, label: "0:00" },
{
value: songDurationSeconds,
label: formatTime(songDurationSeconds),
},
]}
size="lg"
m='xs'
color={disabled ? "gray" : (isValid ? "blue" : "red")}
thumbSize={20}
label={disabled ? undefined : (value) => formatTime(value)}
disabled={disabled}
styles={{
track: { height: 8 },
}}
/>
<Divider />
<Group justify="space-between" align="center">
<Stack gap={2} align="center">
<Text size="xs" c="dimmed">
Start
</Text>
<Text size="sm" fw={500}>
{formatTime(range[0])}
</Text>
<Stack gap="sm">
<Stack gap={4}>
<Group justify="space-between" align="center">
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
Start
</Text>
<TextInput
value={startInputValue}
onChange={handleStartInputChange}
onBlur={handleStartBlur}
disabled={disabled}
size="xs"
w={70}
placeholder="0:00"
ta="center"
styles={{
input: {
fontWeight: 600,
fontSize: '12px'
}
}}
/>
</Group>
<Group gap={4}>
<IncrementButtons
onAdjust={adjustStartTime}
disabled={disabled || startTime <= 0}
isPositive={false}
/>
<IncrementButtons
onAdjust={adjustStartTime}
disabled={disabled || startTime >= songDurationSeconds - 10}
isPositive={true}
/>
</Group>
</Stack>
<Stack gap={2} align="center">
<Text size="xs" c="dimmed">
Duration
</Text>
<Text size="sm" fw={500} c={isValid ? undefined : "red"}>
{segmentDuration}s
</Text>
<Stack gap={4}>
<Group justify="space-between" align="center">
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
End
</Text>
<TextInput
value={endInputValue}
onChange={handleEndInputChange}
onBlur={handleEndBlur}
disabled={disabled}
size="xs"
w={70}
placeholder="0:15"
ta="center"
styles={{
input: {
fontWeight: 600,
fontSize: '12px'
}
}}
/>
</Group>
<Group gap={4}>
<IncrementButtons
onAdjust={adjustEndTime}
disabled={disabled || endTime <= startTime + 10}
isPositive={false}
/>
<IncrementButtons
onAdjust={adjustEndTime}
disabled={disabled || endTime >= songDurationSeconds}
isPositive={true}
/>
</Group>
</Stack>
<Stack gap={2} align="center">
<Text size="xs" c="dimmed">
End
</Text>
<Text size="sm" fw={500}>
{formatTime(range[1])}
</Text>
</Stack>
</Group>
</Stack>
{!isValid && (
<Text size="xs" c="red" ta="center">
{getValidationMessage(range[0], range[1])}
{getValidationMessage(startTime, endTime)}
</Text>
)}
</Stack>

View File

@@ -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
<Stack gap="xs">
<DurationPicker
songDurationMs={180000}
songDurationMs={song?.duration_ms || 180000}
initialStart={song?.song_start || 0}
initialEnd={song?.song_end || 15}
onChange={handleDurationChange}

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { Text, Combobox, InputBase, useCombobox, Group, Avatar, Loader } from "@mantine/core";
import { useState, useRef, useEffect } from "react";
import { Text, TextInput, Group, Avatar, Loader, Paper, Stack, Box } from "@mantine/core";
import { SpotifyTrack } from "@/lib/spotify/types";
import { useDebouncedCallback } from "@mantine/hooks";
@@ -12,10 +12,11 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<SpotifyTrack[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const combobox = useCombobox();
// Standalone search function that doesn't require Spotify context
const searchSpotifyTracks = async (query: string): Promise<SpotifyTrack[]> => {
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) => (
<Combobox.Option value={track.id} key={track.id}>
<Group gap="sm">
{track.album.images[2] && (
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
)}
<div>
<Text size="sm" fw={500}>
{track.name}
</Text>
<Text size="xs" c="dimmed">
{track.artists.map(a => a.name).join(', ')} {track.album.name}
</Text>
</div>
</Group>
</Combobox.Option>
));
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 (
<Combobox
store={combobox}
onOptionSubmit={(value) => {
const track = searchResults.find(t => t.id === value);
if (track) handleSongSelect(track);
}}
width='100%'
zIndex={9999}
withinPortal={false}
>
<Combobox.Target>
<InputBase
rightSection={isLoading ? <Loader size="xs" /> : <Combobox.Chevron />}
value={searchQuery}
onChange={(event) => handleSearchChange(event.currentTarget.value)}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
placeholder={placeholder}
/>
</Combobox.Target>
<Box ref={containerRef} pos="relative" w="100%">
<TextInput
ref={inputRef}
value={searchQuery}
onChange={(event) => handleSearchChange(event.currentTarget.value)}
onKeyDown={handleKeyDown}
onFocus={() => {
if (searchResults.length > 0) setIsOpen(true);
}}
placeholder={placeholder}
rightSection={isLoading ? <Loader size="xs" /> : null}
/>
<Combobox.Dropdown>
<Combobox.Options>
{options.length > 0 ? options :
<Combobox.Empty>
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
</Combobox.Empty>
}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
{isOpen && (
<Paper
shadow="md"
p={0}
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 9999,
maxHeight: '160px',
overflowY: 'auto',
WebkitOverflowScrolling: 'touch',
touchAction: 'pan-y'
}}
onTouchMove={(e) => e.stopPropagation()}
>
{searchResults.length > 0 ? (
<Stack gap={0}>
{searchResults.map((track, index) => (
<Box
key={track.id}
p="sm"
style={{
cursor: 'pointer',
backgroundColor: selectedIndex === index ? 'var(--mantine-color-gray-1)' : 'transparent',
borderBottom: index < searchResults.length - 1 ? '1px solid var(--mantine-color-gray-3)' : 'none'
}}
onClick={() => handleSongSelect(track)}
onMouseEnter={() => setSelectedIndex(index)}
>
<Group gap="sm">
{track.album.images[2] && (
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
)}
<div>
<Text size="sm" fw={500}>
{track.name}
</Text>
<Text size="xs" c="dimmed">
{track.artists.map(a => a.name).join(', ')} {track.album.name}
</Text>
</div>
</Group>
</Box>
))}
</Stack>
) : (
<Box p="md">
<Text size="sm" c="dimmed" ta="center">
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
</Text>
</Box>
)}
</Paper>
)}
</Box>
);
};

View File

@@ -0,0 +1,14 @@
import { Flex, Skeleton } from "@mantine/core";
const HeaderSkeleton = () => {
return (
<Flex h="20dvh" px='xl' w='100%' align='flex-end' gap='md'>
<Skeleton opacity={0} height={200} width={150} />
<Flex align='center' justify='center' gap={4} w='100%'>
<Skeleton height={36} width={200} />
</Flex>
</Flex>
);
};
export default HeaderSkeleton;

View File

@@ -11,7 +11,7 @@ interface HeaderProps {
const Header = ({ name, logo, id }: HeaderProps) => {
return (
<>
<Flex px="xl" w="100%" align="self-end" gap="md">
<Flex h="20dvh" px="xl" w="100%" align="self-end" gap="md">
<Avatar
radius="sm"
name={name}

View File

@@ -0,0 +1,37 @@
import { Box, Flex, Loader } from "@mantine/core";
import SwipeableTabs from "@/components/swipeable-tabs";
import HeaderSkeleton from "./header-skeleton";
const SkeletonLoader = () => (
<Flex h="30vh" w="100%" align="center" justify="center">
<Loader />
</Flex>
)
const ProfileSkeleton = () => {
const tabs = [
{
label: "Overview",
content: <SkeletonLoader />,
},
{
label: "Matches",
content: <SkeletonLoader />,
},
{
label: "Tournaments",
content: <SkeletonLoader />,
},
];
return (
<>
<HeaderSkeleton />
<Box mt="lg">
<SwipeableTabs tabs={tabs} />
</Box>
</>
);
};
export default ProfileSkeleton;