new typeahead

This commit is contained in:
yohlo
2025-09-25 16:11:54 -05:00
parent c0ef535001
commit b3ebf46afa
7 changed files with 314 additions and 195 deletions

View File

@@ -1,9 +1,9 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { SpotifyAuth } from "@/lib/spotify/auth"; import { SpotifyAuth } from "@/lib/spotify/auth";
const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID!; const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!;
const SPOTIFY_CLIENT_SECRET = import.meta.env.SPOTIFY_CLIENT_SECRET!; const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
const SPOTIFY_REDIRECT_URI = import.meta.env.VITE_SPOTIFY_REDIRECT_URI!; const SPOTIFY_REDIRECT_URI = process.env.VITE_SPOTIFY_REDIRECT_URI!;
export const Route = createFileRoute("/api/spotify/callback")({ export const Route = createFileRoute("/api/spotify/callback")({
server: { server: {

View File

@@ -1,8 +1,8 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
async function getClientCredentialsToken(): Promise<string> { async function getClientCredentialsToken(): Promise<string> {
const clientId = import.meta.env.VITE_SPOTIFY_CLIENT_ID; const clientId = process.env.VITE_SPOTIFY_CLIENT_ID;
const clientSecret = import.meta.env.SPOTIFY_CLIENT_SECRET; const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
if (!clientId || !clientSecret) { if (!clientId || !clientSecret) {
throw new Error("Missing Spotify client credentials"); throw new Error("Missing Spotify client credentials");

View File

@@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID!; const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!;
const SPOTIFY_CLIENT_SECRET = import.meta.env.SPOTIFY_CLIENT_SECRET!; const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
export const Route = createFileRoute("/api/spotify/token")({ export const Route = createFileRoute("/api/spotify/token")({
server: { server: {

View File

@@ -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<T = any> {
id: string;
data: T;
}
export interface TypeaheadProps<T> {
onSelect: (option: TypeaheadOption<T>) => void;
searchFn: (query: string) => Promise<TypeaheadOption<T>[]>;
renderOption: (option: TypeaheadOption<T>, isSelected?: boolean) => ReactNode;
format?: (option: TypeaheadOption<T>) => string;
placeholder?: string;
debounceMs?: number;
disabled?: boolean;
initialValue?: string;
}
const Typeahead = <T,>({
onSelect,
searchFn,
renderOption,
format,
placeholder = "Search...",
debounceMs = 300,
disabled = false,
initialValue = ""
}: TypeaheadProps<T>) => {
const [searchQuery, setSearchQuery] = useState(initialValue);
const [searchResults, setSearchResults] = useState<TypeaheadOption<T>[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const 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<T>) => {
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 (
<Box ref={containerRef} pos="relative" w="100%">
<TextInput
ref={inputRef}
value={searchQuery}
onChange={(event) => handleSearchChange(event.currentTarget.value)}
onKeyDown={handleKeyDown}
onFocus={() => {
if (searchResults.length > 0) setIsOpen(true);
}}
placeholder={placeholder}
rightSection={isLoading ? <Loader size="xs" /> : null}
disabled={disabled}
/>
{isOpen && (
<Paper
shadow="md"
p={0}
bd="1px solid var(--mantine-color-dimmed)"
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 9999,
maxHeight: '160px',
overflowY: 'auto',
WebkitOverflowScrolling: 'touch',
touchAction: 'pan-y',
borderTop: 0,
borderTopLeftRadius: 0,
borderTopRightRadius: 0
}}
onTouchMove={(e) => e.stopPropagation()}
>
{searchResults.length > 0 ? (
<Stack gap={0}>
{searchResults.map((option, index) => (
<Box
key={option.id}
style={{
cursor: 'pointer',
backgroundColor: selectedIndex === index ? 'var(--mantine-color-gray-1)' : 'transparent',
}}
onClick={() => handleOptionSelect(option)}
onMouseEnter={() => setSelectedIndex(index)}
>
{renderOption(option, selectedIndex === index)}
</Box>
))}
</Stack>
) : (
<Box p="md">
<Text size="sm" c="dimmed" ta="center">
{searchQuery.trim() ? 'No results found' : 'Start typing to search...'}
</Text>
</Box>
)}
</Paper>
)}
</Box>
);
};
export default Typeahead;

View File

@@ -1,7 +1,6 @@
import { useState, useRef, useEffect } from "react"; import { Text, Group, Avatar, Box } from "@mantine/core";
import { Text, TextInput, Group, Avatar, Loader, Paper, Stack, Box } from "@mantine/core";
import { SpotifyTrack } from "@/lib/spotify/types"; import { SpotifyTrack } from "@/lib/spotify/types";
import { useDebouncedCallback } from "@mantine/hooks"; import Typeahead, { TypeaheadOption } from "@/components/typeahead";
interface SongSearchProps { interface SongSearchProps {
onChange: (track: SpotifyTrack) => void; onChange: (track: SpotifyTrack) => void;
@@ -9,15 +8,7 @@ interface SongSearchProps {
} }
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => { const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
const [searchQuery, setSearchQuery] = useState(""); const searchSpotifyTracks = async (query: string): Promise<TypeaheadOption<SpotifyTrack>[]> => {
const [searchResults, setSearchResults] = useState<SpotifyTrack[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const searchSpotifyTracks = async (query: string): Promise<SpotifyTrack[]> => {
if (!query.trim()) return []; if (!query.trim()) return [];
try { try {
@@ -28,127 +19,35 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
} }
const data = await response.json(); const data = await response.json();
return data.tracks || []; const tracks = data.tracks || [];
return tracks.map((track: SpotifyTrack) => ({
id: track.id,
data: track
}));
} catch (error) { } catch (error) {
console.error('Failed to search tracks:', error); console.error('Failed to search tracks:', error);
return []; return [];
} }
}; };
const debouncedSearch = useDebouncedCallback(async (query: string) => { const handleSongSelect = (option: TypeaheadOption<SpotifyTrack>) => {
if (!query.trim()) { onChange(option.data);
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 = (track: SpotifyTrack) => { const formatTrack = (option: TypeaheadOption<SpotifyTrack>) => {
onChange(track); const track = option.data;
setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`); return `${track.name} - ${track.artists.map(a => a.name).join(', ')}`;
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]) {
handleSongSelect(searchResults[selectedIndex]);
}
break;
case 'Escape':
setIsOpen(false);
setSelectedIndex(-1);
break;
}
}; };
const renderOption = (option: TypeaheadOption<SpotifyTrack>) => {
const track = option.data;
return ( return (
<Box ref={containerRef} pos="relative" w="100%">
<TextInput
ref={inputRef}
value={searchQuery}
onChange={(event) => handleSearchChange(event.currentTarget.value)}
onKeyDown={handleKeyDown}
onFocus={() => {
if (searchResults.length > 0) setIsOpen(true);
}}
placeholder={placeholder}
rightSection={isLoading ? <Loader size="xs" /> : null}
/>
{isOpen && (
<Paper
shadow="md"
p={0}
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 9999,
maxHeight: '160px',
overflowY: 'auto',
WebkitOverflowScrolling: 'touch',
touchAction: 'pan-y'
}}
onTouchMove={(e) => e.stopPropagation()}
>
{searchResults.length > 0 ? (
<Stack gap={0}>
{searchResults.map((track, index) => (
<Box <Box
key={track.id}
p="sm" p="sm"
style={{ style={{
cursor: 'pointer', borderBottom: '1px solid var(--mantine-color-dimmed)'
backgroundColor: selectedIndex === index ? 'var(--mantine-color-gray-1)' : 'transparent',
borderBottom: index < searchResults.length - 1 ? '1px solid var(--mantine-color-gray-3)' : 'none'
}} }}
onClick={() => handleSongSelect(track)}
onMouseEnter={() => setSelectedIndex(index)}
> >
<Group gap="sm"> <Group gap="sm">
{track.album.images[2] && ( {track.album.images[2] && (
@@ -164,19 +63,18 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
</div> </div>
</Group> </Group>
</Box> </Box>
))}
</Stack>
) : (
<Box p="md">
<Text size="sm" c="dimmed" ta="center">
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
</Text>
</Box>
)}
</Paper>
)}
</Box>
); );
}; };
return (
<Typeahead
onSelect={handleSongSelect}
searchFn={searchSpotifyTracks}
renderOption={renderOption}
format={formatTrack}
placeholder={placeholder}
/>
)
};
export default SongSearch; export default SongSearch;

View File

@@ -1,11 +1,11 @@
import { import {
Autocomplete,
Stack, Stack,
ActionIcon, ActionIcon,
Text, Text,
Group, Group,
Loader, Loader,
} from "@mantine/core"; } from "@mantine/core";
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
import { TrashIcon } from "@phosphor-icons/react"; import { TrashIcon } from "@phosphor-icons/react";
import { useState, useCallback, useMemo, memo } from "react"; import { useState, useCallback, useMemo, memo } from "react";
import { useTournament, useUnenrolledTeams } from "../queries"; import { useTournament, useUnenrolledTeams } from "../queries";
@@ -68,8 +68,6 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => {
}); });
const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => { const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
const [search, setSearch] = useState("");
const { data: tournament, isLoading: tournamentLoading } = const { data: tournament, isLoading: tournamentLoading } =
useTournament(tournamentId); useTournament(tournamentId);
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } = const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
@@ -78,27 +76,24 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam(); const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam(); const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
const autocompleteData = useMemo( const searchTeams = async (query: string): Promise<TypeaheadOption<Team>[]> => {
() => if (!query.trim()) return [];
unenrolledTeams.map((team: Team) => ({
value: team.id, const filtered = unenrolledTeams.filter((team: Team) =>
label: team.name, team.name.toLowerCase().includes(query.toLowerCase())
})),
[unenrolledTeams]
); );
return filtered.map((team: Team) => ({
id: team.id,
data: team
}));
};
const handleEnrollTeam = useCallback( const handleEnrollTeam = useCallback(
(teamId: string) => { (option: TypeaheadOption<Team>) => {
enrollTeam( enrollTeam({ tournamentId, teamId: option.data.id });
{ tournamentId, teamId },
{
onSuccess: () => {
setSearch("");
}, },
} [enrollTeam, tournamentId]
);
},
[enrollTeam, tournamentId, setSearch]
); );
const handleUnenrollTeam = useCallback( const handleUnenrollTeam = useCallback(
@@ -108,6 +103,31 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
[unenrollTeam, tournamentId] [unenrollTeam, tournamentId]
); );
const renderTeamOption = (option: TypeaheadOption<Team>) => {
const team = option.data;
return (
<Group py="xs" px="sm" gap="sm" align="center">
<Avatar
size={32}
radius="sm"
name={team.name}
src={
team.logo
? `/api/files/teams/${team.id}/${team.logo}`
: undefined
}
/>
<Text fw={500} truncate>
{team.name}
</Text>
</Group>
);
};
const formatTeam = (option: TypeaheadOption<Team>) => {
return option.data.name;
};
const isLoading = tournamentLoading || unenrolledLoading; const isLoading = tournamentLoading || unenrolledLoading;
const enrolledTeams = tournament?.teams || []; const enrolledTeams = tournament?.teams || [];
const hasEnrolledTeams = enrolledTeams.length > 0; const hasEnrolledTeams = enrolledTeams.length > 0;
@@ -118,16 +138,13 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
<Text fw={600} size="sm"> <Text fw={600} size="sm">
Add Team Add Team
</Text> </Text>
<Autocomplete <Typeahead
placeholder="Search for teams to enroll..." placeholder="Search for teams to enroll..."
data={autocompleteData} onSelect={handleEnrollTeam}
value={search} searchFn={searchTeams}
onChange={setSearch} renderOption={renderTeamOption}
onOptionSubmit={handleEnrollTeam} format={formatTeam}
disabled={isEnrolling || unenrolledLoading} disabled={isEnrolling || unenrolledLoading}
rightSection={isEnrolling ? <Loader size="xs" /> : null}
maxDropdownHeight={200}
limit={10}
/> />
</Stack> </Stack>

View File

@@ -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 { PlusIcon } from '@phosphor-icons/react';
import React, { useMemo, useState } from 'react'; import React, { useMemo } from 'react';
import Typeahead, { TypeaheadOption } from '@/components/typeahead';
interface TeamSelectionViewProps { interface TeamSelectionViewProps {
options: ComboboxItem[]; options: ComboboxItem[];
@@ -11,11 +12,39 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
options, options,
onSelect onSelect
}) => { }) => {
const [value, setValue] = useState<string>(''); const [selectedTeam, setSelectedTeam] = React.useState<ComboboxItem | null>(null);
const selectedOption = useMemo(() => options.find(option => option.label === value), [value, options])
const searchTeams = async (query: string): Promise<TypeaheadOption<ComboboxItem>[]> => {
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<ComboboxItem>) => {
setSelectedTeam(option.data);
};
const renderTeamOption = (option: TypeaheadOption<ComboboxItem>) => {
return (
<Group py="xs" px="sm" gap="sm">
<Text fw={500}>{option.data.label}</Text>
</Group>
);
};
const formatTeam = (option: TypeaheadOption<ComboboxItem>) => {
return option.data.label;
};
const handleCreateNewTeamClicked = () => onSelect(undefined); const handleCreateNewTeamClicked = () => onSelect(undefined);
const handleSelectExistingTeam = () => onSelect(selectedOption?.value) const handleSelectExistingTeam = () => onSelect(selectedTeam?.value);
return ( return (
<Stack gap="md"> <Stack gap="md">
@@ -31,17 +60,17 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
<Divider my="sm" label="or" /> <Divider my="sm" label="or" />
<Stack gap="sm"> <Stack gap="sm">
<Autocomplete <Typeahead
placeholder="Select one of your existing teams" placeholder="Select one of your existing teams"
value={value} onSelect={handleTeamSelect}
onChange={setValue} searchFn={searchTeams}
data={options.map(option => option.label)} renderOption={renderTeamOption}
comboboxProps={{ withinPortal: false }} format={formatTeam}
/> />
<Button <Button
onClick={handleSelectExistingTeam} onClick={handleSelectExistingTeam}
disabled={!selectedOption} disabled={!selectedTeam}
fullWidth fullWidth
> >
Enroll Selected Team Enroll Selected Team