diff --git a/src/app/routes/api/spotify/callback.ts b/src/app/routes/api/spotify/callback.ts index 0b728d8..2329424 100644 --- a/src/app/routes/api/spotify/callback.ts +++ b/src/app/routes/api/spotify/callback.ts @@ -1,9 +1,9 @@ import { createFileRoute } from "@tanstack/react-router"; import { SpotifyAuth } from "@/lib/spotify/auth"; -const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID!; -const SPOTIFY_CLIENT_SECRET = import.meta.env.SPOTIFY_CLIENT_SECRET!; -const SPOTIFY_REDIRECT_URI = import.meta.env.VITE_SPOTIFY_REDIRECT_URI!; +const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!; +const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!; +const SPOTIFY_REDIRECT_URI = process.env.VITE_SPOTIFY_REDIRECT_URI!; export const Route = createFileRoute("/api/spotify/callback")({ server: { diff --git a/src/app/routes/api/spotify/search.ts b/src/app/routes/api/spotify/search.ts index 518a4c6..84d189d 100644 --- a/src/app/routes/api/spotify/search.ts +++ b/src/app/routes/api/spotify/search.ts @@ -1,8 +1,8 @@ import { createFileRoute } from "@tanstack/react-router"; async function getClientCredentialsToken(): Promise { - const clientId = import.meta.env.VITE_SPOTIFY_CLIENT_ID; - const clientSecret = import.meta.env.SPOTIFY_CLIENT_SECRET; + 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"); diff --git a/src/app/routes/api/spotify/token.ts b/src/app/routes/api/spotify/token.ts index 7160965..86c3ded 100644 --- a/src/app/routes/api/spotify/token.ts +++ b/src/app/routes/api/spotify/token.ts @@ -1,7 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; -const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID!; -const SPOTIFY_CLIENT_SECRET = import.meta.env.SPOTIFY_CLIENT_SECRET!; +const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!; +const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!; export const Route = createFileRoute("/api/spotify/token")({ server: { diff --git a/src/components/typeahead.tsx b/src/components/typeahead.tsx new file mode 100644 index 0000000..1843057 --- /dev/null +++ b/src/components/typeahead.tsx @@ -0,0 +1,175 @@ +import { useState, useRef, useEffect, ReactNode } from "react"; +import { TextInput, Loader, Paper, Stack, Box, Text } from "@mantine/core"; +import { useDebouncedCallback } from "@mantine/hooks"; + +export interface TypeaheadOption { + id: string; + data: T; +} + +export interface TypeaheadProps { + onSelect: (option: TypeaheadOption) => void; + searchFn: (query: string) => Promise[]>; + renderOption: (option: TypeaheadOption, isSelected?: boolean) => ReactNode; + format?: (option: TypeaheadOption) => string; + placeholder?: string; + debounceMs?: number; + disabled?: boolean; + initialValue?: string; +} + +const Typeahead = ({ + onSelect, + searchFn, + renderOption, + format, + placeholder = "Search...", + debounceMs = 300, + disabled = false, + initialValue = "" +}: TypeaheadProps) => { + const [searchQuery, setSearchQuery] = useState(initialValue); + const [searchResults, setSearchResults] = useState[]>([]); + const [isLoading, setIsLoading] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const containerRef = useRef(null); + const inputRef = useRef(null); + + const debouncedSearch = useDebouncedCallback(async (query: string) => { + if (!query.trim()) { + setSearchResults([]); + setIsOpen(false); + return; + } + + setIsLoading(true); + try { + const results = await searchFn(query); + setSearchResults(results); + setIsOpen(results.length > 0); + setSelectedIndex(-1); + } catch (error) { + console.error('Search failed:', error); + setSearchResults([]); + setIsOpen(false); + } finally { + setIsLoading(false); + } + }, debounceMs); + + const handleSearchChange = (value: string) => { + setSearchQuery(value); + debouncedSearch(value); + }; + + const handleOptionSelect = (option: TypeaheadOption) => { + onSelect(option); + const displayValue = format ? format(option) : String(option.data); + setSearchQuery(displayValue); + setIsOpen(false); + setSelectedIndex(-1); + }; + + 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]) { + handleOptionSelect(searchResults[selectedIndex]); + } + break; + case 'Escape': + setIsOpen(false); + setSelectedIndex(-1); + break; + } + }; + + return ( + + handleSearchChange(event.currentTarget.value)} + onKeyDown={handleKeyDown} + onFocus={() => { + if (searchResults.length > 0) setIsOpen(true); + }} + placeholder={placeholder} + rightSection={isLoading ? : null} + disabled={disabled} + /> + + {isOpen && ( + e.stopPropagation()} + > + {searchResults.length > 0 ? ( + + {searchResults.map((option, index) => ( + handleOptionSelect(option)} + onMouseEnter={() => setSelectedIndex(index)} + > + {renderOption(option, selectedIndex === index)} + + ))} + + ) : ( + + + {searchQuery.trim() ? 'No results found' : 'Start typing to search...'} + + + )} + + )} + + ); +}; + +export default Typeahead; \ 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 index bef59ba..49971b9 100644 --- a/src/features/teams/components/team-form/song-search.tsx +++ b/src/features/teams/components/team-form/song-search.tsx @@ -1,7 +1,6 @@ -import { useState, useRef, useEffect } from "react"; -import { Text, TextInput, Group, Avatar, Loader, Paper, Stack, Box } from "@mantine/core"; +import { Text, Group, Avatar, Box } from "@mantine/core"; import { SpotifyTrack } from "@/lib/spotify/types"; -import { useDebouncedCallback } from "@mantine/hooks"; +import Typeahead, { TypeaheadOption } from "@/components/typeahead"; interface SongSearchProps { onChange: (track: SpotifyTrack) => void; @@ -9,15 +8,7 @@ interface SongSearchProps { } const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => { - const [searchQuery, setSearchQuery] = useState(""); - const [searchResults, setSearchResults] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isOpen, setIsOpen] = useState(false); - const [selectedIndex, setSelectedIndex] = useState(-1); - const containerRef = useRef(null); - const inputRef = useRef(null); - - const searchSpotifyTracks = async (query: string): Promise => { + const searchSpotifyTracks = async (query: string): Promise[]> => { if (!query.trim()) return []; try { @@ -28,155 +19,62 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc } const data = await response.json(); - return data.tracks || []; + const tracks = data.tracks || []; + + return tracks.map((track: SpotifyTrack) => ({ + id: track.id, + data: track + })); } catch (error) { console.error('Failed to search tracks:', error); return []; } }; - const debouncedSearch = useDebouncedCallback(async (query: string) => { - if (!query.trim()) { - setSearchResults([]); - setIsOpen(false); - return; - } - - setIsLoading(true); - try { - const results = await searchSpotifyTracks(query); - setSearchResults(results); - setIsOpen(results.length > 0); - setSelectedIndex(-1); - } catch (error) { - console.error('Search failed:', error); - setSearchResults([]); - setIsOpen(false); - } finally { - setIsLoading(false); - } - }, 300); - - const handleSearchChange = (value: string) => { - setSearchQuery(value); - debouncedSearch(value); + const handleSongSelect = (option: TypeaheadOption) => { + onChange(option.data); }; - const handleSongSelect = (track: SpotifyTrack) => { - onChange(track); - setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`); - setIsOpen(false); - setSelectedIndex(-1); + const formatTrack = (option: TypeaheadOption) => { + const track = option.data; + return `${track.name} - ${track.artists.map(a => a.name).join(', ')}`; }; - 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; - } + const renderOption = (option: TypeaheadOption) => { + const track = option.data; + return ( + + + {track.album.images[2] && ( + + )} +
+ + {track.name} + + + {track.artists.map(a => a.name).join(', ')} • {track.album.name} + +
+
+
+ ); }; return ( - - handleSearchChange(event.currentTarget.value)} - onKeyDown={handleKeyDown} - onFocus={() => { - if (searchResults.length > 0) setIsOpen(true); - }} - placeholder={placeholder} - rightSection={isLoading ? : null} - /> - - {isOpen && ( - e.stopPropagation()} - > - {searchResults.length > 0 ? ( - - {searchResults.map((track, index) => ( - handleSongSelect(track)} - onMouseEnter={() => setSelectedIndex(index)} - > - - {track.album.images[2] && ( - - )} -
- - {track.name} - - - {track.artists.map(a => a.name).join(', ')} • {track.album.name} - -
-
-
- ))} -
- ) : ( - - - {searchQuery.trim() ? 'No songs found' : 'Start typing to search...'} - - - )} -
- )} -
- ); + + ) }; export default SongSearch; \ No newline at end of file diff --git a/src/features/tournaments/components/edit-enrolled-teams.tsx b/src/features/tournaments/components/edit-enrolled-teams.tsx index f2bb3f7..02f0d80 100644 --- a/src/features/tournaments/components/edit-enrolled-teams.tsx +++ b/src/features/tournaments/components/edit-enrolled-teams.tsx @@ -1,11 +1,11 @@ import { - Autocomplete, Stack, ActionIcon, Text, Group, Loader, } from "@mantine/core"; +import Typeahead, { TypeaheadOption } from "@/components/typeahead"; import { TrashIcon } from "@phosphor-icons/react"; import { useState, useCallback, useMemo, memo } from "react"; import { useTournament, useUnenrolledTeams } from "../queries"; @@ -68,8 +68,6 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => { }); const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => { - const [search, setSearch] = useState(""); - const { data: tournament, isLoading: tournamentLoading } = useTournament(tournamentId); const { data: unenrolledTeams = [], isLoading: unenrolledLoading } = @@ -78,27 +76,24 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => { const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam(); const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam(); - const autocompleteData = useMemo( - () => - unenrolledTeams.map((team: Team) => ({ - value: team.id, - label: team.name, - })), - [unenrolledTeams] - ); + const searchTeams = async (query: string): Promise[]> => { + if (!query.trim()) return []; + + const filtered = unenrolledTeams.filter((team: Team) => + team.name.toLowerCase().includes(query.toLowerCase()) + ); + + return filtered.map((team: Team) => ({ + id: team.id, + data: team + })); + }; const handleEnrollTeam = useCallback( - (teamId: string) => { - enrollTeam( - { tournamentId, teamId }, - { - onSuccess: () => { - setSearch(""); - }, - } - ); + (option: TypeaheadOption) => { + enrollTeam({ tournamentId, teamId: option.data.id }); }, - [enrollTeam, tournamentId, setSearch] + [enrollTeam, tournamentId] ); const handleUnenrollTeam = useCallback( @@ -108,6 +103,31 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => { [unenrollTeam, tournamentId] ); + const renderTeamOption = (option: TypeaheadOption) => { + const team = option.data; + return ( + + + + {team.name} + + + ); + }; + + const formatTeam = (option: TypeaheadOption) => { + return option.data.name; + }; + const isLoading = tournamentLoading || unenrolledLoading; const enrolledTeams = tournament?.teams || []; const hasEnrolledTeams = enrolledTeams.length > 0; @@ -118,16 +138,13 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => { Add Team - : null} - maxDropdownHeight={200} - limit={10} /> diff --git a/src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx b/src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx index 904b888..a17d4a5 100644 --- a/src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx +++ b/src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx @@ -1,6 +1,7 @@ -import { Stack, Button, Divider, Autocomplete, Group, ComboboxItem } from '@mantine/core'; +import { Stack, Button, Divider, Group, ComboboxItem, Text } from '@mantine/core'; import { PlusIcon } from '@phosphor-icons/react'; -import React, { useMemo, useState } from 'react'; +import React, { useMemo } from 'react'; +import Typeahead, { TypeaheadOption } from '@/components/typeahead'; interface TeamSelectionViewProps { options: ComboboxItem[]; @@ -11,11 +12,39 @@ const TeamSelectionView: React.FC = React.memo(({ options, onSelect }) => { - const [value, setValue] = useState(''); - const selectedOption = useMemo(() => options.find(option => option.label === value), [value, options]) + const [selectedTeam, setSelectedTeam] = React.useState(null); + + const searchTeams = async (query: string): Promise[]> => { + if (!query.trim()) return []; + + const filtered = options.filter(option => + option.label.toLowerCase().includes(query.toLowerCase()) + ); + + return filtered.map(option => ({ + id: String(option.value), + data: option + })); + }; + + const handleTeamSelect = (option: TypeaheadOption) => { + setSelectedTeam(option.data); + }; + + const renderTeamOption = (option: TypeaheadOption) => { + return ( + + {option.data.label} + + ); + }; + + const formatTeam = (option: TypeaheadOption) => { + return option.data.label; + }; const handleCreateNewTeamClicked = () => onSelect(undefined); - const handleSelectExistingTeam = () => onSelect(selectedOption?.value) + const handleSelectExistingTeam = () => onSelect(selectedTeam?.value); return ( @@ -31,17 +60,17 @@ const TeamSelectionView: React.FC = React.memo(({ - option.label)} - comboboxProps={{ withinPortal: false }} + onSelect={handleTeamSelect} + searchFn={searchTeams} + renderOption={renderTeamOption} + format={formatTeam} />