'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(null); const [songs, setSongs] = useState([]); const [genres, setGenres] = useState([]); const [specials, setSpecials] = useState([]); const [loading, setLoading] = useState(false); const [editingId, setEditingId] = useState(null); const [editTitle, setEditTitle] = useState(''); const [editArtist, setEditArtist] = useState(''); const [editReleaseYear, setEditReleaseYear] = useState(''); const [editExcludeFromGlobal, setEditExcludeFromGlobal] = useState(false); const [editGenreIds, setEditGenreIds] = useState([]); const [editSpecialIds, setEditSpecialIds] = useState([]); const [message, setMessage] = useState(''); // Upload state (analog zum Admin-Upload, aber vereinfacht) const [files, setFiles] = useState([]); const [uploadGenreIds, setUploadGenreIds] = useState([]); 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([]); const fileInputRef = useRef(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('artist'); const [sortDirection, setSortDirection] = useState('asc'); const [searchQuery, setSearchQuery] = useState(''); const [selectedFilter, setSelectedFilter] = useState(''); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 10; const [playingSongId, setPlayingSongId] = useState(null); const [audioElement, setAudioElement] = useState(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) => { const selected = Array.from(e.target.files || []); if (selected.length === 0) return; setFiles(prev => [...prev, ...selected]); }; const handleDragEnter = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (!isDragging) { setIsDragging(true); } }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }; const handleDrop = (e: React.DragEvent) => { 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 (

Kuratoren-Login

{message && (

{message}

)}
); } // 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 (

Kuratoren-Dashboard

{curatorInfo && (

Eingeloggt als {curatorInfo.username} {curatorInfo.isGlobalCurator && ' (Globaler Kurator)'}

)}
{loading &&

Lade Daten...

} {message && (

{message}

)}

Titel hochladen

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.

fileInputRef.current?.click()} >
📁

{files.length > 0 ? `${files.length} Datei(en) ausgewählt` : 'MP3-Dateien hierher ziehen'}

oder klicken, um Dateien auszuwählen

{files.length > 0 && (

Ausgewählte Dateien:

{files.map((file, index) => (
📄 {file.name}
))}
)} {isUploading && (

Upload: {uploadProgress.current} / {uploadProgress.total}

0 ? `${(uploadProgress.current / uploadProgress.total) * 100}%` : '0%', height: '100%', background: '#4f46e5', transition: 'width 0.3s', }} />
)}
Genres zuordnen
{genres .filter(g => curatorInfo?.genreIds.includes(g.id)) .map(genre => ( ))} {curatorInfo && curatorInfo.genreIds.length === 0 && ( Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin. )}
{uploadResults.length > 0 && (
{uploadResults.map((r, idx) => (
{r.filename} –{' '} {r.success ? '✅ erfolgreich' : r.isDuplicate ? `⚠️ Duplikat: ${r.error}` : `❌ Fehler: ${r.error}`}
))}
)}

Titel in deinen Genres & Specials ({filteredSongs.length} Titel)

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.

{/* Suche & Filter */}
{ setSearchQuery(e.target.value); setCurrentPage(1); }} style={{ flex: '1', minWidth: '200px', padding: '0.4rem 0.6rem', borderRadius: '0.25rem', border: '1px solid #d1d5db', }} /> {(searchQuery || selectedFilter) && ( )}
{visibleSongs.length === 0 ? (

Keine passenden Songs in deinen Genres/Specials gefunden.

) : ( <>
{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 ( ); })}
handleSort('id')} > ID {sortField === 'id' && (sortDirection === 'asc' ? '↑' : '↓')} Play handleSort('title')} > Titel {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')} handleSort('artist')} > Artist {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')} handleSort('releaseYear')} > Jahr {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')} Genres / Specials handleSort('createdAt')} > Hinzugefügt {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')} handleSort('activations')} > Aktivierungen {sortField === 'activations' && (sortDirection === 'asc' ? '↑' : '↓')} handleSort('averageRating')} > Rating {sortField === 'averageRating' && (sortDirection === 'asc' ? '↑' : '↓')} Exclude Global Aktionen
{song.id} {isEditing ? ( setEditTitle(e.target.value)} style={{ width: '100%', padding: '0.25rem' }} /> ) : ( song.title )} {isEditing ? ( setEditArtist(e.target.value)} style={{ width: '100%', padding: '0.25rem' }} /> ) : ( song.artist )} {isEditing ? ( setEditReleaseYear( e.target.value === '' ? '' : Number(e.target.value) ) } style={{ width: '5rem', padding: '0.25rem' }} /> ) : song.releaseYear ? ( song.releaseYear ) : ( '-' )} {isEditing ? (
{genres .filter(g => curatorInfo?.genreIds.includes(g.id)) .map(genre => ( ))}
{song.genres .filter( g => !curatorInfo?.genreIds.includes(g.id) ) .map(g => ( {typeof g.name === 'string' ? g.name : g.name?.de ?? g.name?.en} ))} {song.specials.map(s => ( {typeof s.name === 'string' ? s.name : s.name?.de ?? s.name?.en} ))}
) : (
{song.genres.map(g => ( {typeof g.name === 'string' ? g.name : g.name?.de ?? g.name?.en} ))} {song.specials.map(s => ( {typeof s.name === 'string' ? s.name : s.name?.de ?? s.name?.en} ))}
)}
{new Date(song.createdAt).toLocaleDateString()} {song.activations ?? song.puzzles?.length ?? 0} {ratingText} {isEditing ? ( setEditExcludeFromGlobal(e.target.checked) } disabled={!curatorInfo?.isGlobalCurator} /> ) : song.excludeFromGlobal ? ( 'Ja' ) : ( 'Nein' )} {!curatorInfo?.isGlobalCurator && ( Nur globale Kuratoren dürfen dieses Flag ändern. )} {isEditing ? ( <> ) : ( <> )}
{/* Pagination */} {totalPages > 1 && (
Seite {page} von {totalPages}
)} )}
); }