- Updated recharts version from 3.2.1 to 2.15.4 in package.json. - Removed unused imports and types in GlobalSearch component. - Adjusted spacing in CardHeader component. - Refactored ChartTooltipContent and ChartLegendContent components to filter out items with type 'none' and improve readability.
403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
import { filesService } from '@/lib/api/services/files'
|
|
import { playerService } from '@/lib/api/services/player'
|
|
import { playlistsService } from '@/lib/api/services/playlists'
|
|
import { soundsService, type Sound } from '@/lib/api/services/sounds'
|
|
import { formatDuration } from '@/utils/format-duration'
|
|
import { Music, PlayCircle, Search, X } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
|
|
interface GlobalSearchProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
}
|
|
|
|
type SearchResultType = 'sound' | 'playlist' | 'playlist-track'
|
|
|
|
interface SearchResult {
|
|
id: string
|
|
type: SearchResultType
|
|
title: string
|
|
subtitle?: string
|
|
duration?: number
|
|
thumbnail?: string
|
|
soundType?: 'SDB' | 'TTS' | 'EXT'
|
|
playlistId?: number
|
|
trackIndex?: number
|
|
}
|
|
|
|
export function GlobalSearch({ isOpen, onClose }: GlobalSearchProps) {
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [results, setResults] = useState<SearchResult[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [currentPlaylistTracks, setCurrentPlaylistTracks] = useState<Sound[]>([])
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
setSearchQuery('')
|
|
setResults([])
|
|
setCurrentPlaylistTracks([])
|
|
} else {
|
|
// Load current playlist tracks when opening
|
|
loadCurrentPlaylistTracks()
|
|
}
|
|
}, [isOpen])
|
|
|
|
useEffect(() => {
|
|
if (searchQuery.trim()) {
|
|
performSearch()
|
|
} else {
|
|
setResults([])
|
|
}
|
|
}, [searchQuery])
|
|
|
|
const loadCurrentPlaylistTracks = async () => {
|
|
try {
|
|
const state = await playerService.getState()
|
|
if (state.playlist) {
|
|
setCurrentPlaylistTracks(state.playlist.sounds as unknown as Sound[])
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load current playlist tracks:', error)
|
|
}
|
|
}
|
|
|
|
const performSearch = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const query = searchQuery.trim().toLowerCase()
|
|
const newResults: SearchResult[] = []
|
|
|
|
// Search sounds (SDB and TTS)
|
|
const sounds = await soundsService.getSounds({
|
|
search: query,
|
|
types: ['SDB', 'TTS'],
|
|
limit: 20,
|
|
})
|
|
|
|
sounds.forEach((sound) => {
|
|
newResults.push({
|
|
id: `sound-${sound.id}`,
|
|
type: 'sound',
|
|
title: sound.name,
|
|
subtitle: sound.type,
|
|
duration: sound.duration,
|
|
thumbnail: sound.thumbnail,
|
|
soundType: sound.type,
|
|
})
|
|
})
|
|
|
|
// Search playlists
|
|
const playlistsResponse = await playlistsService.getPlaylists({
|
|
search: query,
|
|
limit: 10,
|
|
})
|
|
|
|
playlistsResponse.playlists.forEach((playlist) => {
|
|
newResults.push({
|
|
id: `playlist-${playlist.id}`,
|
|
type: 'playlist',
|
|
title: playlist.name,
|
|
subtitle: `${playlist.sound_count} tracks`,
|
|
duration: playlist.total_duration,
|
|
playlistId: playlist.id,
|
|
})
|
|
})
|
|
|
|
// Search current playlist tracks
|
|
currentPlaylistTracks.forEach((track, index) => {
|
|
if (track.name.toLowerCase().includes(query)) {
|
|
newResults.push({
|
|
id: `track-${track.id}-${index}`,
|
|
type: 'playlist-track',
|
|
title: track.name,
|
|
subtitle: 'Current playlist',
|
|
duration: track.duration,
|
|
thumbnail: track.thumbnail,
|
|
trackIndex: index,
|
|
})
|
|
}
|
|
})
|
|
|
|
setResults(newResults)
|
|
} catch (error) {
|
|
console.error('Search failed:', error)
|
|
toast.error('Search failed')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleResultClick = async (result: SearchResult) => {
|
|
try {
|
|
if (result.type === 'sound') {
|
|
const soundId = parseInt(result.id.replace('sound-', ''))
|
|
await soundsService.playSound(soundId)
|
|
toast.success(`Playing ${result.title}`)
|
|
} else if (result.type === 'playlist' && result.playlistId) {
|
|
await playlistsService.setCurrentPlaylist(result.playlistId)
|
|
toast.success(`Set ${result.title} as current playlist`)
|
|
} else if (result.type === 'playlist-track' && result.trackIndex !== undefined) {
|
|
await playerService.playAtIndex(result.trackIndex)
|
|
toast.success(`Playing ${result.title}`)
|
|
}
|
|
onClose()
|
|
} catch (error) {
|
|
console.error('Action failed:', error)
|
|
toast.error('Action failed')
|
|
}
|
|
}
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
onClose()
|
|
}
|
|
}
|
|
|
|
// Handle global escape key
|
|
useEffect(() => {
|
|
if (!isOpen) return
|
|
|
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
onClose()
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleGlobalKeyDown)
|
|
return () => window.removeEventListener('keydown', handleGlobalKeyDown)
|
|
}, [isOpen, onClose])
|
|
|
|
if (!isOpen) return null
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="fixed top-[20%] left-1/2 -translate-x-1/2 w-full max-w-2xl bg-background border rounded-lg shadow-lg"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Search Input */}
|
|
<div className="relative p-4 border-b">
|
|
<Search className="absolute left-6 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
type="text"
|
|
placeholder="Search sounds, playlists, or tracks..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
className="pl-10 pr-10"
|
|
autoFocus
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="absolute right-4 top-1/2 -translate-y-1/2"
|
|
onClick={onClose}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Results */}
|
|
<ScrollArea className="h-[60vh]">
|
|
<div className="p-2">
|
|
{loading && (
|
|
<div className="p-4 text-center text-muted-foreground">
|
|
Searching...
|
|
</div>
|
|
)}
|
|
|
|
{!loading && searchQuery && results.length === 0 && (
|
|
<div className="p-4 text-center text-muted-foreground">
|
|
No results found
|
|
</div>
|
|
)}
|
|
|
|
{!loading && results.length > 0 && (
|
|
<div className="space-y-3">
|
|
{/* Sounds Section */}
|
|
{results.filter((r) => r.type === 'sound').length > 0 && (
|
|
<div>
|
|
<div className="px-2 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
|
Sounds ({results.filter((r) => r.type === 'sound').length})
|
|
</div>
|
|
<div className="space-y-1">
|
|
{results
|
|
.filter((r) => r.type === 'sound')
|
|
.map((result) => (
|
|
<button
|
|
key={result.id}
|
|
className="w-full flex items-center gap-3 p-2 rounded hover:bg-muted/50 text-left transition-colors"
|
|
onClick={() => handleResultClick(result)}
|
|
>
|
|
<div className="flex-shrink-0">
|
|
{result.thumbnail ? (
|
|
<div className="w-10 h-10 rounded bg-muted overflow-hidden">
|
|
<img
|
|
src={filesService.getThumbnailUrl(
|
|
parseInt(result.id.split('-')[1])
|
|
)}
|
|
alt=""
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
|
|
<Music className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium truncate">{result.title}</div>
|
|
{result.subtitle && (
|
|
<div className="text-sm text-muted-foreground truncate">
|
|
{result.subtitle}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex-shrink-0 flex items-center gap-2">
|
|
{result.soundType && (
|
|
<Badge
|
|
variant={
|
|
result.soundType === 'SDB' ? 'default' : 'secondary'
|
|
}
|
|
className="text-xs"
|
|
>
|
|
{result.soundType}
|
|
</Badge>
|
|
)}
|
|
{result.duration && (
|
|
<span className="text-sm text-muted-foreground">
|
|
{formatDuration(result.duration)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Playlists Section */}
|
|
{results.filter((r) => r.type === 'playlist').length > 0 && (
|
|
<div>
|
|
<div className="px-2 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
|
Playlists ({results.filter((r) => r.type === 'playlist').length})
|
|
</div>
|
|
<div className="space-y-1">
|
|
{results
|
|
.filter((r) => r.type === 'playlist')
|
|
.map((result) => (
|
|
<button
|
|
key={result.id}
|
|
className="w-full flex items-center gap-3 p-2 rounded hover:bg-muted/50 text-left transition-colors"
|
|
onClick={() => handleResultClick(result)}
|
|
>
|
|
<div className="flex-shrink-0">
|
|
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
|
|
<PlayCircle className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium truncate">{result.title}</div>
|
|
{result.subtitle && (
|
|
<div className="text-sm text-muted-foreground truncate">
|
|
{result.subtitle}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex-shrink-0 flex items-center gap-2">
|
|
{result.duration && (
|
|
<span className="text-sm text-muted-foreground">
|
|
{formatDuration(result.duration)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Playlist Tracks Section */}
|
|
{results.filter((r) => r.type === 'playlist-track').length > 0 && (
|
|
<div>
|
|
<div className="px-2 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
|
Current Playlist ({results.filter((r) => r.type === 'playlist-track').length})
|
|
</div>
|
|
<div className="space-y-1">
|
|
{results
|
|
.filter((r) => r.type === 'playlist-track')
|
|
.map((result) => (
|
|
<button
|
|
key={result.id}
|
|
className="w-full flex items-center gap-3 p-2 rounded hover:bg-muted/50 text-left transition-colors"
|
|
onClick={() => handleResultClick(result)}
|
|
>
|
|
<div className="flex-shrink-0">
|
|
{result.thumbnail ? (
|
|
<div className="w-10 h-10 rounded bg-muted overflow-hidden">
|
|
<img
|
|
src={filesService.getThumbnailUrl(
|
|
parseInt(result.id.split('-')[1])
|
|
)}
|
|
alt=""
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
|
|
<Music className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium truncate">{result.title}</div>
|
|
{result.subtitle && (
|
|
<div className="text-sm text-muted-foreground truncate">
|
|
{result.subtitle}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex-shrink-0 flex items-center gap-2">
|
|
{result.duration && (
|
|
<span className="text-sm text-muted-foreground">
|
|
{formatDuration(result.duration)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!searchQuery && (
|
|
<div className="p-4 text-center text-muted-foreground text-sm">
|
|
Type to search sounds, playlists, or tracks
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
{/* Footer */}
|
|
<div className="p-2 border-t bg-muted/30">
|
|
<div className="flex items-center justify-between text-xs text-muted-foreground px-2">
|
|
<span>Press ESC to close</span>
|
|
{results.length > 0 && <span>{results.length} results</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|