diff --git a/pb_migrations/1758042930_updated_teams.js b/pb_migrations/1758042930_updated_teams.js new file mode 100644 index 0000000..94b8598 --- /dev/null +++ b/pb_migrations/1758042930_updated_teams.js @@ -0,0 +1,55 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1568971955") + + // remove field + collection.fields.removeById("number3356599746") + + // update field + collection.fields.addAt(9, new Field({ + "hidden": false, + "id": "number1329349942", + "max": null, + "min": null, + "name": "song_end", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1568971955") + + // add field + collection.fields.addAt(8, new Field({ + "hidden": false, + "id": "number3356599746", + "max": null, + "min": null, + "name": "song_year", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + })) + + // update field + collection.fields.addAt(10, new Field({ + "hidden": false, + "id": "number1329349942", + "max": null, + "min": null, + "name": "song_duration", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + })) + + return app.save(collection) +}) diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index fe450ff..78b6498 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -31,6 +31,7 @@ import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authe import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo' import { ServerRoute as ApiTeamsUploadLogoServerRouteImport } from './routes/api/teams/upload-logo' import { ServerRoute as ApiSpotifyTokenServerRouteImport } from './routes/api/spotify/token' +import { ServerRoute as ApiSpotifySearchServerRouteImport } from './routes/api/spotify/search' import { ServerRoute as ApiSpotifyResumeServerRouteImport } from './routes/api/spotify/resume' import { ServerRoute as ApiSpotifyPlaybackServerRouteImport } from './routes/api/spotify/playback' import { ServerRoute as ApiSpotifyCaptureServerRouteImport } from './routes/api/spotify/capture' @@ -146,6 +147,11 @@ const ApiSpotifyTokenServerRoute = ApiSpotifyTokenServerRouteImport.update({ path: '/api/spotify/token', getParentRoute: () => rootServerRouteImport, } as any) +const ApiSpotifySearchServerRoute = ApiSpotifySearchServerRouteImport.update({ + id: '/api/spotify/search', + path: '/api/spotify/search', + getParentRoute: () => rootServerRouteImport, +} as any) const ApiSpotifyResumeServerRoute = ApiSpotifyResumeServerRouteImport.update({ id: '/api/spotify/resume', path: '/api/spotify/resume', @@ -310,6 +316,7 @@ export interface FileServerRoutesByFullPath { '/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute '/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute '/api/spotify/resume': typeof ApiSpotifyResumeServerRoute + '/api/spotify/search': typeof ApiSpotifySearchServerRoute '/api/spotify/token': typeof ApiSpotifyTokenServerRoute '/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute @@ -322,6 +329,7 @@ export interface FileServerRoutesByTo { '/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute '/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute '/api/spotify/resume': typeof ApiSpotifyResumeServerRoute + '/api/spotify/search': typeof ApiSpotifySearchServerRoute '/api/spotify/token': typeof ApiSpotifyTokenServerRoute '/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute @@ -335,6 +343,7 @@ export interface FileServerRoutesById { '/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute '/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute '/api/spotify/resume': typeof ApiSpotifyResumeServerRoute + '/api/spotify/search': typeof ApiSpotifySearchServerRoute '/api/spotify/token': typeof ApiSpotifyTokenServerRoute '/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute @@ -349,6 +358,7 @@ export interface FileServerRouteTypes { | '/api/spotify/capture' | '/api/spotify/playback' | '/api/spotify/resume' + | '/api/spotify/search' | '/api/spotify/token' | '/api/teams/upload-logo' | '/api/tournaments/upload-logo' @@ -361,6 +371,7 @@ export interface FileServerRouteTypes { | '/api/spotify/capture' | '/api/spotify/playback' | '/api/spotify/resume' + | '/api/spotify/search' | '/api/spotify/token' | '/api/teams/upload-logo' | '/api/tournaments/upload-logo' @@ -373,6 +384,7 @@ export interface FileServerRouteTypes { | '/api/spotify/capture' | '/api/spotify/playback' | '/api/spotify/resume' + | '/api/spotify/search' | '/api/spotify/token' | '/api/teams/upload-logo' | '/api/tournaments/upload-logo' @@ -386,6 +398,7 @@ export interface RootServerRouteChildren { ApiSpotifyCaptureServerRoute: typeof ApiSpotifyCaptureServerRoute ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute ApiSpotifyResumeServerRoute: typeof ApiSpotifyResumeServerRoute + ApiSpotifySearchServerRoute: typeof ApiSpotifySearchServerRoute ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute ApiTeamsUploadLogoServerRoute: typeof ApiTeamsUploadLogoServerRoute ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute @@ -538,6 +551,13 @@ declare module '@tanstack/react-start/server' { preLoaderRoute: typeof ApiSpotifyTokenServerRouteImport parentRoute: typeof rootServerRouteImport } + '/api/spotify/search': { + id: '/api/spotify/search' + path: '/api/spotify/search' + fullPath: '/api/spotify/search' + preLoaderRoute: typeof ApiSpotifySearchServerRouteImport + parentRoute: typeof rootServerRouteImport + } '/api/spotify/resume': { id: '/api/spotify/resume' path: '/api/spotify/resume' @@ -651,6 +671,7 @@ const rootServerRouteChildren: RootServerRouteChildren = { ApiSpotifyCaptureServerRoute: ApiSpotifyCaptureServerRoute, ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute, ApiSpotifyResumeServerRoute: ApiSpotifyResumeServerRoute, + ApiSpotifySearchServerRoute: ApiSpotifySearchServerRoute, ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute, ApiTeamsUploadLogoServerRoute: ApiTeamsUploadLogoServerRoute, ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute, diff --git a/src/app/routes/api/spotify/search.ts b/src/app/routes/api/spotify/search.ts new file mode 100644 index 0000000..8b988b2 --- /dev/null +++ b/src/app/routes/api/spotify/search.ts @@ -0,0 +1,81 @@ +import { createServerFileRoute } from '@tanstack/react-start/server' + +// Function to get Client Credentials access token +async function getClientCredentialsToken(): Promise { + const clientId = process.env.VITE_SPOTIFY_CLIENT_ID + const clientSecret = process.env.SPOTIFY_CLIENT_SECRET + + if (!clientId || !clientSecret) { + throw new Error('Missing Spotify client credentials') + } + + const response = await fetch('https://accounts.spotify.com/api/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`, + }, + body: 'grant_type=client_credentials', + }) + + if (!response.ok) { + throw new Error('Failed to get Spotify access token') + } + + const data = await response.json() + return data.access_token +} + +export const ServerRoute = createServerFileRoute('/api/spotify/search').methods({ + GET: async ({ request }: { request: Request }) => { + try { + const url = new URL(request.url) + const query = url.searchParams.get('q') + + if (!query) { + return new Response( + JSON.stringify({ error: 'Query parameter q is required' }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + + // Get client credentials access token + const accessToken = await getClientCredentialsToken() + + // Search using Spotify API directly + const searchUrl = `https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=20` + + const searchResponse = await fetch(searchUrl, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + }, + }) + + if (!searchResponse.ok) { + throw new Error('Spotify search request failed') + } + + const searchResult = await searchResponse.json() + + return new Response( + JSON.stringify({ tracks: searchResult.tracks.items }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + } catch (error) { + console.error('Search error:', error) + return new Response( + JSON.stringify({ error: 'Search failed', details: error instanceof Error ? error.message : 'Unknown error' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + }, +}) \ No newline at end of file diff --git a/src/contexts/spotify-context.tsx b/src/contexts/spotify-context.tsx index 7722b15..cd767f1 100644 --- a/src/contexts/spotify-context.tsx +++ b/src/contexts/spotify-context.tsx @@ -389,6 +389,24 @@ export const SpotifyProvider: React.FC = ({ children }) => { setCapturedState(null); }, []); + const searchTracks = useCallback(async (query: string): Promise => { + 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 contextValue: SpotifyContextType = { ...authState, currentTrack, @@ -415,6 +433,8 @@ export const SpotifyProvider: React.FC = ({ children }) => { capturePlaybackState, resumePlaybackState, clearCapturedState, + // Search + searchTracks, }; if (!isAdmin) { diff --git a/src/features/teams/components/team-form/duration-picker.tsx b/src/features/teams/components/team-form/duration-picker.tsx new file mode 100644 index 0000000..f2a6f88 --- /dev/null +++ b/src/features/teams/components/team-form/duration-picker.tsx @@ -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 ( + +
+ + Start and End + + + {disabled ? "Select a song to choose segment timing" : "Choose a 10-15 second segment for your walkout song"} + +
+ + formatTime(value)} + disabled={disabled} + styles={{ + track: { height: 8 }, + }} + /> + + + + + + + Start + + + {formatTime(range[0])} + + + + + + Duration + + + {segmentDuration}s + + + + + + End + + + {formatTime(range[1])} + + + + + {!isValid && ( + + {getValidationMessage(range[0], range[1])} + + )} +
+ ); +}; + +export default DurationPicker; diff --git a/src/features/teams/components/team-form/index.tsx b/src/features/teams/components/team-form/index.tsx index 03cbbba..28508f3 100644 --- a/src/features/teams/components/team-form/index.tsx +++ b/src/features/teams/components/team-form/index.tsx @@ -34,31 +34,62 @@ const TeamForm = ({ const config: UseFormInput = { 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 ( console.log(values))} + onSubmit={form.onSubmit(handleSubmit)} onCancel={close} submitText={isEditMode ? "Update Team" : "Create Team"} cancelText="Cancel" @@ -140,6 +162,7 @@ const TeamForm = ({ @@ -152,114 +175,52 @@ const TeamForm = ({ {...form.getInputProps("logo")} /> - { - tournamentId && ( - - ) - } - - ( - - - {value} - - )} - /> - - ( - - - {value} - - )} - /> - - - - -{/* - - !date ? 'Select a time' : - new Date(date).toLocaleDateString("en-US", { - weekday: "short", - year: "numeric", - month: "short", - day: "numeric", - hour: "numeric", - minute: "numeric", - hour12: true, - }) - } - /> - - - !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 && ( - - !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 && ( + )} - */} + + + + {/* + ( + + + {value} + + )} + /> + + ( + + + {value} + + )} + /> + */} + ); diff --git a/src/features/teams/components/team-form/players-picker.tsx b/src/features/teams/components/team-form/players-picker.tsx index 4a34657..c340b7e 100644 --- a/src/features/teams/components/team-form/players-picker.tsx +++ b/src/features/teams/components/team-form/players-picker.tsx @@ -42,18 +42,20 @@ const PlayersPicker = ({ tournamentId, value = [], onChange, disabled, error }: ); diff --git a/src/features/teams/components/team-form/song-picker.tsx b/src/features/teams/components/team-form/song-picker.tsx index c20629a..298ae12 100644 --- a/src/features/teams/components/team-form/song-picker.tsx +++ b/src/features/teams/components/team-form/song-picker.tsx @@ -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 + form: UseFormReturnType; + error?: string; } -const SongPicker = ({ form }: SongPickerProps) => { - const [song, setSong] = useState(); - - 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 ( ( - - Song picker - - )} + value={currentSong} + formatValue={(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 ( + + Search for a Song + + + + + + + {!song?.song_image_url && } + +
+ + {song?.song_name || "No song selected"} + + + {song?.song_artist || "Select a song to see details"} + + + {song?.song_album || ""} + +
+
+ + + + +
+
+ ); +}; + export default SongPicker; \ No newline at end of file diff --git a/src/features/teams/components/team-form/song-search.tsx b/src/features/teams/components/team-form/song-search.tsx new file mode 100644 index 0000000..cb7f65a --- /dev/null +++ b/src/features/teams/components/team-form/song-search.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(false); + + const combobox = useCombobox(); + + // Standalone search function that doesn't require Spotify context + const searchSpotifyTracks = async (query: string): Promise => { + 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) => ( + + + {track.album.images[2] && ( + + )} +
+ + {track.name} + + + {track.artists.map(a => a.name).join(', ')} • {track.album.name} + +
+
+
+ )); + + 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} + /> + + + + + {options.length > 0 ? options : + + {searchQuery.trim() ? 'No songs found' : 'Start typing to search...'} + + } + + + + ); +}; + +export default SongSearch; \ No newline at end of file diff --git a/src/features/teams/components/team-form/song-summary.tsx b/src/features/teams/components/team-form/song-summary.tsx new file mode 100644 index 0000000..691f584 --- /dev/null +++ b/src/features/teams/components/team-form/song-summary.tsx @@ -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 ( + + + No song selected + + ); + } + + const hasTimeSegment = song.song_start !== undefined && song.song_end !== undefined; + + return ( + + {song.song_image_url && ( + + )} + + + {song.song_name} + + + + {song.song_artist} + + {hasTimeSegment && ( + + {formatTime(song.song_start)} - {formatTime(song.song_end)} + + )} + + + + ); +}; + +export default SongSummary; \ No newline at end of file diff --git a/src/features/teams/types.ts b/src/features/teams/types.ts index c77639b..c034a53 100644 --- a/src/features/teams/types.ts +++ b/src/features/teams/types.ts @@ -12,7 +12,6 @@ export interface Team { song_name: string; song_artist: string; song_album: string; - song_year: number; song_start: number; song_end: number; song_image_url: string; @@ -47,7 +46,6 @@ export const teamInputSchema = z song_name: z.string().max(255).optional(), song_artist: z.string().max(255).optional(), song_album: z.string().max(255).optional(), - song_year: z.number().int().optional(), song_start: z.number().int().optional(), song_end: z.number().int().optional(), song_image_url: z.url("Invalid song image URL").optional(), @@ -79,7 +77,6 @@ export const teamUpdateSchema = z song_name: z.string().max(255).optional(), song_artist: z.string().max(255).optional(), song_album: z.string().max(255).optional(), - song_year: z.number().int().optional(), song_start: z.number().int().optional(), song_end: z.number().int().optional(), song_image_url: z.url("Invalid song image URL").optional(), diff --git a/src/features/tournaments/components/upcoming-tournament/enroll-team/index.tsx b/src/features/tournaments/components/upcoming-tournament/enroll-team/index.tsx index d145e1d..1a271d6 100644 --- a/src/features/tournaments/components/upcoming-tournament/enroll-team/index.tsx +++ b/src/features/tournaments/components/upcoming-tournament/enroll-team/index.tsx @@ -2,9 +2,12 @@ import Button from "@/components/button"; import Sheet from "@/components/sheet/sheet"; import { useAuth } from "@/contexts/auth-context"; import { useSheet } from "@/hooks/use-sheet"; -import { useMemo, useState, useCallback } from "react"; +import { useMemo, useState, useCallback, useEffect } from "react"; import TeamSelectionView from "./team-selection-view"; import TeamForm from "@/features/teams/components/team-form"; +import { teamQueries } from "@/features/teams/queries"; +import { Team } from "@/features/teams/types"; +import { useQuery } from "@tanstack/react-query"; interface EnrollTeamProps { tournamentId: string; @@ -13,12 +16,25 @@ interface EnrollTeamProps { const EnrollTeam = ({ tournamentId }: EnrollTeamProps) => { const { open, isOpen, toggle } = useSheet(); const { user } = useAuth(); - + const hasTeams = useMemo(() => !!user?.teams?.length, [user?.teams]); const [selectedTeamId, setSelectedTeamId] = useState(); - const [showTeamForm, setShowTeamForm] = useState(!!hasTeams); + const [showTeamForm, setShowTeamForm] = useState(!hasTeams); + const [selectedTeam, setSelectedTeam] = useState(null); - const teamOptions = useMemo(() => + const { data: teamData } = useQuery({ + ...teamQueries.details(selectedTeamId!), + enabled: !!selectedTeamId, + }); + + useEffect(() => { + if (teamData?.success) { + setSelectedTeam(teamData.data); + setShowTeamForm(true); + } + }, [teamData]); + + const teamOptions = useMemo(() => user?.teams?.map(team => ({ value: team.id, label: team.name @@ -26,11 +42,34 @@ const EnrollTeam = ({ tournamentId }: EnrollTeamProps) => { [user?.teams] ); - const handleSelect = useCallback((teamId: string | undefined) => { - setSelectedTeamId(teamId); - setShowTeamForm(true); + const handleBack = useCallback(() => { + setSelectedTeamId(undefined); + setSelectedTeam(null); + setShowTeamForm(false); }, []); + const handleSelect = useCallback((teamId: string | undefined) => { + setSelectedTeamId(teamId); + if (!teamId) { + setShowTeamForm(true); + } + }, []); + + const initialValues = useMemo(() => { + if (!selectedTeam) return undefined; + + return { + name: selectedTeam.name, + song_id: selectedTeam.song_id, + song_name: selectedTeam.song_name, + song_artist: selectedTeam.song_artist, + song_album: selectedTeam.song_album, + song_start: selectedTeam.song_start, + song_end: selectedTeam.song_end, + song_image_url: selectedTeam.song_image_url, + players: selectedTeam.players.map(player => player.id), + }; + }, [selectedTeam]); return ( <> @@ -38,9 +77,14 @@ const EnrollTeam = ({ tournamentId }: EnrollTeamProps) => { Enroll Your Team - + {showTeamForm ? ( - + ) : ( <> = React.memo(({ options, onSelect }) => { - const [value, setValue] = useState(); - const selectedOption = useMemo(() => options.find(option => option.label === value), [value]) - + const [value, setValue] = useState(''); + const selectedOption = useMemo(() => options.find(option => option.label === value), [value, options]) const handleCreateNewTeamClicked = () => onSelect(undefined); const handleSelectExistingTeam = () => onSelect(selectedOption?.value) @@ -28,19 +27,19 @@ const TeamSelectionView: React.FC = React.memo(({ > Create New Team - + - + option.label)} comboboxProps={{ withinPortal: false }} /> - -