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() {
|
export default function AdminPage() {
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const [message, setMessage] = useState('');
|
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 [songs, setSongs] = useState<Song[]>([]);
|
||||||
const [genres, setGenres] = useState<Genre[]>([]);
|
const [genres, setGenres] = useState<Genre[]>([]);
|
||||||
const [newGenreName, setNewGenreName] = useState('');
|
const [newGenreName, setNewGenreName] = useState('');
|
||||||
@@ -52,6 +56,7 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
// Search and pagination state
|
// Search and pagination state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedGenreFilter, setSelectedGenreFilter] = useState<number | null>(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const itemsPerPage = 10;
|
const itemsPerPage = 10;
|
||||||
|
|
||||||
@@ -192,14 +197,26 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpload = async (e: React.FormEvent) => {
|
const handleBatchUpload = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!file) return;
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
setUploadResults([]);
|
||||||
|
setUploadProgress({ current: 0, total: files.length });
|
||||||
|
setMessage('');
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
|
||||||
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
setMessage('Uploading...');
|
|
||||||
const res = await fetch('/api/songs', {
|
const res = await fetch('/api/songs', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
@@ -207,30 +224,72 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const validation = data.validation;
|
results.push({
|
||||||
|
filename: file.name,
|
||||||
let statusMessage = '✅ Song uploaded successfully!\n\n';
|
success: true,
|
||||||
statusMessage += `📊 Audio Info:\n`;
|
song: data.song,
|
||||||
statusMessage += `• Format: ${validation.codec || 'unknown'}\n`;
|
validation: data.validation
|
||||||
statusMessage += `• Bitrate: ${Math.round(validation.bitrate / 1000)} kbps\n`;
|
});
|
||||||
statusMessage += `• Sample Rate: ${validation.sampleRate} Hz\n`;
|
} else {
|
||||||
statusMessage += `• Duration: ${Math.round(validation.duration)} seconds\n`;
|
results.push({
|
||||||
statusMessage += `• Cover Art: ${validation.hasCover ? '✅ Yes' : '❌ No'}\n`;
|
filename: file.name,
|
||||||
|
success: false,
|
||||||
if (validation.warnings.length > 0) {
|
error: 'Upload failed'
|
||||||
statusMessage += `\n⚠️ Warnings:\n`;
|
|
||||||
validation.warnings.forEach((warning: string) => {
|
|
||||||
statusMessage += `• ${warning}\n`;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
filename: file.name,
|
||||||
|
success: false,
|
||||||
|
error: 'Network error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setMessage(statusMessage);
|
setUploadResults(results);
|
||||||
setFile(null);
|
setFiles([]);
|
||||||
setUploadedSong(data.song);
|
setIsUploading(false);
|
||||||
setUploadGenreIds([]); // Reset selection
|
|
||||||
fetchSongs();
|
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 {
|
} 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
|
// Filter and sort songs
|
||||||
const filteredSongs = songs.filter(song =>
|
const filteredSongs = songs.filter(song => {
|
||||||
song.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
// Text search filter
|
||||||
song.artist.toLowerCase().includes(searchQuery.toLowerCase())
|
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) => {
|
const sortedSongs = [...filteredSongs].sort((a, b) => {
|
||||||
// Handle numeric sorting for ID
|
// Handle numeric sorting for ID
|
||||||
@@ -529,27 +595,88 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Upload New Song</h2>
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Upload Songs</h2>
|
||||||
<form onSubmit={handleUpload}>
|
<form onSubmit={handleBatchUpload}>
|
||||||
<div className="form-group">
|
{/* Drag & Drop Zone */}
|
||||||
<label className="form-label">MP3 File (Title and Artist will be extracted from ID3 tags)</label>
|
<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
|
<input
|
||||||
|
id="file-input"
|
||||||
type="file"
|
type="file"
|
||||||
accept="audio/mpeg"
|
accept="audio/mpeg"
|
||||||
onChange={e => setFile(e.target.files?.[0] || null)}
|
multiple
|
||||||
className="form-input"
|
onChange={handleFileChange}
|
||||||
required
|
style={{ display: 'none' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</button>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div style={{
|
<div style={{
|
||||||
marginTop: '1rem',
|
marginTop: '1rem',
|
||||||
padding: '1rem',
|
padding: '1rem',
|
||||||
background: message.includes('⚠️') ? '#fff3cd' : '#d4edda',
|
background: message.includes('❌') ? '#fee2e2' : '#d4edda',
|
||||||
border: `1px solid ${message.includes('⚠️') ? '#ffc107' : '#28a745'}`,
|
border: `1px solid ${message.includes('❌') ? '#ef4444' : '#28a745'}`,
|
||||||
borderRadius: '0.25rem',
|
borderRadius: '0.25rem',
|
||||||
whiteSpace: 'pre-line',
|
whiteSpace: 'pre-line',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
@@ -559,41 +686,6 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</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>
|
||||||
|
|
||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
@@ -601,15 +693,47 @@ export default function AdminPage() {
|
|||||||
Song Library ({songs.length} songs)
|
Song Library ({songs.length} songs)
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search and Filter */}
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div style={{ marginBottom: '1rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by title or artist..."
|
placeholder="Search by title or artist..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
className="form-input"
|
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>
|
||||||
|
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user