diff --git a/app/curator/CuratorPageClient.tsx b/app/curator/CuratorPageClient.tsx new file mode 100644 index 0000000..ebeae75 --- /dev/null +++ b/app/curator/CuratorPageClient.tsx @@ -0,0 +1,1333 @@ +'use client'; + +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 CuratorPageClient() { + 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')} + +
+ +
+ + )} +
+
+ ); +} + + + diff --git a/app/curator/page.tsx b/app/curator/page.tsx index cfb2955..a72ec91 100644 --- a/app/curator/page.tsx +++ b/app/curator/page.tsx @@ -1,1338 +1,11 @@ -'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). +// Server-Wrapper für die Kuratoren-Seite. +// Markiert die Route als dynamisch und rendert die eigentliche Client-Komponente. 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, - }; -} +import CuratorPageClient from './CuratorPageClient'; 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')} - -
- -
- - )} -
-
- ); + return ; }