Files
Audio-Classifier/frontend/app/page.tsx
2025-12-23 09:40:14 +01:00

288 lines
13 KiB
TypeScript

"use client"
import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
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
function formatGenre(genre: string): { category: string; subgenre: string } {
const parts = genre.split('---')
return {
category: parts[0] || genre,
subgenre: parts[1] || ''
}
}
// Extract unique values for filter options
function extractFilterOptions(tracks: Track[]) {
const genres = new Set<string>()
const moods = new Set<string>()
const instruments = new Set<string>()
const keys = new Set<string>()
tracks.forEach(track => {
const genreCategory = formatGenre(track.classification.genre.primary).category
genres.add(genreCategory)
if (track.classification.mood.primary) {
moods.add(track.classification.mood.primary)
}
track.classification.instruments?.forEach(instrument => {
instruments.add(instrument)
})
if (track.features.key) {
keys.add(track.features.key)
}
})
return {
genres: Array.from(genres).sort(),
moods: Array.from(moods).sort(),
instruments: Array.from(instruments).sort(),
keys: Array.from(keys).sort(),
}
}
export default function Home() {
const [filters, setFilters] = useState<FilterParams>({})
const [page, setPage] = useState(0)
const [currentTrack, setCurrentTrack] = useState<Track | null>(null)
const [searchQuery, setSearchQuery] = useState("")
const limit = 25
const { data: tracksData, isLoading: isLoadingTracks } = useQuery({
queryKey: ['tracks', filters, page],
queryFn: () => getTracks({ ...filters, skip: page * limit, limit }),
})
// 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])
const filterOptions = useMemo(() => {
if (!tracksData?.tracks || tracksData.tracks.length === 0) {
return { genres: [], moods: [], instruments: [], keys: [] }
}
return extractFilterOptions(tracksData.tracks)
}, [tracksData])
const totalPages = tracksData ? Math.ceil(tracksData.total / limit) : 0
return (
<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 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 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>
{/* 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>
{/* 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>
)}
<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>
)}
</>
)
})()}
{/* 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>
)}
</div>
)}
</div>
</div>
</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>
<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>
<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>
</div>
)
}