816 lines
37 KiB
TypeScript
816 lines
37 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
|
||
interface Genre {
|
||
id: number;
|
||
name: string;
|
||
_count?: {
|
||
songs: number;
|
||
};
|
||
}
|
||
|
||
interface Song {
|
||
id: number;
|
||
title: string;
|
||
artist: string;
|
||
filename: string;
|
||
createdAt: string;
|
||
activations: number;
|
||
genres: Genre[];
|
||
}
|
||
|
||
type SortField = 'id' | 'title' | 'artist' | 'createdAt';
|
||
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 [message, setMessage] = useState('');
|
||
const [songs, setSongs] = useState<Song[]>([]);
|
||
const [genres, setGenres] = useState<Genre[]>([]);
|
||
const [newGenreName, setNewGenreName] = useState('');
|
||
|
||
// Edit state
|
||
const [editingId, setEditingId] = useState<number | null>(null);
|
||
const [editTitle, setEditTitle] = useState('');
|
||
const [editArtist, setEditArtist] = useState('');
|
||
const [editGenreIds, setEditGenreIds] = useState<number[]>([]);
|
||
|
||
// Post-upload state
|
||
const [uploadedSong, setUploadedSong] = useState<Song | null>(null);
|
||
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
|
||
|
||
// AI Categorization state
|
||
const [isCategorizing, setIsCategorizing] = useState(false);
|
||
const [categorizationResults, setCategorizationResults] = useState<any>(null);
|
||
|
||
// Sort state
|
||
const [sortField, setSortField] = useState<SortField>('artist');
|
||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||
|
||
// Search and pagination state
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const itemsPerPage = 10;
|
||
|
||
// Audio state
|
||
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
|
||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
||
|
||
// Check for existing auth on mount
|
||
useEffect(() => {
|
||
const authToken = localStorage.getItem('hoerdle_admin_auth');
|
||
if (authToken === 'authenticated') {
|
||
setIsAuthenticated(true);
|
||
fetchSongs();
|
||
fetchGenres();
|
||
}
|
||
}, []);
|
||
|
||
const handleLogin = async () => {
|
||
const res = await fetch('/api/admin/login', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ password }),
|
||
});
|
||
if (res.ok) {
|
||
localStorage.setItem('hoerdle_admin_auth', 'authenticated');
|
||
setIsAuthenticated(true);
|
||
fetchSongs();
|
||
fetchGenres();
|
||
} else {
|
||
alert('Wrong password');
|
||
}
|
||
};
|
||
|
||
const fetchSongs = async () => {
|
||
const res = await fetch('/api/songs');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setSongs(data);
|
||
}
|
||
};
|
||
|
||
const fetchGenres = async () => {
|
||
const res = await fetch('/api/genres');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setGenres(data);
|
||
}
|
||
};
|
||
|
||
const createGenre = async () => {
|
||
if (!newGenreName.trim()) return;
|
||
const res = await fetch('/api/genres', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ name: newGenreName }),
|
||
});
|
||
if (res.ok) {
|
||
setNewGenreName('');
|
||
fetchGenres();
|
||
} else {
|
||
alert('Failed to create genre');
|
||
}
|
||
};
|
||
|
||
const deleteGenre = async (id: number) => {
|
||
if (!confirm('Delete this genre?')) return;
|
||
const res = await fetch('/api/genres', {
|
||
method: 'DELETE',
|
||
body: JSON.stringify({ id }),
|
||
});
|
||
if (res.ok) {
|
||
fetchGenres();
|
||
} else {
|
||
alert('Failed to delete genre');
|
||
}
|
||
};
|
||
|
||
const handleAICategorization = async () => {
|
||
if (!confirm('This will categorize all songs without genres using AI. Continue?')) return;
|
||
|
||
setIsCategorizing(true);
|
||
setCategorizationResults(null);
|
||
|
||
try {
|
||
let offset = 0;
|
||
let hasMore = true;
|
||
let allResults: any[] = [];
|
||
let totalUncategorized = 0;
|
||
let totalProcessed = 0;
|
||
|
||
// Process in batches
|
||
while (hasMore) {
|
||
const res = await fetch('/api/categorize', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ offset })
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const error = await res.json();
|
||
alert(`Categorization failed: ${error.error || 'Unknown error'}`);
|
||
break;
|
||
}
|
||
|
||
const data = await res.json();
|
||
totalUncategorized = data.totalUncategorized;
|
||
totalProcessed = data.processed;
|
||
hasMore = data.hasMore;
|
||
offset = data.nextOffset || 0;
|
||
|
||
// Accumulate results
|
||
allResults = [...allResults, ...data.results];
|
||
|
||
// Update UI with progress
|
||
setCategorizationResults({
|
||
message: `Processing: ${totalProcessed} / ${totalUncategorized} songs...`,
|
||
totalProcessed: totalUncategorized,
|
||
totalCategorized: allResults.length,
|
||
results: allResults,
|
||
inProgress: hasMore
|
||
});
|
||
}
|
||
|
||
// Final update
|
||
setCategorizationResults({
|
||
message: `Completed! Processed ${totalUncategorized} songs, categorized ${allResults.length}`,
|
||
totalProcessed: totalUncategorized,
|
||
totalCategorized: allResults.length,
|
||
results: allResults,
|
||
inProgress: false
|
||
});
|
||
|
||
fetchSongs(); // Refresh song list
|
||
fetchGenres(); // Refresh genre counts
|
||
} catch (error) {
|
||
alert('Failed to categorize songs');
|
||
console.error(error);
|
||
} finally {
|
||
setIsCategorizing(false);
|
||
}
|
||
};
|
||
|
||
const handleUpload = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!file) return;
|
||
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
setMessage('Uploading...');
|
||
const res = await fetch('/api/songs', {
|
||
method: 'POST',
|
||
body: formData,
|
||
});
|
||
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
const validation = data.validation;
|
||
|
||
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`;
|
||
|
||
if (validation.warnings.length > 0) {
|
||
statusMessage += `\n⚠️ Warnings:\n`;
|
||
validation.warnings.forEach((warning: string) => {
|
||
statusMessage += `• ${warning}\n`;
|
||
});
|
||
}
|
||
|
||
setMessage(statusMessage);
|
||
setFile(null);
|
||
setUploadedSong(data.song);
|
||
setUploadGenreIds([]); // Reset selection
|
||
fetchSongs();
|
||
} else {
|
||
setMessage('Upload failed.');
|
||
}
|
||
};
|
||
|
||
const saveUploadedSongGenres = async () => {
|
||
if (!uploadedSong) return;
|
||
|
||
const res = await fetch('/api/songs', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
id: uploadedSong.id,
|
||
title: uploadedSong.title,
|
||
artist: uploadedSong.artist,
|
||
genreIds: uploadGenreIds
|
||
}),
|
||
});
|
||
|
||
if (res.ok) {
|
||
setUploadedSong(null);
|
||
setUploadGenreIds([]);
|
||
fetchSongs();
|
||
setMessage(prev => prev + '\n✅ Genres assigned successfully!');
|
||
} else {
|
||
alert('Failed to assign genres');
|
||
}
|
||
};
|
||
|
||
const startEditing = (song: Song) => {
|
||
setEditingId(song.id);
|
||
setEditTitle(song.title);
|
||
setEditArtist(song.artist);
|
||
setEditGenreIds(song.genres.map(g => g.id));
|
||
};
|
||
|
||
const cancelEditing = () => {
|
||
setEditingId(null);
|
||
setEditTitle('');
|
||
setEditArtist('');
|
||
setEditGenreIds([]);
|
||
};
|
||
|
||
const saveEditing = async (id: number) => {
|
||
const res = await fetch('/api/songs', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
id,
|
||
title: editTitle,
|
||
artist: editArtist,
|
||
genreIds: editGenreIds
|
||
}),
|
||
});
|
||
|
||
if (res.ok) {
|
||
setEditingId(null);
|
||
fetchSongs();
|
||
} else {
|
||
alert('Failed to update song');
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id: number, title: string) => {
|
||
if (!confirm(`Are you sure you want to delete "${title}"? This will also delete the file.`)) {
|
||
return;
|
||
}
|
||
|
||
const res = await fetch('/api/songs', {
|
||
method: 'DELETE',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id }),
|
||
});
|
||
|
||
if (res.ok) {
|
||
fetchSongs();
|
||
} else {
|
||
alert('Failed to delete song');
|
||
}
|
||
};
|
||
|
||
const handleSort = (field: SortField) => {
|
||
if (sortField === field) {
|
||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||
} else {
|
||
setSortField(field);
|
||
setSortDirection('asc');
|
||
}
|
||
};
|
||
|
||
const handlePlayPause = (song: Song) => {
|
||
if (playingSongId === song.id) {
|
||
// Pause current song
|
||
audioElement?.pause();
|
||
setPlayingSongId(null);
|
||
} else {
|
||
// Stop any currently playing song
|
||
audioElement?.pause();
|
||
|
||
// Play new song
|
||
const audio = new Audio(`/uploads/${song.filename}`);
|
||
|
||
// Handle playback errors
|
||
audio.onerror = () => {
|
||
alert(`Failed to load audio file: ${song.filename}\nThe file may be corrupted or missing.`);
|
||
setPlayingSongId(null);
|
||
setAudioElement(null);
|
||
};
|
||
|
||
audio.play()
|
||
.then(() => {
|
||
setAudioElement(audio);
|
||
setPlayingSongId(song.id);
|
||
})
|
||
.catch((error) => {
|
||
console.error('Playback error:', error);
|
||
alert(`Failed to play audio: ${error.message}`);
|
||
setPlayingSongId(null);
|
||
setAudioElement(null);
|
||
});
|
||
|
||
// Reset when song ends
|
||
audio.onended = () => {
|
||
setPlayingSongId(null);
|
||
setAudioElement(null);
|
||
};
|
||
}
|
||
};
|
||
|
||
// Filter and sort songs
|
||
const filteredSongs = songs.filter(song =>
|
||
song.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
song.artist.toLowerCase().includes(searchQuery.toLowerCase())
|
||
);
|
||
|
||
const sortedSongs = [...filteredSongs].sort((a, b) => {
|
||
// Handle numeric sorting for ID
|
||
if (sortField === 'id') {
|
||
return sortDirection === 'asc' ? a.id - b.id : b.id - a.id;
|
||
}
|
||
|
||
// String sorting for other fields
|
||
const valA = String(a[sortField]).toLowerCase();
|
||
const valB = String(b[sortField]).toLowerCase();
|
||
|
||
if (valA < valB) return sortDirection === 'asc' ? -1 : 1;
|
||
if (valA > valB) return sortDirection === 'asc' ? 1 : -1;
|
||
return 0;
|
||
});
|
||
|
||
// Pagination
|
||
const totalPages = Math.ceil(sortedSongs.length / itemsPerPage);
|
||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||
const paginatedSongs = sortedSongs.slice(startIndex, startIndex + itemsPerPage);
|
||
|
||
// Reset to page 1 when search changes
|
||
useEffect(() => {
|
||
setCurrentPage(1);
|
||
}, [searchQuery]);
|
||
|
||
if (!isAuthenticated) {
|
||
return (
|
||
<div className="container" style={{ justifyContent: 'center' }}>
|
||
<h1 className="title" style={{ marginBottom: '1rem', fontSize: '1.5rem' }}>Admin Login</h1>
|
||
<input
|
||
type="password"
|
||
value={password}
|
||
onChange={e => setPassword(e.target.value)}
|
||
className="form-input"
|
||
style={{ marginBottom: '1rem', maxWidth: '300px' }}
|
||
placeholder="Password"
|
||
/>
|
||
<button onClick={handleLogin} className="btn-primary">Login</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="admin-container">
|
||
<h1 className="title" style={{ marginBottom: '2rem' }}>Admin Dashboard</h1>
|
||
|
||
{/* Genre Management */}
|
||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Genres</h2>
|
||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
|
||
<input
|
||
type="text"
|
||
value={newGenreName}
|
||
onChange={e => setNewGenreName(e.target.value)}
|
||
placeholder="New Genre Name"
|
||
className="form-input"
|
||
style={{ maxWidth: '200px' }}
|
||
/>
|
||
<button onClick={createGenre} className="btn-primary">Add Genre</button>
|
||
</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||
{genres.map(genre => (
|
||
<div key={genre.id} style={{
|
||
background: '#f3f4f6',
|
||
padding: '0.25rem 0.75rem',
|
||
borderRadius: '999px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '0.5rem',
|
||
fontSize: '0.875rem'
|
||
}}>
|
||
<span>{genre.name} ({genre._count?.songs || 0})</span>
|
||
<button
|
||
onClick={() => deleteGenre(genre.id)}
|
||
style={{ border: 'none', background: 'none', cursor: 'pointer', color: '#ef4444', fontWeight: 'bold' }}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* AI Categorization */}
|
||
<div style={{ marginTop: '1.5rem', paddingTop: '1rem', borderTop: '1px solid #e5e7eb' }}>
|
||
<button
|
||
onClick={handleAICategorization}
|
||
disabled={isCategorizing || genres.length === 0}
|
||
className="btn-primary"
|
||
style={{
|
||
opacity: isCategorizing || genres.length === 0 ? 0.5 : 1,
|
||
cursor: isCategorizing || genres.length === 0 ? 'not-allowed' : 'pointer'
|
||
}}
|
||
>
|
||
{isCategorizing ? '🤖 Categorizing...' : '🤖 Auto-Categorize Songs with AI'}
|
||
</button>
|
||
{genres.length === 0 && (
|
||
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
|
||
Please create at least one genre first.
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Categorization Results */}
|
||
{categorizationResults && (
|
||
<div style={{
|
||
marginTop: '1rem',
|
||
padding: '1rem',
|
||
background: '#f0fdf4',
|
||
border: '1px solid #86efac',
|
||
borderRadius: '0.5rem'
|
||
}}>
|
||
<h3 style={{ fontWeight: 'bold', marginBottom: '0.5rem', color: '#166534' }}>
|
||
✅ Categorization Complete
|
||
</h3>
|
||
<p style={{ fontSize: '0.875rem', marginBottom: '0.5rem' }}>
|
||
{categorizationResults.message}
|
||
</p>
|
||
{categorizationResults.results && categorizationResults.results.length > 0 && (
|
||
<div style={{ marginTop: '1rem' }}>
|
||
<p style={{ fontWeight: 'bold', fontSize: '0.875rem', marginBottom: '0.5rem' }}>Updated Songs:</p>
|
||
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||
{categorizationResults.results.map((result: any) => (
|
||
<div key={result.songId} style={{
|
||
padding: '0.5rem',
|
||
background: 'white',
|
||
borderRadius: '0.25rem',
|
||
marginBottom: '0.5rem',
|
||
fontSize: '0.875rem'
|
||
}}>
|
||
<strong>{result.title}</strong> by {result.artist}
|
||
<div style={{ display: 'flex', gap: '0.25rem', marginTop: '0.25rem', flexWrap: 'wrap' }}>
|
||
{result.assignedGenres.map((genre: string) => (
|
||
<span key={genre} style={{
|
||
background: '#dbeafe',
|
||
padding: '0.1rem 0.4rem',
|
||
borderRadius: '0.25rem',
|
||
fontSize: '0.75rem'
|
||
}}>
|
||
{genre}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<button
|
||
onClick={() => setCategorizationResults(null)}
|
||
style={{
|
||
marginTop: '1rem',
|
||
padding: '0.5rem 1rem',
|
||
background: 'white',
|
||
border: '1px solid #d1d5db',
|
||
borderRadius: '0.25rem',
|
||
cursor: 'pointer'
|
||
}}
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
)}
|
||
</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>
|
||
<input
|
||
type="file"
|
||
accept="audio/mpeg"
|
||
onChange={e => setFile(e.target.files?.[0] || null)}
|
||
className="form-input"
|
||
required
|
||
/>
|
||
</div>
|
||
<button type="submit" className="btn-primary">
|
||
Upload Song
|
||
</button>
|
||
{message && (
|
||
<div style={{
|
||
marginTop: '1rem',
|
||
padding: '1rem',
|
||
background: message.includes('⚠️') ? '#fff3cd' : '#d4edda',
|
||
border: `1px solid ${message.includes('⚠️') ? '#ffc107' : '#28a745'}`,
|
||
borderRadius: '0.25rem',
|
||
whiteSpace: 'pre-line',
|
||
fontSize: '0.875rem',
|
||
fontFamily: 'monospace'
|
||
}}>
|
||
{message}
|
||
</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">
|
||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
||
Song Library ({songs.length} songs)
|
||
</h2>
|
||
|
||
{/* Search */}
|
||
<div style={{ marginBottom: '1rem' }}>
|
||
<input
|
||
type="text"
|
||
placeholder="Search by title or artist..."
|
||
value={searchQuery}
|
||
onChange={e => setSearchQuery(e.target.value)}
|
||
className="form-input"
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ overflowX: 'auto' }}>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||
<thead>
|
||
<tr style={{ borderBottom: '2px solid #e5e7eb', textAlign: 'left' }}>
|
||
<th
|
||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||
onClick={() => handleSort('id')}
|
||
>
|
||
ID {sortField === 'id' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||
</th>
|
||
<th
|
||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||
onClick={() => handleSort('title')}
|
||
>
|
||
Title {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||
</th>
|
||
<th
|
||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||
onClick={() => handleSort('artist')}
|
||
>
|
||
Artist {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||
</th>
|
||
<th style={{ padding: '0.75rem' }}>Genres</th>
|
||
<th
|
||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||
onClick={() => handleSort('createdAt')}
|
||
>
|
||
Added {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||
</th>
|
||
<th style={{ padding: '0.75rem' }}>Activations</th>
|
||
<th style={{ padding: '0.75rem' }}>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{paginatedSongs.map(song => (
|
||
<tr key={song.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||
<td style={{ padding: '0.75rem' }}>{song.id}</td>
|
||
|
||
{editingId === song.id ? (
|
||
<>
|
||
<td style={{ padding: '0.75rem' }}>
|
||
<input
|
||
type="text"
|
||
value={editTitle}
|
||
onChange={e => setEditTitle(e.target.value)}
|
||
className="form-input"
|
||
style={{ padding: '0.25rem' }}
|
||
/>
|
||
</td>
|
||
<td style={{ padding: '0.75rem' }}>
|
||
<input
|
||
type="text"
|
||
value={editArtist}
|
||
onChange={e => setEditArtist(e.target.value)}
|
||
className="form-input"
|
||
style={{ padding: '0.25rem' }}
|
||
/>
|
||
</td>
|
||
<td style={{ padding: '0.75rem' }}>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||
{genres.map(genre => (
|
||
<label key={genre.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.75rem' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={editGenreIds.includes(genre.id)}
|
||
onChange={e => {
|
||
if (e.target.checked) {
|
||
setEditGenreIds([...editGenreIds, genre.id]);
|
||
} else {
|
||
setEditGenreIds(editGenreIds.filter(id => id !== genre.id));
|
||
}
|
||
}}
|
||
/>
|
||
{genre.name}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</td>
|
||
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
|
||
{new Date(song.createdAt).toLocaleDateString('de-DE')}
|
||
</td>
|
||
<td style={{ padding: '0.75rem', color: '#666' }}>{song.activations}</td>
|
||
<td style={{ padding: '0.75rem' }}>
|
||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||
<button
|
||
onClick={() => saveEditing(song.id)}
|
||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
||
title="Save"
|
||
>
|
||
✅
|
||
</button>
|
||
<button
|
||
onClick={cancelEditing}
|
||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
||
title="Cancel"
|
||
>
|
||
❌
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</>
|
||
) : (
|
||
<>
|
||
<td style={{ padding: '0.75rem', fontWeight: 'bold' }}>{song.title}</td>
|
||
<td style={{ padding: '0.75rem' }}>{song.artist}</td>
|
||
<td style={{ padding: '0.75rem' }}>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||
{song.genres?.map(g => (
|
||
<span key={g.id} style={{
|
||
background: '#e5e7eb',
|
||
padding: '0.1rem 0.4rem',
|
||
borderRadius: '0.25rem',
|
||
fontSize: '0.7rem'
|
||
}}>
|
||
{g.name}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</td>
|
||
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
|
||
{new Date(song.createdAt).toLocaleDateString('de-DE')}
|
||
</td>
|
||
<td style={{ padding: '0.75rem', color: '#666' }}>{song.activations}</td>
|
||
<td style={{ padding: '0.75rem' }}>
|
||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||
<button
|
||
onClick={() => handlePlayPause(song)}
|
||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
||
title={playingSongId === song.id ? "Pause" : "Play"}
|
||
>
|
||
{playingSongId === song.id ? '⏸️' : '▶️'}
|
||
</button>
|
||
<button
|
||
onClick={() => startEditing(song)}
|
||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
||
title="Edit"
|
||
>
|
||
✏️
|
||
</button>
|
||
<button
|
||
onClick={() => handleDelete(song.id, song.title)}
|
||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
||
title="Delete"
|
||
>
|
||
🗑️
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</>
|
||
)}
|
||
</tr>
|
||
))}
|
||
{paginatedSongs.length === 0 && (
|
||
<tr>
|
||
<td colSpan={7} style={{ padding: '1rem', textAlign: 'center', color: '#666' }}>
|
||
{searchQuery ? 'No songs found matching your search.' : 'No songs uploaded yet.'}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{totalPages > 1 && (
|
||
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'center', gap: '0.5rem', alignItems: 'center' }}>
|
||
<button
|
||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||
disabled={currentPage === 1}
|
||
style={{
|
||
padding: '0.5rem 1rem',
|
||
border: '1px solid #d1d5db',
|
||
background: currentPage === 1 ? '#f3f4f6' : '#fff',
|
||
cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
|
||
borderRadius: '0.25rem'
|
||
}}
|
||
>
|
||
Previous
|
||
</button>
|
||
<span style={{ color: '#666' }}>
|
||
Page {currentPage} of {totalPages}
|
||
</span>
|
||
<button
|
||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||
disabled={currentPage === totalPages}
|
||
style={{
|
||
padding: '0.5rem 1rem',
|
||
border: '1px solid #d1d5db',
|
||
background: currentPage === totalPages ? '#f3f4f6' : '#fff',
|
||
cursor: currentPage === totalPages ? 'not-allowed' : 'pointer',
|
||
borderRadius: '0.25rem'
|
||
}}
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|