'use client'; import { useState, useEffect } 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; _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; activations: number; puzzles: DailyPuzzle[]; genres: Genre[]; specials: Special[]; averageRating: number; ratingCount: number; } type SortField = 'id' | 'title' | 'artist' | 'createdAt'; type SortDirection = 'asc' | 'desc'; export default function AdminPage() { const [password, setPassword] = useState(''); const [isAuthenticated, setIsAuthenticated] = useState(false); const [files, setFiles] = useState([]); 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([]); const [isDragging, setIsDragging] = useState(false); const [songs, setSongs] = useState([]); const [genres, setGenres] = useState([]); const [newGenreName, setNewGenreName] = useState(''); const [newGenreSubtitle, setNewGenreSubtitle] = useState(''); const [editingGenreId, setEditingGenreId] = useState(null); const [editGenreName, setEditGenreName] = useState(''); const [editGenreSubtitle, setEditGenreSubtitle] = useState(''); // Specials state const [specials, setSpecials] = useState([]); 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(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(null); const [editTitle, setEditTitle] = useState(''); const [editArtist, setEditArtist] = useState(''); const [editGenreIds, setEditGenreIds] = useState([]); const [editSpecialIds, setEditSpecialIds] = useState([]); // Post-upload state const [uploadedSong, setUploadedSong] = useState(null); const [uploadGenreIds, setUploadGenreIds] = useState([]); // AI Categorization state const [isCategorizing, setIsCategorizing] = useState(false); const [categorizationResults, setCategorizationResults] = useState(null); // Sort state const [sortField, setSortField] = useState('artist'); const [sortDirection, setSortDirection] = useState('asc'); // Search and pagination state const [searchQuery, setSearchQuery] = useState(''); const [selectedGenreFilter, setSelectedGenreFilter] = useState(''); const [selectedSpecialFilter, setSelectedSpecialFilter] = useState(null); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 10; // Audio state const [playingSongId, setPlayingSongId] = useState(null); const [audioElement, setAudioElement] = useState(null); // Daily Puzzles state const [dailyPuzzles, setDailyPuzzles] = useState([]); const [playingPuzzleId, setPlayingPuzzleId] = useState(null); const [showDailyPuzzles, setShowDailyPuzzles] = useState(false); // 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 fetchSongs = async () => { const res = await fetch('/api/songs'); if (res.ok) { const data = await res.json(); setSongs(data); } }; const fetchGenres = async () => { const res = await fetch('/api/genres'); 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', body: JSON.stringify({ name: newGenreName, subtitle: newGenreSubtitle }), }); if (res.ok) { setNewGenreName(''); setNewGenreSubtitle(''); fetchGenres(); } else { alert('Failed to create genre'); } }; const startEditGenre = (genre: Genre) => { setEditingGenreId(genre.id); setEditGenreName(genre.name); setEditGenreSubtitle(genre.subtitle || ''); }; const saveEditedGenre = async () => { if (editingGenreId === null) return; const res = await fetch('/api/genres', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: editingGenreId, name: editGenreName, subtitle: editGenreSubtitle }), }); if (res.ok) { setEditingGenreId(null); fetchGenres(); } else { alert('Failed to update genre'); } }; // Specials functions const fetchSpecials = async () => { const res = await fetch('/api/specials'); 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: { 'Content-Type': 'application/json' }, 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: { 'Content-Type': 'application/json' }, 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'); 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: { 'Content-Type': 'application/json' }, 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: { 'Content-Type': 'application/json' }, 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', 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: { 'Content-Type': 'application/json' }, 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 { const formData = new FormData(); formData.append('file', file); const res = await fetch('/api/songs', { method: 'POST', body: formData, }); if (res.ok) { const data = await res.json(); results.push({ filename: file.name, success: true, song: data.song, validation: data.validation }); } else { results.push({ filename: file.name, success: false, error: 'Upload failed' }); } } catch (error) { results.push({ filename: file.name, success: false, error: 'Network error' }); } } setUploadResults(results); setFiles([]); setIsUploading(false); fetchSongs(); fetchGenres(); // Auto-trigger categorization after uploads const successCount = results.filter(r => r.success).length; if (successCount > 0) { setMessage(`βœ… Uploaded ${successCount}/${files.length} songs successfully!\n\nπŸ€– Starting auto-categorization...`); // Small delay to let user see the message setTimeout(() => { handleAICategorization(); }, 1000); } else { setMessage(`❌ All uploads failed.`); } }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); const droppedFiles = Array.from(e.dataTransfer.files).filter( file => file.type === 'audio/mpeg' || file.name.endsWith('.mp3') ); if (droppedFiles.length > 0) { setFiles(droppedFiles); } }; const handleFileChange = (e: React.ChangeEvent) => { if (e.target.files) { setFiles(Array.from(e.target.files)); } }; const saveUploadedSongGenres = async () => { if (!uploadedSong) return; const res = await fetch('/api/songs', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: uploadedSong.id, title: uploadedSong.title, artist: uploadedSong.artist, genreIds: uploadGenreIds }), }); if (res.ok) { setUploadedSong(null); setUploadGenreIds([]); fetchSongs(); fetchGenres(); 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); setEditGenreIds(song.genres.map(g => g.id)); setEditSpecialIds(song.specials ? song.specials.map(s => s.id) : []); }; const cancelEditing = () => { setEditingId(null); setEditTitle(''); setEditArtist(''); setEditGenreIds([]); setEditSpecialIds([]); }; const saveEditing = async (id: number) => { const res = await fetch('/api/songs', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, title: editTitle, artist: editArtist, genreIds: editGenreIds, specialIds: editSpecialIds }), }); if (res.ok) { setEditingId(null); fetchSongs(); fetchGenres(); } 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: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), }); if (res.ok) { fetchSongs(); fetchGenres(); } 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; } } return matchesSearch && matchesFilter; }); const sortedSongs = [...filteredSongs].sort((a, b) => { // Handle numeric sorting for ID if (sortField === 'id') { return sortDirection === 'asc' ? a.id - b.id : b.id - a.id; } // 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 (

Admin Login

setPassword(e.target.value)} className="form-input" style={{ marginBottom: '1rem', maxWidth: '300px' }} placeholder="Password" />
); } return (

HΓΆrdle Admin Dashboard

{/* Special Management */}

Manage Specials

setNewSpecialName(e.target.value)} className="form-input" required />
setNewSpecialSubtitle(e.target.value)} className="form-input" />
setNewSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} style={{ width: '80px' }} />
setNewSpecialUnlockSteps(e.target.value)} className="form-input" style={{ width: '200px' }} />
setNewSpecialLaunchDate(e.target.value)} className="form-input" />
setNewSpecialEndDate(e.target.value)} className="form-input" />
setNewSpecialCurator(e.target.value)} className="form-input" />
{specials.map(special => (
{special.name} ({special._count?.songs || 0}) {special.subtitle && - {special.subtitle}} Curate
))}
{editingSpecialId !== null && (

Edit Special

setEditSpecialName(e.target.value)} className="form-input" />
setEditSpecialSubtitle(e.target.value)} className="form-input" />
setEditSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} style={{ width: '80px' }} />
setEditSpecialUnlockSteps(e.target.value)} className="form-input" style={{ width: '200px' }} />
setEditSpecialLaunchDate(e.target.value)} className="form-input" />
setEditSpecialEndDate(e.target.value)} className="form-input" />
setEditSpecialCurator(e.target.value)} className="form-input" />
)}
{/* Genre Management */}

