Refacto UX
This commit is contained in:
@@ -2,12 +2,12 @@
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getTracks, getStats } from "@/lib/api"
|
||||
import { getTracks } from "@/lib/api"
|
||||
import type { FilterParams, Track } from "@/lib/types"
|
||||
import FilterPanel from "@/components/FilterPanel"
|
||||
import AudioPlayer from "@/components/AudioPlayer"
|
||||
|
||||
// Helper function to format Discogs genre labels (e.g., "Pop---Ballad" -> ["Pop", "Ballad"])
|
||||
// Helper function to format Discogs genre labels
|
||||
function formatGenre(genre: string): { category: string; subgenre: string } {
|
||||
const parts = genre.split('---')
|
||||
return {
|
||||
@@ -24,21 +24,17 @@ function extractFilterOptions(tracks: Track[]) {
|
||||
const keys = new Set<string>()
|
||||
|
||||
tracks.forEach(track => {
|
||||
// Extract genre category (before "---")
|
||||
const genreCategory = formatGenre(track.classification.genre.primary).category
|
||||
genres.add(genreCategory)
|
||||
|
||||
// Extract primary mood
|
||||
if (track.classification.mood.primary) {
|
||||
moods.add(track.classification.mood.primary)
|
||||
}
|
||||
|
||||
// Extract instruments
|
||||
track.classification.instruments?.forEach(instrument => {
|
||||
instruments.add(instrument)
|
||||
})
|
||||
|
||||
// Extract key
|
||||
if (track.features.key) {
|
||||
keys.add(track.features.key)
|
||||
}
|
||||
@@ -56,21 +52,27 @@ export default function Home() {
|
||||
const [filters, setFilters] = useState<FilterParams>({})
|
||||
const [page, setPage] = useState(0)
|
||||
const [currentTrack, setCurrentTrack] = useState<Track | null>(null)
|
||||
const limit = 50
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const limit = 25
|
||||
|
||||
const { data: tracksData, isLoading: isLoadingTracks } = useQuery({
|
||||
queryKey: ['tracks', filters, page],
|
||||
queryFn: () => getTracks({ ...filters, skip: page * limit, limit }),
|
||||
})
|
||||
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['stats'],
|
||||
queryFn: getStats,
|
||||
})
|
||||
// Filter tracks by search query on client side
|
||||
const filteredTracks = useMemo(() => {
|
||||
if (!tracksData?.tracks) return []
|
||||
if (!searchQuery.trim()) return tracksData.tracks
|
||||
|
||||
const query = searchQuery.toLowerCase()
|
||||
return tracksData.tracks.filter(track =>
|
||||
track.filename.toLowerCase().includes(query) ||
|
||||
track.metadata?.title?.toLowerCase().includes(query) ||
|
||||
track.metadata?.artist?.toLowerCase().includes(query)
|
||||
)
|
||||
}, [tracksData?.tracks, searchQuery])
|
||||
|
||||
// Extract filter options from displayed tracks
|
||||
// For better UX, we use the current page's tracks to populate filters
|
||||
// TODO: Add a dedicated API endpoint to get all unique filter values
|
||||
const filterOptions = useMemo(() => {
|
||||
if (!tracksData?.tracks || tracksData.tracks.length === 0) {
|
||||
return { genres: [], moods: [], instruments: [], keys: [] }
|
||||
@@ -78,212 +80,205 @@ export default function Home() {
|
||||
return extractFilterOptions(tracksData.tracks)
|
||||
}, [tracksData])
|
||||
|
||||
const totalPages = tracksData ? Math.ceil(tracksData.total / limit) : 0
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Audio Classifier</h1>
|
||||
<p className="text-gray-600">Intelligent music library management</p>
|
||||
<header className="bg-white border-b border-slate-200 shadow-sm sticky top-0 z-40">
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">Bibliothèque Musicale</h1>
|
||||
<p className="text-sm text-slate-600">Gestion intelligente de votre collection audio</p>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="flex-1 max-w-md ml-8">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher un titre, artiste..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full px-4 py-2 pl-10 bg-slate-50 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent text-sm"
|
||||
/>
|
||||
<svg className="absolute left-3 top-2.5 w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-6 text-sm text-slate-600">
|
||||
{tracksData?.total || 0} piste{(tracksData?.total || 0) > 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-32">
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-white p-4 rounded-lg shadow">
|
||||
<p className="text-gray-600 text-sm">Total Tracks</p>
|
||||
<p className="text-2xl font-bold">{stats.total_tracks}</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow">
|
||||
<p className="text-gray-600 text-sm">Avg BPM</p>
|
||||
<p className="text-2xl font-bold">{stats.average_bpm}</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow">
|
||||
<p className="text-gray-600 text-sm">Total Hours</p>
|
||||
<p className="text-2xl font-bold">{stats.total_duration_hours}h</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow">
|
||||
<p className="text-gray-600 text-sm">Genres</p>
|
||||
<p className="text-2xl font-bold">{stats.genres.length}</p>
|
||||
</div>
|
||||
{/* Main content with sidebar */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-72 bg-white border-r border-slate-200 overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Filtres</h2>
|
||||
<FilterPanel
|
||||
filters={filters}
|
||||
onFiltersChange={(newFilters) => {
|
||||
setFilters(newFilters)
|
||||
setPage(0)
|
||||
}}
|
||||
availableGenres={filterOptions.genres}
|
||||
availableMoods={filterOptions.moods}
|
||||
availableInstruments={filterOptions.instruments}
|
||||
availableKeys={filterOptions.keys}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Filter Panel */}
|
||||
<FilterPanel
|
||||
filters={filters}
|
||||
onFiltersChange={(newFilters) => {
|
||||
setFilters(newFilters)
|
||||
setPage(0) // Reset to first page when filters change
|
||||
}}
|
||||
availableGenres={filterOptions.genres}
|
||||
availableMoods={filterOptions.moods}
|
||||
availableInstruments={filterOptions.instruments}
|
||||
availableKeys={filterOptions.keys}
|
||||
/>
|
||||
{/* Tracks list */}
|
||||
<main className="flex-1 overflow-y-auto pb-32">
|
||||
<div className="p-6">
|
||||
{isLoadingTracks ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-slate-600">Chargement...</div>
|
||||
</div>
|
||||
) : filteredTracks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-slate-500">
|
||||
<svg className="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||
</svg>
|
||||
<p className="text-lg font-medium">Aucune piste trouvée</p>
|
||||
<p className="text-sm mt-2">Essayez de modifier vos filtres ou votre recherche</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{filteredTracks.map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className={`bg-white rounded-lg p-4 border transition-all hover:shadow-md ${
|
||||
currentTrack?.id === track.id
|
||||
? 'border-orange-500 shadow-md'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Play button */}
|
||||
<button
|
||||
onClick={() => setCurrentTrack(track)}
|
||||
className="flex-shrink-0 w-12 h-12 flex items-center justify-center bg-orange-500 hover:bg-orange-600 rounded-full transition-colors shadow-sm"
|
||||
>
|
||||
{currentTrack?.id === track.id ? (
|
||||
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Tracks List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="text-xl font-semibold">Music Library</h2>
|
||||
<p className="text-gray-600 text-sm">
|
||||
{tracksData?.total || 0} tracks total
|
||||
</p>
|
||||
</div>
|
||||
{/* Track info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-slate-800 truncate text-base">
|
||||
{track.metadata?.title || track.filename}
|
||||
</h3>
|
||||
{track.metadata?.artist && (
|
||||
<p className="text-sm text-slate-600 truncate">{track.metadata.artist}</p>
|
||||
)}
|
||||
|
||||
{isLoadingTracks ? (
|
||||
<div className="p-8 text-center text-gray-600">Loading...</div>
|
||||
) : tracksData?.tracks.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-600">
|
||||
No tracks found. Start by analyzing your audio library!
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{tracksData?.tracks.map((track) => (
|
||||
<div key={track.id} className="p-4 hover:bg-gray-50">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-900">{track.filename}</h3>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{/* Genre */}
|
||||
{(() => {
|
||||
const genre = formatGenre(track.classification.genre.primary)
|
||||
return (
|
||||
<>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-orange-100 text-orange-800">
|
||||
{genre.category}
|
||||
</span>
|
||||
{genre.subgenre && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-orange-50 text-orange-700">
|
||||
{genre.subgenre}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Primary metadata */}
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{(() => {
|
||||
const genre = formatGenre(track.classification.genre.primary)
|
||||
return (
|
||||
<>
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-blue-100 text-blue-800 font-semibold">
|
||||
{genre.category}
|
||||
</span>
|
||||
{genre.subgenre && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-blue-50 text-blue-700">
|
||||
{genre.subgenre}
|
||||
{/* Mood */}
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-purple-100 text-purple-800">
|
||||
{track.classification.mood.primary}
|
||||
</span>
|
||||
|
||||
{/* Key & BPM */}
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-slate-100 text-slate-700">
|
||||
{track.features.key}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-slate-100 text-slate-700">
|
||||
{Math.round(track.features.tempo_bpm)} BPM
|
||||
</span>
|
||||
|
||||
{/* Duration */}
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-slate-100 text-slate-700">
|
||||
{Math.floor(track.duration_seconds / 60)}:{String(Math.floor(track.duration_seconds % 60)).padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Instruments */}
|
||||
{track.classification.instruments && track.classification.instruments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
<span className="text-xs text-slate-500">Instruments:</span>
|
||||
{track.classification.instruments.slice(0, 5).map((instrument, i) => (
|
||||
<span key={i} className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-emerald-50 text-emerald-700">
|
||||
{instrument}
|
||||
</span>
|
||||
))}
|
||||
{track.classification.instruments.length > 5 && (
|
||||
<span className="text-xs text-slate-400">+{track.classification.instruments.length - 5}</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-purple-100 text-purple-800">
|
||||
{track.classification.mood.primary}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{Math.round(track.features.tempo_bpm)} BPM
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{track.features.key}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{Math.floor(track.duration_seconds / 60)}:{String(Math.floor(track.duration_seconds % 60)).padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Secondary genres */}
|
||||
{track.classification.genre.secondary && track.classification.genre.secondary.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<span className="text-xs text-gray-400">Also:</span>
|
||||
{track.classification.genre.secondary.slice(0, 3).map((g, i) => {
|
||||
const genre = formatGenre(g)
|
||||
return (
|
||||
<span key={i} className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-600">
|
||||
{genre.category}{genre.subgenre && ` • ${genre.subgenre}`}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Secondary moods */}
|
||||
{track.classification.mood.secondary && track.classification.mood.secondary.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<span className="text-xs text-gray-400">Moods:</span>
|
||||
{track.classification.mood.secondary.map((mood, i) => (
|
||||
<span key={i} className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-purple-50 text-purple-600">
|
||||
{mood}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instruments */}
|
||||
{track.classification.instruments && track.classification.instruments.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<span className="text-xs text-gray-400">Instruments:</span>
|
||||
{track.classification.instruments.slice(0, 6).map((instrument, i) => (
|
||||
<span key={i} className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-green-50 text-green-700">
|
||||
{instrument}
|
||||
</span>
|
||||
))}
|
||||
{track.classification.instruments.length > 6 && (
|
||||
<span className="text-xs text-gray-400">+{track.classification.instruments.length - 6} more</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentTrack(track)}
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
{currentTrack?.id === track.id ? '▶ Playing' : 'Play'}
|
||||
</button>
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_API_URL}/api/audio/download/${track.id}`}
|
||||
download
|
||||
className="px-3 py-1 text-sm bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{tracksData && tracksData.total > limit && (
|
||||
<div className="p-4 border-t flex justify-between items-center">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-gray-600">
|
||||
Page {page + 1} of {Math.ceil(tracksData.total / limit)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={(page + 1) * limit >= tracksData.total}
|
||||
className="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
← Précédent
|
||||
</button>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="mt-8 bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<h3 className="font-semibold text-blue-900 mb-2">Getting Started</h3>
|
||||
<ol className="list-decimal list-inside space-y-1 text-blue-800 text-sm">
|
||||
<li>Make sure the backend is running (<code>docker-compose up</code>)</li>
|
||||
<li>Use the API to analyze your audio library:
|
||||
<pre className="mt-2 bg-blue-100 p-2 rounded text-xs">
|
||||
{`curl -X POST http://localhost:8000/api/analyze/folder \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"path": "/audio/your_music", "recursive": true}'`}
|
||||
</pre>
|
||||
</li>
|
||||
<li>Refresh this page to see your analyzed tracks</li>
|
||||
</ol>
|
||||
</div>
|
||||
</main>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-600">
|
||||
Page <span className="font-semibold">{page + 1}</span> sur <span className="font-semibold">{totalPages}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Fixed Audio Player at bottom - always visible */}
|
||||
<button
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={(page + 1) >= totalPages}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Suivant →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Fixed Audio Player at bottom */}
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50">
|
||||
<AudioPlayer track={currentTrack} />
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,6 @@ export default function FilterPanel({
|
||||
}: FilterPanelProps) {
|
||||
const [localFilters, setLocalFilters] = useState<FilterParams>(filters)
|
||||
|
||||
// Update local filters when parent changes
|
||||
useEffect(() => {
|
||||
setLocalFilters(filters)
|
||||
}, [filters])
|
||||
@@ -39,208 +38,156 @@ export default function FilterPanel({
|
||||
onFiltersChange(emptyFilters)
|
||||
}
|
||||
|
||||
const hasActiveFilters = Object.keys(localFilters).length > 0
|
||||
const hasActiveFilters = Object.keys(localFilters).filter(key =>
|
||||
localFilters[key as keyof FilterParams] !== undefined &&
|
||||
localFilters[key as keyof FilterParams] !== ""
|
||||
).length > 0
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Filters</h2>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Genre Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Genre
|
||||
</label>
|
||||
<select
|
||||
value={localFilters.genre || ""}
|
||||
onChange={(e) => handleFilterChange("genre", e.target.value || undefined)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Genres</option>
|
||||
{availableGenres.map((genre) => (
|
||||
<option key={genre} value={genre}>
|
||||
{genre}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Mood Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Mood
|
||||
</label>
|
||||
<select
|
||||
value={localFilters.mood || ""}
|
||||
onChange={(e) => handleFilterChange("mood", e.target.value || undefined)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Moods</option>
|
||||
{availableMoods.map((mood) => (
|
||||
<option key={mood} value={mood}>
|
||||
{mood}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Instrument Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Instrument
|
||||
</label>
|
||||
<select
|
||||
value={localFilters.instrument || ""}
|
||||
onChange={(e) => handleFilterChange("instrument", e.target.value || undefined)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Instruments</option>
|
||||
{availableInstruments.map((instrument) => (
|
||||
<option key={instrument} value={instrument}>
|
||||
{instrument}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Key Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Key
|
||||
</label>
|
||||
<select
|
||||
value={localFilters.key || ""}
|
||||
onChange={(e) => handleFilterChange("key", e.target.value || undefined)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Keys</option>
|
||||
{availableKeys.map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Tempo Range Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tempo
|
||||
</label>
|
||||
<select
|
||||
value={localFilters.tempo_range || ""}
|
||||
onChange={(e) => handleFilterChange("tempo_range", e.target.value || undefined)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Tempos</option>
|
||||
<option value="slow">Slow (<100 BPM)</option>
|
||||
<option value="medium">Medium (100-140 BPM)</option>
|
||||
<option value="fast">Fast (>140 BPM)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Sort By */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Sort By
|
||||
</label>
|
||||
<select
|
||||
value={localFilters.sort_by || "analyzed_at"}
|
||||
onChange={(e) => handleFilterChange("sort_by", e.target.value as any)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="analyzed_at">Date Added</option>
|
||||
<option value="filename">Filename</option>
|
||||
<option value="tempo_bpm">BPM</option>
|
||||
<option value="duration_seconds">Duration</option>
|
||||
<option value="energy">Energy</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Sort Order */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Order
|
||||
</label>
|
||||
<select
|
||||
value={localFilters.sort_desc ? "desc" : "asc"}
|
||||
onChange={(e) => handleFilterChange("sort_desc", e.target.value === "desc")}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="asc">Ascending</option>
|
||||
<option value="desc">Descending</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Filters Display */}
|
||||
<div className="space-y-6">
|
||||
{/* Clear all button */}
|
||||
{hasActiveFilters && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{localFilters.genre && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-blue-100 text-blue-800">
|
||||
Genre: {localFilters.genre}
|
||||
<button
|
||||
onClick={() => handleFilterChange("genre", undefined)}
|
||||
className="ml-1 hover:text-blue-900"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{localFilters.mood && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-purple-100 text-purple-800">
|
||||
Mood: {localFilters.mood}
|
||||
<button
|
||||
onClick={() => handleFilterChange("mood", undefined)}
|
||||
className="ml-1 hover:text-purple-900"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{localFilters.instrument && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-green-100 text-green-800">
|
||||
Instrument: {localFilters.instrument}
|
||||
<button
|
||||
onClick={() => handleFilterChange("instrument", undefined)}
|
||||
className="ml-1 hover:text-green-900"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{localFilters.key && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-yellow-100 text-yellow-800">
|
||||
Key: {localFilters.key}
|
||||
<button
|
||||
onClick={() => handleFilterChange("key", undefined)}
|
||||
className="ml-1 hover:text-yellow-900"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{localFilters.tempo_range && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-orange-100 text-orange-800">
|
||||
Tempo: {localFilters.tempo_range}
|
||||
<button
|
||||
onClick={() => handleFilterChange("tempo_range", undefined)}
|
||||
className="ml-1 hover:text-orange-900"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="w-full text-sm text-orange-600 hover:text-orange-700 font-medium py-2 px-3 bg-orange-50 rounded-lg hover:bg-orange-100 transition-colors"
|
||||
>
|
||||
Effacer tous les filtres
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Genre Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
||||
Genre
|
||||
</label>
|
||||
<select
|
||||
value={localFilters.genre || ""}
|
||||
onChange={(e) => handleFilterChange("genre", e.target.value || undefined)}
|
||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Tous les genres</option>
|
||||
{availableGenres.map((genre) => (
|
||||
<option key={genre} value={genre}>
|
||||
{genre}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Mood Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
||||
Ambiance
|
||||
</label>
|
||||
<select
|
||||
value={localFilters.mood || ""}
|
||||
onChange={(e) => handleFilterChange("mood", e.target.value || undefined)}
|
||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Toutes les ambiances</option>
|
||||
{availableMoods.map((mood) => (
|
||||
<option key={mood} value={mood}>
|
||||
{mood}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Instrument Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
||||
Instrument
|
||||
</label>
|
||||
<select
|
||||
value={localFilters.instrument || ""}
|
||||
onChange={(e) => handleFilterChange("instrument", e.target.value || undefined)}
|
||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Tous les instruments</option>
|
||||
{availableInstruments.map((instrument) => (
|
||||
<option key={instrument} value={instrument}>
|
||||
{instrument}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Key Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
||||
Tonalité
|
||||
</label>
|
||||
<select
|
||||
value={localFilters.key || ""}
|
||||
onChange={(e) => handleFilterChange("key", e.target.value || undefined)}
|
||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Toutes les tonalités</option>
|
||||
{availableKeys.map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Tempo Range Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
||||
Tempo
|
||||
</label>
|
||||
<select
|
||||
value={localFilters.tempo_range || ""}
|
||||
onChange={(e) => handleFilterChange("tempo_range", e.target.value || undefined)}
|
||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Tous les tempos</option>
|
||||
<option value="slow">Lent (< 100 BPM)</option>
|
||||
<option value="medium">Moyen (100-140 BPM)</option>
|
||||
<option value="fast">Rapide (> 140 BPM)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Active filters summary */}
|
||||
{hasActiveFilters && (
|
||||
<div className="pt-4 border-t border-slate-200">
|
||||
<p className="text-xs font-semibold text-slate-700 mb-2">Filtres actifs:</p>
|
||||
<div className="space-y-1">
|
||||
{localFilters.genre && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-600">Genre:</span>
|
||||
<span className="font-medium text-slate-800">{localFilters.genre}</span>
|
||||
</div>
|
||||
)}
|
||||
{localFilters.mood && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-600">Ambiance:</span>
|
||||
<span className="font-medium text-slate-800">{localFilters.mood}</span>
|
||||
</div>
|
||||
)}
|
||||
{localFilters.instrument && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-600">Instrument:</span>
|
||||
<span className="font-medium text-slate-800">{localFilters.instrument}</span>
|
||||
</div>
|
||||
)}
|
||||
{localFilters.key && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-600">Tonalité:</span>
|
||||
<span className="font-medium text-slate-800">{localFilters.key}</span>
|
||||
</div>
|
||||
)}
|
||||
{localFilters.tempo_range && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-600">Tempo:</span>
|
||||
<span className="font-medium text-slate-800">
|
||||
{localFilters.tempo_range === 'slow' && 'Lent'}
|
||||
{localFilters.tempo_range === 'medium' && 'Moyen'}
|
||||
{localFilters.tempo_range === 'fast' && 'Rapide'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user