From 83e1281079c516d9102b88b76cb48662868fa101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Sat, 6 Dec 2025 21:50:59 +0100 Subject: [PATCH] fix: restore deleted curator implementation files --- app/curator/CuratorPageClient.tsx | 2164 +++++++++++++++++ app/curator/help/CuratorHelpClient.tsx | 171 ++ app/curator/help/page.tsx | 8 + app/curator/page.tsx | 11 + .../specials/CuratorSpecialsClient.tsx | 156 ++ app/curator/specials/[id]/page.tsx | 177 ++ app/curator/specials/page.tsx | 13 + 7 files changed, 2700 insertions(+) create mode 100644 app/curator/CuratorPageClient.tsx create mode 100644 app/curator/help/CuratorHelpClient.tsx create mode 100644 app/curator/help/page.tsx create mode 100644 app/curator/page.tsx create mode 100644 app/curator/specials/CuratorSpecialsClient.tsx create mode 100644 app/curator/specials/[id]/page.tsx create mode 100644 app/curator/specials/page.tsx diff --git a/app/curator/CuratorPageClient.tsx b/app/curator/CuratorPageClient.tsx new file mode 100644 index 0000000..bf829f2 --- /dev/null +++ b/app/curator/CuratorPageClient.tsx @@ -0,0 +1,2164 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useTranslations, useLocale } from 'next-intl'; +import { Link } from '@/lib/navigation'; +import HelpTooltip from '@/components/HelpTooltip'; + +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; + coverImage: string | 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[]; +} + +interface CuratorComment { + id: number; + message: string; + createdAt: string; + readAt: string | null; + puzzle: { + id: number; + date: string; + puzzleNumber: number; + song: { + title: string; + artist: string; + }; + genre: { + id: number; + name: any; + } | null; + special: { + id: number; + name: any; + } | null; + }; +} + +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 tNav = useTranslations('Navigation'); + const tHelp = useTranslations('CuratorHelp'); + const locale = useLocale(); + 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 [uploadSpecialIds, setUploadSpecialIds] = 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); + const [hoveredCoverSongId, setHoveredCoverSongId] = useState(null); + + // Comments state + const [comments, setComments] = useState([]); + const [loadingComments, setLoadingComments] = useState(false); + const [showComments, setShowComments] = useState(false); + + // Batch edit state + const [selectedSongIds, setSelectedSongIds] = useState>(new Set()); + const [batchGenreIds, setBatchGenreIds] = useState([]); + const [batchSpecialIds, setBatchSpecialIds] = useState([]); + const [batchArtist, setBatchArtist] = useState(''); + const [batchExcludeFromGlobal, setBatchExcludeFromGlobal] = useState(undefined); + const [isBatchUpdating, setIsBatchUpdating] = useState(false); + + 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(), fetchComments()]); + } finally { + setLoading(false); + } + }; + + const fetchComments = async () => { + try { + setLoadingComments(true); + const res = await fetch('/api/curator-comments', { + headers: getCuratorAuthHeaders(), + }); + if (res.ok) { + const data: CuratorComment[] = await res.json(); + setComments(data); + } else { + setMessage(t('loadCommentsError')); + } + } catch (error) { + setMessage(t('loadCommentsError')); + } finally { + setLoadingComments(false); + } + }; + + const markCommentAsRead = async (commentId: number) => { + try { + const res = await fetch(`/api/curator-comments/${commentId}/read`, { + method: 'POST', + headers: getCuratorAuthHeaders(), + }); + if (res.ok) { + // Update local state + setComments(comments.map(c => + c.id === commentId ? { ...c, readAt: new Date().toISOString() } : c + )); + } + } catch (error) { + console.error('Error marking comment as read:', error); + } + }; + + const archiveComment = async (commentId: number) => { + try { + const res = await fetch(`/api/curator-comments/${commentId}/archive`, { + method: 'POST', + headers: getCuratorAuthHeaders(), + }); + if (res.ok) { + // Remove comment from local state (archived comments are not shown) + setComments(comments.filter(c => c.id !== commentId)); + } else { + setMessage(t('archiveCommentError')); + } + } catch (error) { + console.error('Error archiving comment:', error); + setMessage(t('archiveCommentError')); + } + }; + + 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')); + } + }; + + // Batch edit functions + const toggleSongSelection = (songId: number) => { + setSelectedSongIds(prev => { + const newSet = new Set(prev); + if (newSet.has(songId)) { + newSet.delete(songId); + } else { + // Only allow selection of editable songs + const song = songs.find(s => s.id === songId); + if (song && canEditSong(song)) { + newSet.add(songId); + } + } + return newSet; + }); + }; + + const selectAllVisible = () => { + const editableVisibleIds = visibleSongs + .filter(song => canEditSong(song)) + .map(song => song.id); + setSelectedSongIds(new Set(editableVisibleIds)); + }; + + const clearSelection = () => { + setSelectedSongIds(new Set()); + setBatchGenreIds([]); + setBatchSpecialIds([]); + setBatchArtist(''); + setBatchExcludeFromGlobal(undefined); + }; + + const handleBatchUpdate = async () => { + if (selectedSongIds.size === 0) { + setMessage(t('noSongsSelected') || 'No songs selected'); + return; + } + + const hasGenreToggle = batchGenreIds.length > 0; + const hasSpecialToggle = batchSpecialIds.length > 0; + const hasArtistChange = batchArtist.trim() !== ''; + const hasExcludeGlobalChange = batchExcludeFromGlobal !== undefined; + + if (!hasGenreToggle && !hasSpecialToggle && !hasArtistChange && !hasExcludeGlobalChange) { + setMessage(t('noBatchOperations') || 'No batch operations specified'); + return; + } + + setIsBatchUpdating(true); + setMessage(''); + + try { + const res = await fetch('/api/songs/batch', { + method: 'POST', + headers: getCuratorAuthHeaders(), + body: JSON.stringify({ + songIds: Array.from(selectedSongIds), + genreToggleIds: hasGenreToggle ? batchGenreIds : undefined, + specialToggleIds: hasSpecialToggle ? batchSpecialIds : undefined, + artist: hasArtistChange ? batchArtist.trim() : undefined, + excludeFromGlobal: hasExcludeGlobalChange ? batchExcludeFromGlobal : undefined, + }), + }); + + if (res.ok) { + const result = await res.json(); + await fetchSongs(); + + let msg = t('batchUpdateSuccess') || `Successfully updated ${result.success} of ${result.processed} songs`; + if (result.skipped > 0) { + msg += ` (${result.skipped} skipped)`; + } + if (result.errors.length > 0) { + msg += `\nErrors: ${result.errors.map((e: any) => `Song ${e.songId}: ${e.error}`).join(', ')}`; + } + setMessage(msg); + + // Clear selection after successful update + clearSelection(); + } else { + const errText = await res.text(); + setMessage(t('batchUpdateError') || `Error: ${errText}`); + } + } catch (e) { + setMessage(t('batchUpdateNetworkError') || 'Network error during batch update'); + } finally { + setIsBatchUpdating(false); + } + }; + + 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 toggleUploadSpecial = (specialId: number) => { + setUploadSpecialIds(prev => + prev.includes(specialId) ? prev.filter(id => id !== specialId) : [...prev, specialId] + ); + }; + + 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/Specials den erfolgreich hochgeladenen Songs zuweisen + if (uploadGenreIds.length > 0 || uploadSpecialIds.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.length > 0 ? uploadGenreIds : undefined, + specialIds: uploadSpecialIds.length > 0 ? uploadSpecialIds : undefined, + }), + }); + } catch { + // Fehler beim Zuweisen werden nur geloggt, nicht abgebrochen + console.error(`Failed to assign genres/specials 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')} +

