Files
hoerdle/app/admin/page.tsx

1771 lines
88 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useRef } from 'react';
interface Special {
id: number;
name: string;
subtitle?: string;
maxAttempts: number;
unlockSteps: string;
launchDate?: string;
endDate?: string;
curator?: string;
_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;
}
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
type SortDirection = 'asc' | 'desc';
export default function AdminPage() {
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('');
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 [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('');
// 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);
// 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
const [searchQuery, setSearchQuery] = useState('');
const [selectedGenreFilter, setSelectedGenreFilter] = useState<string>('');
const [selectedSpecialFilter, setSelectedSpecialFilter] = useState<number | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
// Audio state
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 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();
}
}, []);
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();
} 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,
}),
});
if (res.ok) {
setNewSpecialName('');
setNewSpecialSubtitle('');
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(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 || '');
};
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');
}
};
// 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);
}
};
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);
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`;
}
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 handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
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);
};
}
};
// Filter and sort songs
const filteredSongs = songs.filter(song => {
// Text search filter
const matchesSearch = song.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
song.artist.toLowerCase().includes(searchQuery.toLowerCase());
// Genre filter
// Unified Filter
let matchesFilter = true;
if (selectedGenreFilter) {
if (selectedGenreFilter.startsWith('genre:')) {
const genreId = Number(selectedGenreFilter.split(':')[1]);
matchesFilter = genreId === -1
? song.genres.length === 0
: song.genres.some(g => g.id === genreId);
} else if (selectedGenreFilter.startsWith('special:')) {
const specialId = Number(selectedGenreFilter.split(':')[1]);
matchesFilter = song.specials?.some(s => s.id === specialId) || false;
} else if (selectedGenreFilter === 'daily') {
const today = new Date().toISOString().split('T')[0];
matchesFilter = song.puzzles?.some(p => p.date === today) || false;
} else if (selectedGenreFilter === 'no-global') {
matchesFilter = song.excludeFromGlobal === true;
}
}
return matchesSearch && matchesFilter;
});
const sortedSongs = [...filteredSongs].sort((a, b) => {
// Handle numeric sorting for ID, Release Year, Activations, and Rating
if (sortField === 'id') {
return sortDirection === 'asc' ? a.id - b.id : b.id - a.id;
}
if (sortField === 'releaseYear') {
const yearA = a.releaseYear || 0;
const yearB = b.releaseYear || 0;
return sortDirection === 'asc' ? yearA - yearB : yearB - yearA;
}
if (sortField === 'activations') {
return sortDirection === 'asc' ? a.activations - b.activations : b.activations - a.activations;
}
if (sortField === 'averageRating') {
return sortDirection === 'asc' ? a.averageRating - b.averageRating : b.averageRating - a.averageRating;
}
// String sorting for other fields
const valA = String(a[sortField]).toLowerCase();
const valB = String(b[sortField]).toLowerCase();
if (valA < valB) return sortDirection === 'asc' ? -1 : 1;
if (valA > valB) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
// Pagination
const totalPages = Math.ceil(sortedSongs.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const paginatedSongs = sortedSongs.slice(startIndex, startIndex + itemsPerPage);
// Reset to page 1 when search changes
useEffect(() => {
setCurrentPage(1);
}, [searchQuery]);
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" />
</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.name} ({special._count?.songs || 0})</span>
{special.subtitle && <span style={{ fontSize: '0.75rem', color: '#666', marginLeft: '0.25rem' }}>- {special.subtitle}</span>}
<a href={`/admin/specials/${special.id}`} className="btn-primary" style={{ marginRight: '0.5rem', textDecoration: 'none' }}>Curate</a>
<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" />
</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>
<div className="admin-card" style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Upload Songs</h2>
<form onSubmit={handleBatchUpload}>
{/* Drag & Drop Zone */}
<div
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{
border: isDragging ? '2px solid #4f46e5' : '2px dashed #d1d5db',
borderRadius: '0.5rem',
padding: '2rem',
textAlign: 'center',
background: isDragging ? '#eef2ff' : '#f9fafb',
marginBottom: '1rem',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onClick={() => fileInputRef.current?.click()}
>
<div style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>📁</div>
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
{files.length > 0 ? `${files.length} file(s) selected` : 'Drag & drop MP3 files here'}
</p>
<p style={{ fontSize: '0.875rem', color: '#666' }}>
or click to browse
</p>
<input
ref={fileInputRef}
type="file"
accept="audio/mpeg"
multiple
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</div>
{/* File List */}
{files.length > 0 && (
<div style={{ marginBottom: '1rem' }}>
<p style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>Selected Files:</p>
<div style={{ maxHeight: '200px', overflowY: 'auto', background: '#f9fafb', padding: '0.5rem', borderRadius: '0.25rem' }}>
{files.map((file, index) => (
<div key={index} style={{ padding: '0.25rem 0', fontSize: '0.875rem' }}>
📄 {file.name}
</div>
))}
</div>
</div>
)}
{/* Upload Progress */}
{isUploading && (
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#eef2ff', borderRadius: '0.5rem' }}>
<p style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>
Uploading: {uploadProgress.current} / {uploadProgress.total}
</p>
<div style={{ width: '100%', height: '8px', background: '#d1d5db', borderRadius: '4px', overflow: 'hidden' }}>
<div style={{
width: `${(uploadProgress.current / uploadProgress.total) * 100}%`,
height: '100%',
background: '#4f46e5',
transition: 'width 0.3s'
}} />
</div>
</div>
)}
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={uploadExcludeFromGlobal}
onChange={e => setUploadExcludeFromGlobal(e.target.checked)}
style={{ width: '1.25rem', height: '1.25rem' }}
/>
<span style={{ fontWeight: '500' }}>Exclude from Global Daily Puzzle</span>
</label>
<p style={{ fontSize: '0.875rem', color: '#666', marginLeft: '1.75rem', marginTop: '0.25rem' }}>
If checked, these songs will only appear in Genre or Special puzzles.
</p>
</div>
<button
type="submit"
className="btn-primary"
disabled={files.length === 0 || isUploading}
style={{ opacity: files.length === 0 || isUploading ? 0.5 : 1 }}
>
{isUploading ? 'Uploading...' : `Upload ${files.length} Song(s)`}
</button>
{message && (
<div style={{
marginTop: '1rem',
padding: '0.75rem',
background: '#d1fae5',
color: '#065f46',
borderRadius: '0.25rem'
}}>
{message}
</div>
)}
</form>
</div>
{/* 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>
<div className="admin-card">
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
Song Library ({songs.length} songs)
</h2>
{/* Search and Filter */}
<div style={{ marginBottom: '1rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<input
type="text"
placeholder="Search by title or artist..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="form-input"
style={{ flex: '1', minWidth: '200px' }}
/>
<select
value={selectedGenreFilter}
onChange={e => setSelectedGenreFilter(e.target.value)}
className="form-input"
style={{ minWidth: '150px' }}
>
<option value="">All Content</option>
<option value="daily">📅 Song of the Day</option>
<option value="no-global">🚫 No Global</option>
<optgroup label="Genres">
<option value="genre:-1">No Genre</option>
{genres.map(genre => (
<option key={genre.id} value={`genre:${genre.id}`}>
{genre.name} ({genre._count?.songs || 0})
</option>
))}
</optgroup>
<optgroup label="Specials">
{specials.map(special => (
<option key={special.id} value={`special:${special.id}`}>
★ {special.name} ({special._count?.songs || 0})
</option>
))}
</optgroup>
</select>
{(searchQuery || selectedGenreFilter) && (
<button
onClick={() => {
setSearchQuery('');
setSelectedGenreFilter('');
}}
style={{
padding: '0.5rem 1rem',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.875rem'
}}
>
Clear Filters
</button>
)}
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid #e5e7eb', textAlign: 'left' }}>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('id')}
>
ID {sortField === 'id' && (sortDirection === 'asc' ? '' : '')}
</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('title')}
>
Song {sortField === 'title' && (sortDirection === 'asc' ? '' : '')}
</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('releaseYear')}
>
Year {sortField === 'releaseYear' && (sortDirection === 'asc' ? '' : '')}
</th>
<th style={{ padding: '0.75rem' }}>Genres / Specials</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('createdAt')}
>
Added {sortField === 'createdAt' && (sortDirection === 'asc' ? '' : '')}
</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('activations')}
>
Activations {sortField === 'activations' && (sortDirection === 'asc' ? '' : '')}
</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('averageRating')}
>
Rating {sortField === 'averageRating' && (sortDirection === 'asc' ? '' : '')}
</th>
<th style={{ padding: '0.75rem' }}>Actions</th>
</tr>
</thead>
<tbody>
{paginatedSongs.map(song => (
<tr key={song.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={{ padding: '0.75rem' }}>{song.id}</td>
{editingId === song.id ? (
<>
<td style={{ padding: '0.75rem' }}>
<input
type="text"
value={editTitle}
onChange={e => setEditTitle(e.target.value)}
className="form-input"
style={{ padding: '0.25rem', marginBottom: '0.5rem', width: '100%' }}
placeholder="Title"
/>
<input
type="text"
value={editArtist}
onChange={e => setEditArtist(e.target.value)}
className="form-input"
style={{ padding: '0.25rem', width: '100%' }}
placeholder="Artist"
/>
</td>
<td style={{ padding: '0.75rem' }}>
<input
type="number"
value={editReleaseYear}
onChange={e => setEditReleaseYear(e.target.value === '' ? '' : Number(e.target.value))}
className="form-input"
style={{ padding: '0.25rem', width: '80px' }}
placeholder="Year"
/>
</td>
<td style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{genres.map(genre => (
<label key={genre.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.75rem' }}>
<input
type="checkbox"
checked={editGenreIds.includes(genre.id)}
onChange={e => {
if (e.target.checked) {
setEditGenreIds([...editGenreIds, genre.id]);
} else {
setEditGenreIds(editGenreIds.filter(id => id !== genre.id));
}
}}
/>
{genre.name}
</label>
))}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.5rem', borderTop: '1px dashed #eee', paddingTop: '0.25rem' }}>
{specials.map(special => (
<label key={special.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.75rem', color: '#4b5563' }}>
<input
type="checkbox"
checked={editSpecialIds.includes(special.id)}
onChange={e => {
if (e.target.checked) {
setEditSpecialIds([...editSpecialIds, special.id]);
} else {
setEditSpecialIds(editSpecialIds.filter(id => id !== special.id));
}
}}
/>
{special.name}
</label>
))}
</div>
<div style={{ marginTop: '0.5rem', borderTop: '1px dashed #eee', paddingTop: '0.5rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', cursor: 'pointer', color: '#b91c1c' }}>
<input
type="checkbox"
checked={editExcludeFromGlobal}
onChange={e => setEditExcludeFromGlobal(e.target.checked)}
/>
Exclude from Global
</label>
</div>
</td>
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
{new Date(song.createdAt).toLocaleDateString('de-DE')}
</td>
<td style={{ padding: '0.75rem', color: '#666' }}>{song.activations}</td>
<td style={{ padding: '0.75rem', color: '#666' }}>
{song.averageRating > 0 ? (
<span title={`${song.ratingCount} ratings`}>
{song.averageRating.toFixed(1)} ★ <span style={{ color: '#999', fontSize: '0.8rem' }}>({song.ratingCount})</span>
</span>
) : (
<span style={{ color: '#ccc' }}>-</span>
)}
</td>
<td style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={() => saveEditing(song.id)}
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
title="Save"
>
</button>
<button
onClick={cancelEditing}
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
title="Cancel"
>
</button>
</div>
</td>
</>
) : (
<>
<td style={{ padding: '0.75rem' }}>
<div style={{ fontWeight: 'bold', color: '#111827' }}>{song.title}</div>
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>{song.artist}</div>
{song.excludeFromGlobal && (
<div style={{ marginTop: '0.25rem' }}>
<span style={{
background: '#fee2e2',
color: '#991b1b',
padding: '0.1rem 0.4rem',
borderRadius: '0.25rem',
fontSize: '0.7rem',
border: '1px solid #fecaca',
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem'
}}>
🚫 No Global
</span>
</div>
)}
{/* Daily Puzzle Badges */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
{song.puzzles?.filter(p => p.date === new Date().toISOString().split('T')[0]).map(p => {
if (!p.genreId && !p.specialId) {
return (
<span key={p.id} style={{ background: '#dbeafe', color: '#1e40af', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #93c5fd' }}>
🌍 Global Daily
</span>
);
}
if (p.genreId) {
const genreName = genres.find(g => g.id === p.genreId)?.name;
return (
<span key={p.id} style={{ background: '#f3f4f6', color: '#374151', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #d1d5db' }}>
🏷️ {genreName} Daily
</span>
);
}
if (p.specialId) {
const specialName = specials.find(s => s.id === p.specialId)?.name;
return (
<span key={p.id} style={{ background: '#fce7f3', color: '#be185d', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #fbcfe8' }}>
★ {specialName} Daily
</span>
);
}
return null;
})}
</div>
</td>
<td style={{ padding: '0.75rem', color: '#666' }}>
{song.releaseYear || '-'}
</td>
<td style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{song.genres?.map(g => (
<span key={g.id} style={{
background: '#e5e7eb',
padding: '0.1rem 0.4rem',
borderRadius: '0.25rem',
fontSize: '0.7rem'
}}>
{g.name}
</span>
))}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
{song.specials?.map(s => (
<span key={s.id} style={{
background: '#fce7f3',
color: '#9d174d',
padding: '0.1rem 0.4rem',
borderRadius: '0.25rem',
fontSize: '0.7rem'
}}>
{s.name}
</span>
))}
</div>
</td>
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
{new Date(song.createdAt).toLocaleDateString('de-DE')}
</td>
<td style={{ padding: '0.75rem', color: '#666' }}>{song.activations}</td>
<td style={{ padding: '0.75rem', color: '#666' }}>
{song.averageRating > 0 ? (
<span title={`${song.ratingCount} ratings`}>
{song.averageRating.toFixed(1)} ★ <span style={{ color: '#999', fontSize: '0.8rem' }}>({song.ratingCount})</span>
</span>
) : (
<span style={{ color: '#ccc' }}>-</span>
)}
</td>
<td style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={() => handlePlayPause(song)}
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
title={playingSongId === song.id ? "Pause" : "Play"}
>
{playingSongId === song.id ? '' : ''}
</button>
<button
onClick={() => startEditing(song)}
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
title="Edit"
>
✏️
</button>
<button
onClick={() => handleDelete(song.id, song.title)}
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
title="Delete"
>
🗑️
</button>
</div>
</td>
</>
)}
</tr>
))}
{paginatedSongs.length === 0 && (
<tr>
<td colSpan={7} style={{ padding: '1rem', textAlign: 'center', color: '#666' }}>
{searchQuery ? 'No songs found matching your search.' : 'No songs uploaded yet.'}
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'center', gap: '0.5rem', alignItems: 'center' }}>
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
style={{
padding: '0.5rem 1rem',
border: '1px solid #d1d5db',
background: currentPage === 1 ? '#f3f4f6' : '#fff',
cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
borderRadius: '0.25rem'
}}
>
Previous
</button>
<span style={{ color: '#666' }}>
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
style={{
padding: '0.5rem 1rem',
border: '1px solid #d1d5db',
background: currentPage === totalPages ? '#f3f4f6' : '#fff',
cursor: currentPage === totalPages ? 'not-allowed' : 'pointer',
borderRadius: '0.25rem'
}}
>
Next
</button>
</div>
)}
</div>
<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>
);
}