Files
hoerdle/app/curator/CuratorPageClient.tsx
Hördle Bot 296a227d22 feat: Batch-Edit-Funktionalität für Curator Track-Liste
- Neue API-Route /api/songs/batch für Batch-Updates
- Checkbox-Spalte in Tabelle mit Select-All-Funktionalität
- Batch-Edit-Toolbar mit Genre/Special Toggle, Artist-Änderung und Exclude Global Flag
- Visuelle Hervorhebung ausgewählter Zeilen
- Unterstützt Toggle-Modus für Genres/Specials (hinzufügen/entfernen)
- Validiert Kurator-Berechtigungen für jeden Song
- Transaktionsbasierte Updates für Konsistenz
2025-12-04 00:38:08 +01:00

1942 lines
102 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';
interface Genre {
id: number;
name: any;
}
interface Special {
id: number;
name: any;
}
interface Song {
id: number;
title: string;
artist: string;
filename: string;
createdAt: string;
releaseYear: number | null;
activations?: number;
puzzles?: any[];
genres: Genre[];
specials: Special[];
excludeFromGlobal: boolean;
averageRating?: number;
ratingCount?: number;
}
interface CuratorInfo {
id: number;
username: string;
isGlobalCurator: boolean;
genreIds: number[];
specialIds: number[];
}
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 [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 [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);
// 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 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 den erfolgreich hochgeladenen Songs zuweisen
if (uploadGenreIds.length > 0) {
const successfulUploads = results.filter(r => r.success && r.song);
for (const result of successfulUploads) {
try {
await fetch('/api/songs', {
method: 'PUT',
headers: getCuratorAuthHeaders(),
body: JSON.stringify({
id: result.song.id,
title: result.song.title,
artist: result.song.artist,
releaseYear: result.song.releaseYear,
genreIds: uploadGenreIds,
}),
});
} catch {
// Fehler beim Genre-Assigning werden nur geloggt, nicht abgebrochen
console.error(`Failed to assign genres to ${result.song.title}`);
}
}
}
await fetchSongs();
const successCount = results.filter(r => r.success).length;
const duplicateCount = results.filter(r => r.isDuplicate).length;
const failedCount = results.filter(r => !r.success && !r.isDuplicate).length;
let msg = t('uploadSummary', { success: successCount, total: results.length });
if (duplicateCount > 0) msg += `\n` + t('uploadSummaryDuplicates', { count: duplicateCount });
if (failedCount > 0) msg += `\n` + t('uploadSummaryFailed', { count: failedCount });
setMessage(msg);
};
if (!isAuthenticated) {
return (
<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>
<h1 style={{ fontSize: '1.75rem', marginBottom: '0.25rem' }}>Kuratoren-Dashboard</h1>
{curatorInfo && (
<p style={{ color: '#4b5563', fontSize: '0.9rem' }}>
{t('loggedInAs', { username: curatorInfo.username })}
{curatorInfo.isGlobalCurator && t('globalCuratorSuffix')}
</p>
)}
</div>
<button
type="button"
onClick={handleLogout}
style={{
padding: '0.5rem 1rem',
background: '#6b7280',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: 'pointer',
}}
>
{t('logout')}
</button>
</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' }}>
<h2 style={{ fontSize: '1.25rem', marginBottom: 0 }}>
{t('commentsTitle')} ({comments.length})
</h2>
{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' }}>
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>{t('uploadSectionTitle')}</h2>
<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={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('assignGenresLabel')}</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>
<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' }}>
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>
{t('tracklistTitle', { count: filteredSongs.length })}
</h2>
<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' }}>
<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',
}}
/>
<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>
{(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' }}>
<strong style={{ fontSize: '1rem' }}>
{t('batchEditTitle') || `Batch Edit: ${selectedSongIds.size} ${selectedSongIds.size === 1 ? 'song' : 'songs'} selected`}
</strong>
<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>
<label style={{ display: 'block', fontWeight: 500, marginBottom: '0.25rem', fontSize: '0.9rem' }}>
{t('batchToggleGenres') || 'Toggle Genres'}
</label>
<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>
<label style={{ display: 'block', fontWeight: 500, marginBottom: '0.25rem', fontSize: '0.9rem' }}>
{t('batchToggleSpecials') || 'Toggle Specials'}
</label>
<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>
<label style={{ display: 'block', fontWeight: 500, marginBottom: '0.25rem', fontSize: '0.9rem' }}>
{t('batchChangeArtist') || 'Change Artist'}
</label>
<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('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' }}>
{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>
);
}