Kuratoren-Accounts und Anpassungen im Admin- und Kuratoren-Dashboard

This commit is contained in:
Hördle Bot
2025-12-03 12:52:38 +01:00
parent 49e98ade3c
commit 38148ace8d
12 changed files with 2171 additions and 620 deletions

View File

@@ -76,6 +76,14 @@ interface PoliticalStatement {
locale: string;
}
interface Curator {
id: number;
username: string;
isGlobalCurator: boolean;
genreIds: number[];
specialIds: number[];
}
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
type SortDirection = 'asc' | 'desc';
@@ -159,14 +167,18 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
const [sortField, setSortField] = useState<SortField>('artist');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
// Search and pagination state
// Search and pagination state (wird nur noch in Resten der alten Song Library verwendet, kann später entfernt werden)
const [searchQuery, setSearchQuery] = useState('');
const [selectedGenreFilter, setSelectedGenreFilter] = useState<string>('');
const [selectedSpecialFilter, setSelectedSpecialFilter] = useState<number | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
// Audio state
// Legacy Song-Library-Helper (Liste selbst ist obsolet; wir halten diese Werte nur, damit altes JSX nicht crasht)
const paginatedSongs: Song[] = [];
const totalPages = 1;
// Audio state (für Daily Puzzles)
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
@@ -184,6 +196,16 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
const [newPoliticalStatementActive, setNewPoliticalStatementActive] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
// Curators state
const [curators, setCurators] = useState<Curator[]>([]);
const [showCurators, setShowCurators] = useState(false);
const [editingCuratorId, setEditingCuratorId] = useState<number | null>(null);
const [curatorUsername, setCuratorUsername] = useState('');
const [curatorPassword, setCuratorPassword] = useState('');
const [curatorIsGlobal, setCuratorIsGlobal] = useState(false);
const [curatorGenreIds, setCuratorGenreIds] = useState<number[]>([]);
const [curatorSpecialIds, setCuratorSpecialIds] = useState<number[]>([]);
// Check for existing auth on mount
useEffect(() => {
const authToken = localStorage.getItem('hoerdle_admin_auth');
@@ -194,6 +216,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
fetchDailyPuzzles();
fetchSpecials();
fetchNews();
fetchCurators();
}
}, []);
@@ -210,6 +233,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
fetchDailyPuzzles();
fetchSpecials();
fetchNews();
fetchCurators();
} else {
alert(t('wrongPassword'));
}
@@ -224,6 +248,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
setGenres([]);
setSpecials([]);
setDailyPuzzles([]);
setCurators([]);
};
// Helper function to add auth headers to requests
@@ -245,6 +270,16 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
}
};
const fetchCurators = async () => {
const res = await fetch('/api/curators', {
headers: getAuthHeaders()
});
if (res.ok) {
const data = await res.json();
setCurators(data);
}
};
const fetchGenres = async () => {
const res = await fetch('/api/genres', {
headers: getAuthHeaders()
@@ -719,6 +754,85 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
}
};
const resetCuratorForm = () => {
setEditingCuratorId(null);
setCuratorUsername('');
setCuratorPassword('');
setCuratorIsGlobal(false);
setCuratorGenreIds([]);
setCuratorSpecialIds([]);
};
const startEditCurator = (curator: Curator) => {
setEditingCuratorId(curator.id);
setCuratorUsername(curator.username);
setCuratorPassword('');
setCuratorIsGlobal(curator.isGlobalCurator);
setCuratorGenreIds(curator.genreIds || []);
setCuratorSpecialIds(curator.specialIds || []);
};
const toggleCuratorGenre = (genreId: number) => {
setCuratorGenreIds(prev =>
prev.includes(genreId) ? prev.filter(id => id !== genreId) : [...prev, genreId]
);
};
const toggleCuratorSpecial = (specialId: number) => {
setCuratorSpecialIds(prev =>
prev.includes(specialId) ? prev.filter(id => id !== specialId) : [...prev, specialId]
);
};
const handleSaveCurator = async (e: React.FormEvent) => {
e.preventDefault();
if (!curatorUsername.trim()) return;
const payload: any = {
username: curatorUsername.trim(),
isGlobalCurator: curatorIsGlobal,
genreIds: curatorGenreIds,
specialIds: curatorSpecialIds,
};
if (curatorPassword.trim()) {
payload.password = curatorPassword;
}
const url = '/api/curators';
const method = editingCuratorId ? 'PUT' : 'POST';
if (editingCuratorId) {
payload.id = editingCuratorId;
}
const res = await fetch(url, {
method,
headers: getAuthHeaders(),
body: JSON.stringify(payload),
});
if (res.ok) {
resetCuratorForm();
fetchCurators();
} else {
alert('Failed to save curator');
}
};
const handleDeleteCurator = async (id: number) => {
if (!confirm('Kurator wirklich löschen?')) return;
const res = await fetch('/api/curators', {
method: 'DELETE',
headers: getAuthHeaders(),
body: JSON.stringify({ id }),
});
if (res.ok) {
fetchCurators();
} else {
alert('Failed to delete curator');
}
};
const handleBatchUpload = async (e: React.FormEvent) => {
e.preventDefault();
if (files.length === 0) return;
@@ -1019,15 +1133,6 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
}
};
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
@@ -1067,70 +1172,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
}
};
// 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
// Unified Filter
let matchesFilter = true;
if (selectedGenreFilter) {
if (selectedGenreFilter.startsWith('genre:')) {
const genreId = Number(selectedGenreFilter.split(':')[1]);
matchesFilter = genreId === -1
? song.genres.length === 0
: song.genres.some(g => g.id === genreId);
} else if (selectedGenreFilter.startsWith('special:')) {
const specialId = Number(selectedGenreFilter.split(':')[1]);
matchesFilter = song.specials?.some(s => s.id === specialId) || false;
} else if (selectedGenreFilter === 'daily') {
const today = new Date().toISOString().split('T')[0];
matchesFilter = song.puzzles?.some(p => p.date === today) || false;
} else if (selectedGenreFilter === 'no-global') {
matchesFilter = song.excludeFromGlobal === true;
}
}
return matchesSearch && matchesFilter;
});
const sortedSongs = [...filteredSongs].sort((a, b) => {
// Handle numeric sorting for ID, Release Year, Activations, and Rating
if (sortField === 'id') {
return sortDirection === 'asc' ? a.id - b.id : b.id - a.id;
}
if (sortField === 'releaseYear') {
const yearA = a.releaseYear || 0;
const yearB = b.releaseYear || 0;
return sortDirection === 'asc' ? yearA - yearB : yearB - yearA;
}
if (sortField === 'activations') {
return sortDirection === 'asc' ? a.activations - b.activations : b.activations - a.activations;
}
if (sortField === 'averageRating') {
return sortDirection === 'asc' ? a.averageRating - b.averageRating : b.averageRating - a.averageRating;
}
// 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]);
// Song Library ist in das Kuratoren-Dashboard umgezogen, daher keine Song-Filter/Pagination mehr im Admin nötig.
if (!isAuthenticated) {
return (
@@ -1826,155 +1868,193 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
)}
</div>
{/* Curator Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>{t('uploadSongs')}</h2>
<form onSubmit={handleBatchUpload}>
{/* Drag & Drop Zone */}
<div
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
{t('manageCurators')}
</h2>
<button
onClick={() => setShowCurators(!showCurators)}
style={{
border: isDragging ? '2px solid #4f46e5' : '2px dashed #d1d5db',
borderRadius: '0.5rem',
padding: '2rem',
textAlign: 'center',
background: isDragging ? '#eef2ff' : '#f9fafb',
marginBottom: '1rem',
padding: '0.5rem 1rem',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: 'pointer',
transition: 'all 0.2s'
fontSize: '0.875rem'
}}
onClick={() => fileInputRef.current?.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
ref={fileInputRef}
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}
{showCurators ? t('hide') : t('show')}
</button>
</div>
{showCurators && (
<>
<form onSubmit={handleSaveCurator} style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
<input
type="text"
value={curatorUsername}
onChange={e => setCuratorUsername(e.target.value)}
placeholder={t('curatorUsername')}
className="form-input"
style={{ minWidth: '200px', flex: '1 1 200px' }}
required
/>
<input
type="password"
value={curatorPassword}
onChange={e => setCuratorPassword(e.target.value)}
placeholder={t('curatorPassword')}
className="form-input"
style={{ minWidth: '200px', flex: '1 1 200px' }}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem' }}>
<input
type="checkbox"
checked={curatorIsGlobal}
onChange={e => setCuratorIsGlobal(e.target.checked)}
/>
{t('isGlobalCurator')}
</label>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem' }}>
<div style={{ flex: '1 1 200px' }}>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('assignedGenres')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{genres.map(genre => (
<label
key={genre.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
borderRadius: '999px',
background: curatorGenreIds.includes(genre.id) ? '#e5f3ff' : '#f3f4f6',
fontSize: '0.8rem',
cursor: 'pointer'
}}
>
<input
type="checkbox"
checked={curatorGenreIds.includes(genre.id)}
onChange={() => toggleCuratorGenre(genre.id)}
/>
{typeof genre.name === 'string'
? genre.name
: getLocalizedValue(genre.name, activeTab)}
</label>
))}
</div>
</div>
))}
<div style={{ flex: '1 1 200px' }}>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('assignedSpecials')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{specials.map(special => (
<label
key={special.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
borderRadius: '999px',
background: curatorSpecialIds.includes(special.id) ? '#fee2e2' : '#f3f4f6',
fontSize: '0.8rem',
cursor: 'pointer'
}}
>
<input
type="checkbox"
checked={curatorSpecialIds.includes(special.id)}
onChange={() => toggleCuratorSpecial(special.id)}
/>
{typeof special.name === 'string'
? special.name
: getLocalizedValue(special.name, activeTab)}
</label>
))}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
<button type="submit" className="btn-primary">
{editingCuratorId ? t('save') : t('addCurator')}
</button>
{editingCuratorId && (
<button
type="button"
className="btn-secondary"
onClick={resetCuratorForm}
>
{t('cancel')}
</button>
)}
</div>
</div>
</div>
)}
</form>
{/* 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>
)}
<div style={{ marginBottom: '1rem' }}>
<label style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem' }}>
Assign Genres (optional)
</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{genres.map(genre => (
<label
key={genre.id}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{curators.length === 0 && (
<p style={{ color: '#666', fontSize: '0.875rem' }}>{t('noCurators')}</p>
)}
{curators.map(curator => (
<div
key={curator.id}
style={{
padding: '0.75rem',
borderRadius: '0.5rem',
border: '1px solid #e5e7eb',
background: curator.isGlobalCurator ? '#eff6ff' : '#f9fafb',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
background: batchUploadGenreIds.includes(genre.id) ? '#dbeafe' : '#f3f4f6',
border: batchUploadGenreIds.includes(genre.id) ? '2px solid #3b82f6' : '2px solid transparent',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.875rem',
transition: 'all 0.2s'
gap: '0.5rem',
flexWrap: 'wrap'
}}
>
<input
type="checkbox"
checked={batchUploadGenreIds.includes(genre.id)}
onChange={e => {
if (e.target.checked) {
setBatchUploadGenreIds([...batchUploadGenreIds, genre.id]);
} else {
setBatchUploadGenreIds(batchUploadGenreIds.filter(id => id !== genre.id));
}
}}
style={{ margin: 0 }}
/>
{getLocalizedValue(genre.name, activeTab)}
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<div style={{ fontWeight: 600 }}>{curator.username}</div>
<div style={{ fontSize: '0.8rem', color: '#4b5563' }}>
{curator.isGlobalCurator && <span>Globaler Kurator · </span>}
<span>
{t('assignedGenres')}: {curator.genreIds.length}
</span>
{' · '}
<span>
{t('assignedSpecials')}: {curator.specialIds.length}
</span>
</div>
</div>
<div style={{ display: 'flex', gap: '0.25rem' }}>
<button
type="button"
className="btn-secondary"
style={{ padding: '0.25rem 0.6rem', fontSize: '0.8rem' }}
onClick={() => startEditCurator(curator)}
>
{t('edit')}
</button>
<button
type="button"
className="btn-danger"
style={{ padding: '0.25rem 0.6rem', fontSize: '0.8rem' }}
onClick={() => handleDeleteCurator(curator.id)}
>
{t('delete')}
</button>
</div>
</div>
))}
</div>
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.25rem' }}>
Selected genres will be assigned to all uploaded songs.
</p>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={uploadExcludeFromGlobal}
onChange={e => setUploadExcludeFromGlobal(e.target.checked)}
style={{ width: '1.25rem', height: '1.25rem' }}
/>
<span style={{ fontWeight: '500' }}>Exclude from Global Daily Puzzle</span>
</label>
<p style={{ fontSize: '0.875rem', color: '#666', marginLeft: '1.75rem', marginTop: '0.25rem' }}>
If checked, these songs will only appear in Genre or Special puzzles.
</p>
</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: '0.75rem',
background: '#d1fae5',
color: '#065f46',
borderRadius: '0.25rem'
}}>
{message}
</div>
)}
</form>
</>
)}
</div>
{/* Upload Songs wurde in das Kuratoren-Dashboard verlagert */}
{/* Today's Daily Puzzles */}
<div className="admin-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
@@ -2049,397 +2129,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
))}
</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)}
className="form-input"
style={{ minWidth: '150px' }}
>
<option value="">All Content</option>
<option value="daily">📅 Song of the Day</option>
<option value="no-global">🚫 No Global</option>
<optgroup label="Genres">
<option value="genre:-1">No Genre</option>
{genres.map(genre => (
<option key={genre.id} value={`genre:${genre.id}`}>
{getLocalizedValue(genre.name, activeTab)} ({genre._count?.songs || 0})
</option>
))}
</optgroup>
<optgroup label="Specials">
{specials.map(special => (
<option key={special.id} value={`special:${special.id}`}>
{getLocalizedValue(special.name, activeTab)} ({special._count?.songs || 0})
</option>
))}
</optgroup>
</select>
{(searchQuery || selectedGenreFilter) && (
<button
onClick={() => {
setSearchQuery('');
setSelectedGenreFilter('');
}}
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')}
>
Song {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('releaseYear')}
>
Year {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th style={{ padding: '0.75rem' }}>Genres / Specials</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('createdAt')}
>
Added {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('activations')}
>
Activations {sortField === 'activations' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('averageRating')}
>
Rating {sortField === 'averageRating' && (sortDirection === 'asc' ? '↑' : '↓')}
</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', marginBottom: '0.5rem', width: '100%' }}
placeholder="Title"
/>
<input
type="text"
value={editArtist}
onChange={e => setEditArtist(e.target.value)}
className="form-input"
style={{ padding: '0.25rem', width: '100%' }}
placeholder="Artist"
/>
</td>
<td style={{ padding: '0.75rem' }}>
<input
type="number"
value={editReleaseYear}
onChange={e => setEditReleaseYear(e.target.value === '' ? '' : Number(e.target.value))}
className="form-input"
style={{ padding: '0.25rem', width: '80px' }}
placeholder="Year"
/>
</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));
}
}}
/>
{getLocalizedValue(genre.name, activeTab)}
</label>
))}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.5rem', borderTop: '1px dashed #eee', paddingTop: '0.25rem' }}>
{specials.map(special => (
<label key={special.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.75rem', color: '#4b5563' }}>
<input
type="checkbox"
checked={editSpecialIds.includes(special.id)}
onChange={e => {
if (e.target.checked) {
setEditSpecialIds([...editSpecialIds, special.id]);
} else {
setEditSpecialIds(editSpecialIds.filter(id => id !== special.id));
}
}}
/>
{getLocalizedValue(special.name, activeTab)}
</label>
))}
</div>
<div style={{ marginTop: '0.5rem', borderTop: '1px dashed #eee', paddingTop: '0.5rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', cursor: 'pointer', color: '#b91c1c' }}>
<input
type="checkbox"
checked={editExcludeFromGlobal}
onChange={e => setEditExcludeFromGlobal(e.target.checked)}
/>
Exclude from Global
</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', color: '#666' }}>
{song.averageRating > 0 ? (
<span title={`${song.ratingCount} ratings`}>
{song.averageRating.toFixed(1)} <span style={{ color: '#999', fontSize: '0.8rem' }}>({song.ratingCount})</span>
</span>
) : (
<span style={{ color: '#ccc' }}>-</span>
)}
</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' }}>
<div style={{ fontWeight: 'bold', color: '#111827' }}>{song.title}</div>
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>{song.artist}</div>
{song.excludeFromGlobal && (
<div style={{ marginTop: '0.25rem' }}>
<span style={{
background: '#fee2e2',
color: '#991b1b',
padding: '0.1rem 0.4rem',
borderRadius: '0.25rem',
fontSize: '0.7rem',
border: '1px solid #fecaca',
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem'
}}>
🚫 No Global
</span>
</div>
)}
{/* Daily Puzzle Badges */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
{song.puzzles?.filter(p => p.date === new Date().toISOString().split('T')[0]).map(p => {
if (!p.genreId && !p.specialId) {
return (
<span key={p.id} style={{ background: '#dbeafe', color: '#1e40af', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #93c5fd' }}>
🌍 Global Daily
</span>
);
}
if (p.genreId) {
const genreName = genres.find(g => g.id === p.genreId)?.name;
return (
<span key={p.id} style={{ background: '#f3f4f6', color: '#374151', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #d1d5db' }}>
🏷 {getLocalizedValue(genreName, activeTab)} Daily
</span>
);
}
if (p.specialId) {
const specialName = specials.find(s => s.id === p.specialId)?.name;
return (
<span key={p.id} style={{ background: '#fce7f3', color: '#be185d', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #fbcfe8' }}>
{getLocalizedValue(specialName, activeTab)} Daily
</span>
);
}
return null;
})}
</div>
</td>
<td style={{ padding: '0.75rem', color: '#666' }}>
{song.releaseYear || '-'}
</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'
}}>
{getLocalizedValue(g.name, activeTab)}
</span>
))}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
{song.specials?.map(s => (
<span key={s.id} style={{
background: '#fce7f3',
color: '#9d174d',
padding: '0.1rem 0.4rem',
borderRadius: '0.25rem',
fontSize: '0.7rem'
}}>
{getLocalizedValue(s.name, activeTab)}
</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', color: '#666' }}>
{song.averageRating > 0 ? (
<span title={`${song.ratingCount} ratings`}>
{song.averageRating.toFixed(1)} <span style={{ color: '#999', fontSize: '0.8rem' }}>({song.ratingCount})</span>
</span>
) : (
<span style={{ color: '#ccc' }}>-</span>
)}
</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={t('deletePuzzle')}
>
🗑
</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>
{/* Song Library wurde in das Kuratoren-Dashboard verlagert */}
<div className="admin-card" style={{ marginTop: '2rem', border: '1px solid #ef4444' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem', color: '#ef4444' }}>