1186 lines
56 KiB
TypeScript
1186 lines
56 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect, useRef } from 'react';
|
||
|
||
|
||
interface Special {
|
||
id: number;
|
||
name: string;
|
||
subtitle?: string;
|
||
maxAttempts: number;
|
||
unlockSteps: string;
|
||
launchDate?: string;
|
||
endDate?: string;
|
||
curator?: string;
|
||
hidden?: boolean;
|
||
_count?: {
|
||
songs: number;
|
||
};
|
||
}
|
||
|
||
interface Genre {
|
||
id: number;
|
||
name: string;
|
||
subtitle?: string;
|
||
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: string;
|
||
content: string;
|
||
author: string | null;
|
||
publishedAt: string;
|
||
featured: boolean;
|
||
specialId: number | null;
|
||
special: {
|
||
id: number;
|
||
name: string;
|
||
} | null;
|
||
}
|
||
|
||
export default function AdminPage() {
|
||
const [password, setPassword] = useState('');
|
||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||
const [message, setMessage] = useState('');
|
||
const [songs, setSongs] = useState<Song[]>([]);
|
||
const [genres, setGenres] = useState<Genre[]>([]);
|
||
const [newGenreName, setNewGenreName] = useState('');
|
||
const [newGenreSubtitle, setNewGenreSubtitle] = useState('');
|
||
const [newGenreActive, setNewGenreActive] = useState(true);
|
||
const [editingGenreId, setEditingGenreId] = useState<number | null>(null);
|
||
const [editGenreName, setEditGenreName] = useState('');
|
||
const [editGenreSubtitle, setEditGenreSubtitle] = useState('');
|
||
const [editGenreActive, setEditGenreActive] = useState(true);
|
||
|
||
// Specials state
|
||
const [specials, setSpecials] = useState<Special[]>([]);
|
||
const [newSpecialName, setNewSpecialName] = useState('');
|
||
const [newSpecialSubtitle, setNewSpecialSubtitle] = useState('');
|
||
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 [newSpecialHidden, setNewSpecialHidden] = useState(false);
|
||
|
||
const [editingSpecialId, setEditingSpecialId] = useState<number | null>(null);
|
||
const [editSpecialName, setEditSpecialName] = useState('');
|
||
const [editSpecialSubtitle, setEditSpecialSubtitle] = useState('');
|
||
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('');
|
||
const [editSpecialHidden, setEditSpecialHidden] = useState(false);
|
||
|
||
// News state
|
||
const [news, setNews] = useState<News[]>([]);
|
||
const [newNewsTitle, setNewNewsTitle] = useState('');
|
||
const [newNewsContent, setNewNewsContent] = useState('');
|
||
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('');
|
||
const [editNewsContent, setEditNewsContent] = useState('');
|
||
const [editNewsAuthor, setEditNewsAuthor] = useState('');
|
||
const [editNewsFeatured, setEditNewsFeatured] = useState(false);
|
||
const [editNewsSpecialId, setEditNewsSpecialId] = useState<number | null>(null);
|
||
|
||
// AI Categorization state
|
||
const [isCategorizing, setIsCategorizing] = useState(false);
|
||
const [categorizationResults, setCategorizationResults] = useState<any>(null);
|
||
|
||
// Audio state
|
||
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 fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// Check for existing auth on mount
|
||
useEffect(() => {
|
||
const authToken = localStorage.getItem('hoerdle_admin_auth');
|
||
if (authToken === 'authenticated') {
|
||
setIsAuthenticated(true);
|
||
fetchSongs();
|
||
fetchGenres();
|
||
fetchDailyPuzzles();
|
||
fetchSpecials();
|
||
fetchNews();
|
||
}
|
||
}, []);
|
||
|
||
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();
|
||
} else {
|
||
alert('Wrong password');
|
||
}
|
||
};
|
||
|
||
const handleLogout = () => {
|
||
localStorage.removeItem('hoerdle_admin_auth');
|
||
setIsAuthenticated(false);
|
||
setPassword('');
|
||
// Reset all state
|
||
setSongs([]);
|
||
setGenres([]);
|
||
setSpecials([]);
|
||
setDailyPuzzles([]);
|
||
};
|
||
|
||
// 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 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.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('');
|
||
setNewGenreSubtitle('');
|
||
setNewGenreActive(true);
|
||
fetchGenres();
|
||
} else {
|
||
alert('Failed to create genre');
|
||
}
|
||
};
|
||
|
||
const startEditGenre = (genre: Genre) => {
|
||
setEditingGenreId(genre.id);
|
||
setEditGenreName(genre.name);
|
||
setEditGenreSubtitle(genre.subtitle || '');
|
||
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();
|
||
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('');
|
||
setNewSpecialSubtitle('');
|
||
setNewSpecialMaxAttempts(7);
|
||
setNewSpecialUnlockSteps('[2,4,7,11,16,30,60]');
|
||
setNewSpecialLaunchDate('');
|
||
setNewSpecialEndDate('');
|
||
setNewSpecialCurator('');
|
||
setNewSpecialHidden(false);
|
||
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(special.name);
|
||
setEditSpecialSubtitle(special.subtitle || '');
|
||
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 || '');
|
||
setEditSpecialHidden(special.hidden || false);
|
||
};
|
||
|
||
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,
|
||
hidden: editSpecialHidden,
|
||
}),
|
||
});
|
||
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);
|
||
}
|
||
};
|
||
|
||
const handleCreateNews = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!newNewsTitle.trim() || !newNewsContent.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('');
|
||
setNewNewsContent('');
|
||
setNewNewsAuthor('');
|
||
setNewNewsFeatured(false);
|
||
setNewNewsSpecialId(null);
|
||
fetchNews();
|
||
} else {
|
||
alert('Failed to create news');
|
||
}
|
||
};
|
||
|
||
const startEditNews = (newsItem: News) => {
|
||
setEditingNewsId(newsItem.id);
|
||
setEditNewsTitle(newsItem.title);
|
||
setEditNewsContent(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 after auth
|
||
useEffect(() => {
|
||
if (isAuthenticated) fetchSpecials();
|
||
}, [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);
|
||
}
|
||
};
|
||
|
||
|
||
if (!isAuthenticated) {
|
||
return (
|
||
<div className="container" style={{ justifyContent: 'center' }}>
|
||
<h1 className="title" style={{ marginBottom: '1rem', fontSize: '1.5rem' }}>Admin Login</h1>
|
||
<input
|
||
type="password"
|
||
value={password}
|
||
onChange={e => setPassword(e.target.value)}
|
||
className="form-input"
|
||
style={{ marginBottom: '1rem', maxWidth: '300px' }}
|
||
placeholder="Password"
|
||
/>
|
||
<button onClick={handleLogin} className="btn-primary">Login</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="admin-container">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||
<h1 className="title" style={{ margin: 0 }}>Hördle Admin Dashboard</h1>
|
||
<button
|
||
onClick={handleLogout}
|
||
className="btn-secondary"
|
||
style={{
|
||
padding: '0.5rem 1rem',
|
||
backgroundColor: '#dc3545',
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '4px',
|
||
cursor: 'pointer',
|
||
fontSize: '0.9rem'
|
||
}}
|
||
>
|
||
🚪 Logout
|
||
</button>
|
||
</div>
|
||
|
||
{/* Special Management */}
|
||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Specials</h2>
|
||
<form onSubmit={handleCreateSpecial} style={{ marginBottom: '1rem' }}>
|
||
<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' }}>Name</label>
|
||
<input type="text" placeholder="Special name" value={newSpecialName} onChange={e => setNewSpecialName(e.target.value)} className="form-input" required />
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label>
|
||
<input type="text" placeholder="Subtitle" value={newSpecialSubtitle} onChange={e => setNewSpecialSubtitle(e.target.value)} className="form-input" />
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Max Attempts</label>
|
||
<input type="number" placeholder="Max attempts" 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' }}>Unlock Steps</label>
|
||
<input type="text" placeholder="Unlock steps JSON" 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' }}>Launch Date</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' }}>End Date</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' }}>Curator</label>
|
||
<input type="text" placeholder="Curator name" value={newSpecialCurator} onChange={e => setNewSpecialCurator(e.target.value)} className="form-input" />
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer', marginTop: '0.5rem' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={newSpecialHidden}
|
||
onChange={e => setNewSpecialHidden(e.target.checked)}
|
||
style={{ width: '1rem', height: '1rem' }}
|
||
/>
|
||
Hidden Special (not in navigation)
|
||
</label>
|
||
</div>
|
||
<button type="submit" className="btn-primary" style={{ height: '38px' }}>Add Special</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>} {special.name} ({special._count?.songs || 0})
|
||
</span>
|
||
{special.subtitle && (
|
||
<span
|
||
style={{
|
||
fontSize: '0.75rem',
|
||
color: '#666',
|
||
marginLeft: '0.25rem',
|
||
}}
|
||
>
|
||
- {special.subtitle}
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={() => startEditSpecial(special)}
|
||
className="btn-secondary"
|
||
style={{ marginRight: '0.5rem' }}
|
||
>
|
||
Edit
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeleteSpecial(special.id)}
|
||
className="btn-danger"
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{editingSpecialId !== null && (
|
||
<div style={{ marginTop: '1rem', padding: '1rem', background: '#f9fafb', borderRadius: '0.5rem' }}>
|
||
<h3>Edit Special</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' }}>Name</label>
|
||
<input type="text" value={editSpecialName} onChange={e => setEditSpecialName(e.target.value)} className="form-input" />
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label>
|
||
<input type="text" value={editSpecialSubtitle} onChange={e => setEditSpecialSubtitle(e.target.value)} className="form-input" />
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Max Attempts</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' }}>Unlock Steps</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' }}>Launch Date</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' }}>End Date</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' }}>Curator</label>
|
||
<input type="text" value={editSpecialCurator} onChange={e => setEditSpecialCurator(e.target.value)} className="form-input" />
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer', marginTop: '0.5rem' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={editSpecialHidden}
|
||
onChange={e => setEditSpecialHidden(e.target.checked)}
|
||
style={{ width: '1rem', height: '1rem' }}
|
||
/>
|
||
Hidden Special
|
||
</label>
|
||
</div>
|
||
<button onClick={saveEditedSpecial} className="btn-primary" style={{ height: '38px' }}>Save</button>
|
||
<button onClick={() => setEditingSpecialId(null)} className="btn-secondary" style={{ height: '38px' }}>Cancel</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Genre Management */}
|
||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Genres</h2>
|
||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', alignItems: 'center' }}>
|
||
<input
|
||
type="text"
|
||
value={newGenreName}
|
||
onChange={e => setNewGenreName(e.target.value)}
|
||
placeholder="New Genre Name"
|
||
className="form-input"
|
||
style={{ maxWidth: '200px' }}
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={newGenreSubtitle}
|
||
onChange={e => setNewGenreSubtitle(e.target.value)}
|
||
placeholder="Subtitle"
|
||
className="form-input"
|
||
style={{ maxWidth: '300px' }}
|
||
/>
|
||
<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)}
|
||
/>
|
||
Active
|
||
</label>
|
||
<button onClick={createGenre} className="btn-primary">Add Genre</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>{genre.name} ({genre._count?.songs || 0})</span>
|
||
{genre.subtitle && <span style={{ fontSize: '0.75rem', color: '#666' }}>- {genre.subtitle}</span>}
|
||
<button onClick={() => startEditGenre(genre)} className="btn-secondary" style={{ padding: '0.1rem 0.5rem', fontSize: '0.75rem' }}>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>Edit Genre</h3>
|
||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end' }}>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Name</label>
|
||
<input type="text" value={editGenreName} onChange={e => setEditGenreName(e.target.value)} className="form-input" />
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label>
|
||
<input type="text" value={editGenreSubtitle} onChange={e => setEditGenreSubtitle(e.target.value)} className="form-input" style={{ width: '300px' }} />
|
||
</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)}
|
||
/>
|
||
Active
|
||
</label>
|
||
</div>
|
||
<button onClick={saveEditedGenre} className="btn-primary">Save</button>
|
||
<button onClick={() => setEditingGenreId(null)} className="btn-secondary">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' }}>
|
||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage News & Announcements</h2>
|
||
<form onSubmit={handleCreateNews} style={{ marginBottom: '1rem' }}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||
<input
|
||
type="text"
|
||
value={newNewsTitle}
|
||
onChange={e => setNewNewsTitle(e.target.value)}
|
||
placeholder="News Title"
|
||
className="form-input"
|
||
required
|
||
/>
|
||
<textarea
|
||
value={newNewsContent}
|
||
onChange={e => setNewNewsContent(e.target.value)}
|
||
placeholder="Content (Markdown supported)"
|
||
className="form-input"
|
||
rows={4}
|
||
required
|
||
style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||
/>
|
||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||
<input
|
||
type="text"
|
||
value={newNewsAuthor}
|
||
onChange={e => setNewNewsAuthor(e.target.value)}
|
||
placeholder="Author (optional)"
|
||
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="">No Special Link</option>
|
||
{specials.map(s => (
|
||
<option key={s.id} value={s.id}>{s.name}</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)}
|
||
/>
|
||
Featured
|
||
</label>
|
||
<button type="submit" className="btn-primary">Add News</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}
|
||
onChange={e => setEditNewsTitle(e.target.value)}
|
||
className="form-input"
|
||
/>
|
||
<textarea
|
||
value={editNewsContent}
|
||
onChange={e => setEditNewsContent(e.target.value)}
|
||
className="form-input"
|
||
rows={4}
|
||
style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||
/>
|
||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||
<input
|
||
type="text"
|
||
value={editNewsAuthor}
|
||
onChange={e => setEditNewsAuthor(e.target.value)}
|
||
placeholder="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="">No Special Link</option>
|
||
{specials.map(s => (
|
||
<option key={s.id} value={s.id}>{s.name}</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)}
|
||
/>
|
||
Featured
|
||
</label>
|
||
<button onClick={saveEditedNews} className="btn-primary">Save</button>
|
||
<button onClick={() => setEditingNewsId(null)} className="btn-secondary">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' }}>{newsItem.title}</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 && ` • ★ ${newsItem.special.name}`}
|
||
</div>
|
||
<p style={{ margin: 0, fontSize: '0.875rem', whiteSpace: 'pre-wrap' }}>
|
||
{newsItem.content.length > 150
|
||
? newsItem.content.substring(0, 150) + '...'
|
||
: newsItem.content}
|
||
</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' }}>Delete</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
{news.length === 0 && (
|
||
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', padding: '1rem' }}>
|
||
No news items yet. Create one above!
|
||
</p>
|
||
)}
|
||
</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 }}>
|
||
Today's Daily Puzzles
|
||
</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' }}>No daily puzzles found for today.</p>
|
||
) : (
|
||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||
<thead>
|
||
<tr style={{ borderBottom: '2px solid #e5e7eb' }}>
|
||
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: 'bold' }}>Category</th>
|
||
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: 'bold' }}>Song</th>
|
||
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: 'bold' }}>Artist</th>
|
||
<th style={{ padding: '0.75rem', textAlign: 'center', fontWeight: 'bold' }}>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="Delete"
|
||
>
|
||
🗑️
|
||
</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>
|
||
);
|
||
}
|