skeletons, tournament stats, polish, bug fixes
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
37
src/features/teams/components/team-profile/skeleton.tsx
Normal file
37
src/features/teams/components/team-profile/skeleton.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user