2182 lines
118 KiB
TypeScript
2182 lines
118 KiB
TypeScript
'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', position: 'relative' }}>
|
||
<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',
|
||
position: 'sticky',
|
||
right: 0,
|
||
backgroundColor: 'white',
|
||
zIndex: 10,
|
||
}}
|
||
>
|
||
{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);
|
||
|
||
const rowBackgroundColor = isSelected ? '#eff6ff' : 'white';
|
||
return (
|
||
<tr
|
||
key={song.id}
|
||
style={{
|
||
borderBottom: '1px solid #f3f4f6',
|
||
backgroundColor: rowBackgroundColor,
|
||
}}
|
||
>
|
||
<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={`/api/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',
|
||
position: 'sticky',
|
||
right: 0,
|
||
backgroundColor: rowBackgroundColor,
|
||
zIndex: 10,
|
||
}}
|
||
>
|
||
{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',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
💾
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={cancelEditing}
|
||
style={{
|
||
padding: '0.25rem 0.5rem',
|
||
background: '#e5e7eb',
|
||
border: 'none',
|
||
borderRadius: '0.25rem',
|
||
cursor: 'pointer',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
✖
|
||
</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>
|
||
);
|
||
}
|
||
|
||
|
||
|