Files
ace-step-ui/components/SearchPage.tsx
T

612 lines
25 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Search, Play, Pause, Heart, ChevronRight, ChevronLeft, Copy, Check, X, Loader2 } from 'lucide-react';
import { Song, Playlist } from '../types';
import { songsApi, usersApi, playlistsApi, searchApi, UserProfile, getAudioUrl } from '../services/api';
interface SearchPageProps {
onPlaySong?: (song: Song, list?: Song[]) => void;
currentSong?: Song | null;
isPlaying?: boolean;
onNavigateToProfile?: (username: string) => void;
onNavigateToSong?: (songId: string) => void;
onNavigateToPlaylist?: (playlistId: string) => void;
}
const GENRES = [
'Pop', 'Rock', 'Electronic', 'Hip Hop', 'Country', 'Latin', 'Heavy Metal', 'Disco',
'K-Pop', 'EDM', 'R&B', 'Indie', 'Folk', 'Funk', 'Jazz', 'Alternative Pop',
'House', 'Afrobeats', 'Reggaeton', 'Rap', 'Blues', 'Gospel', 'Reggae',
'Synthwave', 'J-Pop', 'Punk', 'Soul', 'Techno', 'Classical', 'Bossa Nova',
'Ska', 'Bluegrass', 'Indie Surf', 'Lo-Fi Beats', 'Trap', 'Grunge', 'Chillhop',
'New Wave', 'Drum And Bass', 'Acoustic Cover', 'Cinematic Dubstep', 'Modern Bollywood',
'Opera', 'Ambient', 'Focus', 'A Capella', 'Meditation', 'Sleep'
];
const MAX_RESULTS = 20;
interface ExtendedSong extends Song {
creator_avatar?: string | null;
}
export const SearchPage: React.FC<SearchPageProps> = ({
onPlaySong,
currentSong,
isPlaying,
onNavigateToProfile,
onNavigateToSong,
onNavigateToPlaylist,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [featuredSongs, setFeaturedSongs] = useState<ExtendedSong[]>([]);
const [featuredCreators, setFeaturedCreators] = useState<Array<UserProfile & { song_count?: number }>>([]);
const [featuredPlaylists, setFeaturedPlaylists] = useState<Array<Playlist & { creator?: string; creator_avatar?: string; song_count?: number }>>([]);
const [searchResults, setSearchResults] = useState<{
songs: ExtendedSong[];
creators: Array<UserProfile & { song_count?: number }>;
playlists: Array<Playlist & { creator?: string; creator_avatar?: string; song_count?: number }>;
} | null>(null);
const [loading, setLoading] = useState(true);
const [searching, setSearching] = useState(false);
const [copiedTag, setCopiedTag] = useState<string | null>(null);
const songsScrollRef = useRef<HTMLDivElement>(null);
const creatorsScrollRef = useRef<HTMLDivElement>(null);
const playlistsScrollRef = useRef<HTMLDivElement>(null);
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
loadFeaturedContent();
}, []);
const transformSong = (s: any): ExtendedSong => ({
...s,
id: s.id,
title: s.title,
lyrics: s.lyrics || '',
style: s.style || s.caption || '',
coverUrl: s.cover_url || s.coverUrl || `https://picsum.photos/seed/${s.id}/400/400`,
duration: s.duration ? (typeof s.duration === 'string' ? s.duration : `${Math.floor(s.duration / 60)}:${String(Math.floor(s.duration % 60)).padStart(2, '0')}`) : '0:00',
createdAt: new Date(s.created_at || s.createdAt),
tags: s.tags || [],
audioUrl: getAudioUrl(s.audio_url || s.audioUrl, s.id),
isPublic: s.is_public ?? s.isPublic,
likeCount: s.like_count || s.likeCount || 0,
viewCount: s.view_count || s.viewCount || 0,
creator: s.creator,
creator_avatar: s.creator_avatar || s.creatorAvatar || null,
});
// Shuffle array randomly
const shuffleArray = <T,>(array: T[]): T[] => {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
};
const loadFeaturedContent = async () => {
setLoading(true);
try {
const [songsRes, creatorsRes, playlistsRes] = await Promise.allSettled([
songsApi.getFeaturedSongs(),
usersApi.getFeaturedCreators().catch(() => ({ creators: [] })),
playlistsApi.getFeaturedPlaylists().catch(() => ({ playlists: [] })),
]);
if (songsRes.status === 'fulfilled') {
const songs = songsRes.value.songs.map(transformSong);
setFeaturedSongs(shuffleArray(songs).slice(0, MAX_RESULTS));
}
if (creatorsRes.status === 'fulfilled' && creatorsRes.value.creators?.length > 0) {
setFeaturedCreators(creatorsRes.value.creators.slice(0, MAX_RESULTS));
} else if (songsRes.status === 'fulfilled' && songsRes.value.songs?.length > 0) {
const uniqueCreators = new Map<string, UserProfile & { song_count?: number }>();
songsRes.value.songs.forEach((song: any) => {
if (song.creator && !uniqueCreators.has(song.creator)) {
uniqueCreators.set(song.creator, {
id: song.user_id || song.userId || song.creator,
username: song.creator,
email: '',
created_at: song.created_at || song.createdAt,
avatar_url: song.creator_avatar || song.creatorAvatar || null,
});
}
});
setFeaturedCreators(Array.from(uniqueCreators.values()).slice(0, MAX_RESULTS));
}
if (playlistsRes.status === 'fulfilled') {
setFeaturedPlaylists((playlistsRes.value.playlists || []).slice(0, MAX_RESULTS));
}
} catch (error) {
console.error('Failed to load featured content:', error);
} finally {
setLoading(false);
}
};
const performSearch = useCallback(async (query: string) => {
if (!query.trim()) {
setSearchResults(null);
return;
}
setSearching(true);
try {
const results = await searchApi.search(query);
setSearchResults({
songs: (results.songs || []).slice(0, MAX_RESULTS).map(transformSong),
creators: (results.creators || []).slice(0, MAX_RESULTS),
playlists: (results.playlists || []).slice(0, MAX_RESULTS),
});
} catch (error) {
console.error('Search failed:', error);
setSearchResults({ songs: [], creators: [], playlists: [] });
} finally {
setSearching(false);
}
}, []);
const handleSearchChange = (value: string) => {
setSearchQuery(value);
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
if (!value.trim()) {
setSearchResults(null);
return;
}
searchTimeoutRef.current = setTimeout(() => {
performSearch(value);
}, 300);
};
const handleGenreClick = (genre: string) => {
setSearchQuery(genre);
performSearch(genre);
};
const handleCopyTag = (tag: string) => {
navigator.clipboard.writeText(tag);
setCopiedTag(tag);
setTimeout(() => setCopiedTag(null), 2000);
};
const formatNumber = (count: number | undefined): string => {
if (!count) return '0';
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return count.toString();
};
const scroll = (ref: React.RefObject<HTMLDivElement | null>, direction: 'left' | 'right') => {
if (ref.current) {
const scrollAmount = 400;
ref.current.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth'
});
}
};
const displaySongs = searchResults?.songs || featuredSongs;
const displayCreators = searchResults?.creators || featuredCreators;
const displayPlaylists = searchResults?.playlists || featuredPlaylists;
const isSearching = searchQuery.trim().length > 0;
return (
<div className="flex-1 bg-zinc-50 dark:bg-[#0a0a0a] h-full overflow-y-auto custom-scrollbar">
<div className="max-w-[1400px] mx-auto px-6 py-6">
{/* Search Input */}
<div className="mb-8">
<div className="relative max-w-3xl">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-400" size={20} />
<input
type="text"
placeholder="Search for songs, playlists, creators, or genres"
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className="w-full h-11 pl-12 pr-12 bg-white dark:bg-zinc-900/80 border border-zinc-200 dark:border-white/10 rounded-full text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:border-pink-500 dark:focus:border-pink-500 focus:ring-2 focus:ring-pink-500/20 transition-all"
/>
{searching ? (
<Loader2 className="absolute right-4 top-1/2 -translate-y-1/2 text-pink-500 animate-spin" size={18} />
) : searchQuery && (
<button
onClick={() => { setSearchQuery(''); setSearchResults(null); }}
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:hover:text-white"
>
<X size={18} />
</button>
)}
</div>
</div>
{/* Songs Section */}
<section className="mb-10">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">
{isSearching ? `Songs matching "${searchQuery}"` : 'Featured Songs'}
{isSearching && displaySongs.length > 0 && (
<span className="ml-2 text-sm font-normal text-zinc-500">({displaySongs.length})</span>
)}
</h2>
{!isSearching && displaySongs.length > 4 && (
<button
onClick={() => scroll(songsScrollRef, 'right')}
className="p-1.5 rounded-full hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
>
<ChevronRight size={20} />
</button>
)}
</div>
{loading ? (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="bg-zinc-200 dark:bg-zinc-800 rounded-lg h-[72px]" />
</div>
))}
</div>
) : displaySongs.length > 0 ? (
<div
ref={songsScrollRef}
className={isSearching
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"
: "grid grid-cols-2 lg:grid-cols-4 gap-3 auto-rows-max"
}
>
{displaySongs.slice(0, isSearching ? MAX_RESULTS : 8).map((song) => (
<FeaturedSongCard
key={song.id}
song={song}
isPlaying={currentSong?.id === song.id && isPlaying}
onPlay={() => onPlaySong?.(song, displaySongs)}
onNavigateToProfile={onNavigateToProfile}
onCopyTag={handleCopyTag}
copiedTag={copiedTag}
formatNumber={formatNumber}
/>
))}
</div>
) : isSearching ? (
<div className="text-center py-8 text-zinc-500 text-sm">
No songs found matching "{searchQuery}"
</div>
) : null}
</section>
{/* Creators Section */}
<section className="mb-10">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">
{isSearching ? `Creators matching "${searchQuery}"` : 'Featured Creators'}
{isSearching && displayCreators.length > 0 && (
<span className="ml-2 text-sm font-normal text-zinc-500">({displayCreators.length})</span>
)}
</h2>
{!isSearching && displayCreators.length > 6 && (
<div className="flex gap-1">
<button
onClick={() => scroll(creatorsScrollRef, 'left')}
className="p-1.5 rounded-full hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
>
<ChevronLeft size={20} />
</button>
<button
onClick={() => scroll(creatorsScrollRef, 'right')}
className="p-1.5 rounded-full hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
>
<ChevronRight size={20} />
</button>
</div>
)}
</div>
{loading ? (
<div className="flex gap-5 overflow-x-auto pb-2">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex-shrink-0 w-[110px] animate-pulse">
<div className="w-[90px] h-[90px] mx-auto rounded-full bg-zinc-200 dark:bg-zinc-800 mb-2" />
<div className="h-4 bg-zinc-200 dark:bg-zinc-800 rounded mx-2" />
</div>
))}
</div>
) : displayCreators.length > 0 ? (
<div
ref={creatorsScrollRef}
className={isSearching
? "grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-4"
: "flex gap-5 overflow-x-auto pb-2 scrollbar-hide"
}
style={isSearching ? {} : { scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{displayCreators.map((creator) => (
<CreatorCard
key={creator.id}
creator={creator}
onNavigateToProfile={onNavigateToProfile}
/>
))}
</div>
) : (
<div className="text-center py-8 text-zinc-500 text-sm">
{isSearching ? `No creators found matching "${searchQuery}"` : 'No creators yet. Be the first to share your music!'}
</div>
)}
</section>
{/* Playlists Section */}
<section className="mb-10">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">
{isSearching ? `Playlists matching "${searchQuery}"` : 'Featured Playlists'}
{isSearching && displayPlaylists.length > 0 && (
<span className="ml-2 text-sm font-normal text-zinc-500">({displayPlaylists.length})</span>
)}
</h2>
{!isSearching && displayPlaylists.length > 5 && (
<div className="flex gap-1">
<button
onClick={() => scroll(playlistsScrollRef, 'left')}
className="p-1.5 rounded-full hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
>
<ChevronLeft size={20} />
</button>
<button
onClick={() => scroll(playlistsScrollRef, 'right')}
className="p-1.5 rounded-full hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
>
<ChevronRight size={20} />
</button>
</div>
)}
</div>
{loading ? (
<div className="flex gap-4 overflow-x-auto pb-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex-shrink-0 w-[140px] animate-pulse">
<div className="aspect-square rounded-lg bg-zinc-200 dark:bg-zinc-800 mb-2" />
<div className="h-4 bg-zinc-200 dark:bg-zinc-800 rounded mb-1" />
<div className="h-3 bg-zinc-200 dark:bg-zinc-800 rounded w-2/3" />
</div>
))}
</div>
) : displayPlaylists.length > 0 ? (
<div
ref={playlistsScrollRef}
className={isSearching
? "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4"
: "flex gap-4 overflow-x-auto pb-2 scrollbar-hide"
}
style={isSearching ? {} : { scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{displayPlaylists.map((playlist) => (
<PlaylistCard
key={playlist.id}
playlist={playlist}
onNavigateToPlaylist={onNavigateToPlaylist}
onNavigateToProfile={onNavigateToProfile}
/>
))}
</div>
) : (
<div className="text-center py-8 text-zinc-500 text-sm">
{isSearching ? `No playlists found matching "${searchQuery}"` : 'No public playlists yet. Create one and share your favorites!'}
</div>
)}
</section>
{/* Genres */}
<section className="mb-10">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Genres</h2>
<div className="flex flex-wrap gap-2">
{GENRES.map((genre) => (
<button
key={genre}
onClick={() => handleGenreClick(genre)}
className={`px-3 py-1.5 border rounded-full text-sm transition-all duration-200 group flex items-center gap-1.5 ${
searchQuery === genre
? 'bg-pink-500 border-pink-500 text-white'
: 'bg-zinc-100 dark:bg-zinc-800/60 border-zinc-200 dark:border-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700/60 hover:border-pink-500/30 hover:text-pink-600 dark:hover:text-pink-400'
}`}
>
{genre}
<Copy
size={12}
className={`opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer ${searchQuery === genre ? 'text-white/70' : ''}`}
onClick={(e) => { e.stopPropagation(); handleCopyTag(genre); }}
/>
{copiedTag === genre && <Check size={12} className="text-green-500" />}
</button>
))}
</div>
</section>
</div>
</div>
);
};
interface FeaturedSongCardProps {
song: ExtendedSong;
isPlaying?: boolean;
onPlay: () => void;
onNavigateToProfile?: (username: string) => void;
onCopyTag: (tag: string) => void;
copiedTag: string | null;
formatNumber: (n: number | undefined) => string;
}
const FeaturedSongCard: React.FC<FeaturedSongCardProps> = ({
song,
isPlaying,
onPlay,
onNavigateToProfile,
onCopyTag,
copiedTag,
formatNumber,
}) => {
const [isHovered, setIsHovered] = useState(false);
const tags = song.style?.split(',').map(t => t.trim()).filter(Boolean).slice(0, 2) || [];
return (
<div
className="flex items-center gap-3 p-2 bg-white dark:bg-zinc-900/40 rounded-xl border border-zinc-100 dark:border-white/5 hover:border-pink-500/30 hover:bg-zinc-50 dark:hover:bg-zinc-800/40 transition-all cursor-pointer group"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-12 h-12 rounded-lg overflow-hidden flex-shrink-0" onClick={onPlay}>
<img
src={song.coverUrl}
alt={song.title}
className="w-full h-full object-cover"
/>
<div className={`absolute inset-0 bg-black/50 flex items-center justify-center transition-opacity ${isHovered || isPlaying ? 'opacity-100' : 'opacity-0'}`}>
{isPlaying ? (
<Pause size={16} className="text-white" fill="white" />
) : (
<Play size={16} className="text-white ml-0.5" fill="white" />
)}
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<span className="font-semibold text-zinc-900 dark:text-white text-sm truncate max-w-[140px]">{song.title}</span>
{song.isPublic !== false && (
<span className="flex-shrink-0 text-[8px] font-bold text-white bg-gradient-to-r from-pink-500 to-purple-500 px-1 py-0.5 rounded">
v5
</span>
)}
</div>
<div className="text-[11px] text-zinc-500 dark:text-zinc-400 truncate mb-1">
{tags.map((tag, i) => (
<button
key={i}
onClick={(e) => { e.stopPropagation(); onCopyTag(tag); }}
className="hover:text-pink-500 dark:hover:text-pink-400 transition-colors"
>
{tag}{i < tags.length - 1 ? ', ' : ''}
{copiedTag === tag && <Check size={10} className="inline ml-0.5 text-green-500" />}
</button>
))}
</div>
<div className="flex items-center gap-2 text-[10px] text-zinc-400">
{song.creator && (
<button
onClick={(e) => { e.stopPropagation(); onNavigateToProfile?.(song.creator!); }}
className="flex items-center gap-1 hover:text-pink-500 transition-colors max-w-[80px]"
>
{song.creator_avatar ? (
<img
src={song.creator_avatar}
alt={song.creator}
className="w-3.5 h-3.5 rounded-full object-cover"
/>
) : (
<div className="w-3.5 h-3.5 rounded-full bg-gradient-to-br from-pink-500 to-purple-500 flex items-center justify-center text-[7px] text-white font-bold flex-shrink-0">
{song.creator.charAt(0).toUpperCase()}
</div>
)}
<span className="truncate">{song.creator}</span>
</button>
)}
<span className="flex items-center gap-0.5 flex-shrink-0">
<Play size={9} /> {formatNumber(song.viewCount)}
</span>
<span className="flex items-center gap-0.5 flex-shrink-0">
<Heart size={9} /> {formatNumber(song.likeCount)}
</span>
</div>
</div>
</div>
);
};
interface CreatorCardProps {
creator: UserProfile & { song_count?: number };
onNavigateToProfile?: (username: string) => void;
}
const CreatorCard: React.FC<CreatorCardProps> = ({
creator,
onNavigateToProfile,
}) => {
return (
<div
className="flex-shrink-0 w-[110px] text-center cursor-pointer group"
onClick={() => onNavigateToProfile?.(creator.username)}
>
<div className="w-[90px] h-[90px] mx-auto rounded-full overflow-hidden mb-2 ring-2 ring-transparent group-hover:ring-pink-500 transition-all shadow-lg">
<img
src={creator.avatar_url || `https://api.dicebear.com/7.x/avataaars/svg?seed=${creator.username}`}
alt={creator.username}
className="w-full h-full object-cover"
/>
</div>
<div className="font-semibold text-zinc-900 dark:text-white text-sm truncate group-hover:text-pink-500 transition-colors px-1">
{creator.username}
</div>
<div className="text-[11px] text-zinc-500 truncate px-1">@{creator.username.toLowerCase().replace(/\s/g, '')}</div>
</div>
);
};
interface PlaylistCardProps {
playlist: Playlist & { creator?: string; creator_avatar?: string; song_count?: number };
onNavigateToPlaylist?: (playlistId: string) => void;
onNavigateToProfile?: (username: string) => void;
}
const PlaylistCard: React.FC<PlaylistCardProps> = ({
playlist,
onNavigateToPlaylist,
onNavigateToProfile,
}) => {
return (
<div
className="flex-shrink-0 w-[140px] cursor-pointer group"
onClick={() => onNavigateToPlaylist?.(playlist.id)}
>
<div className="aspect-square rounded-lg overflow-hidden mb-2 shadow-md relative bg-zinc-200 dark:bg-zinc-800">
<img
src={playlist.cover_url || `https://picsum.photos/seed/${playlist.id}/400/400`}
alt={playlist.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<div className="font-semibold text-zinc-900 dark:text-white text-sm truncate group-hover:text-pink-500 transition-colors">
{playlist.name}
</div>
<div className="text-[11px] text-zinc-500 mb-1">{playlist.song_count || 0} songs</div>
{playlist.creator && (
<div
className="flex items-center gap-1.5 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onNavigateToProfile?.(playlist.creator!);
}}
>
<div className="w-4 h-4 rounded-full overflow-hidden flex-shrink-0 bg-zinc-300 dark:bg-zinc-700">
<img
src={playlist.creator_avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${playlist.creator}`}
alt={playlist.creator}
className="w-full h-full object-cover"
/>
</div>
<span className="text-[11px] text-zinc-500 hover:text-pink-500 transition-colors truncate">
{playlist.creator}
</span>
</div>
)}
</div>
);
};