working team update/create (still need enroll)
This commit is contained in:
202
src/features/teams/components/team-form/duration-picker.tsx
Normal file
202
src/features/teams/components/team-form/duration-picker.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
121
src/features/teams/components/team-form/song-search.tsx
Normal file
121
src/features/teams/components/team-form/song-search.tsx
Normal 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;
|
||||
67
src/features/teams/components/team-form/song-summary.tsx
Normal file
67
src/features/teams/components/team-form/song-summary.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user