Frontend: - FilterPanel: Remplacer select par checkboxes pour instruments - Zone scrollable (max-height 12rem) pour la liste - Affichage des instruments sélectionnés dans résumé filtres actifs Backend: - API tracks: Nouveau paramètre instruments (List[str]) - Backward compatible avec ancien paramètre instrument - CRUD: Filtrage AND (track doit avoir TOUS les instruments) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
219 lines
8.2 KiB
TypeScript
219 lines
8.2 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect } from "react"
|
|
import type { FilterParams } from "@/lib/types"
|
|
|
|
interface FilterPanelProps {
|
|
filters: FilterParams
|
|
onFiltersChange: (filters: FilterParams) => void
|
|
availableGenres: string[]
|
|
availableMoods: string[]
|
|
availableInstruments: string[]
|
|
availableKeys: string[]
|
|
}
|
|
|
|
export default function FilterPanel({
|
|
filters,
|
|
onFiltersChange,
|
|
availableGenres,
|
|
availableMoods,
|
|
availableInstruments,
|
|
availableKeys,
|
|
}: FilterPanelProps) {
|
|
const [localFilters, setLocalFilters] = useState<FilterParams>(filters)
|
|
|
|
useEffect(() => {
|
|
setLocalFilters(filters)
|
|
}, [filters])
|
|
|
|
const handleFilterChange = (key: keyof FilterParams, value: any) => {
|
|
const newFilters = { ...localFilters, [key]: value }
|
|
setLocalFilters(newFilters)
|
|
onFiltersChange(newFilters)
|
|
}
|
|
|
|
const clearFilters = () => {
|
|
const emptyFilters: FilterParams = {}
|
|
setLocalFilters(emptyFilters)
|
|
onFiltersChange(emptyFilters)
|
|
}
|
|
|
|
const hasActiveFilters = Object.keys(localFilters).filter(key =>
|
|
localFilters[key as keyof FilterParams] !== undefined &&
|
|
localFilters[key as keyof FilterParams] !== ""
|
|
).length > 0
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Clear all button */}
|
|
{hasActiveFilters && (
|
|
<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 - Multiple Selection */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
|
Instruments
|
|
</label>
|
|
<div className="space-y-2 max-h-48 overflow-y-auto bg-slate-50 border border-slate-300 rounded-lg p-2">
|
|
{availableInstruments.length === 0 ? (
|
|
<p className="text-xs text-slate-500 p-2">Aucun instrument disponible</p>
|
|
) : (
|
|
availableInstruments.map((instrument) => {
|
|
const isSelected = localFilters.instruments?.includes(instrument) || false
|
|
return (
|
|
<label
|
|
key={instrument}
|
|
className="flex items-center gap-2 p-2 hover:bg-slate-100 rounded cursor-pointer transition-colors"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={isSelected}
|
|
onChange={(e) => {
|
|
const currentInstruments = localFilters.instruments || []
|
|
const newInstruments = e.target.checked
|
|
? [...currentInstruments, instrument]
|
|
: currentInstruments.filter(i => i !== instrument)
|
|
handleFilterChange("instruments", newInstruments.length > 0 ? newInstruments : undefined)
|
|
}}
|
|
className="w-4 h-4 text-orange-500 border-slate-300 rounded focus:ring-2 focus:ring-orange-500 focus:ring-offset-0"
|
|
/>
|
|
<span className="text-sm text-slate-700">{instrument}</span>
|
|
</label>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</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.instruments && localFilters.instruments.length > 0 && (
|
|
<div className="text-xs">
|
|
<span className="text-slate-600">Instruments:</span>
|
|
<div className="flex flex-wrap gap-1 mt-1">
|
|
{localFilters.instruments.map((instrument) => (
|
|
<span key={instrument} className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-emerald-50 text-emerald-700">
|
|
{instrument}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</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>
|
|
)
|
|
}
|