Files
hoerdle/app/curator/page.tsx

1301 lines
66 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 { useEffect, useRef, useState } from 'react';
interface Genre {
id: number;
name: any;
}
interface Special {
id: number;
name: any;
}
interface Song {
id: number;
title: string;
artist: string;
filename: string;
createdAt: string;
releaseYear: number | null;
activations?: number;
puzzles?: any[];
genres: Genre[];
specials: Special[];
excludeFromGlobal: boolean;
averageRating?: number;
ratingCount?: number;
}
interface CuratorInfo {
id: number;
username: string;
isGlobalCurator: boolean;
genreIds: number[];
specialIds: number[];
}
function getCuratorAuthHeaders() {
const authToken = localStorage.getItem('hoerdle_curator_auth');
const username = localStorage.getItem('hoerdle_curator_username') || '';
return {
'Content-Type': 'application/json',
'x-curator-auth': authToken || '',
'x-curator-username': username,
};
}
function getCuratorUploadHeaders() {
const authToken = localStorage.getItem('hoerdle_curator_auth');
const username = localStorage.getItem('hoerdle_curator_username') || '';
return {
'x-curator-auth': authToken || '',
'x-curator-username': username,
};
}
export default function CuratorPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [curatorInfo, setCuratorInfo] = useState<CuratorInfo | null>(null);
const [songs, setSongs] = useState<Song[]>([]);
const [genres, setGenres] = useState<Genre[]>([]);
const [specials, setSpecials] = useState<Special[]>([]);
const [loading, setLoading] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [editTitle, setEditTitle] = useState('');
const [editArtist, setEditArtist] = useState('');
const [editReleaseYear, setEditReleaseYear] = useState<number | ''>('');
const [editExcludeFromGlobal, setEditExcludeFromGlobal] = useState(false);
const [editGenreIds, setEditGenreIds] = useState<number[]>([]);
const [editSpecialIds, setEditSpecialIds] = useState<number[]>([]);
const [message, setMessage] = useState('');
// Upload state (analog zum Admin-Upload, aber vereinfacht)
const [files, setFiles] = useState<File[]>([]);
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [uploadProgress, setUploadProgress] = useState<{ current: number; total: number }>({
current: 0,
total: 0,
});
const [uploadResults, setUploadResults] = useState<any[]>([]);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// Search / Sort / Pagination / Audio (ähnlich Admin-Song-Library)
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
type SortDirection = 'asc' | 'desc';
const [sortField, setSortField] = useState<SortField>('artist');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [searchQuery, setSearchQuery] = useState('');
const [selectedFilter, setSelectedFilter] = useState<string>('');
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
useEffect(() => {
const authToken = localStorage.getItem('hoerdle_curator_auth');
const storedUsername = localStorage.getItem('hoerdle_curator_username');
if (authToken === 'authenticated' && storedUsername) {
setIsAuthenticated(true);
setUsername(storedUsername);
bootstrapCuratorData();
}
}, []);
const bootstrapCuratorData = async () => {
try {
setLoading(true);
await Promise.all([fetchCuratorInfo(), fetchSongs(), fetchGenres(), fetchSpecials()]);
} finally {
setLoading(false);
}
};
const fetchCuratorInfo = async () => {
const res = await fetch('/api/curator/me', {
headers: getCuratorAuthHeaders(),
});
if (res.ok) {
const data: CuratorInfo = await res.json();
setCuratorInfo(data);
localStorage.setItem('hoerdle_curator_is_global', String(data.isGlobalCurator));
} else {
setMessage('Fehler beim Laden der Kuratoren-Informationen.');
}
};
const fetchSongs = async () => {
const res = await fetch('/api/songs', {
headers: getCuratorAuthHeaders(),
});
if (res.ok) {
const data: Song[] = await res.json();
setSongs(data);
} else {
setMessage('Fehler beim Laden der Songs.');
}
};
const fetchGenres = async () => {
const res = await fetch('/api/genres');
if (res.ok) {
const data = await res.json();
setGenres(data);
}
};
const fetchSpecials = async () => {
const res = await fetch('/api/specials');
if (res.ok) {
const data = await res.json();
setSpecials(data);
}
};
const handleLogin = async () => {
setMessage('');
try {
const res = await fetch('/api/curator/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (res.ok) {
const data = await res.json();
localStorage.setItem('hoerdle_curator_auth', 'authenticated');
localStorage.setItem('hoerdle_curator_username', data.curator.username);
localStorage.setItem('hoerdle_curator_is_global', String(data.curator.isGlobalCurator));
setIsAuthenticated(true);
setPassword('');
await bootstrapCuratorData();
} else {
const err = await res.json().catch(() => null);
setMessage(err?.error || 'Login fehlgeschlagen.');
}
} catch (e) {
setMessage('Netzwerkfehler beim Login.');
}
};
const handleLogout = () => {
localStorage.removeItem('hoerdle_curator_auth');
localStorage.removeItem('hoerdle_curator_username');
localStorage.removeItem('hoerdle_curator_is_global');
setIsAuthenticated(false);
setCuratorInfo(null);
setSongs([]);
setMessage('');
};
const startEditing = (song: Song) => {
setEditingId(song.id);
setEditTitle(song.title);
setEditArtist(song.artist);
setEditReleaseYear(song.releaseYear || '');
setEditExcludeFromGlobal(song.excludeFromGlobal || false);
setEditGenreIds(song.genres.map(g => g.id));
setEditSpecialIds(song.specials.map(s => s.id));
};
const cancelEditing = () => {
setEditingId(null);
setEditTitle('');
setEditArtist('');
setEditReleaseYear('');
setEditExcludeFromGlobal(false);
setEditGenreIds([]);
setEditSpecialIds([]);
};
const saveEditing = async (id: number) => {
if (!curatorInfo) return;
setMessage('');
const isGlobalCurator = curatorInfo.isGlobalCurator;
// Nur Genres/Specials, für die der Kurator zuständig ist, dürfen aktiv geändert werden.
const manageableGenreIds = editGenreIds.filter(gid => curatorInfo.genreIds.includes(gid));
const manageableSpecialIds = editSpecialIds.filter(sid => curatorInfo.specialIds.includes(sid));
try {
const res = await fetch('/api/songs', {
method: 'PUT',
headers: getCuratorAuthHeaders(),
body: JSON.stringify({
id,
title: editTitle,
artist: editArtist,
releaseYear: editReleaseYear === '' ? null : Number(editReleaseYear),
genreIds: manageableGenreIds,
specialIds: manageableSpecialIds,
excludeFromGlobal: isGlobalCurator ? editExcludeFromGlobal : undefined,
}),
});
if (res.ok) {
setEditingId(null);
await fetchSongs();
setMessage('Song erfolgreich aktualisiert.');
} else {
const errText = await res.text();
setMessage(`Fehler beim Speichern: ${errText}`);
}
} catch (e) {
setMessage('Netzwerkfehler beim Speichern.');
}
};
const canEditSong = (song: Song): boolean => {
if (!curatorInfo) return false;
const songGenreIds = song.genres.map(g => g.id);
const songSpecialIds = song.specials.map(s => s.id);
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
// Songs ohne Genres/Specials dürfen von Kuratoren generell bearbeitet werden
return true;
}
const hasGenre = songGenreIds.some(id => curatorInfo.genreIds.includes(id));
const hasSpecial = songSpecialIds.some(id => curatorInfo.specialIds.includes(id));
return hasGenre || hasSpecial;
};
const canDeleteSong = (song: Song): boolean => {
if (!curatorInfo) return false;
const songGenreIds = song.genres.map(g => g.id);
const songSpecialIds = song.specials.map(s => s.id);
const allGenresAllowed = songGenreIds.every(id => curatorInfo.genreIds.includes(id));
const allSpecialsAllowed = songSpecialIds.every(id => curatorInfo.specialIds.includes(id));
return allGenresAllowed && allSpecialsAllowed;
};
const handleDelete = async (song: Song) => {
if (!canDeleteSong(song)) {
setMessage('Du darfst diesen Song nicht löschen.');
return;
}
if (!confirm(`Möchtest du "${song.title}" wirklich löschen?`)) return;
try {
const res = await fetch('/api/songs', {
method: 'DELETE',
headers: getCuratorAuthHeaders(),
body: JSON.stringify({ id: song.id }),
});
if (res.ok) {
await fetchSongs();
setMessage('Song gelöscht.');
} else {
const errText = await res.text();
setMessage(`Fehler beim Löschen: ${errText}`);
}
} catch (e) {
setMessage('Netzwerkfehler beim Löschen.');
}
};
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) {
audioElement?.pause();
setPlayingSongId(null);
} else {
audioElement?.pause();
const audio = new Audio(`/api/audio/${song.filename}`);
audio.onerror = () => {
setPlayingSongId(null);
setAudioElement(null);
alert(`Audio-Datei konnte nicht geladen werden: ${song.filename}`);
};
audio.play()
.then(() => {
setAudioElement(audio);
setPlayingSongId(song.id);
})
.catch(error => {
console.error('Playback error:', error);
setPlayingSongId(null);
setAudioElement(null);
});
}
};
const toggleUploadGenre = (genreId: number) => {
setUploadGenreIds(prev =>
prev.includes(genreId) ? prev.filter(id => id !== genreId) : [...prev, genreId]
);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files || []);
if (selected.length === 0) return;
setFiles(prev => [...prev, ...selected]);
};
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (!isDragging) {
setIsDragging(true);
}
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files || []).filter(
f => f.type === 'audio/mpeg' || f.name.toLowerCase().endsWith('.mp3')
);
if (droppedFiles.length === 0) return;
setFiles(prev => [...prev, ...droppedFiles]);
};
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: any[] = [];
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);
// excludeFromGlobal wird für Kuratoren serverseitig immer auf true gesetzt
const res = await fetch('/api/songs', {
method: 'POST',
headers: getCuratorUploadHeaders(),
body: formData,
});
if (res.ok) {
const data = await res.json();
results.push({
filename: file.name,
success: true,
song: data.song,
validation: data.validation,
});
} else if (res.status === 409) {
const data = await res.json();
results.push({
filename: file.name,
success: false,
isDuplicate: true,
duplicate: data.duplicate,
error: `Duplicate: Already exists as "${data.duplicate.title}" by "${data.duplicate.artist}"`,
});
} else {
const errorText = await res.text();
results.push({
filename: file.name,
success: false,
error: `Upload failed (${res.status}): ${errorText.substring(0, 100)}`,
});
}
} catch (error) {
results.push({
filename: file.name,
success: false,
error: `Network error: ${error instanceof Error ? error.message : 'Unknown error'}`,
});
}
}
setUploadResults(results);
setFiles([]);
setIsUploading(false);
// Genres den erfolgreich hochgeladenen Songs zuweisen
if (uploadGenreIds.length > 0) {
const successfulUploads = results.filter(r => r.success && r.song);
for (const result of successfulUploads) {
try {
await fetch('/api/songs', {
method: 'PUT',
headers: getCuratorAuthHeaders(),
body: JSON.stringify({
id: result.song.id,
title: result.song.title,
artist: result.song.artist,
releaseYear: result.song.releaseYear,
genreIds: uploadGenreIds,
}),
});
} catch {
// Fehler beim Genre-Assigning werden nur geloggt, nicht abgebrochen
console.error(`Failed to assign genres to ${result.song.title}`);
}
}
}
await fetchSongs();
const successCount = results.filter(r => r.success).length;
const duplicateCount = results.filter(r => r.isDuplicate).length;
const failedCount = results.filter(r => !r.success && !r.isDuplicate).length;
let msg = `${successCount}/${results.length} Uploads erfolgreich.`;
if (duplicateCount > 0) msg += `\n⚠ ${duplicateCount} Duplikat(e) übersprungen.`;
if (failedCount > 0) msg += `\n❌ ${failedCount} fehlgeschlagen.`;
setMessage(msg);
};
if (!isAuthenticated) {
return (
<main style={{ maxWidth: '480px', margin: '2rem auto', padding: '1rem' }}>
<h1 style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>Kuratoren-Login</h1>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<label>
Benutzername
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }}
/>
</label>
<label>
Passwort
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }}
/>
</label>
<button
type="button"
onClick={handleLogin}
style={{
padding: '0.5rem 1rem',
background: '#111827',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: 'pointer',
marginTop: '0.5rem',
}}
>
Einloggen
</button>
{message && (
<p style={{ color: '#b91c1c', marginTop: '0.5rem', whiteSpace: 'pre-line' }}>{message}</p>
)}
</div>
</main>
);
}
// Filter, Sort & Pagination basierend auf Admin-Logik, aber auf Kuratoren-Rechte zugeschnitten
const filteredSongs = songs.filter(song => {
// Nur Songs anzeigen, die für den Kurator relevant sind
if (curatorInfo && !canEditSong(song) && !canDeleteSong(song)) {
return false;
}
// Filter nach Global/Genre/Special
if (selectedFilter) {
if (selectedFilter === 'no-global') {
if (!song.excludeFromGlobal) return false;
} else if (selectedFilter.startsWith('genre:')) {
const genreId = Number(selectedFilter.split(':')[1]);
if (!song.genres.some(g => g.id === genreId)) return false;
} else if (selectedFilter.startsWith('special:')) {
const specialId = Number(selectedFilter.split(':')[1]);
if (!song.specials.some(s => s.id === specialId)) return false;
}
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
if (
!song.title.toLowerCase().includes(q) &&
!song.artist.toLowerCase().includes(q)
) {
return false;
}
}
return true;
});
const sortedSongs = [...filteredSongs].sort((a, b) => {
const dir = sortDirection === 'asc' ? 1 : -1;
switch (sortField) {
case 'id':
return (a.id - b.id) * dir;
case 'title':
return a.title.localeCompare(b.title) * dir;
case 'artist':
return a.artist.localeCompare(b.artist) * dir;
case 'createdAt':
return (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) * dir;
case 'releaseYear':
return ((a.releaseYear || 0) - (b.releaseYear || 0)) * dir;
case 'activations': {
const av = a.activations ?? a.puzzles?.length ?? 0;
const bv = b.activations ?? b.puzzles?.length ?? 0;
return (av - bv) * dir;
}
case 'averageRating':
return ((a.averageRating || 0) - (b.averageRating || 0)) * dir;
default:
return 0;
}
});
const totalPages = Math.max(1, Math.ceil(sortedSongs.length / itemsPerPage));
const page = Math.min(currentPage, totalPages);
const startIndex = (page - 1) * itemsPerPage;
const visibleSongs = sortedSongs.slice(startIndex, startIndex + itemsPerPage);
return (
<main style={{ maxWidth: '960px', margin: '2rem auto', padding: '1rem' }}>
<header
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1.5rem',
}}
>
<div>
<h1 style={{ fontSize: '1.75rem', marginBottom: '0.25rem' }}>Kuratoren-Dashboard</h1>
{curatorInfo && (
<p style={{ color: '#4b5563', fontSize: '0.9rem' }}>
Eingeloggt als <strong>{curatorInfo.username}</strong>
{curatorInfo.isGlobalCurator && ' (Globaler Kurator)'}
</p>
)}
</div>
<button
type="button"
onClick={handleLogout}
style={{
padding: '0.5rem 1rem',
background: '#6b7280',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: 'pointer',
}}
>
Abmelden
</button>
</header>
{loading && <p>Lade Daten...</p>}
{message && (
<p style={{ marginBottom: '1rem', color: '#b91c1c', whiteSpace: 'pre-line' }}>{message}</p>
)}
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>Titel hochladen</h2>
<p style={{ marginBottom: '0.75rem', color: '#4b5563', fontSize: '0.9rem' }}>
Ziehe eine oder mehrere MP3-Dateien hierher oder wähle sie aus. Die Titel werden automatisch analysiert
(inkl. Erkennung des Erscheinungsjahres) und von der globalen Playlist ausgeschlossen. Wähle mindestens
eines deiner Genres aus, um die Titel zuzuordnen.
</p>
<form onSubmit={handleBatchUpload} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxWidth: '640px' }}>
<div
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{
border: isDragging ? '2px solid #4f46e5' : '2px dashed #d1d5db',
borderRadius: '0.5rem',
padding: '1.75rem',
textAlign: 'center',
background: isDragging ? '#eef2ff' : '#f9fafb',
cursor: 'pointer',
transition: 'all 0.2s',
}}
onClick={() => fileInputRef.current?.click()}
>
<div style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>📁</div>
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
{files.length > 0 ? `${files.length} Datei(en) ausgewählt` : 'MP3-Dateien hierher ziehen'}
</p>
<p style={{ fontSize: '0.875rem', color: '#666' }}>oder klicken, um Dateien auszuwählen</p>
<input
ref={fileInputRef}
type="file"
accept="audio/mpeg"
multiple
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</div>
{files.length > 0 && (
<div style={{ marginBottom: '0.5rem' }}>
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>Ausgewählte Dateien:</p>
<div
style={{
maxHeight: '160px',
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>
)}
{isUploading && (
<div
style={{
marginBottom: '0.5rem',
padding: '0.75rem',
background: '#eef2ff',
borderRadius: '0.5rem',
fontSize: '0.875rem',
}}
>
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
Upload: {uploadProgress.current} / {uploadProgress.total}
</p>
<div
style={{
width: '100%',
height: '8px',
background: '#d1d5db',
borderRadius: '4px',
overflow: 'hidden',
}}
>
<div
style={{
width:
uploadProgress.total > 0
? `${(uploadProgress.current / uploadProgress.total) * 100}%`
: '0%',
height: '100%',
background: '#4f46e5',
transition: 'width 0.3s',
}}
/>
</div>
</div>
)}
<div>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>Genres zuordnen</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{genres
.filter(g => curatorInfo?.genreIds.includes(g.id))
.map(genre => (
<label
key={genre.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
borderRadius: '999px',
background: uploadGenreIds.includes(genre.id) ? '#e5f3ff' : '#f3f4f6',
fontSize: '0.8rem',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={uploadGenreIds.includes(genre.id)}
onChange={() => toggleUploadGenre(genre.id)}
/>
{typeof genre.name === 'string' ? genre.name : genre.name?.de ?? genre.name?.en}
</label>
))}
{curatorInfo && curatorInfo.genreIds.length === 0 && (
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>
Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin.
</span>
)}
</div>
</div>
<button
type="submit"
disabled={isUploading || files.length === 0}
style={{
padding: '0.5rem 1rem',
background: isUploading || files.length === 0 ? '#9ca3af' : '#111827',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: isUploading || files.length === 0 ? 'not-allowed' : 'pointer',
alignSelf: 'flex-start',
}}
>
{isUploading ? 'Lade hoch...' : 'Upload starten'}
</button>
{uploadResults.length > 0 && (
<div
style={{
marginTop: '0.75rem',
padding: '0.75rem',
background: '#f9fafb',
borderRadius: '0.5rem',
fontSize: '0.85rem',
}}
>
{uploadResults.map((r, idx) => (
<div key={idx} style={{ marginBottom: '0.25rem' }}>
<strong>{r.filename}</strong> {' '}
{r.success
? '✅ erfolgreich'
: r.isDuplicate
? `⚠️ Duplikat: ${r.error}`
: `❌ Fehler: ${r.error}`}
</div>
))}
</div>
)}
</form>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>
Titel in deinen Genres & Specials ({filteredSongs.length} Titel)
</h2>
<p style={{ marginBottom: '0.75rem', color: '#4b5563', fontSize: '0.9rem' }}>
Du kannst Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind.
Löschen ist nur erlaubt, wenn ein Song ausschließlich deinen Genres/Specials zugeordnet ist.
Genres, Specials, News und politische Statements können nur vom Admin verwaltet werden.
</p>
{/* Suche & Filter */}
<div style={{ marginBottom: '0.75rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<input
type="text"
placeholder="Nach Titel oder Artist suchen..."
value={searchQuery}
onChange={e => {
setSearchQuery(e.target.value);
setCurrentPage(1);
}}
style={{
flex: '1',
minWidth: '200px',
padding: '0.4rem 0.6rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
}}
/>
<select
value={selectedFilter}
onChange={e => {
setSelectedFilter(e.target.value);
setCurrentPage(1);
}}
style={{
minWidth: '180px',
padding: '0.4rem 0.6rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
}}
>
<option value="">Alle Inhalte</option>
<option value="no-global">🚫 Ohne Global</option>
<optgroup label="Genres">
{genres
.filter(g => curatorInfo?.genreIds.includes(g.id))
.map(genre => (
<option key={genre.id} value={`genre:${genre.id}`}>
{typeof genre.name === 'string'
? genre.name
: genre.name?.de ?? genre.name?.en}
</option>
))}
</optgroup>
<optgroup label="Specials">
{specials
.filter(s => curatorInfo?.specialIds.includes(s.id))
.map(special => (
<option key={special.id} value={`special:${special.id}`}>
{' '}
{typeof special.name === 'string'
? special.name
: special.name?.de ?? special.name?.en}
</option>
))}
</optgroup>
</select>
{(searchQuery || selectedFilter) && (
<button
type="button"
onClick={() => {
setSearchQuery('');
setSelectedFilter('');
setCurrentPage(1);
}}
style={{
padding: '0.4rem 0.8rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
background: '#f3f4f6',
cursor: 'pointer',
fontSize: '0.85rem',
}}
>
Filter zurücksetzen
</button>
)}
</div>
{visibleSongs.length === 0 ? (
<p>Keine passenden Songs in deinen Genres/Specials gefunden.</p>
) : (
<>
<div style={{ overflowX: 'auto' }}>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: '0.9rem',
}}
>
<thead>
<tr style={{ borderBottom: '1px solid #e5e7eb' }}>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('id')}
>
ID {sortField === 'id' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th style={{ padding: '0.5rem' }}>Play</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('title')}
>
Titel {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('artist')}
>
Artist {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('releaseYear')}
>
Jahr {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th style={{ padding: '0.5rem' }}>Genres / Specials</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('createdAt')}
>
Hinzugefügt {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('activations')}
>
Aktivierungen {sortField === 'activations' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('averageRating')}
>
Rating {sortField === 'averageRating' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th style={{ padding: '0.5rem' }}>Exclude Global</th>
<th style={{ padding: '0.5rem' }}>Aktionen</th>
</tr>
</thead>
<tbody>
{visibleSongs.map(song => {
const editable = canEditSong(song);
const deletable = canDeleteSong(song);
const isEditing = editingId === song.id;
const ratingText =
song.ratingCount && song.ratingCount > 0
? `${(song.averageRating || 0).toFixed(1)} (${song.ratingCount})`
: '-';
return (
<tr key={song.id} style={{ borderBottom: '1px solid #f3f4f6' }}>
<td style={{ padding: '0.5rem' }}>{song.id}</td>
<td style={{ padding: '0.5rem' }}>
<button
type="button"
onClick={() => handlePlayPause(song)}
style={{
border: 'none',
background: 'none',
cursor: 'pointer',
fontSize: '1.1rem',
}}
title={
playingSongId === song.id
? 'Pause'
: 'Abspielen'
}
>
{playingSongId === song.id ? '⏸️' : '▶️'}
</button>
</td>
<td style={{ padding: '0.5rem' }}>
{isEditing ? (
<input
type="text"
value={editTitle}
onChange={e => setEditTitle(e.target.value)}
style={{ width: '100%', padding: '0.25rem' }}
/>
) : (
song.title
)}
</td>
<td style={{ padding: '0.5rem' }}>
{isEditing ? (
<input
type="text"
value={editArtist}
onChange={e => setEditArtist(e.target.value)}
style={{ width: '100%', padding: '0.25rem' }}
/>
) : (
song.artist
)}
</td>
<td style={{ padding: '0.5rem' }}>
{isEditing ? (
<input
type="number"
value={editReleaseYear}
onChange={e =>
setEditReleaseYear(
e.target.value === '' ? '' : Number(e.target.value)
)
}
style={{ width: '5rem', padding: '0.25rem' }}
/>
) : song.releaseYear ? (
song.releaseYear
) : (
'-'
)}
</td>
<td style={{ padding: '0.5rem' }}>
{isEditing ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{genres
.filter(g => curatorInfo?.genreIds.includes(g.id))
.map(genre => (
<label
key={genre.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.15rem 0.4rem',
borderRadius: '999px',
background: editGenreIds.includes(genre.id)
? '#e5f3ff'
: '#f3f4f6',
fontSize: '0.8rem',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={editGenreIds.includes(genre.id)}
onChange={() =>
setEditGenreIds(prev =>
prev.includes(genre.id)
? prev.filter(id => id !== genre.id)
: [...prev, genre.id]
)
}
/>
{typeof genre.name === 'string'
? genre.name
: genre.name?.de ?? genre.name?.en}
</label>
))}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{song.genres
.filter(
g => !curatorInfo?.genreIds.includes(g.id)
)
.map(g => (
<span
key={`fixed-g-${g.id}`}
style={{
padding: '0.1rem 0.4rem',
borderRadius: '999px',
background: '#e5e7eb',
fontSize: '0.8rem',
}}
>
{typeof g.name === 'string'
? g.name
: g.name?.de ?? g.name?.en}
</span>
))}
{song.specials.map(s => (
<span
key={`s-${s.id}`}
style={{
padding: '0.1rem 0.4rem',
borderRadius: '999px',
background: '#fee2e2',
fontSize: '0.8rem',
}}
>
{typeof s.name === 'string'
? s.name
: s.name?.de ?? s.name?.en}
</span>
))}
</div>
</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{song.genres.map(g => (
<span
key={`g-${g.id}`}
style={{
padding: '0.1rem 0.4rem',
borderRadius: '999px',
background: '#e5e7eb',
}}
>
{typeof g.name === 'string'
? g.name
: g.name?.de ?? g.name?.en}
</span>
))}
{song.specials.map(s => (
<span
key={`s-${s.id}`}
style={{
padding: '0.1rem 0.4rem',
borderRadius: '999px',
background: '#fee2e2',
}}
>
{typeof s.name === 'string'
? s.name
: s.name?.de ?? s.name?.en}
</span>
))}
</div>
)}
</td>
<td style={{ padding: '0.5rem', whiteSpace: 'nowrap' }}>
{new Date(song.createdAt).toLocaleDateString()}
</td>
<td style={{ padding: '0.5rem', textAlign: 'center' }}>
{song.activations ?? song.puzzles?.length ?? 0}
</td>
<td style={{ padding: '0.5rem' }}>{ratingText}</td>
<td style={{ padding: '0.5rem' }}>
{isEditing ? (
<input
type="checkbox"
checked={editExcludeFromGlobal}
onChange={e =>
setEditExcludeFromGlobal(e.target.checked)
}
disabled={!curatorInfo?.isGlobalCurator}
/>
) : song.excludeFromGlobal ? (
'Ja'
) : (
'Nein'
)}
{!curatorInfo?.isGlobalCurator && (
<span
style={{
display: 'block',
fontSize: '0.75rem',
color: '#9ca3af',
}}
>
Nur globale Kuratoren dürfen dieses Flag ändern.
</span>
)}
</td>
<td
style={{
padding: '0.5rem',
whiteSpace: 'nowrap',
}}
>
{isEditing ? (
<>
<button
type="button"
onClick={() => saveEditing(song.id)}
style={{
marginRight: '0.5rem',
padding: '0.25rem 0.5rem',
background: '#10b981',
color: 'white',
border: 'none',
borderRadius: '0.25rem',
cursor: 'pointer',
}}
>
💾
</button>
<button
type="button"
onClick={cancelEditing}
style={{
padding: '0.25rem 0.5rem',
background: '#e5e7eb',
border: 'none',
borderRadius: '0.25rem',
cursor: 'pointer',
}}
>
</button>
</>
) : (
<>
<button
type="button"
onClick={() => startEditing(song)}
disabled={!editable}
style={{
marginRight: '0.5rem',
padding: '0.25rem 0.5rem',
background: editable ? '#e5e7eb' : '#f3f4f6',
color: '#111827',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: editable ? 'pointer' : 'not-allowed',
}}
>
</button>
<button
type="button"
onClick={() => handleDelete(song)}
disabled={!deletable}
style={{
padding: '0.25rem 0.5rem',
background: deletable ? '#b91c1c' : '#fca5a5',
color: 'white',
border: 'none',
borderRadius: '0.25rem',
cursor: deletable ? 'pointer' : 'not-allowed',
}}
>
🗑
</button>
</>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: '0.75rem',
fontSize: '0.875rem',
}}
>
<button
type="button"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={page === 1}
style={{
padding: '0.3rem 0.6rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
background: page === 1 ? '#f3f4f6' : '#fff',
cursor: page === 1 ? 'not-allowed' : 'pointer',
}}
>
Zurück
</button>
<span style={{ color: '#666' }}>
Seite {page} von {totalPages}
</span>
<button
type="button"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
style={{
padding: '0.3rem 0.6rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
background: page === totalPages ? '#f3f4f6' : '#fff',
cursor: page === totalPages ? 'not-allowed' : 'pointer',
}}
>
Weiter
</button>
</div>
)}
</>
)}
</section>
</main>
);
}