Files
hoerdle/app/admin/page.tsx
2025-11-22 12:56:42 +01:00

940 lines
42 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 [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('');
// 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 [selectedGenreFilter, setSelectedGenreFilter] = useState<number | null>(null);
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 handleBatchUpload = async (e: React.FormEvent) => {
e.preventDefault();
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();
formData.append('file', file);
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'
});
}
}
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(`❌ 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));
}
};
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 => {
// 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
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' }}>Hördle 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 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"
multiple
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</div>
{/* 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('❌') ? '#fee2e2' : '#d4edda',
border: `1px solid ${message.includes('❌') ? '#ef4444' : '#28a745'}`,
borderRadius: '0.25rem',
whiteSpace: 'pre-line',
fontSize: '0.875rem',
fontFamily: 'monospace'
}}>
{message}
</div>
)}
</form>
</div>
<div className="admin-card">
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
Song Library ({songs.length} songs)
</h2>
{/* 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' }}>
<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>
);
}