working team update/create (still need enroll)

This commit is contained in:
yohlo
2025-09-16 13:24:39 -05:00
parent cde74a04d5
commit c170e1e1fe
16 changed files with 845 additions and 175 deletions

View File

@@ -0,0 +1,202 @@
import { useState, useEffect, useCallback } from "react";
import { Stack, Text, Group, RangeSlider, Divider } from "@mantine/core";
interface DurationPickerProps {
songDurationMs: number;
initialStart?: number;
initialEnd?: number;
onChange: (startSeconds: number, endSeconds: number) => void;
disabled?: boolean;
}
const DurationPicker = ({
songDurationMs,
initialStart = 0,
initialEnd = 15,
onChange,
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);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
}, []);
const validateRange = useCallback(
(start: number, end: number) => {
const duration = end - start;
const withinBounds = start >= 0 && end <= songDurationSeconds;
const validDuration = duration >= 10 && duration <= 15;
return withinBounds && validDuration;
},
[songDurationSeconds]
);
const getValidationMessage = useCallback((start: number, end: number) => {
const duration = end - start;
if (start < 0 || end > songDurationSeconds) {
return "Segment must be within song duration";
}
if (duration < 10) {
return "Segment must be at least 10 seconds";
}
if (duration > 15) {
return "Segment must be no more than 15 seconds";
}
return null;
}, [songDurationSeconds]);
const handleRangeChange = useCallback(
(newRange: [number, number]) => {
setRange(newRange);
const [start, end] = newRange;
const valid = validateRange(start, end);
setIsValid(valid);
if (valid) {
onChange(start, end);
}
},
[onChange, validateRange]
);
const handleRangeChangeEnd = useCallback(
(newRange: [number, number]) => {
let [start, end] = newRange;
let duration = end - start;
if (duration < 10) {
if (start < songDurationSeconds / 2) {
end = Math.min(start + 10, songDurationSeconds);
} else {
start = Math.max(end - 10, 0);
}
duration = end - start;
}
if (duration > 15) {
const startDiff = Math.abs(start - range[0]);
const endDiff = Math.abs(end - range[1]);
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;
}
}
}
start = Math.max(0, start);
end = Math.min(songDurationSeconds, end);
const finalRange: [number, number] = [start, end];
setRange(finalRange);
setIsValid(validateRange(start, end));
onChange(start, end);
},
[range, songDurationSeconds, onChange, validateRange]
);
useEffect(() => {
if (!validateRange(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);
}
}, [initialStart, initialEnd, songDurationSeconds, validateRange, onChange]);
const segmentDuration = range[1] - range[0];
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>
<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>
<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>
<Stack gap={2} align="center">
<Text size="xs" c="dimmed">
End
</Text>
<Text size="sm" fw={500}>
{formatTime(range[1])}
</Text>
</Stack>
</Group>
{!isValid && (
<Text size="xs" c="red" ta="center">
{getValidationMessage(range[0], range[1])}
</Text>
)}
</Stack>
);
};
export default DurationPicker;

View File

