From adf16210a4827ffe22ccc17dc97b22949dc9e065 Mon Sep 17 00:00:00 2001 From: JSC Date: Sat, 4 Oct 2025 14:39:04 +0200 Subject: [PATCH] feat: implement global search functionality with keyboard shortcut and results display --- src/components/AppLayout.tsx | 24 +- src/components/AppSidebar.tsx | 19 +- src/components/GlobalSearch.tsx | 389 ++++++++++++++++++++++++++++++++ 3 files changed, 429 insertions(+), 3 deletions(-) create mode 100644 src/components/GlobalSearch.tsx diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index a1fde25..955bcd1 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -12,8 +12,9 @@ import { SidebarProvider, SidebarTrigger, } from '@/components/ui/sidebar' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { AppSidebar } from './AppSidebar' +import { GlobalSearch } from './GlobalSearch' import { Player, type PlayerDisplayMode } from './player/Player' interface AppLayoutProps { @@ -43,11 +44,29 @@ export function AppLayout({ children, breadcrumb }: AppLayoutProps) { }, ) + const [isSearchOpen, setIsSearchOpen] = useState(false) + + // Handle keyboard shortcut for global search + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'f') { + e.preventDefault() + setIsSearchOpen(true) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, []) + // Note: localStorage is managed by the Player component return ( - + setIsSearchOpen(true)} + />
@@ -80,6 +99,7 @@ export function AppLayout({ children, breadcrumb }: AppLayoutProps) {
{children}
+ setIsSearchOpen(false)} /> ) } diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 6f22b3a..8a8f2df 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -1,3 +1,4 @@ +import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' import { Sidebar, @@ -17,6 +18,7 @@ import { Users, AudioLines, Mic, + Search, } from 'lucide-react' import { CreditsNav } from './nav/CreditsNav' import { NavGroup } from './nav/NavGroup' @@ -27,9 +29,10 @@ import { CompactPlayer } from './player/CompactPlayer' interface AppSidebarProps { showCompactPlayer?: boolean + onSearchClick?: () => void } -export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) { +export function AppSidebar({ showCompactPlayer = false, onSearchClick }: AppSidebarProps) { const { user, logout } = useAuth() if (!user) return null @@ -46,6 +49,20 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) { +
+ +
+ diff --git a/src/components/GlobalSearch.tsx b/src/components/GlobalSearch.tsx new file mode 100644 index 0000000..c6f914c --- /dev/null +++ b/src/components/GlobalSearch.tsx @@ -0,0 +1,389 @@ +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, type Playlist } from '@/lib/api/services/playlists' +import { soundsService, type Sound } from '@/lib/api/services/sounds' +import { cn } from '@/lib/utils' +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([]) + const [loading, setLoading] = useState(false) + const [currentPlaylistTracks, setCurrentPlaylistTracks] = useState([]) + + 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() + } + } + + if (!isOpen) return null + + return ( +
+
e.stopPropagation()} + > + {/* Search Input */} +
+ + setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + className="pl-10 pr-10" + autoFocus + /> + +
+ + {/* Results */} + +
+ {loading && ( +
+ Searching... +
+ )} + + {!loading && searchQuery && results.length === 0 && ( +
+ No results found +
+ )} + + {!loading && results.length > 0 && ( +
+ {/* Sounds Section */} + {results.filter((r) => r.type === 'sound').length > 0 && ( +
+
+ Sounds ({results.filter((r) => r.type === 'sound').length}) +
+
+ {results + .filter((r) => r.type === 'sound') + .map((result) => ( + + ))} +
+
+ )} + + {/* Playlists Section */} + {results.filter((r) => r.type === 'playlist').length > 0 && ( +
+
+ Playlists ({results.filter((r) => r.type === 'playlist').length}) +
+
+ {results + .filter((r) => r.type === 'playlist') + .map((result) => ( + + ))} +
+
+ )} + + {/* Playlist Tracks Section */} + {results.filter((r) => r.type === 'playlist-track').length > 0 && ( +
+
+ Current Playlist ({results.filter((r) => r.type === 'playlist-track').length}) +
+
+ {results + .filter((r) => r.type === 'playlist-track') + .map((result) => ( + + ))} +
+
+ )} +
+ )} + + {!searchQuery && ( +
+ Type to search sounds, playlists, or tracks +
+ )} +
+
+ + {/* Footer */} +
+
+ Press ESC to close + {results.length > 0 && {results.length} results} +
+
+
+
+ ) +}