'use client'; // Diese Seite ist komplett client-seitig und nutzt Local Storage sowie // Header-basierte Authentifizierung. Wir erzwingen daher eine dynamische // Auslieferung, damit Next.js beim Build keine statische Vorab-Renderung // von /curator versucht (die zu Fehlern führen kann). export const dynamic = 'force-dynamic'; import { useEffect, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; 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 t = useTranslations('Curator'); 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, setItemsPerPage] = useState(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(t('loadCuratorError')); } }; const fetchSongs = async () => { const res = await fetch('/api/songs', { headers: getCuratorAuthHeaders(), }); if (res.ok) { const data: Song[] = await res.json(); setSongs(data); } else { setMessage(t('loadSongsError')); } }; 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 || t('loginFailed')); } } catch (e) { setMessage(t('loginNetworkError')); } }; 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(t('songUpdated')); } else { const errText = await res.text(); setMessage(t('saveError', { error: errText })); } } catch (e) { setMessage(t('saveNetworkError')); } }; 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(t('noDeletePermission')); return; } if (!confirm(t('deleteConfirm', { title: song.title }))) return; try { const res = await fetch('/api/songs', { method: 'DELETE', headers: getCuratorAuthHeaders(), body: JSON.stringify({ id: song.id }), }); if (res.ok) { await fetchSongs(); setMessage(t('songDeleted')); } else { const errText = await res.text(); setMessage(t('deleteError', { error: errText })); } } catch (e) { setMessage(t('deleteNetworkError')); } }; 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 file could not be loaded: ${song.filename}`); }; audio.play() .then(() => { setAudioElement(audio); setPlayingSongId(song.id); }) .catch(error => { console.error('Playback error:', error); setPlayingSongId(null); setAudioElement(null); }); // Reset Zustand, wenn der Track zu Ende gespielt ist audio.onended = () => { 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 = t('uploadSummary', { success: successCount, total: results.length }); if (duplicateCount > 0) msg += `\n` + t('uploadSummaryDuplicates', { count: duplicateCount }); if (failedCount > 0) msg += `\n` + t('uploadSummaryFailed', { count: failedCount }); setMessage(msg); }; if (!isAuthenticated) { return (

{t('loginTitle')}

{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 && (

{t('loggedInAs', { username: curatorInfo.username })} {curatorInfo.isGlobalCurator && t('globalCuratorSuffix')}

)}
{loading &&

{t('loadingData')}

} {message && (

{message}

)}

{t('uploadSectionTitle')}

{t('uploadSectionDescription')}

fileInputRef.current?.click()} >
📁

{files.length > 0 ? t('dropzoneTitleWithFiles', { count: files.length }) : t('dropzoneTitleEmpty')}

{t('dropzoneSubtitle')}

{files.length > 0 && (

{t('selectedFilesTitle')}

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

{t('uploadProgress', { current: uploadProgress.current, total: uploadProgress.total, })}

0 ? `${(uploadProgress.current / uploadProgress.total) * 100}%` : '0%', height: '100%', background: '#4f46e5', transition: 'width 0.3s', }} />
)}
{t('assignGenresLabel')}
{genres .filter(g => curatorInfo?.genreIds?.includes(g.id)) .map(genre => ( ))} {curatorInfo && curatorInfo.genreIds.length === 0 && ( {t('noAssignedGenres')} )}
{uploadResults.length > 0 && (
{uploadResults.map((r, idx) => (
{r.filename} –{' '} {r.success ? t('uploadResultSuccess') : r.isDuplicate ? t('uploadResultDuplicate', { error: r.error }) : t('uploadResultError', { error: r.error })}
))}
)}

{t('tracklistTitle', { count: filteredSongs.length })}

{t('tracklistDescription')}

{/* 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 ? (

{t('noSongsInScope')}

) : ( <>
{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')} > {t('columnId')} {sortField === 'id' && (sortDirection === 'asc' ? '↑' : '↓')} {t('columnPlay')} handleSort('title')} > {t('columnTitle')} {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')} handleSort('artist')} > {t('columnArtist')} {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')} handleSort('releaseYear')} > {t('columnYear')} {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')} {t('columnGenresSpecials')} handleSort('createdAt')} > {t('columnAdded')} {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')} handleSort('activations')} > {t('columnActivations')} {sortField === 'activations' && (sortDirection === 'asc' ? '↑' : '↓')} handleSort('averageRating')} > {t('columnRating')} {sortField === 'averageRating' && (sortDirection === 'asc' ? '↑' : '↓')} {t('columnExcludeGlobal')} {t('columnActions')}
{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 ? ( t('excludeGlobalYes') ) : ( t('excludeGlobalNo') )} {!curatorInfo?.isGlobalCurator && ( {t('excludeGlobalInfo')} )} {isEditing ? ( <> ) : ( <> )}
{/* Pagination & Page Size */}
{t('paginationLabel', { page, total: totalPages })}
{t('pageSizeLabel')}
)}
); }