feat: implement global search functionality with keyboard shortcut and results display
This commit is contained in:
@@ -12,8 +12,9 @@ import {
|
|||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
} from '@/components/ui/sidebar'
|
} from '@/components/ui/sidebar'
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { AppSidebar } from './AppSidebar'
|
import { AppSidebar } from './AppSidebar'
|
||||||
|
import { GlobalSearch } from './GlobalSearch'
|
||||||
import { Player, type PlayerDisplayMode } from './player/Player'
|
import { Player, type PlayerDisplayMode } from './player/Player'
|
||||||
|
|
||||||
interface AppLayoutProps {
|
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
|
// Note: localStorage is managed by the Player component
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar showCompactPlayer={playerDisplayMode === 'sidebar'} />
|
<AppSidebar
|
||||||
|
showCompactPlayer={playerDisplayMode === 'sidebar'}
|
||||||
|
onSearchClick={() => setIsSearchOpen(true)}
|
||||||
|
/>
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2">
|
<header className="flex h-16 shrink-0 items-center gap-2">
|
||||||
<div className="flex items-center gap-2 px-4">
|
<div className="flex items-center gap-2 px-4">
|
||||||
@@ -80,6 +99,7 @@ export function AppLayout({ children, breadcrumb }: AppLayoutProps) {
|
|||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">{children}</div>
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">{children}</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
<Player onPlayerModeChange={setPlayerDisplayMode} />
|
<Player onPlayerModeChange={setPlayerDisplayMode} />
|
||||||
|
<GlobalSearch isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
AudioLines,
|
AudioLines,
|
||||||
Mic,
|
Mic,
|
||||||
|
Search,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { CreditsNav } from './nav/CreditsNav'
|
import { CreditsNav } from './nav/CreditsNav'
|
||||||
import { NavGroup } from './nav/NavGroup'
|
import { NavGroup } from './nav/NavGroup'
|
||||||
@@ -27,9 +29,10 @@ import { CompactPlayer } from './player/CompactPlayer'
|
|||||||
|
|
||||||
interface AppSidebarProps {
|
interface AppSidebarProps {
|
||||||
showCompactPlayer?: boolean
|
showCompactPlayer?: boolean
|
||||||
|
onSearchClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
|
export function AppSidebar({ showCompactPlayer = false, onSearchClick }: AppSidebarProps) {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
|
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
@@ -46,6 +49,20 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
|
|||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
|
<div className="px-2 py-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start gap-2 group-data-[collapsible=icon]:justify-center"
|
||||||
|
onClick={onSearchClick}
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
<span className="group-data-[collapsible=icon]:hidden">Search</span>
|
||||||
|
<kbd className="ml-auto pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100 group-data-[collapsible=icon]:hidden">
|
||||||
|
<span className="text-xs">⌘</span>F
|
||||||
|
</kbd>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-2" />
|
||||||
<NavGroup label="Application">
|
<NavGroup label="Application">
|
||||||
<NavItem href="/" icon={Home} title="Dashboard" />
|
<NavItem href="/" icon={Home} title="Dashboard" />
|
||||||
<NavItem href="/sounds" icon={Music} title="Sounds" />
|
<NavItem href="/sounds" icon={Music} title="Sounds" />
|
||||||
|
|||||||
389
src/components/GlobalSearch.tsx
Normal file
389
src/components/GlobalSearch.tsx
Normal file
@@ -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<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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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="max-h-[60vh]">
|
||||||
|
<div className="p-2 max-h-[60vh] overflow-y-auto">
|
||||||
|
{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user