feat: implement global search functionality with keyboard shortcut and results display
This commit is contained in:
@@ -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 (
|
||||
<SidebarProvider>
|
||||
<AppSidebar showCompactPlayer={playerDisplayMode === 'sidebar'} />
|
||||
<AppSidebar
|
||||
showCompactPlayer={playerDisplayMode === 'sidebar'}
|
||||
onSearchClick={() => setIsSearchOpen(true)}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2">
|
||||
<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>
|
||||
</SidebarInset>
|
||||
<Player onPlayerModeChange={setPlayerDisplayMode} />
|
||||
<GlobalSearch isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} />
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
</SidebarHeader>
|
||||
|
||||
<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">
|
||||
<NavItem href="/" icon={Home} title="Dashboard" />
|
||||
<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