Manage Genres

setNewGenreName(e.target.value)} placeholder="New Genre Name" className="form-input" style={{ maxWidth: '200px' }} /> setNewGenreSubtitle(e.target.value)} placeholder="Subtitle" className="form-input" style={{ maxWidth: '300px' }} />
{genres.map(genre => (
{genre.name} ({genre._count?.songs || 0}) {genre.subtitle && - {genre.subtitle}}
))}
{editingGenreId !== null && (

Edit Genre

setEditGenreName(e.target.value)} className="form-input" />
setEditGenreSubtitle(e.target.value)} className="form-input" style={{ width: '300px' }} />
)} {/* AI Categorization */}
{genres.length === 0 && (

Please create at least one genre first.

)}
{/* Categorization Results */} {categorizationResults && (

βœ… Categorization Complete

{categorizationResults.message}

{categorizationResults.results && categorizationResults.results.length > 0 && (

Updated Songs:

{categorizationResults.results.map((result: any) => (
{result.title} by {result.artist}
{result.assignedGenres.map((genre: string) => ( {genre} ))}
))}
)}
)}

Upload Songs

{/* Drag & Drop Zone */}
document.getElementById('file-input')?.click()} >
πŸ“

{files.length > 0 ? `${files.length} file(s) selected` : 'Drag & drop MP3 files here'}

