Files
sbd2-frontend/src/components/GlobalSearch.tsx
JSC cbd4b93fd4
Some checks failed
Frontend CI / lint (push) Failing after 21s
Frontend CI / build (push) Has been skipped
chore: update recharts dependency and refactor chart components
- 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.
2025-10-04 14:50:40 +02:00

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>
)
}