1301 lines
66 KiB
TypeScript
1301 lines
66 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useRef, useState } from 'react';
|
||
|
||
interface Genre {
|
||
id: number;
|
||
name: any;
|
||
}
|
||
|
||
interface Special {
|
||
id: number;
|
||
name: any;
|
||
}
|
||
|
||
interface Song {
|
||
id: number;
|
||
title: string;
|
||
artist: string;
|
||
filename: string;
|
||
createdAt: string;
|
||
releaseYear: number | null;
|
||
activations?: number;
|
||
puzzles?: any[];
|
||
genres: Genre[];
|
||
specials: Special[];
|
||
excludeFromGlobal: boolean;
|
||
averageRating?: number;
|
||
ratingCount?: number;
|
||
}
|
||
|
||
interface CuratorInfo {
|
||
id: number;
|
||
username: string;
|
||
isGlobalCurator: boolean;
|
||
genreIds: number[];
|
||
specialIds: number[];
|
||
}
|
||
|
||
function getCuratorAuthHeaders() {
|
||
const authToken = localStorage.getItem('hoerdle_curator_auth');
|
||
const username = localStorage.getItem('hoerdle_curator_username') || '';
|
||
return {
|
||
'Content-Type': 'application/json',
|
||
'x-curator-auth': authToken || '',
|
||
'x-curator-username': username,
|
||
};
|
||
}
|
||
|
||
function getCuratorUploadHeaders() {
|
||
const authToken = localStorage.getItem('hoerdle_curator_auth');
|
||
const username = localStorage.getItem('hoerdle_curator_username') || '';
|
||
return {
|
||
'x-curator-auth': authToken || '',
|
||
'x-curator-username': username,
|
||
};
|
||
}
|
||
|
||
export default function CuratorPage() {
|
||
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 = 10;
|
||
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
|
||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
const authToken = localStorage.getItem('hoerdle_curator_auth');
|
||
const storedUsername = localStorage.getItem('hoerdle_curator_username');
|
||
if (authToken === 'authenticated' && storedUsername) {
|
||
setIsAuthenticated(true);
|
||
setUsername(storedUsername);
|
||
bootstrapCuratorData();
|
||
}
|
||
}, []);
|
||
|
||
const bootstrapCuratorData = async () => {
|
||
try {
|
||
setLoading(true);
|
||
await Promise.all([fetchCuratorInfo(), fetchSongs(), fetchGenres(), fetchSpecials()]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const fetchCuratorInfo = async () => {
|
||
const res = await fetch('/api/curator/me', {
|
||
headers: getCuratorAuthHeaders(),
|
||
});
|
||
if (res.ok) {
|
||
const data: CuratorInfo = await res.json();
|
||
setCuratorInfo(data);
|
||
localStorage.setItem('hoerdle_curator_is_global', String(data.isGlobalCurator));
|
||
} else {
|
||
setMessage('Fehler beim Laden der Kuratoren-Informationen.');
|
||
}
|
||
};
|
||
|
||
const fetchSongs = async () => {
|
||
const res = await fetch('/api/songs', {
|
||
headers: getCuratorAuthHeaders(),
|
||
});
|
||
if (res.ok) {
|
||
const data: Song[] = await res.json();
|
||
setSongs(data);
|
||
} else {
|
||
setMessage('Fehler beim Laden der Songs.');
|
||
}
|
||
};
|
||
|
||
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 || 'Login fehlgeschlagen.');
|
||
}
|
||
} catch (e) {
|
||
setMessage('Netzwerkfehler beim Login.');
|
||
}
|
||
};
|
||
|
||
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('Song erfolgreich aktualisiert.');
|
||
} else {
|
||
const errText = await res.text();
|
||
setMessage(`Fehler beim Speichern: ${errText}`);
|
||
}
|
||
} catch (e) {
|
||
setMessage('Netzwerkfehler beim Speichern.');
|
||
}
|
||
};
|
||
|
||
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('Du darfst diesen Song nicht löschen.');
|
||
return;
|
||
}
|
||
if (!confirm(`Möchtest du "${song.title}" wirklich löschen?`)) return;
|
||
|
||
try {
|
||
const res = await fetch('/api/songs', {
|
||
method: 'DELETE',
|
||
headers: getCuratorAuthHeaders(),
|
||
body: JSON.stringify({ id: song.id }),
|
||
});
|
||
if (res.ok) {
|
||
await fetchSongs();
|
||
setMessage('Song gelöscht.');
|
||
} else {
|
||
const errText = await res.text();
|
||
setMessage(`Fehler beim Löschen: ${errText}`);
|
||
}
|
||
} catch (e) {
|
||
setMessage('Netzwerkfehler beim Löschen.');
|
||
}
|
||
};
|
||
|
||
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-Datei konnte nicht geladen werden: ${song.filename}`);
|
||
};
|
||
|
||
audio.play()
|
||
.then(() => {
|
||
setAudioElement(audio);
|
||
setPlayingSongId(song.id);
|
||
})
|
||
.catch(error => {
|
||
console.error('Playback error:', error);
|
||
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 = `✅ ${successCount}/${results.length} Uploads erfolgreich.`;
|
||
if (duplicateCount > 0) msg += `\n⚠️ ${duplicateCount} Duplikat(e) übersprungen.`;
|
||
if (failedCount > 0) msg += `\n❌ ${failedCount} fehlgeschlagen.`;
|
||
setMessage(msg);
|
||
};
|
||
|
||
if (!isAuthenticated) {
|
||
return (
|
||
<main style={{ maxWidth: '480px', margin: '2rem auto', padding: '1rem' }}>
|
||
<h1 style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>Kuratoren-Login</h1>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||
<label>
|
||
Benutzername
|
||
<input
|
||
type="text"
|
||
value={username}
|
||
onChange={e => setUsername(e.target.value)}
|
||
style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }}
|
||
/>
|
||
</label>
|
||
<label>
|
||
Passwort
|
||
<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',
|
||
}}
|
||
>
|
||
Einloggen
|
||
</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' }}>
|
||
Eingeloggt als <strong>{curatorInfo.username}</strong>
|
||
{curatorInfo.isGlobalCurator && ' (Globaler Kurator)'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={handleLogout}
|
||
style={{
|
||
padding: '0.5rem 1rem',
|
||
background: '#6b7280',
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '0.375rem',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
Abmelden
|
||
</button>
|
||
</header>
|
||
|
||
{loading && <p>Lade Daten...</p>}
|
||
{message && (
|
||
<p style={{ marginBottom: '1rem', color: '#b91c1c', whiteSpace: 'pre-line' }}>{message}</p>
|
||
)}
|
||
|
||
<section style={{ marginBottom: '2rem' }}>
|
||
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>Titel hochladen</h2>
|
||
<p style={{ marginBottom: '0.75rem', color: '#4b5563', fontSize: '0.9rem' }}>
|
||
Ziehe eine oder mehrere MP3-Dateien hierher oder wähle sie aus. Die Titel werden automatisch analysiert
|
||
(inkl. Erkennung des Erscheinungsjahres) und von der globalen Playlist ausgeschlossen. Wähle mindestens
|
||
eines deiner Genres aus, um die Titel zuzuordnen.
|
||
</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 ? `${files.length} Datei(en) ausgewählt` : 'MP3-Dateien hierher ziehen'}
|
||
</p>
|
||
<p style={{ fontSize: '0.875rem', color: '#666' }}>oder klicken, um Dateien auszuwählen</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' }}>Ausgewählte Dateien:</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' }}>
|
||
Upload: {uploadProgress.current} / {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' }}>Genres zuordnen</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' }}>
|
||
Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin.
|
||
</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 ? 'Lade hoch...' : 'Upload starten'}
|
||
</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
|
||
? '✅ erfolgreich'
|
||
: r.isDuplicate
|
||
? `⚠️ Duplikat: ${r.error}`
|
||
: `❌ Fehler: ${r.error}`}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</form>
|
||
</section>
|
||
|
||
<section style={{ marginBottom: '2rem' }}>
|
||
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>
|
||
Titel in deinen Genres & Specials ({filteredSongs.length} Titel)
|
||
</h2>
|
||
<p style={{ marginBottom: '0.75rem', color: '#4b5563', fontSize: '0.9rem' }}>
|
||
Du kannst Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind.
|
||
Löschen ist nur erlaubt, wenn ein Song ausschließlich deinen Genres/Specials zugeordnet ist.
|
||
Genres, Specials, News und politische Statements können nur vom Admin verwaltet werden.
|
||
</p>
|
||
|
||
{/* Suche & Filter */}
|
||
<div style={{ marginBottom: '0.75rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||
<input
|
||
type="text"
|
||
placeholder="Nach Titel oder Artist suchen..."
|
||
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="">Alle Inhalte</option>
|
||
<option value="no-global">🚫 Ohne Global</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',
|
||
}}
|
||
>
|
||
Filter zurücksetzen
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{visibleSongs.length === 0 ? (
|
||
<p>Keine passenden Songs in deinen Genres/Specials gefunden.</p>
|
||
) : (
|
||
<>
|
||
<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', cursor: 'pointer' }}
|
||
onClick={() => handleSort('id')}
|
||
>
|
||
ID {sortField === 'id' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||
</th>
|
||
<th style={{ padding: '0.5rem' }}>Play</th>
|
||
<th
|
||
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
||
onClick={() => handleSort('title')}
|
||
>
|
||
Titel {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||
</th>
|
||
<th
|
||
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
||
onClick={() => handleSort('artist')}
|
||
>
|
||
Artist {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||
</th>
|
||
<th
|
||
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
||
onClick={() => handleSort('releaseYear')}
|
||
>
|
||
Jahr {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||
</th>
|
||
<th style={{ padding: '0.5rem' }}>Genres / Specials</th>
|
||
<th
|
||
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
||
onClick={() => handleSort('createdAt')}
|
||
>
|
||
Hinzugefügt {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||
</th>
|
||
<th
|
||
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
||
onClick={() => handleSort('activations')}
|
||
>
|
||
Aktivierungen {sortField === 'activations' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||
</th>
|
||
<th
|
||
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
||
onClick={() => handleSort('averageRating')}
|
||
>
|
||
Rating {sortField === 'averageRating' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||
</th>
|
||
<th style={{ padding: '0.5rem' }}>Exclude Global</th>
|
||
<th style={{ padding: '0.5rem' }}>Aktionen</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})`
|
||
: '-';
|
||
|
||
return (
|
||
<tr key={song.id} style={{ borderBottom: '1px solid #f3f4f6' }}>
|
||
<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
|
||
? 'Pause'
|
||
: 'Abspielen'
|
||
}
|
||
>
|
||
{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>
|
||
))}
|
||
</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.map(s => (
|
||
<span
|
||
key={`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 ? (
|
||
'Ja'
|
||
) : (
|
||
'Nein'
|
||
)}
|
||
{!curatorInfo?.isGlobalCurator && (
|
||
<span
|
||
style={{
|
||
display: 'block',
|
||
fontSize: '0.75rem',
|
||
color: '#9ca3af',
|
||
}}
|
||
>
|
||
Nur globale Kuratoren dürfen dieses Flag ändern.
|
||
</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 */}
|
||
{totalPages > 1 && (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginTop: '0.75rem',
|
||
fontSize: '0.875rem',
|
||
}}
|
||
>
|
||
<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',
|
||
}}
|
||
>
|
||
Zurück
|
||
</button>
|
||
<span style={{ color: '#666' }}>
|
||
Seite {page} von {totalPages}
|
||
</span>
|
||
<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',
|
||
}}
|
||
>
|
||
Weiter
|
||
</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
|