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 { 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: {

View File

@@ -1,8 +1,8 @@
import { createFileRoute } from "@tanstack/react-router";
async function getClientCredentialsToken(): Promise<string> {
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");

View File

@@ -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: {

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, 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<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[]> => {
const searchSpotifyTracks = async (query: string): Promise<TypeaheadOption<SpotifyTrack>[]> => {
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<SpotifyTrack>) => {
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<SpotifyTrack>) => {
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<SpotifyTrack>) => {
const track = option.data;
return (
<Box
p="sm"
style={{
borderBottom: '1px solid var(--mantine-color-dimmed)'
}}
>
<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>
</Box>
);
};
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
key={track.id}
p="sm"
style={{
cursor: 'pointer',
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">
{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>
</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>
);
<Typeahead
onSelect={handleSongSelect}
searchFn={searchSpotifyTracks}
renderOption={renderOption}
format={formatTrack}
placeholder={placeholder}
/>
)
};
export default SongSearch;

View File

@@ -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<TypeaheadOption<Team>[]> => {
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<Team>) => {
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<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 enrolledTeams = tournament?.teams || [];
const hasEnrolledTeams = enrolledTeams.length > 0;
@@ -118,16 +138,13 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
<Text fw={600} size="sm">
Add Team
</Text>
<Autocomplete
<Typeahead
placeholder="Search for teams to enroll..."
data={autocompleteData}
value={search}
onChange={setSearch}
onOptionSubmit={handleEnrollTeam}
onSelect={handleEnrollTeam}
searchFn={searchTeams}
renderOption={renderTeamOption}
format={formatTeam}
disabled={isEnrolling || unenrolledLoading}
rightSection={isEnrolling ? <Loader size="xs" /> : null}
maxDropdownHeight={200}
limit={10}
/>
</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 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<TeamSelectionViewProps> = React.memo(({
options,
onSelect
}) => {
const [value, setValue] = useState<string>('');
const selectedOption = useMemo(() => options.find(option => option.label === value), [value, options])
const [selectedTeam, setSelectedTeam] = React.useState<ComboboxItem | null>(null);
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 handleSelectExistingTeam = () => onSelect(selectedOption?.value)
const handleSelectExistingTeam = () => onSelect(selectedTeam?.value);
return (
<Stack gap="md">
@@ -31,17 +60,17 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
<Divider my="sm" label="or" />
<Stack gap="sm">
<Autocomplete
<Typeahead
placeholder="Select one of your existing teams"
value={value}
onChange={setValue}
data={options.map(option => option.label)}
comboboxProps={{ withinPortal: false }}
onSelect={handleTeamSelect}
searchFn={searchTeams}
renderOption={renderTeamOption}
format={formatTeam}
/>
<Button
onClick={handleSelectExistingTeam}
disabled={!selectedOption}
disabled={!selectedTeam}
fullWidth
>
Enroll Selected Team