+ )} +
+
+ + ✨ {t('curateSpecialsButton')} + + + ℹ {tHelp('helpButton')} + + +
+
+ + {loading &&

{t('loadingData')}

} + {message && ( +

{message}

+ )} + + {/* Comments Section */} + {(() => { + const unreadCount = comments.filter(c => !c.readAt).length; + const hasUnread = unreadCount > 0; + + return ( +
+
+
+
+

+ {t('commentsTitle')} ({comments.length}) +

+ +
+ {hasUnread && ( + + {unreadCount} {t('newComments')} + + )} +
+ +
+ + {showComments && ( + <> + {loadingComments ? ( +

{t('loadingComments')}

+ ) : comments.length === 0 ? ( +

{t('noComments')}

+ ) : ( +
+ {comments.map(comment => { + const genreName = comment.puzzle.genre + ? typeof comment.puzzle.genre.name === 'string' + ? comment.puzzle.genre.name + : comment.puzzle.genre.name?.de ?? comment.puzzle.genre.name?.en + : null; + const specialName = comment.puzzle.special + ? typeof comment.puzzle.special.name === 'string' + ? comment.puzzle.special.name + : comment.puzzle.special.name?.de ?? comment.puzzle.special.name?.en + : null; + const isRead = comment.readAt !== null; + + // Determine category label + let categoryLabel = ''; + if (specialName) { + categoryLabel = `★ ${specialName}`; + } else if (genreName) { + categoryLabel = genreName; + } else { + categoryLabel = tNav('global'); + } + + return ( +
{ + if (!isRead) { + markCommentAsRead(comment.id); + } + }} + > + {!isRead && ( +
+ )} +
+
+ + Hördle #{comment.puzzle.puzzleNumber} + + + ({categoryLabel}) + +
+ + {new Date(comment.createdAt).toLocaleDateString()} {new Date(comment.createdAt).toLocaleTimeString()} + +
+
+ {comment.puzzle.song.title} - {comment.puzzle.song.artist} +
+
+ {comment.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')} + + )} +
+
+ +
+
+
{t('assignSpecialsLabel')}
+ +
+
+ {specials + .filter(s => curatorInfo?.specialIds?.includes(s.id)) + .map(special => ( + + ))} +
+
+
+
+ + + + {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')}

+ ) : ( + <> + {/* Batch Edit Toolbar */} + {selectedSongIds.size > 0 && ( +
+
+
+ + {t('batchEditTitle') || `Batch Edit: ${selectedSongIds.size} ${selectedSongIds.size === 1 ? 'song' : 'songs'} selected`} + + +
+ +
+ +
+ {/* Genre Toggle */} +
+
+ + +
+
+ {genres + .filter(g => curatorInfo?.genreIds?.includes(g.id)) + .map(genre => ( + + ))} +
+
+ + {/* Special Toggle */} +
+
+ + +
+
+ {specials + .filter(s => curatorInfo?.specialIds?.includes(s.id)) + .map(special => ( + + ))} +
+
+ + {/* Artist Change */} +
+
+ + +
+ setBatchArtist(e.target.value)} + placeholder={t('batchArtistPlaceholder') || 'Enter new artist name'} + style={{ + width: '100%', + maxWidth: '400px', + padding: '0.4rem 0.6rem', + borderRadius: '0.25rem', + border: '1px solid #d1d5db', + fontSize: '0.9rem', + }} + /> +
+ + {/* Exclude Global Flag */} + {curatorInfo?.isGlobalCurator && ( +
+ + +
+ )} + + {/* Apply Button */} +
+ +
+
+
+ )} + +
+ + + + + + + + + + + + + + + + + + + + {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})` + : '-'; + + const isSelected = selectedSongIds.has(song.id); + + return ( + + + + + + + + + + + + + + + + ); + })} + +
+ 0 && visibleSongs.every(song => canEditSong(song) && selectedSongIds.has(song.id))} + onChange={(e) => { + if (e.target.checked) { + selectAllVisible(); + } else { + clearSelection(); + } + }} + style={{ cursor: 'pointer' }} + title={t('selectAll') || 'Select all'} + /> + 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('columnCover')}{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')}
+ toggleSongSelection(song.id)} + disabled={!editable} + style={{ cursor: editable ? 'pointer' : 'not-allowed' }} + title={editable ? (t('selectSong') || 'Select song') : (t('cannotEditSong') || 'Cannot edit this song')} + /> + {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 + ) : ( + '-' + )} + song.coverImage && setHoveredCoverSongId(song.id)} + onMouseLeave={() => setHoveredCoverSongId(null)} + > + {song.coverImage ? '✓' : '-'} + {hoveredCoverSongId === song.id && song.coverImage && ( +
+ {`Cover +
+ )} +
+ {isEditing ? ( +
+
+ {genres + .filter(g => curatorInfo?.genreIds?.includes(g.id)) + .map(genre => ( + + ))} + {specials + .filter(s => curatorInfo?.specialIds?.includes(s.id)) + .map(special => ( + + ))} +
+
+ {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 + .filter( + s => !curatorInfo?.specialIds?.includes(s.id) + ) + .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/help/CuratorHelpClient.tsx b/app/curator/help/CuratorHelpClient.tsx new file mode 100644 index 0000000..3b14c68 --- /dev/null +++ b/app/curator/help/CuratorHelpClient.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { useTranslations, useLocale } from 'next-intl'; +import { Link } from '@/lib/navigation'; + +export default function CuratorHelpClient() { + const t = useTranslations('CuratorHelp'); + const locale = useLocale(); + + return ( +
+
+
+

{t('title')}

+ + {t('backToDashboard')} + +
+
+ +
+ {/* Einführung */} +
+

+ {t('introductionTitle')} +

+
+

{t('introductionText')}

+

{t('permissionsTitle')}

+
    +
  • {t('permission1')}
  • +
  • {t('permission2')}
  • +
  • {t('permission3')}
  • +
  • {t('permission4')}
  • +
+

+ {t('note')}: {t('permissionNote')} +

+
+
+ + {/* Song-Upload */} +
+

+ {t('uploadTitle')} +

+
+

{t('uploadStepsTitle')}

+
    +
  1. {t('uploadStep1')}
  2. +
  3. {t('uploadStep2')}
  4. +
  5. {t('uploadStep3')}
  6. +
  7. {t('uploadStep4')}
  8. +
+

{t('uploadBestPracticesTitle')}

+
    +
  • {t('uploadBestPractice1')}
  • +
  • {t('uploadBestPractice2')}
  • +
  • {t('uploadBestPractice3')}
  • +
+

+ {t('tip')}: {t('uploadTip')} +

+
+
+ + {/* Song-Bearbeitung */} +
+

+ {t('editingTitle')} +

+
+

{t('singleEditTitle')}

+

{t('singleEditText')}

+

{t('batchEditTitle')}

+

{t('batchEditText')}

+
    +
  • {t('batchEditFeature1')}
  • +
  • {t('batchEditFeature2')}
  • +
  • {t('batchEditFeature3')}
  • +
  • {t('batchEditFeature4')}
  • +
+

{t('genreSpecialAssignmentTitle')}

+

{t('genreSpecialAssignmentText')}

+
+
+ + {/* Specials kuratieren */} +
+

+ {t('curateSpecialsHelpTitle')} +

+
+

{t('curateSpecialsHelpIntro')}

+

+ {t('curateSpecialsHelpStepsTitle')} +

+
    +
  1. {t('curateSpecialsHelpStep1')}
  2. +
  3. {t('curateSpecialsHelpStep2')}
  4. +
  5. {t('curateSpecialsHelpStep3')}
  6. +
  7. {t('curateSpecialsHelpStep4')}
  8. +
+

+ {t('note')}: {t('curateSpecialsPermissionsNote')} +

+
+
+ + {/* Kommentar-Verwaltung */} +
+

+ {t('commentsTitle')} +

+
+

{t('commentsText')}

+

{t('commentsActionsTitle')}

+
    +
  • {t('markAsRead')}: {t('markAsReadText')}
  • +
  • {t('archive')}: {t('archiveText')}
  • +
+
+
+ + {/* Best Practices */} +
+

+ {t('bestPracticesTitle')} +

+
+
    +
  • {t('bestPractice1')}
  • +
  • {t('bestPractice2')}
  • +
  • {t('bestPractice3')}
  • +
  • {t('bestPractice4')}
  • +
  • {t('bestPractice5')}
  • +
+
+
+ + {/* Troubleshooting */} +
+

+ {t('troubleshootingTitle')} +

+
+

{t('troubleshootingQ1')}

+

{t('troubleshootingA1')}

+

{t('troubleshootingQ2')}

+

{t('troubleshootingA2')}

+

{t('troubleshootingQ3')}

+

{t('troubleshootingA3')}

+

{t('troubleshootingQ4')}

+

{t('troubleshootingA4')}

+
+
+
+
+ ); +} + diff --git a/app/curator/help/page.tsx b/app/curator/help/page.tsx new file mode 100644 index 0000000..2759fd6 --- /dev/null +++ b/app/curator/help/page.tsx @@ -0,0 +1,8 @@ +export const dynamic = 'force-dynamic'; + +import CuratorHelpClient from './CuratorHelpClient'; + +export default function CuratorHelpPage() { + return ; +} + diff --git a/app/curator/page.tsx b/app/curator/page.tsx new file mode 100644 index 0000000..a72ec91 --- /dev/null +++ b/app/curator/page.tsx @@ -0,0 +1,11 @@ +// Server-Wrapper für die Kuratoren-Seite. +// Markiert die Route als dynamisch und rendert die eigentliche Client-Komponente. +export const dynamic = 'force-dynamic'; + +import CuratorPageClient from './CuratorPageClient'; + +export default function CuratorPage() { + return ; +} + + diff --git a/app/curator/specials/CuratorSpecialsClient.tsx b/app/curator/specials/CuratorSpecialsClient.tsx new file mode 100644 index 0000000..c5ce683 --- /dev/null +++ b/app/curator/specials/CuratorSpecialsClient.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, usePathname } from 'next/navigation'; +import { useLocale, useTranslations } from 'next-intl'; +import { Link } from '@/lib/navigation'; +import { getCuratorAuthHeaders } from '@/lib/curatorAuth'; +import { getLocalizedValue } from '@/lib/i18n'; + +interface CuratorSpecial { + id: number; + name: string | { de?: string; en?: string }; + songCount: number; +} + +export default function CuratorSpecialsClient() { + const router = useRouter(); + const pathname = usePathname(); + const urlLocale = pathname?.split('/')[1] as 'de' | 'en' | undefined; + const intlLocale = useLocale() as 'de' | 'en'; + const locale: 'de' | 'en' = urlLocale === 'de' || urlLocale === 'en' ? urlLocale : intlLocale; + const t = useTranslations('Curator'); + + const [specials, setSpecials] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchSpecials = async () => { + try { + setLoading(true); + const res = await fetch('/api/curator/specials', { + headers: getCuratorAuthHeaders(), + }); + if (!res.ok) { + if (res.status === 403) { + setError(t('specialForbidden')); + } else { + setError('Failed to load specials'); + } + return; + } + const data = await res.json(); + setSpecials(data); + } catch (e) { + setError('Failed to load specials'); + } finally { + setLoading(false); + } + }; + + fetchSpecials(); + }, [t]); + + if (loading) { + return ( +
+

{t('loading')}

+
+ ); + } + + if (error) { + return ( +
+

{error}

+ + {t('backToDashboard') || 'Back to Dashboard'} + +
+ ); + } + + return ( +
+
+
+

+ {t('curateSpecialsTitle') || 'Curate Specials'} +

+ + {t('backToDashboard') || 'Back to Dashboard'} + +
+
+ + {specials.length === 0 ? ( +
+

{t('noSpecialsAssigned') || 'No specials assigned to you.'}

+
+ ) : ( +
+ {specials.map((special) => ( + { + e.currentTarget.style.background = '#f3f4f6'; + e.currentTarget.style.borderColor = '#d1d5db'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = '#f9fafb'; + e.currentTarget.style.borderColor = '#e5e7eb'; + }} + > +
+
+

+ {getLocalizedValue(special.name, locale)} +

+

+ {special.songCount} {special.songCount === 1 ? 'song' : 'songs'} +

+
+
+
+ + ))} +
+ )} +
+ ); +} + diff --git a/app/curator/specials/[id]/page.tsx b/app/curator/specials/[id]/page.tsx new file mode 100644 index 0000000..686ed7d --- /dev/null +++ b/app/curator/specials/[id]/page.tsx @@ -0,0 +1,177 @@ +'use client'; + +export const dynamic = 'force-dynamic'; + +import { useEffect, useState } from 'react'; +import { useParams, useRouter, usePathname } from 'next/navigation'; +import { useLocale, useTranslations } from 'next-intl'; +import CurateSpecialEditor, { CurateSpecial } from '@/components/CurateSpecialEditor'; +import { getCuratorAuthHeaders } from '@/lib/curatorAuth'; +import HelpTooltip from '@/components/HelpTooltip'; + +export default function CuratorSpecialEditorPage() { + const params = useParams(); + const router = useRouter(); + const pathname = usePathname(); + const urlLocale = pathname?.split('/')[1] as 'de' | 'en' | undefined; + const intlLocale = useLocale() as 'de' | 'en'; + const locale: 'de' | 'en' = urlLocale === 'de' || urlLocale === 'en' ? urlLocale : intlLocale; + const t = useTranslations('Curator'); + const tHelp = useTranslations('CuratorHelp'); + + const specialId = params?.id as string; + + const [special, setSpecial] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchSpecial = async (showLoading = true) => { + try { + if (showLoading) { + setLoading(true); + } + const res = await fetch(`/api/curator/specials/${specialId}`, { + headers: getCuratorAuthHeaders(), + }); + if (res.status === 403) { + setError(t('specialForbidden')); + return; + } + if (!res.ok) { + setError('Failed to load special'); + return; + } + const data = await res.json(); + setSpecial(data); + } catch (e) { + setError('Failed to load special'); + } finally { + if (showLoading) { + setLoading(false); + } + } + }; + + useEffect(() => { + if (specialId) { + fetchSpecial(true); + } + }, [specialId, t]); + + const handleSaveStartTime = async (songId: number, startTime: number) => { + const res = await fetch(`/api/curator/specials/${specialId}/songs`, { + method: 'PUT', + headers: { + ...getCuratorAuthHeaders(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ songId, startTime }), + }); + if (res.status === 403) { + setError(t('specialForbidden')); + } else if (!res.ok) { + setError('Failed to save changes'); + } else { + // Reload special data to update the start time in the song list + await fetchSpecial(false); + } + }; + + if (loading) { + return ( +
+

{t('loadingData')}

+
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + if (!special) { + return ( +
+

{t('specialNotFound')}

+ +
+ ); + } + + return ( +
+
+
+

+ {t('curateSpecialHeaderPrefix')} +

+ +
+ +
+ + router.push(`/${locale}/curator/specials`)} + onSaveStartTime={handleSaveStartTime} + backLabel={t('backToCuratorSpecials')} + headerPrefix={t('curateSpecialHeaderPrefix')} + noSongsHint={t('curateSpecialNoSongs')} + noSongsSubHint={t('curateSpecialNoSongsSub')} + instructionsText={t('curateSpecialInstructions')} + savingLabel={t('saving')} + saveChangesLabel={t('saveChanges')} + savedLabel={t('saved')} + /> +
+ ); +} + + diff --git a/app/curator/specials/page.tsx b/app/curator/specials/page.tsx new file mode 100644 index 0000000..eded37e --- /dev/null +++ b/app/curator/specials/page.tsx @@ -0,0 +1,13 @@ +'use client'; + +// Root /curator/specials route without locale: +// redirect users to the default English locale version. + +import { redirect } from 'next/navigation'; + +export default function CuratorSpecialsPage() { + redirect('/en/curator/specials'); +} + + +