Feat: Add genre filter to song library table

This commit is contained in:
Hördle Bot
2025-11-22 12:56:42 +01:00
parent 7d117d3bd4
commit c270f2098f

View File

@@ -26,8 +26,12 @@ type SortDirection = 'asc' | 'desc';
export default function AdminPage() {
const [password, setPassword] = useState('');
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [message, setMessage] = useState('');
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<{ current: number, total: number }>({ current: 0, total: 0 });
const [uploadResults, setUploadResults] = useState<any[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [songs, setSongs] = useState<Song[]>([]);
const [genres, setGenres] = useState<Genre[]>([]);
const [newGenreName, setNewGenreName] = useState('');
@@ -52,6 +56,7 @@ export default function AdminPage() {
// Search and pagination state
const [searchQuery, setSearchQuery] = useState('');
const [selectedGenreFilter, setSelectedGenreFilter] = useState<number | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
@@ -192,45 +197,99 @@ export default function AdminPage() {
}
};
const handleUpload = async (e: React.FormEvent) => {
const handleBatchUpload = async (e: React.FormEvent) => {
e.preventDefault();
if (!file) return;
if (files.length === 0) return;
const formData = new FormData();
formData.append('file', file);
setIsUploading(true);
setUploadResults([]);
setUploadProgress({ current: 0, total: files.length });
setMessage('');
setMessage('Uploading...');
const res = await fetch('/api/songs', {
method: 'POST',
body: formData,
});
const results = [];
if (res.ok) {
const data = await res.json();
const validation = data.validation;
// Upload files sequentially to avoid timeout
for (let i = 0; i < files.length; i++) {
const file = files[i];
setUploadProgress({ current: i + 1, total: files.length });
let statusMessage = '✅ Song uploaded successfully!\n\n';
statusMessage += `📊 Audio Info:\n`;
statusMessage += `• Format: ${validation.codec || 'unknown'}\n`;
statusMessage += `• Bitrate: ${Math.round(validation.bitrate / 1000)} kbps\n`;
statusMessage += `• Sample Rate: ${validation.sampleRate} Hz\n`;
statusMessage += `• Duration: ${Math.round(validation.duration)} seconds\n`;
statusMessage += `• Cover Art: ${validation.hasCover ? '✅ Yes' : '❌ No'}\n`;
try {
const formData = new FormData();
formData.append('file', file);
if (validation.warnings.length > 0) {
statusMessage += `\n⚠ Warnings:\n`;
validation.warnings.forEach((warning: string) => {
statusMessage += `${warning}\n`;
const res = await fetch('/api/songs', {
method: 'POST',
body: formData,
});
if (res.ok) {
const data = await res.json();
results.push({
filename: file.name,
success: true,
song: data.song,
validation: data.validation
});
} else {
results.push({
filename: file.name,
success: false,
error: 'Upload failed'
});
}
} catch (error) {
results.push({
filename: file.name,
success: false,
error: 'Network error'
});
}
}
setMessage(statusMessage);
setFile(null);
setUploadedSong(data.song);
setUploadGenreIds([]); // Reset selection
fetchSongs();
setUploadResults(results);
setFiles([]);
setIsUploading(false);
fetchSongs();
// Auto-trigger categorization after uploads
const successCount = results.filter(r => r.success).length;
if (successCount > 0) {
setMessage(`✅ Uploaded ${successCount}/${files.length} songs successfully!\n\n🤖 Starting auto-categorization...`);
// Small delay to let user see the message
setTimeout(() => {
handleAICategorization();
}, 1000);
} else {
setMessage('Upload failed.');
setMessage(`❌ All uploads failed.`);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files).filter(
file => file.type === 'audio/mpeg' || file.name.endsWith('.mp3')
);
if (droppedFiles.length > 0) {
setFiles(droppedFiles);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(Array.from(e.target.files));
}
};
@@ -359,10 +418,17 @@ export default function AdminPage() {
};
// Filter and sort songs
const filteredSongs = songs.filter(song =>
song.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
song.artist.toLowerCase().includes(searchQuery.toLowerCase())
);
const filteredSongs = songs.filter(song => {
// Text search filter
const matchesSearch = song.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
song.artist.toLowerCase().includes(searchQuery.toLowerCase());
// Genre filter
const matchesGenre = selectedGenreFilter === null ||
song.genres.some(g => g.id === selectedGenreFilter);
return matchesSearch && matchesGenre;
});
const sortedSongs = [...filteredSongs].sort((a, b) => {
// Handle numeric sorting for ID
@@ -529,27 +595,88 @@ export default function AdminPage() {
</div>
<div className="admin-card" style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Upload New Song</h2>
<form onSubmit={handleUpload}>
<div className="form-group">
<label className="form-label">MP3 File (Title and Artist will be extracted from ID3 tags)</label>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Upload Songs</h2>
<form onSubmit={handleBatchUpload}>
{/* Drag & Drop Zone */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{
border: isDragging ? '2px solid #4f46e5' : '2px dashed #d1d5db',
borderRadius: '0.5rem',
padding: '2rem',
textAlign: 'center',
background: isDragging ? '#eef2ff' : '#f9fafb',
marginBottom: '1rem',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onClick={() => document.getElementById('file-input')?.click()}
>
<div style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>📁</div>
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
{files.length > 0 ? `${files.length} file(s) selected` : 'Drag & drop MP3 files here'}
</p>
<p style={{ fontSize: '0.875rem', color: '#666' }}>
or click to browse
</p>
<input
id="file-input"
type="file"
accept="audio/mpeg"
onChange={e => setFile(e.target.files?.[0] || null)}
className="form-input"
required
multiple
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</div>
<button type="submit" className="btn-primary">
Upload Song
{/* File List */}
{files.length > 0 && (
<div style={{ marginBottom: '1rem' }}>
<p style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>Selected Files:</p>
<div style={{ maxHeight: '200px', overflowY: 'auto', background: '#f9fafb', padding: '0.5rem', borderRadius: '0.25rem' }}>
{files.map((file, index) => (
<div key={index} style={{ padding: '0.25rem 0', fontSize: '0.875rem' }}>
📄 {file.name}
</div>
))}
</div>
</div>
)}
{/* Upload Progress */}
{isUploading && (
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#eef2ff', borderRadius: '0.5rem' }}>
<p style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>
Uploading: {uploadProgress.current} / {uploadProgress.total}
</p>
<div style={{ width: '100%', height: '8px', background: '#d1d5db', borderRadius: '4px', overflow: 'hidden' }}>
<div style={{
width: `${(uploadProgress.current / uploadProgress.total) * 100}%`,
height: '100%',
background: '#4f46e5',
transition: 'width 0.3s'
}} />
</div>
</div>
)}
<button
type="submit"
className="btn-primary"
disabled={files.length === 0 || isUploading}
style={{ opacity: files.length === 0 || isUploading ? 0.5 : 1 }}
>
{isUploading ? 'Uploading...' : `Upload ${files.length} Song(s)`}
</button>
{message && (
<div style={{
marginTop: '1rem',
padding: '1rem',
background: message.includes('⚠️') ? '#fff3cd' : '#d4edda',
border: `1px solid ${message.includes('⚠️') ? '#ffc107' : '#28a745'}`,
background: message.includes('') ? '#fee2e2' : '#d4edda',
border: `1px solid ${message.includes('') ? '#ef4444' : '#28a745'}`,
borderRadius: '0.25rem',
whiteSpace: 'pre-line',
fontSize: '0.875rem',
@@ -559,41 +686,6 @@ export default function AdminPage() {
</div>
)}
</form>
{/* Post-upload Genre Selection */}
{uploadedSong && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: '#eef2ff', borderRadius: '0.5rem', border: '1px solid #c7d2fe' }}>
<h3 style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>Assign Genres to "{uploadedSong.title}"</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '1rem' }}>
{genres.map(genre => (
<label key={genre.id} style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
background: 'white',
padding: '0.25rem 0.5rem',
borderRadius: '0.25rem',
cursor: 'pointer',
border: uploadGenreIds.includes(genre.id) ? '1px solid #4f46e5' : '1px solid #e5e7eb'
}}>
<input
type="checkbox"
checked={uploadGenreIds.includes(genre.id)}
onChange={e => {
if (e.target.checked) {
setUploadGenreIds([...uploadGenreIds, genre.id]);
} else {
setUploadGenreIds(uploadGenreIds.filter(id => id !== genre.id));
}
}}
/>
{genre.name}
</label>
))}
</div>
<button onClick={saveUploadedSongGenres} className="btn-primary">Save Genres</button>
</div>
)}
</div>
<div className="admin-card">
@@ -601,15 +693,47 @@ export default function AdminPage() {
Song Library ({songs.length} songs)
</h2>
{/* Search */}
<div style={{ marginBottom: '1rem' }}>
{/* Search and Filter */}
<div style={{ marginBottom: '1rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<input
type="text"
placeholder="Search by title or artist..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="form-input"
style={{ flex: '1', minWidth: '200px' }}
/>
<select
value={selectedGenreFilter || ''}
onChange={e => setSelectedGenreFilter(e.target.value ? Number(e.target.value) : null)}
className="form-input"
style={{ minWidth: '150px' }}
>
<option value="">All Genres</option>
{genres.map(genre => (
<option key={genre.id} value={genre.id}>
{genre.name} ({genre._count?.songs || 0})
</option>
))}
</select>
{(searchQuery || selectedGenreFilter) && (
<button
onClick={() => {
setSearchQuery('');
setSelectedGenreFilter(null);
}}
style={{
padding: '0.5rem 1rem',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.875rem'
}}
>
Clear Filters
</button>
)}
</div>
<div style={{ overflowX: 'auto' }}>