Files
hoerdle/app/curator/CuratorPageClient.tsx
Hördle Bot 6741eeb7fa feat: Album-Cover-Anzeige in Titelliste mit Tooltip hinzugefügt
- Neue Spalte 'Cover' in der Curator-Titelliste zeigt an, ob ein Album-Cover vorhanden ist
- Tooltip zeigt das Cover-Bild beim Hovern über die Cover-Spalte
- Übersetzungen für DE und EN hinzugefügt
2025-12-06 14:24:00 +01:00

2165 lines
117 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useRef, useState } from 'react';
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<CuratorInfo | null>(null);
const [songs, setSongs] = useState<Song[]>([]);
const [genres, setGenres] = useState<Genre[]>([]);
const [specials, setSpecials] = useState<Special[]>([]);
const [loading, setLoading] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [editTitle, setEditTitle] = useState('');
const [editArtist, setEditArtist] = useState('');
const [editReleaseYear, setEditReleaseYear] = useState<number | ''>('');
const [editExcludeFromGlobal, setEditExcludeFromGlobal] = useState(false);
const [editGenreIds, setEditGenreIds] = useState<number[]>([]);
const [editSpecialIds, setEditSpecialIds] = useState<number[]>([]);
const [message, setMessage] = useState('');
// Upload state (analog zum Admin-Upload, aber vereinfacht)
const [files, setFiles] = useState<File[]>([]);
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
const [uploadSpecialIds, setUploadSpecialIds] = useState<number[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [uploadProgress, setUploadProgress] = useState<{ current: number; total: number }>({
current: 0,
total: 0,
});
const [uploadResults, setUploadResults] = useState<any[]>([]);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// Search / Sort / Pagination / Audio (ähnlich Admin-Song-Library)
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
type SortDirection = 'asc' | 'desc';
const [sortField, setSortField] = useState<SortField>('artist');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [searchQuery, setSearchQuery] = useState('');
const [selectedFilter, setSelectedFilter] = useState<string>('');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
const [hoveredCoverSongId, setHoveredCoverSongId] = useState<number | null>(null);
// Comments state
const [comments, setComments] = useState<CuratorComment[]>([]);
const [loadingComments, setLoadingComments] = useState(false);
const [showComments, setShowComments] = useState(false);
// Batch edit state
const [selectedSongIds, setSelectedSongIds] = useState<Set<number>>(new Set());
const [batchGenreIds, setBatchGenreIds] = useState<number[]>([]);
const [batchSpecialIds, setBatchSpecialIds] = useState<number[]>([]);
const [batchArtist, setBatchArtist] = useState('');
const [batchExcludeFromGlobal, setBatchExcludeFromGlobal] = useState<boolean | undefined>(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<HTMLInputElement>) => {
const selected = Array.from(e.target.files || []);
if (selected.length === 0) return;
setFiles(prev => [...prev, ...selected]);
};
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (!isDragging) {
setIsDragging(true);
}
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files || []).filter(
f => f.type === 'audio/mpeg' || f.name.toLowerCase().endsWith('.mp3')
);
if (droppedFiles.length === 0) return;
setFiles(prev => [...prev, ...droppedFiles]);
};
const handleBatchUpload = async (e: React.FormEvent) => {
e.preventDefault();
if (files.length === 0) return;
setIsUploading(true);
setUploadResults([]);
setUploadProgress({ current: 0, total: files.length });
setMessage('');
const results: any[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
setUploadProgress({ current: i + 1, total: files.length });
try {
const formData = new FormData();
formData.append('file', file);
// excludeFromGlobal wird für Kuratoren serverseitig immer auf true gesetzt
const res = await fetch('/api/songs', {
method: 'POST',
headers: getCuratorUploadHeaders(),
body: formData,
});
if (res.ok) {
const data = await res.json();
results.push({
filename: file.name,
success: true,
song: data.song,
validation: data.validation,
});
} else if (res.status === 409) {
const data = await res.json();
results.push({
filename: file.name,
success: false,
isDuplicate: true,
duplicate: data.duplicate,
error: `Duplicate: Already exists as "${data.duplicate.title}" by "${data.duplicate.artist}"`,
});
} else {
const errorText = await res.text();
results.push({
filename: file.name,
success: false,
error: `Upload failed (${res.status}): ${errorText.substring(0, 100)}`,
});
}
} catch (error) {
results.push({
filename: file.name,
success: false,
error: `Network error: ${error instanceof Error ? error.message : 'Unknown error'}`,
});
}
}
setUploadResults(results);
setFiles([]);
setIsUploading(false);
// Genres/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 (
<main style={{ maxWidth: '480px', margin: '2rem auto', padding: '1rem' }}>
<h1 style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>{t('loginTitle')}</h1>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<label>
{t('loginUsername')}
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }}
/>
</label>
<label>
{t('loginPassword')}
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }}
/>
</label>
<button
type="button"
onClick={handleLogin}
style={{
padding: '0.5rem 1rem',
background: '#111827',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: 'pointer',
marginTop: '0.5rem',
}}
>
{t('loginButton')}
</button>
{message && (
<p style={{ color: '#b91c1c', marginTop: '0.5rem', whiteSpace: 'pre-line' }}>{message}</p>
)}
</div>
</main>
);
}
// Filter, Sort & Pagination basierend auf Admin-Logik, aber auf Kuratoren-Rechte zugeschnitten
const filteredSongs = songs.filter(song => {
// Nur Songs anzeigen, die für den Kurator relevant sind
if (curatorInfo && !canEditSong(song) && !canDeleteSong(song)) {
return false;
}
// Filter nach Global/Genre/Special
if (selectedFilter) {
if (selectedFilter === 'no-global') {
if (!song.excludeFromGlobal) return false;
} else if (selectedFilter.startsWith('genre:')) {
const genreId = Number(selectedFilter.split(':')[1]);
if (!song.genres.some(g => g.id === genreId)) return false;
} else if (selectedFilter.startsWith('special:')) {
const specialId = Number(selectedFilter.split(':')[1]);
if (!song.specials.some(s => s.id === specialId)) return false;
}
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
if (
!song.title.toLowerCase().includes(q) &&
!song.artist.toLowerCase().includes(q)
) {
return false;
}
}
return true;
});
const sortedSongs = [...filteredSongs].sort((a, b) => {
const dir = sortDirection === 'asc' ? 1 : -1;
switch (sortField) {
case 'id':
return (a.id - b.id) * dir;
case 'title':
return a.title.localeCompare(b.title) * dir;
case 'artist':
return a.artist.localeCompare(b.artist) * dir;
case 'createdAt':
return (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) * dir;
case 'releaseYear':
return ((a.releaseYear || 0) - (b.releaseYear || 0)) * dir;
case 'activations': {
const av = a.activations ?? a.puzzles?.length ?? 0;
const bv = b.activations ?? b.puzzles?.length ?? 0;
return (av - bv) * dir;
}
case 'averageRating':
return ((a.averageRating || 0) - (b.averageRating || 0)) * dir;
default:
return 0;
}
});
const totalPages = Math.max(1, Math.ceil(sortedSongs.length / itemsPerPage));
const page = Math.min(currentPage, totalPages);
const startIndex = (page - 1) * itemsPerPage;
const visibleSongs = sortedSongs.slice(startIndex, startIndex + itemsPerPage);
return (
<main style={{ maxWidth: '960px', margin: '2rem auto', padding: '1rem' }}>
<header
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1.5rem',
}}
>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<h1 style={{ fontSize: '1.75rem', marginBottom: '0.25rem' }}>Kuratoren-Dashboard</h1>
<HelpTooltip
shortText={tHelp('tooltipDashboardShort')}
longText={tHelp('tooltipDashboardLong')}
position="bottom"
/>
</div>
{curatorInfo && (
<p style={{ color: '#4b5563', fontSize: '0.9rem' }}>
{t('loggedInAs', { username: curatorInfo.username })}
{curatorInfo.isGlobalCurator && t('globalCuratorSuffix')}
</p>
)}
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<Link
href="/curator/specials"
style={{
padding: '0.5rem 1rem',
background: '#10b981',
color: 'white',
textDecoration: 'none',
borderRadius: '0.375rem',
fontSize: '0.9rem',
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
lineHeight: '1.5',
boxSizing: 'border-box',
fontFamily: 'inherit',
}}
>
{t('curateSpecialsButton')}
</Link>
<Link
href="/curator/help"
style={{
padding: '0.5rem 1rem',
background: '#3b82f6',
color: 'white',
textDecoration: 'none',
borderRadius: '0.375rem',
fontSize: '0.9rem',
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
lineHeight: '1.5',
boxSizing: 'border-box',
fontFamily: 'inherit',
}}
>
{tHelp('helpButton')}
</Link>
<button
type="button"
onClick={handleLogout}
style={{
padding: '0.5rem 1rem',
background: '#6b7280',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: 'pointer',
fontSize: '0.9rem',
display: 'inline-flex',
alignItems: 'center',
lineHeight: '1.5',
boxSizing: 'border-box',
fontFamily: 'inherit',
}}
>
{t('logout')}
</button>
</div>
</header>
{loading && <p>{t('loadingData')}</p>}
{message && (
<p style={{ marginBottom: '1rem', color: '#b91c1c', whiteSpace: 'pre-line' }}>{message}</p>
)}
{/* Comments Section */}
{(() => {
const unreadCount = comments.filter(c => !c.readAt).length;
const hasUnread = unreadCount > 0;
return (
<section style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<h2 style={{ fontSize: '1.25rem', marginBottom: 0 }}>
{t('commentsTitle')} ({comments.length})
</h2>
<HelpTooltip
shortText={tHelp('tooltipCommentsShort')}
longText={tHelp('tooltipCommentsLong')}
position="right"
/>
</div>
{hasUnread && (
<span style={{
padding: '0.25rem 0.75rem',
borderRadius: '1rem',
background: '#ef4444',
color: 'white',
fontSize: '0.875rem',
fontWeight: 'bold',
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
verticalAlign: 'baseline',
lineHeight: '1'
}}>
{unreadCount} {t('newComments')}
</span>
)}
</div>
<button
onClick={() => {
setShowComments(!showComments);
}}
style={{
padding: '0.4rem 0.8rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
background: showComments ? '#3b82f6' : '#fff',
color: showComments ? '#fff' : '#000',
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
{showComments ? t('hideComments') : t('showComments')}
</button>
</div>
{showComments && (
<>
{loadingComments ? (
<p>{t('loadingComments')}</p>
) : comments.length === 0 ? (
<p>{t('noComments')}</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{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 (
<div
key={comment.id}
style={{
padding: '1rem',
borderRadius: '0.5rem',
border: `1px solid ${isRead ? '#d1d5db' : '#3b82f6'}`,
background: isRead ? '#f9fafb' : '#eff6ff',
position: 'relative',
}}
onClick={() => {
if (!isRead) {
markCommentAsRead(comment.id);
}
}}
>
{!isRead && (
<div
style={{
position: 'absolute',
top: '0.5rem',
right: '0.5rem',
width: '12px',
height: '12px',
borderRadius: '50%',
background: '#3b82f6',
}}
title={t('unreadComment')}
/>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem', alignItems: 'center' }}>
<div>
<strong style={{ fontSize: '0.95rem' }}>
Hördle #{comment.puzzle.puzzleNumber}
</strong>
<span style={{ marginLeft: '0.5rem', fontSize: '0.85rem', color: '#6b7280' }}>
({categoryLabel})
</span>
</div>
<span style={{ fontSize: '0.8rem', color: '#6b7280' }}>
{new Date(comment.createdAt).toLocaleDateString()} {new Date(comment.createdAt).toLocaleTimeString()}
</span>
</div>
<div style={{ marginBottom: '0.75rem', fontSize: '0.9rem', fontWeight: '500' }}>
{comment.puzzle.song.title} - {comment.puzzle.song.artist}
</div>
<div style={{ fontSize: '0.9rem', whiteSpace: 'pre-wrap', marginBottom: '0.5rem' }}>
{comment.message}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '0.5rem' }}>
<button
onClick={(e) => {
e.stopPropagation();
if (confirm(t('archiveCommentConfirm'))) {
archiveComment(comment.id);
}
}}
style={{
padding: '0.4rem 0.8rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
background: '#fff',
color: '#6b7280',
cursor: 'pointer',
fontSize: '0.85rem',
}}
title={t('archiveComment')}
>
{t('archiveComment')}
</button>
</div>
</div>
);
})}
</div>
)}
</>
)}
</section>
);
})()}
<section style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
<h2 style={{ fontSize: '1.25rem', margin: 0 }}>{t('uploadSectionTitle')}</h2>
<HelpTooltip
shortText={tHelp('tooltipUploadShort')}
longText={tHelp('tooltipUploadLong')}
position="right"
/>
</div>
<p style={{ marginBottom: '0.75rem', color: '#4b5563', fontSize: '0.9rem' }}>
{t('uploadSectionDescription')}
</p>
<form onSubmit={handleBatchUpload} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxWidth: '640px' }}>
<div
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{
border: isDragging ? '2px solid #4f46e5' : '2px dashed #d1d5db',
borderRadius: '0.5rem',
padding: '1.75rem',
textAlign: 'center',
background: isDragging ? '#eef2ff' : '#f9fafb',
cursor: 'pointer',
transition: 'all 0.2s',
}}
onClick={() => fileInputRef.current?.click()}
>
<div style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>📁</div>
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
{files.length > 0
? t('dropzoneTitleWithFiles', { count: files.length })
: t('dropzoneTitleEmpty')}
</p>
<p style={{ fontSize: '0.875rem', color: '#666' }}>{t('dropzoneSubtitle')}</p>
<input
ref={fileInputRef}
type="file"
accept="audio/mpeg"
multiple
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</div>
{files.length > 0 && (
<div style={{ marginBottom: '0.5rem' }}>
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>{t('selectedFilesTitle')}</p>
<div
style={{
maxHeight: '160px',
overflowY: 'auto',
background: '#f9fafb',
padding: '0.5rem',
borderRadius: '0.25rem',
}}
>
{files.map((file, index) => (
<div key={index} style={{ padding: '0.25rem 0', fontSize: '0.875rem' }}>
📄 {file.name}
</div>
))}
</div>
</div>
)}
{isUploading && (
<div
style={{
marginBottom: '0.5rem',
padding: '0.75rem',
background: '#eef2ff',
borderRadius: '0.5rem',
fontSize: '0.875rem',
}}
>
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
{t('uploadProgress', {
current: uploadProgress.current,
total: uploadProgress.total,
})}
</p>
<div
style={{
width: '100%',
height: '8px',
background: '#d1d5db',
borderRadius: '4px',
overflow: 'hidden',
}}
>
<div
style={{
width:
uploadProgress.total > 0
? `${(uploadProgress.current / uploadProgress.total) * 100}%`
: '0%',
height: '100%',
background: '#4f46e5',
transition: 'width 0.3s',
}}
/>
</div>
</div>
)}
<div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<div style={{ fontWeight: 500 }}>{t('assignGenresLabel')}</div>
<HelpTooltip
shortText={tHelp('tooltipGenreAssignmentShort')}
longText={tHelp('tooltipGenreAssignmentLong')}
position="right"
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{genres
.filter(g => curatorInfo?.genreIds?.includes(g.id))
.map(genre => (
<label
key={genre.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
borderRadius: '999px',
background: uploadGenreIds.includes(genre.id) ? '#e5f3ff' : '#f3f4f6',
fontSize: '0.8rem',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={uploadGenreIds.includes(genre.id)}
onChange={() => toggleUploadGenre(genre.id)}
/>
{typeof genre.name === 'string' ? genre.name : genre.name?.de ?? genre.name?.en}
</label>
))}
{curatorInfo && curatorInfo.genreIds.length === 0 && (
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>
{t('noAssignedGenres')}
</span>
)}
</div>
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem', marginTop: '0.5rem' }}>
<div style={{ fontWeight: 500 }}>{t('assignSpecialsLabel')}</div>
<HelpTooltip
shortText={tHelp('tooltipSpecialAssignmentShort')}
longText={tHelp('tooltipSpecialAssignmentLong')}
position="right"
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{specials
.filter(s => curatorInfo?.specialIds?.includes(s.id))
.map(special => (
<label
key={special.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
borderRadius: '999px',
background: uploadSpecialIds.includes(special.id) ? '#fef3c7' : '#f3f4f6',
fontSize: '0.8rem',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={uploadSpecialIds.includes(special.id)}
onChange={() => toggleUploadSpecial(special.id)}
/>
{typeof special.name === 'string'
? special.name
: special.name?.de ?? special.name?.en}
</label>
))}
</div>
</div>
</div>
</div>
<button
type="submit"
disabled={isUploading || files.length === 0}
style={{
padding: '0.5rem 1rem',
background: isUploading || files.length === 0 ? '#9ca3af' : '#111827',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: isUploading || files.length === 0 ? 'not-allowed' : 'pointer',
alignSelf: 'flex-start',
}}
>
{isUploading ? t('uploadButtonUploading') : t('uploadButtonIdle')}
</button>
{uploadResults.length > 0 && (
<div
style={{
marginTop: '0.75rem',
padding: '0.75rem',
background: '#f9fafb',
borderRadius: '0.5rem',
fontSize: '0.85rem',
}}
>
{uploadResults.map((r, idx) => (
<div key={idx} style={{ marginBottom: '0.25rem' }}>
<strong>{r.filename}</strong> {' '}
{r.success
? t('uploadResultSuccess')
: r.isDuplicate
? t('uploadResultDuplicate', { error: r.error })
: t('uploadResultError', { error: r.error })}
</div>
))}
</div>
)}
</form>
</section>
<section style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
<h2 style={{ fontSize: '1.25rem', margin: 0 }}>
{t('tracklistTitle', { count: filteredSongs.length })}
</h2>
<HelpTooltip
shortText={tHelp('tooltipTracklistShort')}
longText={tHelp('tooltipTracklistLong')}
position="right"
/>
</div>
<p style={{ marginBottom: '0.75rem', color: '#4b5563', fontSize: '0.9rem' }}>
{t('tracklistDescription')}
</p>
{/* Suche & Filter */}
<div style={{ marginBottom: '0.75rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ flex: '1', minWidth: '200px', display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<input
type="text"
placeholder={t('searchPlaceholder')}
value={searchQuery}
onChange={e => {
setSearchQuery(e.target.value);
setCurrentPage(1);
}}
style={{
flex: '1',
minWidth: '200px',
padding: '0.4rem 0.6rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
}}
/>
<HelpTooltip
shortText={tHelp('tooltipSearchShort')}
longText={tHelp('tooltipSearchLong')}
position="top"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<select
value={selectedFilter}
onChange={e => {
setSelectedFilter(e.target.value);
setCurrentPage(1);
}}
style={{
minWidth: '180px',
padding: '0.4rem 0.6rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
}}
>
<option value="">{t('filterAll')}</option>
<option value="no-global">{t('filterNoGlobal')}</option>
<optgroup label="Genres">
{genres
.filter(g => curatorInfo?.genreIds?.includes(g.id))
.map(genre => (
<option key={genre.id} value={`genre:${genre.id}`}>
{typeof genre.name === 'string'
? genre.name
: genre.name?.de ?? genre.name?.en}
</option>
))}
</optgroup>
<optgroup label="Specials">
{specials
.filter(s => curatorInfo?.specialIds?.includes(s.id))
.map(special => (
<option key={special.id} value={`special:${special.id}`}>
{' '}
{typeof special.name === 'string'
? special.name
: special.name?.de ?? special.name?.en}
</option>
))}
</optgroup>
</select>
<HelpTooltip
shortText={tHelp('tooltipFilterShort')}
longText={tHelp('tooltipFilterLong')}
position="top"
/>
</div>
{(searchQuery || selectedFilter) && (
<button
type="button"
onClick={() => {
setSearchQuery('');
setSelectedFilter('');
setCurrentPage(1);
}}
style={{
padding: '0.4rem 0.8rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
background: '#f3f4f6',
cursor: 'pointer',
fontSize: '0.85rem',
}}
>
{t('filterReset')}
</button>
)}
</div>
{visibleSongs.length === 0 ? (
<p>{t('noSongsInScope')}</p>
) : (
<>
{/* Batch Edit Toolbar */}
{selectedSongIds.size > 0 && (
<div
style={{
marginBottom: '1rem',
padding: '1rem',
background: '#f0f9ff',
border: '1px solid #bae6fd',
borderRadius: '0.5rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<strong style={{ fontSize: '1rem' }}>
{t('batchEditTitle') || `Batch Edit: ${selectedSongIds.size} ${selectedSongIds.size === 1 ? 'song' : 'songs'} selected`}
</strong>
<HelpTooltip
shortText={tHelp('tooltipBatchEditShort')}
longText={tHelp('tooltipBatchEditLong')}
position="right"
/>
</div>
<button
type="button"
onClick={clearSelection}
style={{
padding: '0.25rem 0.5rem',
background: '#e5e7eb',
border: 'none',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.85rem',
}}
>
{t('clearSelection') || 'Clear Selection'}
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{/* Genre Toggle */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<label style={{ display: 'block', fontWeight: 500, fontSize: '0.9rem', margin: 0 }}>
{t('batchToggleGenres') || 'Toggle Genres'}
</label>
<HelpTooltip
shortText={tHelp('tooltipBatchGenreToggleShort')}
longText={tHelp('tooltipBatchGenreToggleLong')}
position="right"
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{genres
.filter(g => curatorInfo?.genreIds?.includes(g.id))
.map(genre => (
<label
key={genre.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
borderRadius: '999px',
background: batchGenreIds.includes(genre.id) ? '#e5f3ff' : '#f3f4f6',
fontSize: '0.8rem',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={batchGenreIds.includes(genre.id)}
onChange={() => {
setBatchGenreIds(prev =>
prev.includes(genre.id)
? prev.filter(id => id !== genre.id)
: [...prev, genre.id]
);
}}
/>
{typeof genre.name === 'string'
? genre.name
: genre.name?.de ?? genre.name?.en}
</label>
))}
</div>
</div>
{/* Special Toggle */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<label style={{ display: 'block', fontWeight: 500, fontSize: '0.9rem', margin: 0 }}>
{t('batchToggleSpecials') || 'Toggle Specials'}
</label>
<HelpTooltip
shortText={tHelp('tooltipBatchSpecialToggleShort')}
longText={tHelp('tooltipBatchSpecialToggleLong')}
position="right"
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{specials
.filter(s => curatorInfo?.specialIds?.includes(s.id))
.map(special => (
<label
key={special.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
borderRadius: '999px',
background: batchSpecialIds.includes(special.id) ? '#fee2e2' : '#f3f4f6',
fontSize: '0.8rem',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={batchSpecialIds.includes(special.id)}
onChange={() => {
setBatchSpecialIds(prev =>
prev.includes(special.id)
? prev.filter(id => id !== special.id)
: [...prev, special.id]
);
}}
/>
{' '}
{typeof special.name === 'string'
? special.name
: special.name?.de ?? special.name?.en}
</label>
))}
</div>
</div>
{/* Artist Change */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<label style={{ display: 'block', fontWeight: 500, fontSize: '0.9rem', margin: 0 }}>
{t('batchChangeArtist') || 'Change Artist'}
</label>
<HelpTooltip
shortText={tHelp('tooltipBatchArtistShort')}
longText={tHelp('tooltipBatchArtistLong')}
position="right"
/>
</div>
<input
type="text"
value={batchArtist}
onChange={e => 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',
}}
/>
</div>
{/* Exclude Global Flag */}
{curatorInfo?.isGlobalCurator && (
<div>
<label style={{ display: 'block', fontWeight: 500, marginBottom: '0.25rem', fontSize: '0.9rem' }}>
{t('batchExcludeGlobal') || 'Exclude from Global'}
</label>
<select
value={batchExcludeFromGlobal === undefined ? '' : batchExcludeFromGlobal ? 'true' : 'false'}
onChange={e => {
if (e.target.value === '') {
setBatchExcludeFromGlobal(undefined);
} else {
setBatchExcludeFromGlobal(e.target.value === 'true');
}
}}
style={{
padding: '0.4rem 0.6rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
fontSize: '0.9rem',
}}
>
<option value="">{t('batchNoChange') || 'No change'}</option>
<option value="true">{t('batchExclude') || 'Exclude'}</option>
<option value="false">{t('batchInclude') || 'Include'}</option>
</select>
</div>
)}
{/* Apply Button */}
<div>
<button
type="button"
onClick={handleBatchUpdate}
disabled={isBatchUpdating}
style={{
padding: '0.5rem 1rem',
background: isBatchUpdating ? '#9ca3af' : '#10b981',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: isBatchUpdating ? 'not-allowed' : 'pointer',
fontWeight: 'bold',
fontSize: '0.9rem',
}}
>
{isBatchUpdating
? (t('batchUpdating') || 'Updating...')
: (t('batchApply') || 'Apply Changes')}
</button>
</div>
</div>
</div>
)}
<div style={{ overflowX: 'auto' }}>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: '0.9rem',
}}
>
<thead>
<tr style={{ borderBottom: '1px solid #e5e7eb' }}>
<th style={{ padding: '0.5rem', width: '40px' }}>
<input
type="checkbox"
checked={visibleSongs.length > 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'}
/>
</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('id')}
>
{t('columnId')} {sortField === 'id' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th style={{ padding: '0.5rem' }}>{t('columnPlay')}</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('title')}
>
{t('columnTitle')} {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('artist')}
>
{t('columnArtist')} {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('releaseYear')}
>
{t('columnYear')} {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th style={{ padding: '0.5rem' }}>{t('columnCover')}</th>
<th style={{ padding: '0.5rem' }}>{t('columnGenresSpecials')}</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('createdAt')}
>
{t('columnAdded')} {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('activations')}
>
{t('columnActivations')} {sortField === 'activations' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('averageRating')}
>
{t('columnRating')} {sortField === 'averageRating' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th style={{ padding: '0.5rem' }}>{t('columnExcludeGlobal')}</th>
<th style={{ padding: '0.5rem' }}>{t('columnActions')}</th>
</tr>
</thead>
<tbody>
{visibleSongs.map(song => {
const editable = canEditSong(song);
const deletable = canDeleteSong(song);
const isEditing = editingId === song.id;
const ratingText =
song.ratingCount && song.ratingCount > 0
? `${(song.averageRating || 0).toFixed(1)} (${song.ratingCount})`
: '-';
const isSelected = selectedSongIds.has(song.id);
return (
<tr
key={song.id}
style={{
borderBottom: '1px solid #f3f4f6',
backgroundColor: isSelected ? '#eff6ff' : 'transparent',
}}
>
<td style={{ padding: '0.5rem' }}>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSongSelection(song.id)}
disabled={!editable}
style={{ cursor: editable ? 'pointer' : 'not-allowed' }}
title={editable ? (t('selectSong') || 'Select song') : (t('cannotEditSong') || 'Cannot edit this song')}
/>
</td>
<td style={{ padding: '0.5rem' }}>{song.id}</td>
<td style={{ padding: '0.5rem' }}>
<button
type="button"
onClick={() => handlePlayPause(song)}
style={{
border: 'none',
background: 'none',
cursor: 'pointer',
fontSize: '1.1rem',
}}
title={
playingSongId === song.id
? t('pause')
: t('play')
}
>
{playingSongId === song.id ? '⏸️' : '▶️'}
</button>
</td>
<td style={{ padding: '0.5rem' }}>
{isEditing ? (
<input
type="text"
value={editTitle}
onChange={e => setEditTitle(e.target.value)}
style={{ width: '100%', padding: '0.25rem' }}
/>
) : (
song.title
)}
</td>
<td style={{ padding: '0.5rem' }}>
{isEditing ? (
<input
type="text"
value={editArtist}
onChange={e => setEditArtist(e.target.value)}
style={{ width: '100%', padding: '0.25rem' }}
/>
) : (
song.artist
)}
</td>
<td style={{ padding: '0.5rem' }}>
{isEditing ? (
<input
type="number"
value={editReleaseYear}
onChange={e =>
setEditReleaseYear(
e.target.value === '' ? '' : Number(e.target.value)
)
}
style={{ width: '5rem', padding: '0.25rem' }}
/>
) : song.releaseYear ? (
song.releaseYear
) : (
'-'
)}
</td>
<td
style={{
padding: '0.5rem',
textAlign: 'center',
position: 'relative',
cursor: song.coverImage ? 'pointer' : 'default'
}}
onMouseEnter={() => song.coverImage && setHoveredCoverSongId(song.id)}
onMouseLeave={() => setHoveredCoverSongId(null)}
>
{song.coverImage ? '✓' : '-'}
{hoveredCoverSongId === song.id && song.coverImage && (
<div
style={{
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginTop: '0.5rem',
zIndex: 1000,
padding: '0.5rem',
background: 'white',
border: '1px solid #d1d5db',
borderRadius: '0.5rem',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
pointerEvents: 'none',
}}
>
<img
src={`/uploads/covers/${song.coverImage}`}
alt={`Cover für ${song.title}`}
style={{
width: '200px',
height: '200px',
objectFit: 'cover',
borderRadius: '0.25rem',
display: 'block',
}}
/>
</div>
)}
</td>
<td style={{ padding: '0.5rem' }}>
{isEditing ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{genres
.filter(g => curatorInfo?.genreIds?.includes(g.id))
.map(genre => (
<label
key={genre.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.15rem 0.4rem',
borderRadius: '999px',
background: editGenreIds.includes(genre.id)
? '#e5f3ff'
: '#f3f4f6',
fontSize: '0.8rem',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={editGenreIds.includes(genre.id)}
onChange={() =>
setEditGenreIds(prev =>
prev.includes(genre.id)
? prev.filter(id => id !== genre.id)
: [...prev, genre.id]
)
}
/>
{typeof genre.name === 'string'
? genre.name
: genre.name?.de ?? genre.name?.en}
</label>
))}
{specials
.filter(s => curatorInfo?.specialIds?.includes(s.id))
.map(special => (
<label
key={special.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.15rem 0.4rem',
borderRadius: '999px',
background: editSpecialIds.includes(special.id)
? '#fee2e2'
: '#f3f4f6',
fontSize: '0.8rem',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={editSpecialIds.includes(special.id)}
onChange={() =>
setEditSpecialIds(prev =>
prev.includes(special.id)
? prev.filter(id => id !== special.id)
: [...prev, special.id]
)
}
/>
{' '}
{typeof special.name === 'string'
? special.name
: special.name?.de ?? special.name?.en}
</label>
))}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{song.genres
.filter(
g => !curatorInfo?.genreIds?.includes(g.id)
)
.map(g => (
<span
key={`fixed-g-${g.id}`}
style={{
padding: '0.1rem 0.4rem',
borderRadius: '999px',
background: '#e5e7eb',
fontSize: '0.8rem',
}}
>
{typeof g.name === 'string'
? g.name
: g.name?.de ?? g.name?.en}
</span>
))}
{song.specials
.filter(
s => !curatorInfo?.specialIds?.includes(s.id)
)
.map(s => (
<span
key={`fixed-s-${s.id}`}
style={{
padding: '0.1rem 0.4rem',
borderRadius: '999px',
background: '#fee2e2',
fontSize: '0.8rem',
}}
>
{' '}
{typeof s.name === 'string'
? s.name
: s.name?.de ?? s.name?.en}
</span>
))}
</div>
</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{song.genres.map(g => (
<span
key={`g-${g.id}`}
style={{
padding: '0.1rem 0.4rem',
borderRadius: '999px',
background: '#e5e7eb',
}}
>
{typeof g.name === 'string'
? g.name
: g.name?.de ?? g.name?.en}
</span>
))}
{song.specials.map(s => (
<span
key={`s-${s.id}`}
style={{
padding: '0.1rem 0.4rem',
borderRadius: '999px',
background: '#fee2e2',
}}
>
{typeof s.name === 'string'
? s.name
: s.name?.de ?? s.name?.en}
</span>
))}
</div>
)}
</td>
<td style={{ padding: '0.5rem', whiteSpace: 'nowrap' }}>
{new Date(song.createdAt).toLocaleDateString()}
</td>
<td style={{ padding: '0.5rem', textAlign: 'center' }}>
{song.activations ?? song.puzzles?.length ?? 0}
</td>
<td style={{ padding: '0.5rem' }}>{ratingText}</td>
<td style={{ padding: '0.5rem' }}>
{isEditing ? (
<input
type="checkbox"
checked={editExcludeFromGlobal}
onChange={e =>
setEditExcludeFromGlobal(e.target.checked)
}
disabled={!curatorInfo?.isGlobalCurator}
/>
) : song.excludeFromGlobal ? (
t('excludeGlobalYes')
) : (
t('excludeGlobalNo')
)}
{!curatorInfo?.isGlobalCurator && (
<span
style={{
display: 'block',
fontSize: '0.75rem',
color: '#9ca3af',
}}
>
{t('excludeGlobalInfo')}
</span>
)}
</td>
<td
style={{
padding: '0.5rem',
whiteSpace: 'nowrap',
}}
>
{isEditing ? (
<>
<button
type="button"
onClick={() => saveEditing(song.id)}
style={{
marginRight: '0.5rem',
padding: '0.25rem 0.5rem',
background: '#10b981',
color: 'white',
border: 'none',
borderRadius: '0.25rem',
cursor: 'pointer',
}}
>
💾
</button>
<button
type="button"
onClick={cancelEditing}
style={{
padding: '0.25rem 0.5rem',
background: '#e5e7eb',
border: 'none',
borderRadius: '0.25rem',
cursor: 'pointer',
}}
>
</button>
</>
) : (
<>
<button
type="button"
onClick={() => startEditing(song)}
disabled={!editable}
style={{
marginRight: '0.5rem',
padding: '0.25rem 0.5rem',
background: editable ? '#e5e7eb' : '#f3f4f6',
color: '#111827',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: editable ? 'pointer' : 'not-allowed',
}}
>
</button>
<button
type="button"
onClick={() => handleDelete(song)}
disabled={!deletable}
style={{
padding: '0.25rem 0.5rem',
background: deletable ? '#b91c1c' : '#fca5a5',
color: 'white',
border: 'none',
borderRadius: '0.25rem',
cursor: deletable ? 'pointer' : 'not-allowed',
}}
>
🗑
</button>
</>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination & Page Size */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: '0.75rem',
fontSize: '0.875rem',
gap: '0.75rem',
flexWrap: 'wrap',
}}
>
<button
type="button"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={page === 1}
style={{
padding: '0.3rem 0.6rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
background: page === 1 ? '#f3f4f6' : '#fff',
cursor: page === 1 ? 'not-allowed' : 'pointer',
}}
>
{t('paginationPrev')}
</button>
<span style={{ color: '#666' }}>
{t('paginationLabel', { page, total: totalPages })}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<span>{t('pageSizeLabel')}</span>
<select
value={itemsPerPage}
onChange={e => {
const value = parseInt(e.target.value, 10) || 10;
const safeValue = Math.min(100, Math.max(1, value));
setItemsPerPage(safeValue);
setCurrentPage(1);
}}
style={{
padding: '0.25rem 0.5rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
}}
>
{[10, 25, 50, 100].map(size => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</div>
<button
type="button"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
style={{
padding: '0.3rem 0.6rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
background: page === totalPages ? '#f3f4f6' : '#fff',
cursor: page === totalPages ? 'not-allowed' : 'pointer',
}}
>
{t('paginationNext')}
</button>
</div>
</>
)}
</section>
</main>
);
}