or click to browse

{/* File List */} {files.length > 0 && (

Selected Files:

{files.map((file, index) => (
πŸ“„ {file.name}
))}
)} {/* Upload Progress */} {isUploading && (

Uploading: {uploadProgress.current} / {uploadProgress.total}

)} {message && (
{message}
)}
{/* Today's Daily Puzzles */}

Today's Daily Puzzles

{showDailyPuzzles && (dailyPuzzles.length === 0 ? (

No daily puzzles found for today.

) : ( {dailyPuzzles.map(puzzle => ( ))}
Category Song Artist Actions
{puzzle.category} {puzzle.song.title} {puzzle.song.artist}
))}

Song Library ({songs.length} songs)

{/* Search and Filter */}
setSearchQuery(e.target.value)} className="form-input" style={{ flex: '1', minWidth: '200px' }} /> {(searchQuery || selectedGenreFilter) && ( )}
{paginatedSongs.map(song => ( {editingId === song.id ? ( <> ) : ( <> )} ))} {paginatedSongs.length === 0 && ( )}
handleSort('id')} > ID {sortField === 'id' && (sortDirection === 'asc' ? '↑' : '↓')} handleSort('title')} > Title {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')} Genres handleSort('createdAt')} > Added {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')} Activations Rating Actions
{song.id} setEditTitle(e.target.value)} className="form-input" style={{ padding: '0.25rem', marginBottom: '0.5rem', width: '100%' }} placeholder="Title" /> setEditArtist(e.target.value)} className="form-input" style={{ padding: '0.25rem', width: '100%' }} placeholder="Artist" />
{genres.map(genre => ( ))}
{specials.map(special => ( ))}
{new Date(song.createdAt).toLocaleDateString('de-DE')} {song.activations} {song.averageRating > 0 ? ( {song.averageRating.toFixed(1)} β˜… ({song.ratingCount}) ) : ( - )}
{song.title}
{song.artist}
{/* Daily Puzzle Badges */}
{song.puzzles?.filter(p => p.date === new Date().toISOString().split('T')[0]).map(p => { if (!p.genreId && !p.specialId) { return ( 🌍 Global Daily ); } if (p.genreId) { const genreName = genres.find(g => g.id === p.genreId)?.name; return ( 🏷️ {genreName} Daily ); } if (p.specialId) { const specialName = specials.find(s => s.id === p.specialId)?.name; return ( β˜… {specialName} Daily ); } return null; })}
{song.genres?.map(g => ( {g.name} ))}
{song.specials?.map(s => ( {s.name} ))}
{new Date(song.createdAt).toLocaleDateString('de-DE')} {song.activations} {song.averageRating > 0 ? ( {song.averageRating.toFixed(1)} β˜… ({song.ratingCount}) ) : ( - )}
{searchQuery ? 'No songs found matching your search.' : 'No songs uploaded yet.'}
{/* Pagination */} {totalPages > 1 && (
Page {currentPage} of {totalPages}
)}

Danger Zone

These actions are destructive and cannot be undone.

); }