2188 lines
104 KiB
TypeScript
2188 lines
104 KiB
TypeScript
'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;
|
||
_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 [newSpecialLaunchDate, setNewSpecialLaunchDate] = useState('');
|
||
const [newSpecialEndDate, setNewSpecialEndDate] = useState('');
|
||
const [newSpecialCurator, setNewSpecialCurator] = useState('');
|
||
|
||
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 [editSpecialLaunchDate, setEditSpecialLaunchDate] = useState('');
|
||
const [editSpecialEndDate, setEditSpecialEndDate] = useState('');
|
||
const [editSpecialCurator, setEditSpecialCurator] = useState('');
|
||
|
||
// 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'));
|
||
}
|
||
};
|
||
|
||
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;
|
||
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,
|
||
}),
|
||
});
|
||
if (res.ok) {
|
||
setNewSpecialName({ de: '', en: '' });
|
||
setNewSpecialSubtitle({ de: '', en: '' });
|
||
setNewSpecialMaxAttempts(7);
|
||
setNewSpecialUnlockSteps('[2,4,7,11,16,30,60]');
|
||
setNewSpecialLaunchDate('');
|
||
setNewSpecialEndDate('');
|
||
setNewSpecialCurator('');
|
||
fetchSpecials();
|
||
} else {
|
||
alert('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);
|
||
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 || '');
|
||
};
|
||
|
||
const saveEditedSpecial = async () => {
|
||
if (editingSpecialId === null) return;
|
||
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,
|
||
}),
|
||
});
|
||
if (res.ok) {
|
||
setEditingSpecialId(null);
|
||
fetchSpecials();
|
||
} else {
|
||
alert('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' }}>{t('unlockSteps')}</label>
|
||
<input type="text" placeholder={t('unlockSteps')} value={newSpecialUnlockSteps} onChange={e => setNewSpecialUnlockSteps(e.target.value)} className="form-input" style={{ width: '200px' }} />
|
||
</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>
|
||
<button type="submit" className="btn-primary" style={{ height: '38px' }}>{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>{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>}
|
||
<Link href={`/admin/specials/${special.id}`} className="btn-primary" style={{ marginRight: '0.5rem', textDecoration: 'none' }}>{t('curate')}</Link>
|
||
<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' }}>{t('unlockSteps')}</label>
|
||
<input type="text" value={editSpecialUnlockSteps} onChange={e => setEditSpecialUnlockSteps(e.target.value)} className="form-input" style={{ width: '200px' }} />
|
||
</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>
|
||
<button onClick={saveEditedSpecial} className="btn-primary" style={{ height: '38px' }}>{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>
|
||
);
|
||
}
|