From 771d0d06f38f38165dee770acc47d71f65ddadb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Fri, 28 Nov 2025 15:36:06 +0100 Subject: [PATCH] =?UTF-8?q?Implementiere=20i18n=20f=C3=BCr=20Frontend,=20A?= =?UTF-8?q?dmin=20und=20Datenbank?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[genre]/page.tsx | 110 - app/[locale]/[genre]/page.tsx | 134 + app/[locale]/admin/page.tsx | 2156 +++++++++++++++++ app/[locale]/layout.tsx | 74 + app/[locale]/page.tsx | 138 ++ app/[locale]/special/[name]/page.tsx | 125 + app/api/admin/daily-puzzles/route.ts | 4 +- app/api/categorize/route.ts | 8 +- app/api/daily/route.ts | 16 +- app/api/genres/route.ts | 41 +- app/api/news/route.ts | 27 +- app/api/specials/route.ts | 47 +- app/layout.tsx | 55 - app/page.tsx | 107 - app/special/[name]/page.tsx | 111 - components/Game.tsx | 91 +- components/GuessInput.tsx | 4 +- components/InstallPrompt.tsx | 10 +- components/LanguageSwitcher.tsx | 59 + components/NewsSection.tsx | 27 +- components/OnboardingTour.tsx | 43 +- components/Statistics.tsx | 10 +- i18n/request.ts | 20 + lib/dailyPuzzle.ts | 33 +- lib/i18n.ts | 41 + lib/navigation.ts | 9 + messages/de.json | 108 + messages/en.json | 108 + middleware.ts | 43 +- next.config.ts | 5 +- package-lock.json | 378 ++- package.json | 3 +- .../migration.sql | 11 + .../migration.sql | 60 + prisma/schema.prisma | 12 +- scripts/restore_songs.ts | 18 +- scripts/verify-i18n-migration.ts | 31 + 37 files changed, 3717 insertions(+), 560 deletions(-) delete mode 100644 app/[genre]/page.tsx create mode 100644 app/[locale]/[genre]/page.tsx create mode 100644 app/[locale]/admin/page.tsx create mode 100644 app/[locale]/layout.tsx create mode 100644 app/[locale]/page.tsx create mode 100644 app/[locale]/special/[name]/page.tsx delete mode 100644 app/layout.tsx delete mode 100644 app/page.tsx delete mode 100644 app/special/[name]/page.tsx create mode 100644 components/LanguageSwitcher.tsx create mode 100644 i18n/request.ts create mode 100644 lib/i18n.ts create mode 100644 lib/navigation.ts create mode 100644 messages/de.json create mode 100644 messages/en.json create mode 100644 prisma/migrations/20251128131405_add_i18n_columns/migration.sql create mode 100644 prisma/migrations/20251128132806_switch_to_json_columns/migration.sql create mode 100644 scripts/verify-i18n-migration.ts diff --git a/app/[genre]/page.tsx b/app/[genre]/page.tsx deleted file mode 100644 index e5dc87e..0000000 --- a/app/[genre]/page.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import Game from '@/components/Game'; -import NewsSection from '@/components/NewsSection'; -import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle'; -import Link from 'next/link'; -import { PrismaClient } from '@prisma/client'; -import { notFound } from 'next/navigation'; - -export const dynamic = 'force-dynamic'; - -const prisma = new PrismaClient(); - -interface PageProps { - params: Promise<{ genre: string }>; -} - -export default async function GenrePage({ params }: PageProps) { - const { genre } = await params; - const decodedGenre = decodeURIComponent(genre); - - // Check if genre exists and is active - const currentGenre = await prisma.genre.findUnique({ - where: { name: decodedGenre } - }); - - if (!currentGenre || !currentGenre.active) { - notFound(); - } - - const dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre); - const genres = await prisma.genre.findMany({ - where: { active: true }, - orderBy: { name: 'asc' } - }); - const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } }); - - const now = new Date(); - const activeSpecials = specials.filter(s => { - const isStarted = !s.launchDate || s.launchDate <= now; - const isEnded = s.endDate && s.endDate < now; - return isStarted && !isEnded; - }); - - const upcomingSpecials = specials.filter(s => { - return s.launchDate && s.launchDate > now; - }); - - return ( - <> -
-
- Global - - {/* Genres */} - {genres.map(g => ( - - {g.name} - - ))} - - {/* Separator if both exist */} - {genres.length > 0 && activeSpecials.length > 0 && ( - | - )} - - {/* Specials */} - {activeSpecials.map(s => ( - - ★ {s.name} - - ))} -
- - {/* Upcoming Specials */} - {upcomingSpecials.length > 0 && ( -
- Coming soon: {upcomingSpecials.map(s => ( - - ★ {s.name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString('de-DE', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - timeZone: process.env.TZ - }) : ''}) - {s.curator && Curated by {s.curator}} - - ))} -
- )} -
- - - - ); -} diff --git a/app/[locale]/[genre]/page.tsx b/app/[locale]/[genre]/page.tsx new file mode 100644 index 0000000..619ce82 --- /dev/null +++ b/app/[locale]/[genre]/page.tsx @@ -0,0 +1,134 @@ +import Game from '@/components/Game'; +import NewsSection from '@/components/NewsSection'; +import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle'; +import { Link } from '@/lib/navigation'; +import { PrismaClient } from '@prisma/client'; +import { notFound } from 'next/navigation'; +import { getLocalizedValue } from '@/lib/i18n'; +import { getTranslations } from 'next-intl/server'; + +export const dynamic = 'force-dynamic'; + +const prisma = new PrismaClient(); + +interface PageProps { + params: Promise<{ locale: string; genre: string }>; +} + +export default async function GenrePage({ params }: PageProps) { + const { locale, genre } = await params; + const decodedGenre = decodeURIComponent(genre); + const tNav = await getTranslations('Navigation'); + + // Fetch all genres to find the matching one by localized name + const allGenres = await prisma.genre.findMany(); + const currentGenre = allGenres.find(g => getLocalizedValue(g.name, locale) === decodedGenre); + + if (!currentGenre || !currentGenre.active) { + notFound(); + } + + const dailyPuzzle = await getOrCreateDailyPuzzle(currentGenre); + // getOrCreateDailyPuzzle likely expects string or needs update. + // Actually, getOrCreateDailyPuzzle takes `genreName: string | null`. + // If I pass the JSON object, it might fail. + // But wait, the DB schema for DailyPuzzle stores `genreId`. + // `getOrCreateDailyPuzzle` probably looks up genre by name. + // I should check `lib/dailyPuzzle.ts`. + // For now, I'll pass the localized name, but that might be risky if it tries to create a genre (unlikely). + // Let's assume for now I should pass the localized name if that's what it uses to find/create. + // But if `getOrCreateDailyPuzzle` uses `findUnique({ where: { name: genreName } })`, it will fail because name is JSON. + // I need to update `lib/dailyPuzzle.ts` too! + // I'll mark that as a todo. For now, let's proceed with page creation. + + const genres = allGenres.filter(g => g.active); + // Sort + genres.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale))); + + const specials = await prisma.special.findMany(); + specials.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale))); + + const now = new Date(); + const activeSpecials = specials.filter(s => { + const isStarted = !s.launchDate || s.launchDate <= now; + const isEnded = s.endDate && s.endDate < now; + return isStarted && !isEnded; + }); + + const upcomingSpecials = specials.filter(s => { + return s.launchDate && s.launchDate > now; + }); + + return ( + <> +
+
+ {tNav('global')} + + {/* Genres */} + {genres.map(g => { + const name = getLocalizedValue(g.name, locale); + return ( + + {name} + + ); + })} + + {/* Separator if both exist */} + {genres.length > 0 && activeSpecials.length > 0 && ( + | + )} + + {/* Specials */} + {activeSpecials.map(s => { + const name = getLocalizedValue(s.name, locale); + return ( + + ★ {name} + + ); + })} +
+ + {/* Upcoming Specials */} + {upcomingSpecials.length > 0 && ( +
+ Coming soon: {upcomingSpecials.map(s => { + const name = getLocalizedValue(s.name, locale); + return ( + + ★ {name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString(locale, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + timeZone: process.env.TZ + }) : ''}) + {s.curator && Curated by {s.curator}} + + ); + })} +
+ )} +
+ + + + ); +} diff --git a/app/[locale]/admin/page.tsx b/app/[locale]/admin/page.tsx new file mode 100644 index 0000000..1c5dd48 --- /dev/null +++ b/app/[locale]/admin/page.tsx @@ -0,0 +1,2156 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { getLocalizedValue } from '@/lib/i18n'; + + +interface Special { + id: number; + name: any; + subtitle?: any; + maxAttempts: number; + unlockSteps: string; + launchDate?: string; + endDate?: string; + curator?: string; + _count?: { + songs: number; + }; +} + +interface Genre { + id: number; + name: any; + subtitle?: any; + active: boolean; + _count?: { + songs: number; + }; +} + +interface DailyPuzzle { + id: number; + date: string; + songId: number; + genreId: number | null; + specialId: number | null; +} + +interface Song { + id: number; + title: string; + artist: string; + filename: string; + createdAt: string; + releaseYear: number | null; + activations: number; + puzzles: DailyPuzzle[]; + genres: Genre[]; + specials: Special[]; + averageRating: number; + ratingCount: number; + excludeFromGlobal: boolean; +} + +interface News { + id: number; + title: any; + content: any; + author: string | null; + publishedAt: string; + featured: boolean; + specialId: number | null; + special: { + id: number; + name: any; + } | null; +} + +type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating'; +type SortDirection = 'asc' | 'desc'; + +export default function AdminPage({ params }: { params: { locale: string } }) { + const [activeTab, setActiveTab] = useState<'de' | 'en'>('de'); + 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({ de: '', en: '' }); + const [newGenreSubtitle, setNewGenreSubtitle] = useState({ de: '', en: '' }); + const [newGenreActive, setNewGenreActive] = useState(true); + const [editingGenreId, setEditingGenreId] = useState(null); + const [editGenreName, setEditGenreName] = useState({ de: '', en: '' }); + const [editGenreSubtitle, setEditGenreSubtitle] = useState({ de: '', en: '' }); + const [editGenreActive, setEditGenreActive] = useState(true); + + // Specials state + const [specials, setSpecials] = useState([]); + const [newSpecialName, setNewSpecialName] = useState({ de: '', en: '' }); + const [newSpecialSubtitle, setNewSpecialSubtitle] = useState({ de: '', en: '' }); + const [newSpecialMaxAttempts, setNewSpecialMaxAttempts] = useState(7); + const [newSpecialUnlockSteps, setNewSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]'); + const [newSpecialLaunchDate, setNewSpecialLaunchDate] = useState(''); + const [newSpecialEndDate, setNewSpecialEndDate] = useState(''); + const [newSpecialCurator, setNewSpecialCurator] = useState(''); + + const [editingSpecialId, setEditingSpecialId] = useState(null); + const [editSpecialName, setEditSpecialName] = useState({ de: '', en: '' }); + const [editSpecialSubtitle, setEditSpecialSubtitle] = useState({ de: '', en: '' }); + const [editSpecialMaxAttempts, setEditSpecialMaxAttempts] = useState(7); + const [editSpecialUnlockSteps, setEditSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]'); + const [editSpecialLaunchDate, setEditSpecialLaunchDate] = useState(''); + const [editSpecialEndDate, setEditSpecialEndDate] = useState(''); + const [editSpecialCurator, setEditSpecialCurator] = useState(''); + + // News state + const [news, setNews] = useState([]); + const [newNewsTitle, setNewNewsTitle] = useState({ de: '', en: '' }); + const [newNewsContent, setNewNewsContent] = useState({ de: '', en: '' }); + const [newNewsAuthor, setNewNewsAuthor] = useState(''); + const [newNewsFeatured, setNewNewsFeatured] = useState(false); + const [newNewsSpecialId, setNewNewsSpecialId] = useState(null); + const [editingNewsId, setEditingNewsId] = useState(null); + const [editNewsTitle, setEditNewsTitle] = useState({ de: '', en: '' }); + const [editNewsContent, setEditNewsContent] = useState({ de: '', en: '' }); + const [editNewsAuthor, setEditNewsAuthor] = useState(''); + const [editNewsFeatured, setEditNewsFeatured] = useState(false); + const [editNewsSpecialId, setEditNewsSpecialId] = useState(null); + + // Edit state + const [editingId, setEditingId] = useState(null); + const [editTitle, setEditTitle] = useState(''); + const [editArtist, setEditArtist] = useState(''); + const [editReleaseYear, setEditReleaseYear] = useState(''); + const [editGenreIds, setEditGenreIds] = useState([]); + const [editSpecialIds, setEditSpecialIds] = useState([]); + const [editExcludeFromGlobal, setEditExcludeFromGlobal] = useState(false); + + // Post-upload state + const [uploadedSong, setUploadedSong] = useState(null); + const [uploadGenreIds, setUploadGenreIds] = useState([]); + const [uploadExcludeFromGlobal, setUploadExcludeFromGlobal] = useState(false); + + // Batch upload genre selection + const [batchUploadGenreIds, setBatchUploadGenreIds] = 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); + 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.de.trim() && !newGenreName.en.trim()) return; + const res = await fetch('/api/genres', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ + name: newGenreName, + subtitle: newGenreSubtitle, + active: newGenreActive + }), + }); + if (res.ok) { + setNewGenreName({ de: '', en: '' }); + setNewGenreSubtitle({ de: '', en: '' }); + setNewGenreActive(true); + fetchGenres(); + } else { + alert('Failed to create genre'); + } + }; + + const startEditGenre = (genre: Genre) => { + setEditingGenreId(genre.id); + setEditGenreName(typeof genre.name === 'string' ? { de: genre.name, en: genre.name } : genre.name); + setEditGenreSubtitle(genre.subtitle ? (typeof genre.subtitle === 'string' ? { de: genre.subtitle, en: genre.subtitle } : genre.subtitle) : { de: '', en: '' }); + setEditGenreActive(genre.active !== undefined ? genre.active : true); + }; + + const saveEditedGenre = async () => { + if (editingGenreId === null) return; + const res = await fetch('/api/genres', { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify({ + id: editingGenreId, + name: editGenreName, + subtitle: editGenreSubtitle, + active: editGenreActive + }), + }); + if (res.ok) { + setEditingGenreId(null); + fetchGenres(); + } else { + alert('Failed to update genre'); + } + }; + + // Specials functions + const fetchSpecials = async () => { + const res = await fetch('/api/specials', { + headers: getAuthHeaders() + }); + if (res.ok) { + const data = await res.json(); + setSpecials(data); + } + }; + + const handleCreateSpecial = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newSpecialName.de.trim() && !newSpecialName.en.trim()) return; + const res = await fetch('/api/specials', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ + name: newSpecialName, + subtitle: newSpecialSubtitle, + maxAttempts: newSpecialMaxAttempts, + unlockSteps: newSpecialUnlockSteps, + launchDate: newSpecialLaunchDate || null, + endDate: newSpecialEndDate || null, + curator: newSpecialCurator || null, + }), + }); + if (res.ok) { + setNewSpecialName({ de: '', en: '' }); + setNewSpecialSubtitle({ de: '', en: '' }); + setNewSpecialMaxAttempts(7); + setNewSpecialUnlockSteps('[2,4,7,11,16,30,60]'); + setNewSpecialLaunchDate(''); + setNewSpecialEndDate(''); + setNewSpecialCurator(''); + fetchSpecials(); + } else { + alert('Failed to create special'); + } + }; + + const handleDeleteSpecial = async (id: number) => { + if (!confirm('Delete this special?')) return; + const res = await fetch('/api/specials', { + method: 'DELETE', + headers: getAuthHeaders(), + body: JSON.stringify({ id }), + }); + if (res.ok) fetchSpecials(); + else alert('Failed to delete special'); + }; + + // Daily Puzzles functions + const fetchDailyPuzzles = async () => { + const res = await fetch('/api/admin/daily-puzzles', { + headers: getAuthHeaders() + }); + if (res.ok) { + const data = await res.json(); + setDailyPuzzles(data); + } + }; + + const handleDeletePuzzle = async (puzzleId: number) => { + if (!confirm('Delete this daily puzzle? A new one will be generated automatically.')) return; + const res = await fetch('/api/admin/daily-puzzles', { + method: 'DELETE', + headers: getAuthHeaders(), + body: JSON.stringify({ puzzleId }), + }); + if (res.ok) { + fetchDailyPuzzles(); + alert('Puzzle deleted and regenerated successfully'); + } else { + alert('Failed to delete puzzle'); + } + }; + + const handlePlayPuzzle = (puzzle: any) => { + if (playingPuzzleId === puzzle.id) { + // Pause + audioElement?.pause(); + setPlayingPuzzleId(null); + setAudioElement(null); + } else { + // Stop any currently playing audio + if (audioElement) { + audioElement.pause(); + setAudioElement(null); + } + + const audio = new Audio(puzzle.song.audioUrl); + audio.play() + .then(() => { + setAudioElement(audio); + setPlayingPuzzleId(puzzle.id); + }) + .catch((error) => { + console.error('Playback error:', error); + alert(`Failed to play audio: ${error.message}`); + setPlayingPuzzleId(null); + setAudioElement(null); + }); + + audio.onended = () => { + setPlayingPuzzleId(null); + setAudioElement(null); + }; + } + }; + + const startEditSpecial = (special: Special) => { + setEditingSpecialId(special.id); + setEditSpecialName(typeof special.name === 'string' ? { de: special.name, en: special.name } : special.name); + setEditSpecialSubtitle(special.subtitle ? (typeof special.subtitle === 'string' ? { de: special.subtitle, en: special.subtitle } : special.subtitle) : { de: '', en: '' }); + setEditSpecialMaxAttempts(special.maxAttempts); + setEditSpecialUnlockSteps(special.unlockSteps); + setEditSpecialLaunchDate(special.launchDate ? new Date(special.launchDate).toISOString().split('T')[0] : ''); + setEditSpecialEndDate(special.endDate ? new Date(special.endDate).toISOString().split('T')[0] : ''); + setEditSpecialCurator(special.curator || ''); + }; + + const saveEditedSpecial = async () => { + if (editingSpecialId === null) return; + const res = await fetch('/api/specials', { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify({ + id: editingSpecialId, + name: editSpecialName, + subtitle: editSpecialSubtitle, + maxAttempts: editSpecialMaxAttempts, + unlockSteps: editSpecialUnlockSteps, + launchDate: editSpecialLaunchDate || null, + endDate: editSpecialEndDate || null, + curator: editSpecialCurator || null, + }), + }); + if (res.ok) { + setEditingSpecialId(null); + fetchSpecials(); + } else { + alert('Failed to update special'); + } + }; + + // News functions + const fetchNews = async () => { + const res = await fetch('/api/news', { + headers: getAuthHeaders() + }); + if (res.ok) { + const data = await res.json(); + setNews(data); + } + }; + + const handleCreateNews = async (e: React.FormEvent) => { + e.preventDefault(); + if ((!newNewsTitle.de.trim() && !newNewsTitle.en.trim()) || (!newNewsContent.de.trim() && !newNewsContent.en.trim())) return; + + const res = await fetch('/api/news', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ + title: newNewsTitle, + content: newNewsContent, + author: newNewsAuthor || null, + featured: newNewsFeatured, + specialId: newNewsSpecialId + }), + }); + + if (res.ok) { + setNewNewsTitle({ de: '', en: '' }); + setNewNewsContent({ de: '', en: '' }); + setNewNewsAuthor(''); + setNewNewsFeatured(false); + setNewNewsSpecialId(null); + fetchNews(); + } else { + alert('Failed to create news'); + } + }; + + const startEditNews = (newsItem: News) => { + setEditingNewsId(newsItem.id); + setEditNewsTitle(typeof newsItem.title === 'string' ? { de: newsItem.title, en: newsItem.title } : newsItem.title); + setEditNewsContent(typeof newsItem.content === 'string' ? { de: newsItem.content, en: newsItem.content } : newsItem.content); + setEditNewsAuthor(newsItem.author || ''); + setEditNewsFeatured(newsItem.featured); + setEditNewsSpecialId(newsItem.specialId); + }; + + const saveEditedNews = async () => { + if (editingNewsId === null) return; + + const res = await fetch('/api/news', { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify({ + id: editingNewsId, + title: editNewsTitle, + content: editNewsContent, + author: editNewsAuthor || null, + featured: editNewsFeatured, + specialId: editNewsSpecialId + }), + }); + + if (res.ok) { + setEditingNewsId(null); + fetchNews(); + } else { + alert('Failed to update news'); + } + }; + + const handleDeleteNews = async (id: number) => { + if (!confirm('Delete this news item?')) return; + + const res = await fetch('/api/news', { + method: 'DELETE', + headers: getAuthHeaders(), + body: JSON.stringify({ id }), + }); + + if (res.ok) { + fetchNews(); + } else { + alert('Failed to delete news'); + } + }; + + // Load specials 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); + + // Assign genres to successfully uploaded songs + if (batchUploadGenreIds.length > 0) { + const successfulUploads = results.filter(r => r.success && r.song); + for (const result of successfulUploads) { + try { + await fetch('/api/songs', { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify({ + id: result.song.id, + title: result.song.title, + artist: result.song.artist, + genreIds: batchUploadGenreIds + }), + }); + } catch (error) { + console.error(`Failed to assign genres to ${result.song.title}:`, error); + } + } + } + + fetchSongs(); + fetchGenres(); + fetchSpecials(); // Update special counts + + // Auto-trigger categorization after uploads + const successCount = results.filter(r => r.success).length; + const duplicateCount = results.filter(r => r.isDuplicate).length; + const failedCount = results.filter(r => !r.success && !r.isDuplicate).length; + if (successCount > 0) { + let msg = `✅ Uploaded ${successCount}/${files.length} songs successfully!`; + if (duplicateCount > 0) { + msg += `\n⚠️ Skipped ${duplicateCount} duplicate(s)`; + } + if (failedCount > 0) { + msg += `\n❌ ${failedCount} failed`; + } + if (batchUploadGenreIds.length > 0) { + const selectedGenreNames = genres + .filter(g => batchUploadGenreIds.includes(g.id)) + .map(g => getLocalizedValue(g.name, activeTab)) + .join(', '); + msg += `\n🏷️ Assigned genres: ${selectedGenreNames}`; + } + msg += '\n\n🤖 Starting auto-categorization...'; + setMessage(msg); + // Small delay to let user see the message + setTimeout(() => { + handleAICategorization(); + }, 1000); + } else if (duplicateCount > 0 && failedCount === 0) { + setMessage(`⚠️ All ${duplicateCount} file(s) were duplicates - nothing uploaded.`); + } else { + setMessage(`❌ All uploads failed.`); + } + }; + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'copy'; + if (!isDragging) setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Prevent flickering when dragging over children + if (e.currentTarget.contains(e.relatedTarget as Node)) { + return; + } + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + const droppedFiles = Array.from(e.dataTransfer.files); + + // Validate file types + const validFiles: File[] = []; + const invalidFiles: string[] = []; + + droppedFiles.forEach(file => { + if (file.type === 'audio/mpeg' || file.name.toLowerCase().endsWith('.mp3')) { + validFiles.push(file); + } else { + invalidFiles.push(`${file.name} (${file.type || 'unknown type'})`); + } + }); + + if (invalidFiles.length > 0) { + alert(`⚠️ The following files are not supported:\n\n${invalidFiles.join('\n')}\n\nOnly MP3 files are allowed.`); + } + + if (validFiles.length > 0) { + setFiles(validFiles); + } + }; + + const handleFileChange = (e: React.ChangeEvent) => { + 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 ( +
+

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({ ...newSpecialName, [activeTab]: e.target.value })} className="form-input" required /> +
+
+ + setNewSpecialSubtitle({ ...newSpecialSubtitle, [activeTab]: 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 => ( +
+ {getLocalizedValue(special.name, activeTab)} ({special._count?.songs || 0}) + {special.subtitle && - {getLocalizedValue(special.subtitle, activeTab)}} + Curate + + +
+ ))} +
+ {editingSpecialId !== null && ( +
+

Edit Special

+
+
+ + setEditSpecialName({ ...editSpecialName, [activeTab]: e.target.value })} className="form-input" /> +
+
+ + setEditSpecialSubtitle({ ...editSpecialSubtitle, [activeTab]: 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({ ...newGenreName, [activeTab]: e.target.value })} + placeholder="New Genre Name" + className="form-input" + style={{ maxWidth: '200px' }} + /> + setNewGenreSubtitle({ ...newGenreSubtitle, [activeTab]: e.target.value })} + placeholder="Subtitle" + className="form-input" + style={{ maxWidth: '300px' }} + /> + + +
+
+ {genres.map(genre => ( +
+ {getLocalizedValue(genre.name, activeTab)} ({genre._count?.songs || 0}) + {genre.subtitle && - {getLocalizedValue(genre.subtitle, activeTab)}} + + +
+ ))} +
+ {editingGenreId !== null && ( +
+

Edit Genre

+
+
+ + setEditGenreName({ ...editGenreName, [activeTab]: e.target.value })} className="form-input" /> +
+
+ + setEditGenreSubtitle({ ...editGenreSubtitle, [activeTab]: 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({ ...newNewsTitle, [activeTab]: e.target.value })} + placeholder="News Title" + className="form-input" + required + /> +