@@ -34,31 +34,62 @@ const TeamForm = ({
const config: UseFormInput<TeamInput> = {
initialValues: {
name: initialValues?.name || "",
primary_color: initialValues?.primary_color,
accent_color: initialValues?.accent_color,
// primary_color: initialValues?.primary_color,
// accent_color: initialValues?.accent_color,
song_id: initialValues?.song_id,
song_name: initialValues?.song_name,
song_artist: initialValues?.song_artist,
song_album: initialValues?.song_album,
song_year: initialValues?.song_year,
song_start: initialValues?.song_start,
song_end: initialValues?.song_end,
song_image_url: initialValues?.song_image_url,
logo: undefined,
players: initialValues?.players || []
players: initialValues?.players || [],
},
onSubmitPreventDefault: "always",
validate: {
name: isNotEmpty("Name is required"),
players: (value: string[]) => value.length > 1 && value[1] !== '' ? undefined : "Players are required"
players: (value: string[]) =>
value.length > 1 && value[1] !== ""
? undefined
: "Players are required",
song_name: isNotEmpty("Song is required"),
song_start: (value, values) => {
if (values.song_name && values.song_id) {
if (value === undefined || value === null) {
return "Song start time is required";
}
}
return undefined;
},
song_end: (value, values) => {
if (values.song_name && values.song_id) {
if (value === undefined || value === null) {
return "Song end time is required";
}
if (values.song_start !== undefined && value !== undefined) {
const duration = value - values.song_start;
if (duration < 10) {
return "Song segment must be at least 10 seconds";
}
if (duration > 15) {
return "Song segment must be no more than 15 seconds";
}
}
}
return undefined;
},
},
};
const form = useForm(config);
const queryClient = useQueryClient();
const { mutate: createTournament, isPending: createPending } = useCreateTeam();
const { mutate: updateTournament, isPending: updatePending } = useUpdateTeam(teamId!);
const { mutate: createTournament, isPending: createPending } =
useCreateTeam();
const { mutate: updateTournament, isPending: updatePending } = useUpdateTeam(
teamId!
);
const isPending = createPending || updatePending;
@@ -67,9 +98,6 @@ const TeamForm = ({
const { logo, ...teamData } = values;
const mutation = isEditMode ? updateTournament : createTournament;
const successMessage = isEditMode
? "Team updated successfully!"
: "Team created successfully!";
const errorMessage = isEditMode
? "Failed to update team"
: "Failed to create team";
@@ -102,8 +130,6 @@ const TeamForm = ({
tournamentKeys.details(result.team!.id),
result.team
);
toast.success(successMessage);
} catch (error: any) {
const logoErrorMessage = isEditMode
? `Team updated but logo upload failed: ${error.message}`
@@ -112,16 +138,12 @@ const TeamForm = ({
logger.error("Team logo upload error", error);
}
} else {
toast.success(successMessage);
}
close();
},
onError: (error: any) => {
toast.error(`${errorMessage}: ${error.message}`);
logger.error(
`Team ${isEditMode ? "update" : "create"} error`,
error
);
logger.error(`Team ${isEditMode ? "update" : "create"} error`, error);
},
});
},
@@ -130,7 +152,7 @@ const TeamForm = ({
return (
<SlidePanel
onSubmit={form.onSubmit((values) => console.log(values))}
onSubmit={form.onSubmit(handleSubmit)}
onCancel={close}
submitText={isEditMode ? "Update Team" : "Create Team"}
cancelText="Cancel"
@@ -140,6 +162,7 @@ const TeamForm = ({
<TextInput
label="Name"
withAsterisk
disabled={isEditMode}
key={form.key("name")}
{...form.getInputProps("name")}
/>
@@ -152,114 +175,52 @@ const TeamForm = ({
{...form.getInputProps("logo")}
/>
{
tournamentId && (
<PlayersPicker
tournamentId={tournamentId}
key={form.key("players")}
{...form.getInputProps("players")}
/>
)
}
<SlidePanelField
key={form.key("primary_color")}
{...form.getInputProps("primary_color")}
Component={TeamColorPicker}
title="Select Primary Color"
placeholder="Select Primary Color"
label="Primary Color"
formatValue={(value) => (
<Group>
<Badge variant="filled" radius="sm" color={value} />
{value}
</Group>
)}
/>
<SlidePanelField
key={form.key("accent_color")}
{...form.getInputProps("accent_color")}
Component={TeamColorPicker}
title="Select Accent Color"
placeholder="Select Accent Color"
label="Accent Color"
formatValue={(value) => (
<Group>
<Badge variant="filled" radius="sm" color={value} />
{value}
</Group>
)}
/>
<SongPicker form={form} />
{/*
<SlidePanelField
key={form.key("start_time")}
{...form.getInputProps("start_time")}
Component={DateTimePicker}
title="Select Start Date"
label="Start Date"
withAsterisk
formatValue={(date) =>
!date ? 'Select a time' :
new Date(date).toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
})
}
/>
<SlidePanelField
key={form.key("enroll_time")}
{...form.getInputProps("enroll_time")}
Component={DateTimePicker}
title="Select Enrollment Due Date"
label="Enrollment Due"
withAsterisk
formatValue={(date) =>
!date ? 'Select a time' :
new Date(date).toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
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 ? 'Select a time' :
new Date(date).toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
})
}
{tournamentId && (
<PlayersPicker
tournamentId={tournamentId}
key={form.key("players")}
{...form.getInputProps("players")}
disabled={isEditMode}
/>
)}
*/}
<SongPicker
form={form}
error={form.errors.song_name as string}
/>
{/*
<SlidePanelField
key={form.key("primary_color")}
{...form.getInputProps("primary_color")}
Component={TeamColorPicker}
title="Select Primary Color"
placeholder="Select Primary Color"
label="Primary Color"
formatValue={(value) => (
<Group>
<Badge variant="filled" radius="sm" color={value} />
{value}
</Group>
)}
/>
<SlidePanelField
key={form.key("accent_color")}
{...form.getInputProps("accent_color")}
Component={TeamColorPicker}
title="Select Accent Color"
placeholder="Select Accent Color"
label="Accent Color"
formatValue={(value) => (
<Group>
<Badge variant="filled" radius="sm" color={value} />
{value}
</Group>
)}
/>
*/}
</Stack>
</SlidePanel>
);

View File

@@ -42,18 +42,20 @@ const PlayersPicker = ({ tournamentId, value = [], onChange, disabled, error }:
<Autocomplete
label="Player 1"
data={options}
disabled={!isAdmin || disabled}
disabled
value={playerOne}
onChange={setPlayerOne}
comboboxProps={{ withinPortal: false }}
/>
<Autocomplete
label="Player 2"
data={options}
disabled={disabled && !isAdmin}
disabled={disabled}
value={playerTwo}
onChange={setPlayerTwo}
withAsterisk
error={error}
comboboxProps={{ withinPortal: false }}
/>
</Stack>
);

View File

@@ -1,56 +1,149 @@
import { SlidePanelField } from "@/components/sheet/slide-panel";
import { Stack, Text } from "@mantine/core";
import { Stack, Text, Group, Avatar } from "@mantine/core";
import { UseFormReturnType } from "@mantine/form";
import { TeamInput } from "../../types";
import { useEffect, useState } from "react";
import { useMemo } from "react";
import { SpotifyTrack } from "@/lib/spotify/types";
import SongSearch from "./song-search";
import DurationPicker from "./duration-picker";
import SongSummary from "./song-summary";
import { MusicNote } from "@phosphor-icons/react/dist/ssr";
interface Song {
song_id: string;
song_name: string;
song_artist: string;
song_album: string;
song_year?: number;
song_start?: number;
song_end?: number;
song_image_url: string;
}
interface SongPickerProps {
form: UseFormReturnType<TeamInput>
form: UseFormReturnType<TeamInput>;
error?: string;
}
const SongPicker = ({ form }: SongPickerProps) => {
const [song, setSong] = useState<Song>();
useEffect(() => {
const SongPicker = ({ form, error }: SongPickerProps) => {
const currentSong = useMemo((): Song | undefined => {
const values = form.getValues();
setSong({
song_id: values.song_id || "",
song_name: values.song_name || "",
song_artist: values.song_artist || "",
song_album: values.song_album || "",
song_year: values.song_year,
song_start: values.song_start,
song_end: values.song_end,
song_image_url: values.song_image_url || "",
})
}, []);
if (values.song_id && values.song_name) {
return {
song_id: values.song_id,
song_name: values.song_name,
song_artist: values.song_artist || "",
song_album: values.song_album || "",
song_start: values.song_start,
song_end: values.song_end,
song_image_url: values.song_image_url || "",
};
}
return undefined;
}, [form.values.song_id, form.values.song_name, form.values.song_artist, form.values.song_album, form.values.song_image_url, form.values.song_start, form.values.song_end]);
return (
<SlidePanelField
key={"song-picker"}
value={""}
onChange={console.log}
Component={() => (
<Stack>
<Text>Song picker</Text>
</Stack>
)}
value={currentSong}
formatValue={(song) => <SongSummary song={song} />}
onChange={(updatedSong: Song) => {
if (updatedSong) {
form.setValues({
song_id: updatedSong.song_id,
song_name: updatedSong.song_name,
song_artist: updatedSong.song_artist,
song_album: updatedSong.song_album,
song_start: updatedSong.song_start,
song_end: updatedSong.song_end,
song_image_url: updatedSong.song_image_url,
});
}
}}
error={error}
Component={SongPickerComponent}
componentProps={{ formValues: form.getValues() }}
title={"Select Song"}
label={"Song"}
label={"Walkout Song"}
placeholder={"Select your walkout song"}
/>
);
};
interface SongPickerComponentProps {
value: Song | undefined;
onChange: (song: Song) => void;
formValues: any;
}
const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerComponentProps) => {
const handleSongSelect = (track: SpotifyTrack) => {
const defaultStart = 0;
const defaultEnd = Math.min(15, Math.floor(track.duration_ms / 1000));
const newSong: Song = {
song_id: track.id,
song_name: track.name,
song_artist: track.artists.map(a => a.name).join(', '),
song_album: track.album.name,
song_image_url: track.album.images[0]?.url || '',
song_start: defaultStart,
song_end: defaultEnd,
};
onChange(newSong);
};
const handleDurationChange = (startSeconds: number, endSeconds: number) => {
if (song) {
onChange({
...song,
song_start: startSeconds,
song_end: endSeconds,
});
}
};
return (
<Stack styles={{ root: { width: '100%' }}}>
<Text size="lg" fw={500}>Search for a Song</Text>
<SongSearch onChange={handleSongSelect} />
<Stack gap="lg" mt="md">
<Group gap="sm">
<Avatar
src={song?.song_image_url || null}
size={60}
radius="md"
bg="transparent"
>
{!song?.song_image_url && <MusicNote size={24} color="var(--mantine-color-dimmed)" />}
</Avatar>
<div>
<Text size="sm" fw={500} c={song?.song_name ? undefined : "dimmed"}>
{song?.song_name || "No song selected"}
</Text>
<Text size="xs" c="dimmed">
{song?.song_artist || "Select a song to see details"}
</Text>
<Text size="xs" c="dimmed">
{song?.song_album || ""}
</Text>
</div>
</Group>
<Stack gap="xs">
<DurationPicker
songDurationMs={180000}
initialStart={song?.song_start || 0}
initialEnd={song?.song_end || 15}
onChange={handleDurationChange}
disabled={!song?.song_id}
/>
</Stack>
</Stack>
</Stack>
);
};
export default SongPicker;

View File

@@ -0,0 +1,121 @@
import { useState } from "react";
import { Text, Combobox, InputBase, useCombobox, Group, Avatar, Loader } from "@mantine/core";
import { SpotifyTrack } from "@/lib/spotify/types";
import { useDebouncedCallback } from "@mantine/hooks";
interface SongSearchProps {
onChange: (track: SpotifyTrack) => void;
placeholder?: string;
}
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<SpotifyTrack[]>([]);
const [isLoading, setIsLoading] = useState(false);
const combobox = useCombobox();
// Standalone search function that doesn't require Spotify context
const searchSpotifyTracks = async (query: string): Promise<SpotifyTrack[]> => {
if (!query.trim()) return [];
try {
const response = await fetch(`/api/spotify/search?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Search failed');
}
const data = await response.json();
return data.tracks || [];
} catch (error) {
console.error('Failed to search tracks:', error);
return [];
}
};
const debouncedSearch = useDebouncedCallback(async (query: string) => {
if (!query.trim()) {
setSearchResults([]);
return;
}
setIsLoading(true);
try {
const results = await searchSpotifyTracks(query);
setSearchResults(results);
combobox.openDropdown();
} catch (error) {
console.error('Search failed:', error);
setSearchResults([]);
} finally {
setIsLoading(false);
}
}, 300);
const handleSearchChange = (value: string) => {
setSearchQuery(value);
debouncedSearch(value);
};
const handleSongSelect = (track: SpotifyTrack) => {
onChange(track);
setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`);
combobox.closeDropdown();
};
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>
));
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>
<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>
);
};
export default SongSearch;

View File

@@ -0,0 +1,67 @@
import { Group, Avatar, Text, Box } from "@mantine/core";
import { MusicNoteIcon } from "@phosphor-icons/react";
interface Song {
song_id: string;
song_name: string;
song_artist: string;
song_album: string;
song_start?: number;
song_end?: number;
song_image_url: string;
}
interface SongSummaryProps {
song: Song | undefined;
}
const SongSummary = ({ song }: SongSummaryProps) => {
// Format time helper
const formatTime = (seconds: number | undefined) => {
if (seconds === undefined) return null;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
// If no song selected
if (!song?.song_name) {
return (
<Group gap="xs" c="dimmed">
<MusicNoteIcon size={16} />
<Text size="sm" c="dimmed">No song selected</Text>
</Group>
);
}
const hasTimeSegment = song.song_start !== undefined && song.song_end !== undefined;
return (
<Group gap="sm" wrap="nowrap">
{song.song_image_url && (
<Avatar
src={song.song_image_url}
size={32}
radius="sm"
/>
)}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{song.song_name}
</Text>
<Group gap="xs" wrap="nowrap">
<Text size="xs" c="dimmed" truncate style={{ flex: 1 }}>
{song.song_artist}
</Text>
{hasTimeSegment && (
<Text size="xs" c="dimmed" fw={700}>
{formatTime(song.song_start)} - {formatTime(song.song_end)}
</Text>
)}
</Group>
</Box>
</Group>
);
};
export default SongSummary;