'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; } 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([]); const [genres, setGenres] = useState([]); const [newGenreName, setNewGenreName] = useState(''); const [newGenreSubtitle, setNewGenreSubtitle] = useState(''); const [newGenreActive, setNewGenreActive] = useState(true); const [editingGenreId, setEditingGenreId] = useState(null); const [editGenreName, setEditGenreName] = useState(''); const [editGenreSubtitle, setEditGenreSubtitle] = useState(''); const [editGenreActive, setEditGenreActive] = useState(true); // 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(''); // News state const [news, setNews] = useState([]); const [newNewsTitle, setNewNewsTitle] = useState(''); const [newNewsContent, setNewNewsContent] = useState(''); const [newNewsAuthor, setNewNewsAuthor] = useState(''); const [newNewsFeatured, setNewNewsFeatured] = useState(false); const [newNewsSpecialId, setNewNewsSpecialId] = useState(null); const [editingNewsId, setEditingNewsId] = useState(null); const [editNewsTitle, setEditNewsTitle] = useState(''); const [editNewsContent, setEditNewsContent] = useState(''); const [editNewsAuthor, setEditNewsAuthor] = useState(''); const [editNewsFeatured, setEditNewsFeatured] = useState(false); const [editNewsSpecialId, setEditNewsSpecialId] = useState(null); // AI Categorization state const [isCategorizing, setIsCategorizing] = useState(false); const [categorizationResults, setCategorizationResults] = useState(null); // Audio state const [audioElement, setAudioElement] = useState(null); // Daily Puzzles state const [dailyPuzzles, setDailyPuzzles] = useState([]); const [playingPuzzleId, setPlayingPuzzleId] = useState(null); const [showDailyPuzzles, setShowDailyPuzzles] = useState(false); const fileInputRef = useRef(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, }), }); 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'); } }; // 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 (

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} )}
))}
{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} ))}
))}
)}
)}
{/* News Management */}

Manage News & Announcements

setNewNewsTitle(e.target.value)} placeholder="News Title" className="form-input" required />