Files
hoerdle/app/[locale]/admin/page.tsx
2025-12-06 01:35:01 +01:00

2369 lines
112 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useRef } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { Link } from '@/lib/navigation';
import { getLocalizedValue } from '@/lib/i18n';
interface Special {
id: number;
name: any;
subtitle?: any;
maxAttempts: number;
unlockSteps: string;
launchDate?: string;
endDate?: string;
curator?: string;
hidden?: boolean;
_count?: {
songs: number;
};
}
interface Genre {
id: number;
name: any;
subtitle?: any;
active: boolean;
_count?: {
songs: number;
};
}
interface DailyPuzzle {
id: number;
date: string;
songId: number;
genreId: number | null;
specialId: number | null;
}
interface Song {
id: number;
title: string;
artist: string;
filename: string;
createdAt: string;
releaseYear: number | null;
activations: number;
puzzles: DailyPuzzle[];
genres: Genre[];
specials: Special[];
averageRating: number;
ratingCount: number;
excludeFromGlobal: boolean;
}
interface News {
id: number;
title: any;
content: any;
author: string | null;
publishedAt: string;
featured: boolean;
specialId: number | null;
special: {
id: number;
name: any;
} | null;
}
interface PoliticalStatement {
id: number;
text: string;
active?: boolean;
source?: string;
locale: string;
}
interface Curator {
id: number;
username: string;
isGlobalCurator: boolean;
genreIds: number[];
specialIds: number[];
}
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
type SortDirection = 'asc' | 'desc';
export default function AdminPage({ params }: { params: { locale: string } }) {
const t = useTranslations('Admin');
const locale = useLocale();
const [activeTab, setActiveTab] = useState<'de' | 'en'>('de');
const [password, setPassword] = useState('');
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [files, setFiles] = useState<File[]>([]);
const [message, setMessage] = useState('');
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<{ current: number, total: number }>({ current: 0, total: 0 });
const [uploadResults, setUploadResults] = useState<any[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [songs, setSongs] = useState<Song[]>([]);
const [genres, setGenres] = useState<Genre[]>([]);
const [newGenreName, setNewGenreName] = useState({ de: '', en: '' });
const [newGenreSubtitle, setNewGenreSubtitle] = useState({ de: '', en: '' });
const [newGenreActive, setNewGenreActive] = useState(true);
const [editingGenreId, setEditingGenreId] = useState<number | null>(null);
const [editGenreName, setEditGenreName] = useState({ de: '', en: '' });
const [editGenreSubtitle, setEditGenreSubtitle] = useState({ de: '', en: '' });
const [editGenreActive, setEditGenreActive] = useState(true);
// Specials state
const [specials, setSpecials] = useState<Special[]>([]);
const [newSpecialName, setNewSpecialName] = useState({ de: '', en: '' });
const [newSpecialSubtitle, setNewSpecialSubtitle] = useState({ de: '', en: '' });
const [newSpecialMaxAttempts, setNewSpecialMaxAttempts] = useState(7);
const [newSpecialUnlockSteps, setNewSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]');
const [newSpecialUnlockStepsError, setNewSpecialUnlockStepsError] = useState<string | null>(null);
const [newSpecialLaunchDate, setNewSpecialLaunchDate] = useState('');
const [newSpecialEndDate, setNewSpecialEndDate] = useState('');
const [newSpecialCurator, setNewSpecialCurator] = useState('');
const [newSpecialHidden, setNewSpecialHidden] = useState(false);
const [editingSpecialId, setEditingSpecialId] = useState<number | null>(null);
const [editSpecialName, setEditSpecialName] = useState({ de: '', en: '' });
const [editSpecialSubtitle, setEditSpecialSubtitle] = useState({ de: '', en: '' });
const [editSpecialMaxAttempts, setEditSpecialMaxAttempts] = useState(7);
const [editSpecialUnlockSteps, setEditSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]');
const [editSpecialUnlockStepsError, setEditSpecialUnlockStepsError] = useState<string | null>(null);
const [editSpecialLaunchDate, setEditSpecialLaunchDate] = useState('');
const [editSpecialEndDate, setEditSpecialEndDate] = useState('');
const [editSpecialCurator, setEditSpecialCurator] = useState('');
const [editSpecialHidden, setEditSpecialHidden] = useState(false);
// News state
const [news, setNews] = useState<News[]>([]);
const [newNewsTitle, setNewNewsTitle] = useState({ de: '', en: '' });
const [newNewsContent, setNewNewsContent] = useState({ de: '', en: '' });
const [newNewsAuthor, setNewNewsAuthor] = useState('');
const [newNewsFeatured, setNewNewsFeatured] = useState(false);
const [newNewsSpecialId, setNewNewsSpecialId] = useState<number | null>(null);
const [editingNewsId, setEditingNewsId] = useState<number | null>(null);
const [editNewsTitle, setEditNewsTitle] = useState({ de: '', en: '' });
const [editNewsContent, setEditNewsContent] = useState({ de: '', en: '' });
const [editNewsAuthor, setEditNewsAuthor] = useState('');
const [editNewsFeatured, setEditNewsFeatured] = useState(false);
const [editNewsSpecialId, setEditNewsSpecialId] = useState<number | null>(null);
// Edit state
const [editingId, setEditingId] = useState<number | null>(null);
const [editTitle, setEditTitle] = useState('');
const [editArtist, setEditArtist] = useState('');
const [editReleaseYear, setEditReleaseYear] = useState<number | ''>('');
const [editGenreIds, setEditGenreIds] = useState<number[]>([]);
const [editSpecialIds, setEditSpecialIds] = useState<number[]>([]);
const [editExcludeFromGlobal, setEditExcludeFromGlobal] = useState(false);
// Post-upload state
const [uploadedSong, setUploadedSong] = useState<Song | null>(null);
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
const [uploadExcludeFromGlobal, setUploadExcludeFromGlobal] = useState(false);
// Batch upload genre selection
const [batchUploadGenreIds, setBatchUploadGenreIds] = useState<number[]>([]);
// AI Categorization state
const [isCategorizing, setIsCategorizing] = useState(false);
const [categorizationResults, setCategorizationResults] = useState<any>(null);
// Sort state
const [sortField, setSortField] = useState<SortField>('artist');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
// Search and pagination state (wird nur noch in Resten der alten Song Library verwendet, kann später entfernt werden)
const [searchQuery, setSearchQuery] = useState('');
const [selectedGenreFilter, setSelectedGenreFilter] = useState<string>('');
const [selectedSpecialFilter, setSelectedSpecialFilter] = useState<number | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
// Legacy Song-Library-Helper (Liste selbst ist obsolet; wir halten diese Werte nur, damit altes JSX nicht crasht)
const paginatedSongs: Song[] = [];
const totalPages = 1;
// Audio state (für Daily Puzzles)
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
// Daily Puzzles state
const [dailyPuzzles, setDailyPuzzles] = useState<any[]>([]);
const [playingPuzzleId, setPlayingPuzzleId] = useState<number | null>(null);
const [showDailyPuzzles, setShowDailyPuzzles] = useState(false);
const [showSpecials, setShowSpecials] = useState(false);
const [showGenres, setShowGenres] = useState(false);
const [showNews, setShowNews] = useState(false);
const [showPoliticalStatements, setShowPoliticalStatements] = useState(false);
const [politicalStatementsLocale, setPoliticalStatementsLocale] = useState<'de' | 'en'>('de');
const [politicalStatements, setPoliticalStatements] = useState<PoliticalStatement[]>([]);
const [newPoliticalStatementText, setNewPoliticalStatementText] = useState('');
const [newPoliticalStatementActive, setNewPoliticalStatementActive] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
// Curators state
const [curators, setCurators] = useState<Curator[]>([]);
const [showCurators, setShowCurators] = useState(false);
const [editingCuratorId, setEditingCuratorId] = useState<number | null>(null);
const [curatorUsername, setCuratorUsername] = useState('');
const [curatorPassword, setCuratorPassword] = useState('');
const [curatorIsGlobal, setCuratorIsGlobal] = useState(false);
const [curatorGenreIds, setCuratorGenreIds] = useState<number[]>([]);
const [curatorSpecialIds, setCuratorSpecialIds] = useState<number[]>([]);
// Check for existing auth on mount
useEffect(() => {
const authToken = localStorage.getItem('hoerdle_admin_auth');
if (authToken === 'authenticated') {
setIsAuthenticated(true);
fetchSongs();
fetchGenres();
fetchDailyPuzzles();
fetchSpecials();
fetchNews();
fetchCurators();
}
}, []);
const handleLogin = async () => {
const res = await fetch('/api/admin/login', {
method: 'POST',
body: JSON.stringify({ password }),
});
if (res.ok) {
localStorage.setItem('hoerdle_admin_auth', 'authenticated');
setIsAuthenticated(true);
fetchSongs();
fetchGenres();
fetchDailyPuzzles();
fetchSpecials();
fetchNews();
fetchCurators();
} else {
alert(t('wrongPassword'));
}
};
// Validate JSON for unlock steps
const validateUnlockSteps = (value: string): string | null => {
if (!value.trim()) {
return t('unlockStepsRequired');
}
try {
const parsed = JSON.parse(value);
if (!Array.isArray(parsed)) {
return t('unlockStepsMustBeArray');
}
if (parsed.some((item: any) => typeof item !== 'number' || item < 1)) {
return t('unlockStepsMustBePositiveNumbers');
}
return null;
} catch (e) {
return t('unlockStepsInvalidJson');
}
};
const handleLogout = () => {
localStorage.removeItem('hoerdle_admin_auth');
setIsAuthenticated(false);
setPassword('');
// Reset all state
setSongs([]);
setGenres([]);
setSpecials([]);
setDailyPuzzles([]);
setCurators([]);
};
// Helper function to add auth headers to requests
const getAuthHeaders = () => {
const authToken = localStorage.getItem('hoerdle_admin_auth');
return {
'Content-Type': 'application/json',
'x-admin-auth': authToken || ''
};
};
const fetchSongs = async () => {
const res = await fetch('/api/songs', {
headers: getAuthHeaders()
});
if (res.ok) {
const data = await res.json();
setSongs(data);
}
};
const fetchCurators = async () => {
const res = await fetch('/api/curators', {
headers: getAuthHeaders()
});
if (res.ok) {
const data = await res.json();
setCurators(data);
}
};
const fetchGenres = async () => {
const res = await fetch('/api/genres', {
headers: getAuthHeaders()
});
if (res.ok) {
const data = await res.json();
setGenres(data);
}
};
const createGenre = async () => {
if (!newGenreName.de.trim() && !newGenreName.en.trim()) return;
const res = await fetch('/api/genres', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
name: newGenreName,
subtitle: newGenreSubtitle,
active: newGenreActive
}),
});
if (res.ok) {
setNewGenreName({ de: '', en: '' });
setNewGenreSubtitle({ de: '', en: '' });
setNewGenreActive(true);
fetchGenres();
} else {
alert('Failed to create genre');
}
};
const startEditGenre = (genre: Genre) => {
setEditingGenreId(genre.id);
setEditGenreName(typeof genre.name === 'string' ? { de: genre.name, en: genre.name } : genre.name);
setEditGenreSubtitle(genre.subtitle ? (typeof genre.subtitle === 'string' ? { de: genre.subtitle, en: genre.subtitle } : genre.subtitle) : { de: '', en: '' });
setEditGenreActive(genre.active !== undefined ? genre.active : true);
};
const saveEditedGenre = async () => {
if (editingGenreId === null) return;
const res = await fetch('/api/genres', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({
id: editingGenreId,
name: editGenreName,
subtitle: editGenreSubtitle,
active: editGenreActive
}),
});
if (res.ok) {
setEditingGenreId(null);
fetchGenres();
} else {
alert('Failed to update genre');
}
};
// Specials functions
const fetchSpecials = async () => {
const res = await fetch('/api/specials', {
headers: getAuthHeaders()
});
if (res.ok) {
const data = await res.json();
setSpecials(data);
}
};
const handleCreateSpecial = async (e: React.FormEvent) => {
e.preventDefault();
if (!newSpecialName.de.trim() && !newSpecialName.en.trim()) return;
// Validate unlock steps
const unlockStepsError = validateUnlockSteps(newSpecialUnlockSteps);
if (unlockStepsError) {
setNewSpecialUnlockStepsError(unlockStepsError);
return;
}
setNewSpecialUnlockStepsError(null);
const res = await fetch('/api/specials', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
name: newSpecialName,
subtitle: newSpecialSubtitle,
maxAttempts: newSpecialMaxAttempts,
unlockSteps: newSpecialUnlockSteps,
launchDate: newSpecialLaunchDate || null,
endDate: newSpecialEndDate || null,
curator: newSpecialCurator || null,
hidden: newSpecialHidden,
}),
});
if (res.ok) {
setNewSpecialName({ de: '', en: '' });
setNewSpecialSubtitle({ de: '', en: '' });
setNewSpecialMaxAttempts(7);
setNewSpecialUnlockSteps('[2,4,7,11,16,30,60]');
setNewSpecialUnlockStepsError(null);
setNewSpecialLaunchDate('');
setNewSpecialEndDate('');
setNewSpecialCurator('');
setNewSpecialHidden(false);
fetchSpecials();
} else {
const errorData = await res.json().catch(() => ({}));
alert(errorData.error || 'Failed to create special');
}
};
const handleDeleteSpecial = async (id: number) => {
if (!confirm('Delete this special?')) return;
const res = await fetch('/api/specials', {
method: 'DELETE',
headers: getAuthHeaders(),
body: JSON.stringify({ id }),
});
if (res.ok) fetchSpecials();
else alert('Failed to delete special');
};
// Daily Puzzles functions
const fetchDailyPuzzles = async () => {
const res = await fetch('/api/admin/daily-puzzles', {
headers: getAuthHeaders()
});
if (res.ok) {
const data = await res.json();
setDailyPuzzles(data);
}
};
const handleDeletePuzzle = async (puzzleId: number) => {
if (!confirm('Delete this daily puzzle? A new one will be generated automatically.')) return;
const res = await fetch('/api/admin/daily-puzzles', {
method: 'DELETE',
headers: getAuthHeaders(),
body: JSON.stringify({ puzzleId }),
});
if (res.ok) {
fetchDailyPuzzles();
alert('Puzzle deleted and regenerated successfully');
} else {
alert('Failed to delete puzzle');
}
};
const handlePlayPuzzle = (puzzle: any) => {
if (playingPuzzleId === puzzle.id) {
// Pause
audioElement?.pause();
setPlayingPuzzleId(null);
setAudioElement(null);
} else {
// Stop any currently playing audio
if (audioElement) {
audioElement.pause();
setAudioElement(null);
}
const audio = new Audio(puzzle.song.audioUrl);
audio.play()
.then(() => {
setAudioElement(audio);
setPlayingPuzzleId(puzzle.id);
})
.catch((error) => {
console.error('Playback error:', error);
alert(`Failed to play audio: ${error.message}`);
setPlayingPuzzleId(null);
setAudioElement(null);
});
audio.onended = () => {
setPlayingPuzzleId(null);
setAudioElement(null);
};
}
};
const startEditSpecial = (special: Special) => {
setEditingSpecialId(special.id);
setEditSpecialName(typeof special.name === 'string' ? { de: special.name, en: special.name } : special.name);
setEditSpecialSubtitle(special.subtitle ? (typeof special.subtitle === 'string' ? { de: special.subtitle, en: special.subtitle } : special.subtitle) : { de: '', en: '' });
setEditSpecialMaxAttempts(special.maxAttempts);
setEditSpecialUnlockSteps(special.unlockSteps);
setEditSpecialUnlockStepsError(null);
setEditSpecialLaunchDate(special.launchDate ? new Date(special.launchDate).toISOString().split('T')[0] : '');
setEditSpecialEndDate(special.endDate ? new Date(special.endDate).toISOString().split('T')[0] : '');
setEditSpecialCurator(special.curator || '');
setEditSpecialHidden(special.hidden || false);
};
const saveEditedSpecial = async () => {
if (editingSpecialId === null) return;
// Validate unlock steps
const unlockStepsError = validateUnlockSteps(editSpecialUnlockSteps);
if (unlockStepsError) {
setEditSpecialUnlockStepsError(unlockStepsError);
return;
}
setEditSpecialUnlockStepsError(null);
const res = await fetch('/api/specials', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({
id: editingSpecialId,
name: editSpecialName,
subtitle: editSpecialSubtitle,
maxAttempts: editSpecialMaxAttempts,
unlockSteps: editSpecialUnlockSteps,
launchDate: editSpecialLaunchDate || null,
endDate: editSpecialEndDate || null,
curator: editSpecialCurator || null,
hidden: editSpecialHidden,
}),
});
if (res.ok) {
setEditingSpecialId(null);
setEditSpecialUnlockStepsError(null);
fetchSpecials();
} else {
const errorData = await res.json().catch(() => ({}));
alert(errorData.error || 'Failed to update special');
}
};
// News functions
const fetchNews = async () => {
const res = await fetch('/api/news', {
headers: getAuthHeaders()
});
if (res.ok) {
const data = await res.json();
setNews(data);
}
};
// Political Statements functions (JSON-backed via API)
const fetchPoliticalStatements = async (targetLocale: 'de' | 'en') => {
const res = await fetch(`/api/political-statements?locale=${encodeURIComponent(targetLocale)}&admin=true`, {
headers: getAuthHeaders(),
});
if (res.ok) {
const data = await res.json();
const enriched: PoliticalStatement[] = data.map((s: any) => ({
id: s.id,
text: s.text,
active: s.active !== false,
source: s.source,
locale: targetLocale,
}));
setPoliticalStatements(prev => {
const others = prev.filter(p => p.locale !== targetLocale);
return [...others, ...enriched];
});
}
};
const handleCreatePoliticalStatement = async (e: React.FormEvent) => {
e.preventDefault();
if (!newPoliticalStatementText.trim()) return;
const res = await fetch('/api/political-statements', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
locale: politicalStatementsLocale,
text: newPoliticalStatementText.trim(),
active: newPoliticalStatementActive,
}),
});
if (res.ok) {
setNewPoliticalStatementText('');
setNewPoliticalStatementActive(true);
fetchPoliticalStatements(politicalStatementsLocale);
} else {
alert('Failed to create statement');
}
};
const handleEditPoliticalStatementText = (locale: string, id: number, text: string) => {
setPoliticalStatements(prev =>
prev.map(s => (s.locale === locale && s.id === id ? { ...s, text } : s)),
);
};
const handleEditPoliticalStatementActive = (locale: string, id: number, active: boolean) => {
setPoliticalStatements(prev =>
prev.map(s => (s.locale === locale && s.id === id ? { ...s, active } : s)),
);
};
const handleSavePoliticalStatement = async (locale: string, id: number) => {
const stmt = politicalStatements.find(s => s.locale === locale && s.id === id);
if (!stmt) return;
const res = await fetch('/api/political-statements', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({
locale,
id,
text: stmt.text,
active: stmt.active !== false,
source: stmt.source,
}),
});
if (!res.ok) {
alert('Failed to save statement');
fetchPoliticalStatements(locale as 'de' | 'en');
}
};
const handleDeletePoliticalStatement = async (locale: string, id: number) => {
if (!confirm('Delete this statement?')) return;
const res = await fetch('/api/political-statements', {
method: 'DELETE',
headers: getAuthHeaders(),
body: JSON.stringify({ locale, id }),
});
if (res.ok) {
setPoliticalStatements(prev => prev.filter(s => !(s.locale === locale && s.id === id)));
} else {
alert('Failed to delete statement');
}
};
const handleCreateNews = async (e: React.FormEvent) => {
e.preventDefault();
if ((!newNewsTitle.de.trim() && !newNewsTitle.en.trim()) || (!newNewsContent.de.trim() && !newNewsContent.en.trim())) return;
const res = await fetch('/api/news', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
title: newNewsTitle,
content: newNewsContent,
author: newNewsAuthor || null,
featured: newNewsFeatured,
specialId: newNewsSpecialId
}),
});
if (res.ok) {
setNewNewsTitle({ de: '', en: '' });
setNewNewsContent({ de: '', en: '' });
setNewNewsAuthor('');
setNewNewsFeatured(false);
setNewNewsSpecialId(null);
fetchNews();
} else {
alert('Failed to create news');
}
};
const startEditNews = (newsItem: News) => {
setEditingNewsId(newsItem.id);
setEditNewsTitle(typeof newsItem.title === 'string' ? { de: newsItem.title, en: newsItem.title } : newsItem.title);
setEditNewsContent(typeof newsItem.content === 'string' ? { de: newsItem.content, en: newsItem.content } : newsItem.content);
setEditNewsAuthor(newsItem.author || '');
setEditNewsFeatured(newsItem.featured);
setEditNewsSpecialId(newsItem.specialId);
};
const saveEditedNews = async () => {
if (editingNewsId === null) return;
const res = await fetch('/api/news', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({
id: editingNewsId,
title: editNewsTitle,
content: editNewsContent,
author: editNewsAuthor || null,
featured: editNewsFeatured,
specialId: editNewsSpecialId
}),
});
if (res.ok) {
setEditingNewsId(null);
fetchNews();
} else {
alert('Failed to update news');
}
};
const handleDeleteNews = async (id: number) => {
if (!confirm('Delete this news item?')) return;
const res = await fetch('/api/news', {
method: 'DELETE',
headers: getAuthHeaders(),
body: JSON.stringify({ id }),
});
if (res.ok) {
fetchNews();
} else {
alert('Failed to delete news');
}
};
// Load specials and political statements after auth
useEffect(() => {
if (isAuthenticated) {
fetchSpecials();
fetchPoliticalStatements('de');
fetchPoliticalStatements('en');
}
}, [isAuthenticated]);
const deleteGenre = async (id: number) => {
if (!confirm('Delete this genre?')) return;
const res = await fetch('/api/genres', {
method: 'DELETE',
headers: getAuthHeaders(),
body: JSON.stringify({ id }),
});
if (res.ok) {
fetchGenres();
} else {
alert('Failed to delete genre');
}
};
const handleAICategorization = async () => {
if (!confirm('This will categorize all songs without genres using AI. Continue?')) return;
setIsCategorizing(true);
setCategorizationResults(null);
try {
let offset = 0;
let hasMore = true;
let allResults: any[] = [];
let totalUncategorized = 0;
let totalProcessed = 0;
// Process in batches
while (hasMore) {
const res = await fetch('/api/categorize', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ offset })
});
if (!res.ok) {
const error = await res.json();
alert(`Categorization failed: ${error.error || 'Unknown error'}`);
break;
}
const data = await res.json();
totalUncategorized = data.totalUncategorized;
totalProcessed = data.processed;
hasMore = data.hasMore;
offset = data.nextOffset || 0;
// Accumulate results
allResults = [...allResults, ...data.results];
// Update UI with progress
setCategorizationResults({
message: `Processing: ${totalProcessed} / ${totalUncategorized} songs...`,
totalProcessed: totalUncategorized,
totalCategorized: allResults.length,
results: allResults,
inProgress: hasMore
});
}
// Final update
setCategorizationResults({
message: `Completed! Processed ${totalUncategorized} songs, categorized ${allResults.length}`,
totalProcessed: totalUncategorized,
totalCategorized: allResults.length,
results: allResults,
inProgress: false
});
fetchSongs(); // Refresh song list
fetchGenres(); // Refresh genre counts
} catch (error) {
alert('Failed to categorize songs');
console.error(error);
} finally {
setIsCategorizing(false);
}
};
const resetCuratorForm = () => {
setEditingCuratorId(null);
setCuratorUsername('');
setCuratorPassword('');
setCuratorIsGlobal(false);
setCuratorGenreIds([]);
setCuratorSpecialIds([]);
};
const startEditCurator = (curator: Curator) => {
setEditingCuratorId(curator.id);
setCuratorUsername(curator.username);
setCuratorPassword('');
setCuratorIsGlobal(curator.isGlobalCurator);
setCuratorGenreIds(curator.genreIds || []);
setCuratorSpecialIds(curator.specialIds || []);
};
const toggleCuratorGenre = (genreId: number) => {
setCuratorGenreIds(prev =>
prev.includes(genreId) ? prev.filter(id => id !== genreId) : [...prev, genreId]
);
};
const toggleCuratorSpecial = (specialId: number) => {
setCuratorSpecialIds(prev =>
prev.includes(specialId) ? prev.filter(id => id !== specialId) : [...prev, specialId]
);
};
const handleSaveCurator = async (e: React.FormEvent) => {
e.preventDefault();
if (!curatorUsername.trim()) {
alert('Bitte einen Benutzernamen eingeben.');
return;
}
// Beim Anlegen eines neuen Kurators ist ein Passwort Pflicht.
if (!editingCuratorId && !curatorPassword.trim()) {
alert('Für neue Kuratoren muss ein Passwort gesetzt werden.');
return;
}
const payload: any = {
username: curatorUsername.trim(),
isGlobalCurator: curatorIsGlobal,
genreIds: curatorGenreIds,
specialIds: curatorSpecialIds,
};
if (curatorPassword.trim()) {
payload.password = curatorPassword;
}
const url = '/api/curators';
const method = editingCuratorId ? 'PUT' : 'POST';
if (editingCuratorId) {
payload.id = editingCuratorId;
}
const res = await fetch(url, {
method,
headers: getAuthHeaders(),
body: JSON.stringify(payload),
});
if (res.ok) {
resetCuratorForm();
fetchCurators();
} else {
alert('Failed to save curator');
}
};
const handleDeleteCurator = async (id: number) => {
if (!confirm('Kurator wirklich löschen?')) return;
const res = await fetch('/api/curators', {
method: 'DELETE',
headers: getAuthHeaders(),
body: JSON.stringify({ id }),
});
if (res.ok) {
fetchCurators();
} else {
alert('Failed to delete curator');
}
};
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 = [];
// Upload files sequentially to avoid timeout
for (let i = 0; i < files.length; i++) {
const file = files[i];
setUploadProgress({ current: i + 1, total: files.length });
try {
console.log(`Uploading file ${i + 1}/${files.length}: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB)`);
const formData = new FormData();
formData.append('file', file);
formData.append('excludeFromGlobal', String(uploadExcludeFromGlobal));
const res = await fetch('/api/songs', {
method: 'POST',
headers: { 'x-admin-auth': localStorage.getItem('hoerdle_admin_auth') || '' },
body: formData,
});
console.log(`Response status for ${file.name}: ${res.status}`);
if (res.ok) {
const data = await res.json();
console.log(`Upload successful for ${file.name}:`, data);
results.push({
filename: file.name,
success: true,
song: data.song,
validation: data.validation
});
} else if (res.status === 409) {
// Duplicate detected
const data = await res.json();
console.log(`Duplicate detected for ${file.name}:`, data);
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();
console.error(`Upload failed for ${file.name} (${res.status}):`, errorText);
results.push({
filename: file.name,
success: false,
error: `Upload failed (${res.status}): ${errorText.substring(0, 100)}`
});
}
} catch (error) {
console.error(`Network error for ${file.name}:`, error);
results.push({
filename: file.name,
success: false,
error: `Network error: ${error instanceof Error ? error.message : 'Unknown error'}`
});
}
}
setUploadResults(results);
setFiles([]);
setIsUploading(false);
// Assign genres to successfully uploaded songs
if (batchUploadGenreIds.length > 0) {
const successfulUploads = results.filter(r => r.success && r.song);
for (const result of successfulUploads) {
try {
await fetch('/api/songs', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({
id: result.song.id,
title: result.song.title,
artist: result.song.artist,
genreIds: batchUploadGenreIds
}),
});
} catch (error) {
console.error(`Failed to assign genres to ${result.song.title}:`, error);
}
}
}
fetchSongs();
fetchGenres();
fetchSpecials(); // Update special counts
// Auto-trigger categorization after uploads
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;
if (successCount > 0) {
let msg = `✅ Uploaded ${successCount}/${files.length} songs successfully!`;
if (duplicateCount > 0) {
msg += `\n⚠ Skipped ${duplicateCount} duplicate(s)`;
}
if (failedCount > 0) {
msg += `\n❌ ${failedCount} failed`;
}
if (batchUploadGenreIds.length > 0) {
const selectedGenreNames = genres
.filter(g => batchUploadGenreIds.includes(g.id))
.map(g => getLocalizedValue(g.name, activeTab))
.join(', ');
msg += `\n🏷 Assigned genres: ${selectedGenreNames}`;
}
msg += '\n\n🤖 Starting auto-categorization...';
setMessage(msg);
// Small delay to let user see the message
setTimeout(() => {
handleAICategorization();
}, 1000);
} else if (duplicateCount > 0 && failedCount === 0) {
setMessage(`⚠️ All ${duplicateCount} file(s) were duplicates - nothing uploaded.`);
} else {
setMessage(`❌ All uploads failed.`);
}
};
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
if (!isDragging) setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Prevent flickering when dragging over children
if (e.currentTarget.contains(e.relatedTarget as Node)) {
return;
}
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files);
// Validate file types
const validFiles: File[] = [];
const invalidFiles: string[] = [];
droppedFiles.forEach(file => {
if (file.type === 'audio/mpeg' || file.name.toLowerCase().endsWith('.mp3')) {
validFiles.push(file);
} else {
invalidFiles.push(`${file.name} (${file.type || 'unknown type'})`);
}
});
if (invalidFiles.length > 0) {
alert(`⚠️ The following files are not supported:\n\n${invalidFiles.join('\n')}\n\nOnly MP3 files are allowed.`);
}
if (validFiles.length > 0) {
setFiles(validFiles);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files);
// Validate file types
const validFiles: File[] = [];
const invalidFiles: string[] = [];
selectedFiles.forEach(file => {
if (file.type === 'audio/mpeg' || file.name.toLowerCase().endsWith('.mp3')) {
validFiles.push(file);
} else {
invalidFiles.push(`${file.name} (${file.type || 'unknown type'})`);
}
});
if (invalidFiles.length > 0) {
alert(`⚠️ The following files are not supported:\n\n${invalidFiles.join('\n')}\n\nOnly MP3 files are allowed.`);
}
if (validFiles.length > 0) {
setFiles(validFiles);
}
}
};
const saveUploadedSongGenres = async () => {
if (!uploadedSong) return;
const res = await fetch('/api/songs', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({
id: uploadedSong.id,
title: uploadedSong.title,
artist: uploadedSong.artist,
genreIds: uploadGenreIds
}),
});
if (res.ok) {
setUploadedSong(null);
setUploadGenreIds([]);
fetchSongs();
fetchGenres();
fetchSpecials(); // Update special counts if song was assigned to specials
setMessage(prev => prev + '\n✅ Genres assigned successfully!');
} else {
alert('Failed to assign genres');
}
};
const startEditing = (song: Song) => {
setEditingId(song.id);
setEditTitle(song.title);
setEditArtist(song.artist);
setEditReleaseYear(song.releaseYear || '');
setEditGenreIds(song.genres.map(g => g.id));
setEditSpecialIds(song.specials ? song.specials.map(s => s.id) : []);
setEditExcludeFromGlobal(song.excludeFromGlobal || false);
};
const cancelEditing = () => {
setEditingId(null);
setEditTitle('');
setEditArtist('');
setEditReleaseYear('');
setEditGenreIds([]);
setEditSpecialIds([]);
setEditExcludeFromGlobal(false);
};
const saveEditing = async (id: number) => {
const res = await fetch('/api/songs', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({
id,
title: editTitle,
artist: editArtist,
releaseYear: editReleaseYear === '' ? null : Number(editReleaseYear),
genreIds: editGenreIds,
specialIds: editSpecialIds,
excludeFromGlobal: editExcludeFromGlobal
}),
});
if (res.ok) {
setEditingId(null);
fetchSongs();
fetchGenres();
fetchSpecials(); // Update special counts
} else {
alert('Failed to update song');
}
};
const handleDelete = async (id: number, title: string) => {
if (!confirm(`Are you sure you want to delete "${title}"? This will also delete the file.`)) {
return;
}
const res = await fetch('/api/songs', {
method: 'DELETE',
headers: getAuthHeaders(),
body: JSON.stringify({ id }),
});
if (res.ok) {
fetchSongs();
fetchGenres();
fetchSpecials(); // Update special counts
} else {
alert('Failed to delete song');
}
};
const handlePlayPause = (song: Song) => {
if (playingSongId === song.id) {
// Pause current song
audioElement?.pause();
setPlayingSongId(null);
} else {
// Stop any currently playing song
audioElement?.pause();
// Play new song
const audio = new Audio(`/api/audio/${song.filename}`);
// Handle playback errors
audio.onerror = () => {
alert(`Failed to load audio file: ${song.filename}\nThe file may be corrupted or missing.`);
setPlayingSongId(null);
setAudioElement(null);
};
audio.play()
.then(() => {
setAudioElement(audio);
setPlayingSongId(song.id);
})
.catch((error) => {
console.error('Playback error:', error);
alert(`Failed to play audio: ${error.message}`);
setPlayingSongId(null);
setAudioElement(null);
});
// Reset when song ends
audio.onended = () => {
setPlayingSongId(null);
setAudioElement(null);
};
}
};
// Song Library ist in das Kuratoren-Dashboard umgezogen, daher keine Song-Filter/Pagination mehr im Admin nötig.
if (!isAuthenticated) {
return (
<div className="container" style={{ justifyContent: 'center' }}>
<h1 className="title" style={{ marginBottom: '1rem', fontSize: '1.5rem' }}>{t('login')}</h1>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="form-input"
style={{ marginBottom: '1rem', maxWidth: '300px' }}
placeholder={t('password')}
/>
<button onClick={handleLogin} className="btn-primary">{t('loginButton')}</button>
</div>
);
}
return (
<div className="admin-container">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h1 className="title" style={{ margin: 0 }}>{t('title')}</h1>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<div style={{ display: 'flex', background: '#e5e7eb', borderRadius: '0.25rem', padding: '0.25rem' }}>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setActiveTab('de');
}}
style={{
padding: '0.25rem 0.75rem',
background: activeTab === 'de' ? 'white' : 'transparent',
borderRadius: '0.25rem',
border: 'none',
cursor: 'pointer',
fontWeight: activeTab === 'de' ? 'bold' : 'normal',
boxShadow: activeTab === 'de' ? '0 1px 2px rgba(0,0,0,0.1)' : 'none'
}}
>
DE
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setActiveTab('en');
}}
style={{
padding: '0.25rem 0.75rem',
background: activeTab === 'en' ? 'white' : 'transparent',
borderRadius: '0.25rem',
border: 'none',
cursor: 'pointer',
fontWeight: activeTab === 'en' ? 'bold' : 'normal',
boxShadow: activeTab === 'en' ? '0 1px 2px rgba(0,0,0,0.1)' : 'none'
}}
>
EN
</button>
</div>
<button
onClick={handleLogout}
className="btn-secondary"
style={{
padding: '0.5rem 1rem',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.9rem'
}}
>
🚪 {t('logout')}
</button>
</div>
</div>
{/* Special Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
{t('manageSpecials')}
</h2>
<button
onClick={() => setShowSpecials(!showSpecials)}
style={{
padding: '0.5rem 1rem',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.875rem'
}}
>
{showSpecials ? t('hide') : t('show')}
</button>
</div>
{showSpecials && (
<>
<form onSubmit={handleCreateSpecial} style={{ marginBottom: '1rem' }} key={`special-form-${activeTab}`}>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>{t('name')}</label>
<input type="text" placeholder={t('name')} value={newSpecialName[activeTab] || ''} onChange={e => setNewSpecialName({ ...newSpecialName, [activeTab]: e.target.value })} className="form-input" required key={`special-name-${activeTab}`} />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>{t('subtitle')}</label>
<input type="text" placeholder={t('subtitle')} value={newSpecialSubtitle[activeTab] || ''} onChange={e => setNewSpecialSubtitle({ ...newSpecialSubtitle, [activeTab]: e.target.value })} className="form-input" key={`special-subtitle-${activeTab}`} />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>{t('maxAttempts')}</label>
<input type="number" placeholder={t('maxAttempts')} value={newSpecialMaxAttempts} onChange={e => setNewSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} style={{ width: '80px' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666', display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
{t('unlockSteps')}
{newSpecialUnlockStepsError && (
<span
title={newSpecialUnlockStepsError}
style={{
color: '#ef4444',
cursor: 'help',
fontSize: '0.875rem'
}}
>
</span>
)}
</label>
<input
type="text"
placeholder={t('unlockSteps')}
value={newSpecialUnlockSteps}
onChange={e => {
const value = e.target.value;
setNewSpecialUnlockSteps(value);
const error = validateUnlockSteps(value);
setNewSpecialUnlockStepsError(error);
}}
className="form-input"
title={newSpecialUnlockStepsError || undefined}
style={{
width: '200px',
borderColor: newSpecialUnlockStepsError ? '#ef4444' : undefined
}}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>{t('launchDate')}</label>
<input type="date" value={newSpecialLaunchDate} onChange={e => setNewSpecialLaunchDate(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>{t('endDate')}</label>
<input type="date" value={newSpecialEndDate} onChange={e => setNewSpecialEndDate(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>{t('curator')}</label>
<input type="text" placeholder={t('curator')} value={newSpecialCurator} onChange={e => setNewSpecialCurator(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666', visibility: 'hidden' }}>Hidden</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', height: '38px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={newSpecialHidden}
onChange={e => setNewSpecialHidden(e.target.checked)}
style={{ width: '1rem', height: '1rem' }}
/>
Hidden
</label>
</div>
<button
type="submit"
className="btn-primary"
style={{
height: '38px',
opacity: newSpecialUnlockStepsError ? 0.5 : 1,
cursor: newSpecialUnlockStepsError ? 'not-allowed' : 'pointer'
}}
disabled={!!newSpecialUnlockStepsError}
>
{t('addSpecial')}
</button>
</div>
</form>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{specials.map(special => (
<div
key={special.id}
style={{
background: '#f3f4f6',
padding: '0.25rem 0.75rem',
borderRadius: '999px',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
fontSize: '0.875rem',
}}
>
<span>
{special.hidden && <span title="Hidden from navigation">👁🗨</span>} {getLocalizedValue(special.name, activeTab)} ({special._count?.songs || 0})
</span>
{special.subtitle && (
<span
style={{
fontSize: '0.75rem',
color: '#666',
marginLeft: '0.25rem',
}}
>
- {getLocalizedValue(special.subtitle, activeTab)}
</span>
)}
<button
onClick={() => startEditSpecial(special)}
className="btn-secondary"
style={{ marginRight: '0.5rem' }}
>
{t('edit')}
</button>
<button
onClick={() => handleDeleteSpecial(special.id)}
className="btn-danger"
>
{t('delete')}
</button>
</div>
))}
</div>
{editingSpecialId !== null && (
<div style={{ marginTop: '1rem', padding: '1rem', background: '#f9fafb', borderRadius: '0.5rem' }}>
<h3>{t('editSpecial')}</h3>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>{t('name')}</label>
<input type="text" value={editSpecialName[activeTab] || ''} onChange={e => setEditSpecialName({ ...editSpecialName, [activeTab]: e.target.value })} className="form-input" key={`edit-special-name-${activeTab}`} />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>{t('subtitle')}</label>
<input type="text" value={editSpecialSubtitle[activeTab] || ''} onChange={e => setEditSpecialSubtitle({ ...editSpecialSubtitle, [activeTab]: e.target.value })} className="form-input" key={`edit-special-subtitle-${activeTab}`} />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>{t('maxAttempts')}</label>
<input type="number" value={editSpecialMaxAttempts} onChange={e => setEditSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} style={{ width: '80px' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666', display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
{t('unlockSteps')}
{editSpecialUnlockStepsError && (
<span
title={editSpecialUnlockStepsError}
style={{
color: '#ef4444',
cursor: 'help',
fontSize: '0.875rem'
}}
>
</span>
)}
</label>
<input
type="text"
value={editSpecialUnlockSteps}
onChange={e => {
const value = e.target.value;
setEditSpecialUnlockSteps(value);
const error = validateUnlockSteps(value);
setEditSpecialUnlockStepsError(error);
}}
className="form-input"
title={editSpecialUnlockStepsError || undefined}
style={{
width: '200px',
borderColor: editSpecialUnlockStepsError ? '#ef4444' : undefined
}}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>{t('launchDate')}</label>
<input type="date" value={editSpecialLaunchDate} onChange={e => setEditSpecialLaunchDate(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>{t('endDate')}</label>
<input type="date" value={editSpecialEndDate} onChange={e => setEditSpecialEndDate(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>{t('curator')}</label>
<input type="text" value={editSpecialCurator} onChange={e => setEditSpecialCurator(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666', visibility: 'hidden' }}>Hidden</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', height: '38px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={editSpecialHidden}
onChange={e => setEditSpecialHidden(e.target.checked)}
style={{ width: '1rem', height: '1rem' }}
/>
Hidden
</label>
</div>
<button
onClick={saveEditedSpecial}
className="btn-primary"
style={{
height: '38px',
opacity: editSpecialUnlockStepsError ? 0.5 : 1,
cursor: editSpecialUnlockStepsError ? 'not-allowed' : 'pointer'
}}
disabled={!!editSpecialUnlockStepsError}
>
{t('save')}
</button>
<button onClick={() => setEditingSpecialId(null)} className="btn-secondary" style={{ height: '38px' }}>{t('cancel')}</button>
</div>
</div>
)}
</>
)}
</div>
{/* Genre Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
{t('manageGenres')}
</h2>
<button
onClick={() => setShowGenres(!showGenres)}
style={{
padding: '0.5rem 1rem',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.875rem'
}}
>
{showGenres ? t('hide') : t('show')}
</button>
</div>
{showGenres && (
<>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', alignItems: 'center' }}>
<input
type="text"
value={newGenreName[activeTab] || ''}
onChange={e => setNewGenreName({ ...newGenreName, [activeTab]: e.target.value })}
placeholder={t('newGenreName')}
className="form-input"
style={{ maxWidth: '200px' }}
key={`genre-name-${activeTab}`}
/>
<input
type="text"
value={newGenreSubtitle[activeTab] || ''}
onChange={e => setNewGenreSubtitle({ ...newGenreSubtitle, [activeTab]: e.target.value })}
placeholder={t('subtitle')}
className="form-input"
style={{ maxWidth: '300px' }}
key={`genre-subtitle-${activeTab}`}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={newGenreActive}
onChange={e => setNewGenreActive(e.target.checked)}
/>
{t('active')}
</label>
<button onClick={createGenre} className="btn-primary">{t('addGenre')}</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{genres.map(genre => (
<div key={genre.id} style={{
background: genre.active ? '#f3f4f6' : '#fee2e2',
opacity: genre.active ? 1 : 0.8,
padding: '0.25rem 0.75rem',
borderRadius: '999px',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
fontSize: '0.875rem'
}}>
<span>{getLocalizedValue(genre.name, activeTab)} ({genre._count?.songs || 0})</span>
{genre.subtitle && <span style={{ fontSize: '0.75rem', color: '#666' }}>- {getLocalizedValue(genre.subtitle, activeTab)}</span>}
<button onClick={() => startEditGenre(genre)} className="btn-secondary" style={{ padding: '0.1rem 0.5rem', fontSize: '0.75rem' }}>{t('edit')}</button>
<button onClick={() => deleteGenre(genre.id)} className="btn-danger" style={{ padding: '0.1rem 0.5rem', fontSize: '0.75rem' }}>×</button>
</div>
))}
</div>
{editingGenreId !== null && (
<div style={{ marginTop: '1rem', padding: '1rem', background: '#f9fafb', borderRadius: '0.5rem' }}>
<h3>{t('editGenre')}</h3>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end' }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>{t('name')}</label>
<input type="text" value={editGenreName[activeTab] || ''} onChange={e => setEditGenreName({ ...editGenreName, [activeTab]: e.target.value })} className="form-input" key={`edit-genre-name-${activeTab}`} />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>{t('subtitle')}</label>
<input type="text" value={editGenreSubtitle[activeTab] || ''} onChange={e => setEditGenreSubtitle({ ...editGenreSubtitle, [activeTab]: e.target.value })} className="form-input" style={{ width: '300px' }} key={`edit-genre-subtitle-${activeTab}`} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', paddingBottom: '0.5rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={editGenreActive}
onChange={e => setEditGenreActive(e.target.checked)}
/>
{t('active')}
</label>
</div>
<button onClick={saveEditedGenre} className="btn-primary">{t('save')}</button>
<button onClick={() => setEditingGenreId(null)} className="btn-secondary">{t('cancel')}</button>
</div>
</div>
)}
{/* AI Categorization */}
<div style={{ marginTop: '1.5rem', paddingTop: '1rem', borderTop: '1px solid #e5e7eb' }}>
<button
onClick={handleAICategorization}
disabled={isCategorizing || genres.length === 0}
className="btn-primary"
style={{
opacity: isCategorizing || genres.length === 0 ? 0.5 : 1,
cursor: isCategorizing || genres.length === 0 ? 'not-allowed' : 'pointer'
}}
>
{isCategorizing ? '🤖 Categorizing...' : '🤖 Auto-Categorize Songs with AI'}
</button>
{genres.length === 0 && (
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
Please create at least one genre first.
</p>
)}
</div>
{/* Categorization Results */}
{categorizationResults && (
<div style={{
marginTop: '1rem',
padding: '1rem',
background: '#f0fdf4',
border: '1px solid #86efac',
borderRadius: '0.5rem'
}}>
<h3 style={{ fontWeight: 'bold', marginBottom: '0.5rem', color: '#166534' }}>
Categorization Complete
</h3>
<p style={{ fontSize: '0.875rem', marginBottom: '0.5rem' }}>
{categorizationResults.message}
</p>
{categorizationResults.results && categorizationResults.results.length > 0 && (
<div style={{ marginTop: '1rem' }}>
<p style={{ fontWeight: 'bold', fontSize: '0.875rem', marginBottom: '0.5rem' }}>Updated Songs:</p>
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
{categorizationResults.results.map((result: any) => (
<div key={result.songId} style={{
padding: '0.5rem',
background: 'white',
borderRadius: '0.25rem',
marginBottom: '0.5rem',
fontSize: '0.875rem'
}}>
<strong>{result.title}</strong> by {result.artist}
<div style={{ display: 'flex', gap: '0.25rem', marginTop: '0.25rem', flexWrap: 'wrap' }}>
{result.assignedGenres.map((genre: string) => (
<span key={genre} style={{
background: '#dbeafe',
padding: '0.1rem 0.4rem',
borderRadius: '0.25rem',
fontSize: '0.75rem'
}}>
{genre}
</span>
))}
</div>
</div>
))}
</div>
</div>
)}
<button
onClick={() => setCategorizationResults(null)}
style={{
marginTop: '1rem',
padding: '0.5rem 1rem',
background: 'white',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: 'pointer'
}}
>
Close
</button>
</div>
)}
</>
)}
</div>
{/* News Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
{t('manageNews')}
</h2>
<button
onClick={() => setShowNews(!showNews)}
style={{
padding: '0.5rem 1rem',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.875rem'
}}
>
{showNews ? t('hide') : t('show')}
</button>
</div>
{showNews && (
<>
<form onSubmit={handleCreateNews} style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<input
type="text"
value={newNewsTitle[activeTab] || ''}
onChange={e => setNewNewsTitle({ ...newNewsTitle, [activeTab]: e.target.value })}
placeholder={t('newsTitle')}
className="form-input"
required
key={`news-title-${activeTab}`}
/>
<textarea
value={newNewsContent[activeTab] || ''}
onChange={e => setNewNewsContent({ ...newNewsContent, [activeTab]: e.target.value })}
placeholder={t('content')}
className="form-input"
rows={4}
required
style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}
key={`news-content-${activeTab}`}
/>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="text"
value={newNewsAuthor}
onChange={e => setNewNewsAuthor(e.target.value)}
placeholder={t('author')}
className="form-input"
style={{ maxWidth: '200px' }}
/>
<select
value={newNewsSpecialId || ''}
onChange={e => setNewNewsSpecialId(e.target.value ? Number(e.target.value) : null)}
className="form-input"
style={{ maxWidth: '200px' }}
>
<option value="">{t('noSpecialLink')}</option>
{specials.map(s => (
<option key={s.id} value={s.id}>{getLocalizedValue(s.name, activeTab)}</option>
))}
</select>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={newNewsFeatured}
onChange={e => setNewNewsFeatured(e.target.checked)}
/>
{t('featured')}
</label>
<button type="submit" className="btn-primary">{t('addNews')}</button>
</div>
</div>
</form>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{news.map(newsItem => (
<div key={newsItem.id} style={{
background: newsItem.featured ? '#fef3c7' : '#f3f4f6',
padding: '0.75rem',
borderRadius: '0.5rem',
border: newsItem.featured ? '2px solid #f59e0b' : '1px solid #e5e7eb'
}}>
{editingNewsId === newsItem.id ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<input
type="text"
value={editNewsTitle[activeTab] || ''}
onChange={e => setEditNewsTitle({ ...editNewsTitle, [activeTab]: e.target.value })}
className="form-input"
key={`edit-news-title-${activeTab}`}
/>
<textarea
value={editNewsContent[activeTab] || ''}
onChange={e => setEditNewsContent({ ...editNewsContent, [activeTab]: e.target.value })}
className="form-input"
rows={4}
style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}
key={`edit-news-content-${activeTab}`}
/>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="text"
value={editNewsAuthor}
onChange={e => setEditNewsAuthor(e.target.value)}
placeholder={t('author')}
className="form-input"
style={{ maxWidth: '200px' }}
/>
<select
value={editNewsSpecialId || ''}
onChange={e => setEditNewsSpecialId(e.target.value ? Number(e.target.value) : null)}
className="form-input"
style={{ maxWidth: '200px' }}
>
<option value="">{t('noSpecialLink')}</option>
{specials.map(s => (
<option key={s.id} value={s.id}>{getLocalizedValue(s.name, activeTab)}</option>
))}
</select>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem' }}>
<input
type="checkbox"
checked={editNewsFeatured}
onChange={e => setEditNewsFeatured(e.target.checked)}
/>
{t('featured')}
</label>
<button onClick={saveEditedNews} className="btn-primary">{t('save')}</button>
<button onClick={() => setEditingNewsId(null)} className="btn-secondary">{t('cancel')}</button>
</div>
</div>
) : (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
{newsItem.featured && (
<span style={{
background: '#f59e0b',
color: 'white',
padding: '0.125rem 0.375rem',
borderRadius: '0.25rem',
fontSize: '0.625rem',
fontWeight: '600'
}}>
FEATURED
</span>
)}
<h3 style={{ margin: 0, fontSize: '1rem', fontWeight: '600' }}>{getLocalizedValue(newsItem.title, activeTab)}</h3>
</div>
<div style={{ fontSize: '0.75rem', color: '#666', marginBottom: '0.5rem' }}>
{new Date(newsItem.publishedAt).toLocaleDateString('de-DE')}
{newsItem.author && ` • by ${newsItem.author}`}
{newsItem.special && ` • ★ ${getLocalizedValue(newsItem.special.name, activeTab)}`}
</div>
<p style={{ margin: 0, fontSize: '0.875rem', whiteSpace: 'pre-wrap' }}>
{getLocalizedValue(newsItem.content, activeTab).length > 150
? getLocalizedValue(newsItem.content, activeTab).substring(0, 150) + '...'
: getLocalizedValue(newsItem.content, activeTab)}
</p>
</div>
<div style={{ display: 'flex', gap: '0.25rem', marginLeft: '1rem' }}>
<button onClick={() => startEditNews(newsItem)} className="btn-secondary" style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}>Edit</button>
<button onClick={() => handleDeleteNews(newsItem.id)} className="btn-danger" style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}>{t('delete')}</button>
</div>
</div>
</>
)}
</div>
))}
{news.length === 0 && (
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', padding: '1rem' }}>
{t('noNewsItems')}
</p>
)}
</div>
</>
)}
</div>
{/* Political Statements Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
Political Statements
</h2>
<button
onClick={() => setShowPoliticalStatements(!showPoliticalStatements)}
style={{
padding: '0.5rem 1rem',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.875rem'
}}
>
{showPoliticalStatements ? t('hide') : t('show')}
</button>
</div>
{showPoliticalStatements && (
<>
{/* Language Tabs */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
{(['de', 'en'] as const).map(lang => (
<button
key={lang}
type="button"
onClick={() => setPoliticalStatementsLocale(lang)}
style={{
padding: '0.4rem 0.8rem',
borderRadius: '999px',
border: '1px solid #d1d5db',
background: politicalStatementsLocale === lang ? '#111827' : 'white',
color: politicalStatementsLocale === lang ? 'white' : '#111827',
fontSize: '0.8rem',
cursor: 'pointer'
}}
>
{lang.toUpperCase()}
</button>
))}
</div>
{/* Create Form */}
<form onSubmit={handleCreatePoliticalStatement} style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<textarea
value={newPoliticalStatementText}
onChange={e => setNewPoliticalStatementText(e.target.value)}
placeholder="Statement text"
className="form-input"
rows={3}
required
/>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem' }}>
<input
type="checkbox"
checked={newPoliticalStatementActive}
onChange={e => setNewPoliticalStatementActive(e.target.checked)}
/>
Active
</label>
<button type="submit" className="btn-primary" style={{ fontSize: '0.875rem' }}>
Add Statement ({politicalStatementsLocale.toUpperCase()})
</button>
</div>
</div>
</form>
{/* List */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{politicalStatements
.filter(s => s.locale === politicalStatementsLocale)
.map(stmt => (
<div
key={`${stmt.locale}-${stmt.id}`}
style={{
background: stmt.active ? '#ecfdf5' : '#f3f4f6',
padding: '0.75rem',
borderRadius: '0.5rem',
border: '1px solid #e5e7eb',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem'
}}
>
<textarea
value={stmt.text}
onChange={e => handleEditPoliticalStatementText(stmt.locale, stmt.id, e.target.value)}
className="form-input"
rows={3}
style={{ fontSize: '0.85rem' }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.5rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.8rem' }}>
<input
type="checkbox"
checked={stmt.active !== false}
onChange={e => handleEditPoliticalStatementActive(stmt.locale, stmt.id, e.target.checked)}
/>
Active
</label>
<div style={{ display: 'flex', gap: '0.25rem' }}>
<button
type="button"
className="btn-secondary"
style={{ padding: '0.25rem 0.6rem', fontSize: '0.75rem' }}
onClick={() => handleSavePoliticalStatement(stmt.locale, stmt.id)}
>
Save
</button>
<button
type="button"
className="btn-danger"
style={{ padding: '0.25rem 0.6rem', fontSize: '0.75rem' }}
onClick={() => handleDeletePoliticalStatement(stmt.locale, stmt.id)}
>
Delete
</button>
</div>
</div>
</div>
))}
{politicalStatements.filter(s => s.locale === politicalStatementsLocale).length === 0 && (
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', padding: '0.5rem' }}>
No statements for this language yet.
</p>
)}
</div>
</>
)}
</div>
{/* Curator Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
{t('manageCurators')}
</h2>
<button
onClick={() => setShowCurators(!showCurators)}
style={{
padding: '0.5rem 1rem',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.875rem'
}}
>
{showCurators ? t('hide') : t('show')}
</button>
</div>
{showCurators && (
<>
<form onSubmit={handleSaveCurator} style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
<input
type="text"
value={curatorUsername}
onChange={e => setCuratorUsername(e.target.value)}
placeholder={t('curatorUsername')}
className="form-input"
style={{ minWidth: '200px', flex: '1 1 200px' }}
required
/>
<input
type="password"
value={curatorPassword}
onChange={e => setCuratorPassword(e.target.value)}
placeholder={t('curatorPassword')}
className="form-input"
style={{ minWidth: '200px', flex: '1 1 200px' }}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem' }}>
<input
type="checkbox"
checked={curatorIsGlobal}
onChange={e => setCuratorIsGlobal(e.target.checked)}
/>
{t('isGlobalCurator')}
</label>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem' }}>
<div style={{ flex: '1 1 200px' }}>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('assignedGenres')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{genres.map(genre => (
<label
key={genre.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
borderRadius: '999px',
background: curatorGenreIds.includes(genre.id) ? '#e5f3ff' : '#f3f4f6',
fontSize: '0.8rem',
cursor: 'pointer'
}}
>
<input
type="checkbox"
checked={curatorGenreIds.includes(genre.id)}
onChange={() => toggleCuratorGenre(genre.id)}
/>
{typeof genre.name === 'string'
? genre.name
: getLocalizedValue(genre.name, activeTab)}
</label>
))}
</div>
</div>
<div style={{ flex: '1 1 200px' }}>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('assignedSpecials')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{specials.map(special => (
<label
key={special.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
borderRadius: '999px',
background: curatorSpecialIds.includes(special.id) ? '#fee2e2' : '#f3f4f6',
fontSize: '0.8rem',
cursor: 'pointer'
}}
>
<input
type="checkbox"
checked={curatorSpecialIds.includes(special.id)}
onChange={() => toggleCuratorSpecial(special.id)}
/>
{typeof special.name === 'string'
? special.name
: getLocalizedValue(special.name, activeTab)}
</label>
))}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
<button type="submit" className="btn-primary">
{editingCuratorId ? t('save') : t('addCurator')}
</button>
{editingCuratorId && (
<button
type="button"
className="btn-secondary"
onClick={resetCuratorForm}
>
{t('cancel')}
</button>
)}
</div>
</div>
</form>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{curators.length === 0 && (
<p style={{ color: '#666', fontSize: '0.875rem' }}>{t('noCurators')}</p>
)}
{curators.map(curator => (
<div
key={curator.id}
style={{
padding: '0.75rem',
borderRadius: '0.5rem',
border: '1px solid #e5e7eb',
background: curator.isGlobalCurator ? '#eff6ff' : '#f9fafb',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.5rem',
flexWrap: 'wrap'
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<div style={{ fontWeight: 600 }}>{curator.username}</div>
<div style={{ fontSize: '0.8rem', color: '#4b5563' }}>
{curator.isGlobalCurator && <span>Globaler Kurator · </span>}
<span>
{t('assignedGenres')}: {curator.genreIds.length}
</span>
{' · '}
<span>
{t('assignedSpecials')}: {curator.specialIds.length}
</span>
</div>
</div>
<div style={{ display: 'flex', gap: '0.25rem' }}>
<button
type="button"
className="btn-secondary"
style={{ padding: '0.25rem 0.6rem', fontSize: '0.8rem' }}
onClick={() => startEditCurator(curator)}
>
{t('edit')}
</button>
<button
type="button"
className="btn-danger"
style={{ padding: '0.25rem 0.6rem', fontSize: '0.8rem' }}
onClick={() => handleDeleteCurator(curator.id)}
>
{t('delete')}
</button>
</div>
</div>
))}
</div>
</>
)}
</div>
{/* Upload Songs wurde in das Kuratoren-Dashboard verlagert */}
{/* Today's Daily Puzzles */}
<div className="admin-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
{t('todaysPuzzles')}
</h2>
<button
onClick={() => setShowDailyPuzzles(!showDailyPuzzles)}
style={{
padding: '0.5rem 1rem',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.875rem'
}}
>
{showDailyPuzzles ? '▼ Hide' : '▶ Show'}
</button>
</div>
{showDailyPuzzles && (dailyPuzzles.length === 0 ? (
<p style={{ color: '#6b7280' }}>{t('noPuzzlesToday')}</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #e5e7eb' }}>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: 'bold' }}>{t('category')}</th>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: 'bold' }}>{t('song')}</th>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: 'bold' }}>{t('artist')}</th>
<th style={{ padding: '0.75rem', textAlign: 'center', fontWeight: 'bold' }}>{t('actions')}</th>
</tr>
</thead>
<tbody>
{dailyPuzzles.map(puzzle => (
<tr key={puzzle.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={{ padding: '0.75rem' }}>
<span style={{
background: puzzle.categoryType === 'global' ? '#dbeafe' : puzzle.categoryType === 'special' ? '#fce7f3' : '#f3f4f6',
color: puzzle.categoryType === 'global' ? '#1e40af' : puzzle.categoryType === 'special' ? '#be185d' : '#374151',
padding: '0.25rem 0.5rem',
borderRadius: '0.25rem',
fontSize: '0.875rem',
fontWeight: '500'
}}>
{puzzle.category}
</span>
</td>
<td style={{ padding: '0.75rem', fontWeight: 'bold' }}>{puzzle.song.title}</td>
<td style={{ padding: '0.75rem' }}>{puzzle.song.artist}</td>
<td style={{ padding: '0.75rem', textAlign: 'center' }}>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'center' }}>
<button
onClick={() => handlePlayPuzzle(puzzle)}
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
title={playingPuzzleId === puzzle.id ? "Pause" : "Play"}
>
{playingPuzzleId === puzzle.id ? '⏸️' : '▶️'}
</button>
<button
onClick={() => handleDeletePuzzle(puzzle.id)}
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
title={t('deletePuzzle')}
>
🗑
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
))}
</div>
{/* Song Library wurde in das Kuratoren-Dashboard verlagert */}
<div className="admin-card" style={{ marginTop: '2rem', border: '1px solid #ef4444' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem', color: '#ef4444' }}>
Danger Zone
</h2>
<p style={{ marginBottom: '1rem', color: '#666' }}>
These actions are destructive and cannot be undone.
</p>
<button
onClick={async () => {
if (window.confirm('⚠️ WARNING: This will delete ALL data from the database (Songs, Genres, Specials, Puzzles) and re-import songs from the uploads folder.\n\nExisting genres and specials will be LOST and must be recreated manually.\n\nAre you sure you want to proceed?')) {
try {
setMessage('Rebuilding database... this may take a while.');
const res = await fetch('/api/admin/rebuild', { method: 'POST' });
if (res.ok) {
const data = await res.json();
alert(data.message + '\n\nPlease recreate your Genres and Specials now.');
window.location.reload();
} else {
alert('Rebuild failed. Check server logs.');
setMessage('Rebuild failed.');
}
} catch (e) {
console.error(e);
alert('Rebuild failed due to network error.');
}
}
}}
style={{
padding: '0.75rem 1.5rem',
background: '#ef4444',
color: 'white',
border: 'none',
borderRadius: '0.25rem',
cursor: 'pointer',
fontWeight: 'bold'
}}
>
Rebuild Database
</button>
</div>
</div>
);
}