Feat: Add genre filter to song library table
This commit is contained in:
@@ -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' }}>
|
||||
|
||||
Reference in New Issue
Block a user