Compare commits

..

9 Commits

14 changed files with 278 additions and 181 deletions

View File

@@ -12,18 +12,22 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- Automatische Extraktion von ID3-Tags (Titel, Interpret). - Automatische Extraktion von ID3-Tags (Titel, Interpret).
- Intelligente Artist-Erkennung (unterstützt Multi-Artist-Tags). - Intelligente Artist-Erkennung (unterstützt Multi-Artist-Tags).
- Bearbeitung von Metadaten. - Bearbeitung von Metadaten.
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am). - Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
- Play/Pause-Funktion zum Vorhören in der Bibliothek. - Play/Pause-Funktion zum Vorhören in der Bibliothek.
- **Cover Art:** - **Cover Art:**
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien. - Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
- Anzeige des Covers nach Spielende (Sieg/Niederlage). - Anzeige des Covers nach Spielende (Sieg/Niederlage).
- Automatische Migration bestehender Songs. - Automatische Migration bestehender Songs.
- **Teilen-Funktion:** Ergebnisse können als Emoji-Grid geteilt werden. - **Teilen-Funktion:**
- Ergebnisse können als Emoji-Grid geteilt werden.
- Stern-Symbol (⭐) bei korrekt beantworteter Bonusfrage.
- Automatische Anpassung für Genre- und Special-Rätsel.
- **PWA Support:** Installierbar als App auf Desktop und Mobilgeräten (Manifest & Icons). - **PWA Support:** Installierbar als App auf Desktop und Mobilgeräten (Manifest & Icons).
- **Persistenz:** Spielstatus wird lokal im Browser gespeichert. - **Persistenz:** Spielstatus wird lokal im Browser gespeichert.
- **Benachrichtigungen:** Integration mit Gotify für Push-Nachrichten bei Spielabschluss. - **Benachrichtigungen:** Integration mit Gotify für Push-Nachrichten bei Spielabschluss.
- **Genre-Management:** - **Genre-Management:**
- Erstellen und Verwalten von Musik-Genres. - Erstellen und Verwalten von Musik-Genres.
- **Aktivierung/Deaktivierung:** Genres können aktiviert oder deaktiviert werden (deaktivierte Genres sind nicht auf der Startseite sichtbar und ihre Routen sind nicht erreichbar).
- Manuelle Zuweisung von Genres zu Songs. - Manuelle Zuweisung von Genres zu Songs.
- KI-gestützte automatische Kategorisierung mit OpenRouter (Claude 3.5 Haiku). - KI-gestützte automatische Kategorisierung mit OpenRouter (Claude 3.5 Haiku).
- Genre-spezifische tägliche Rätsel. - Genre-spezifische tägliche Rätsel.

View File

@@ -2,6 +2,7 @@ import Game from '@/components/Game';
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle'; import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
import Link from 'next/link'; import Link from 'next/link';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { notFound } from 'next/navigation';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -14,8 +15,21 @@ interface PageProps {
export default async function GenrePage({ params }: PageProps) { export default async function GenrePage({ params }: PageProps) {
const { genre } = await params; const { genre } = await params;
const decodedGenre = decodeURIComponent(genre); 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 dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre);
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } }); const genres = await prisma.genre.findMany({
where: { active: true },
orderBy: { name: 'asc' }
});
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } }); const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
const now = new Date(); const now = new Date();

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
interface Special { interface Special {
@@ -21,6 +21,7 @@ interface Genre {
id: number; id: number;
name: string; name: string;
subtitle?: string; subtitle?: string;
active: boolean;
_count?: { _count?: {
songs: number; songs: number;
}; };
@@ -47,9 +48,10 @@ interface Song {
specials: Special[]; specials: Special[];
averageRating: number; averageRating: number;
ratingCount: number; ratingCount: number;
excludeFromGlobal: boolean;
} }
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear'; type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
type SortDirection = 'asc' | 'desc'; type SortDirection = 'asc' | 'desc';
export default function AdminPage() { export default function AdminPage() {
@@ -65,9 +67,11 @@ export default function AdminPage() {
const [genres, setGenres] = useState<Genre[]>([]); const [genres, setGenres] = useState<Genre[]>([]);
const [newGenreName, setNewGenreName] = useState(''); const [newGenreName, setNewGenreName] = useState('');
const [newGenreSubtitle, setNewGenreSubtitle] = useState(''); const [newGenreSubtitle, setNewGenreSubtitle] = useState('');
const [newGenreActive, setNewGenreActive] = useState(true);
const [editingGenreId, setEditingGenreId] = useState<number | null>(null); const [editingGenreId, setEditingGenreId] = useState<number | null>(null);
const [editGenreName, setEditGenreName] = useState(''); const [editGenreName, setEditGenreName] = useState('');
const [editGenreSubtitle, setEditGenreSubtitle] = useState(''); const [editGenreSubtitle, setEditGenreSubtitle] = useState('');
const [editGenreActive, setEditGenreActive] = useState(true);
// Specials state // Specials state
const [specials, setSpecials] = useState<Special[]>([]); const [specials, setSpecials] = useState<Special[]>([]);
@@ -95,10 +99,12 @@ export default function AdminPage() {
const [editReleaseYear, setEditReleaseYear] = useState<number | ''>(''); const [editReleaseYear, setEditReleaseYear] = useState<number | ''>('');
const [editGenreIds, setEditGenreIds] = useState<number[]>([]); const [editGenreIds, setEditGenreIds] = useState<number[]>([]);
const [editSpecialIds, setEditSpecialIds] = useState<number[]>([]); const [editSpecialIds, setEditSpecialIds] = useState<number[]>([]);
const [editExcludeFromGlobal, setEditExcludeFromGlobal] = useState(false);
// Post-upload state // Post-upload state
const [uploadedSong, setUploadedSong] = useState<Song | null>(null); const [uploadedSong, setUploadedSong] = useState<Song | null>(null);
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]); const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
const [uploadExcludeFromGlobal, setUploadExcludeFromGlobal] = useState(false);
// AI Categorization state // AI Categorization state
const [isCategorizing, setIsCategorizing] = useState(false); const [isCategorizing, setIsCategorizing] = useState(false);
@@ -123,6 +129,7 @@ export default function AdminPage() {
const [dailyPuzzles, setDailyPuzzles] = useState<any[]>([]); const [dailyPuzzles, setDailyPuzzles] = useState<any[]>([]);
const [playingPuzzleId, setPlayingPuzzleId] = useState<number | null>(null); const [playingPuzzleId, setPlayingPuzzleId] = useState<number | null>(null);
const [showDailyPuzzles, setShowDailyPuzzles] = useState(false); const [showDailyPuzzles, setShowDailyPuzzles] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Check for existing auth on mount // Check for existing auth on mount
useEffect(() => { useEffect(() => {
@@ -196,11 +203,16 @@ export default function AdminPage() {
const res = await fetch('/api/genres', { const res = await fetch('/api/genres', {
method: 'POST', method: 'POST',
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify({ name: newGenreName, subtitle: newGenreSubtitle }), body: JSON.stringify({
name: newGenreName,
subtitle: newGenreSubtitle,
active: newGenreActive
}),
}); });
if (res.ok) { if (res.ok) {
setNewGenreName(''); setNewGenreName('');
setNewGenreSubtitle(''); setNewGenreSubtitle('');
setNewGenreActive(true);
fetchGenres(); fetchGenres();
} else { } else {
alert('Failed to create genre'); alert('Failed to create genre');
@@ -211,6 +223,7 @@ export default function AdminPage() {
setEditingGenreId(genre.id); setEditingGenreId(genre.id);
setEditGenreName(genre.name); setEditGenreName(genre.name);
setEditGenreSubtitle(genre.subtitle || ''); setEditGenreSubtitle(genre.subtitle || '');
setEditGenreActive(genre.active !== undefined ? genre.active : true);
}; };
const saveEditedGenre = async () => { const saveEditedGenre = async () => {
@@ -221,7 +234,8 @@ export default function AdminPage() {
body: JSON.stringify({ body: JSON.stringify({
id: editingGenreId, id: editingGenreId,
name: editGenreName, name: editGenreName,
subtitle: editGenreSubtitle subtitle: editGenreSubtitle,
active: editGenreActive
}), }),
}); });
if (res.ok) { if (res.ok) {
@@ -478,8 +492,11 @@ export default function AdminPage() {
setUploadProgress({ current: i + 1, total: files.length }); setUploadProgress({ current: i + 1, total: files.length });
try { try {
console.log(`Uploading file ${i + 1}/${files.length}: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB)`);
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('excludeFromGlobal', String(uploadExcludeFromGlobal));
const res = await fetch('/api/songs', { const res = await fetch('/api/songs', {
method: 'POST', method: 'POST',
@@ -487,8 +504,11 @@ export default function AdminPage() {
body: formData, body: formData,
}); });
console.log(`Response status for ${file.name}: ${res.status}`);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
console.log(`Upload successful for ${file.name}:`, data);
results.push({ results.push({
filename: file.name, filename: file.name,
success: true, success: true,
@@ -498,6 +518,7 @@ export default function AdminPage() {
} else if (res.status === 409) { } else if (res.status === 409) {
// Duplicate detected // Duplicate detected
const data = await res.json(); const data = await res.json();
console.log(`Duplicate detected for ${file.name}:`, data);
results.push({ results.push({
filename: file.name, filename: file.name,
success: false, success: false,
@@ -506,17 +527,20 @@ export default function AdminPage() {
error: `Duplicate: Already exists as "${data.duplicate.title}" by "${data.duplicate.artist}"` error: `Duplicate: Already exists as "${data.duplicate.title}" by "${data.duplicate.artist}"`
}); });
} else { } else {
const errorText = await res.text();
console.error(`Upload failed for ${file.name} (${res.status}):`, errorText);
results.push({ results.push({
filename: file.name, filename: file.name,
success: false, success: false,
error: 'Upload failed' error: `Upload failed (${res.status}): ${errorText.substring(0, 100)}`
}); });
} }
} catch (error) { } catch (error) {
console.error(`Network error for ${file.name}:`, error);
results.push({ results.push({
filename: file.name, filename: file.name,
success: false, success: false,
error: 'Network error' error: `Network error: ${error instanceof Error ? error.message : 'Unknown error'}`
}); });
} }
} }
@@ -553,32 +577,81 @@ export default function AdminPage() {
} }
}; };
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragOver = (e: React.DragEvent) => { const handleDragOver = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
setIsDragging(true); e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
if (!isDragging) setIsDragging(true);
}; };
const handleDragLeave = (e: React.DragEvent) => { const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
// Prevent flickering when dragging over children
if (e.currentTarget.contains(e.relatedTarget as Node)) {
return;
}
setIsDragging(false); setIsDragging(false);
}; };
const handleDrop = (e: React.DragEvent) => { const handleDrop = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
setIsDragging(false); setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files).filter( const droppedFiles = Array.from(e.dataTransfer.files);
file => file.type === 'audio/mpeg' || file.name.endsWith('.mp3')
);
if (droppedFiles.length > 0) { // Validate file types
setFiles(droppedFiles); 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>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) { if (e.target.files) {
setFiles(Array.from(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);
}
} }
}; };
@@ -615,6 +688,7 @@ export default function AdminPage() {
setEditReleaseYear(song.releaseYear || ''); setEditReleaseYear(song.releaseYear || '');
setEditGenreIds(song.genres.map(g => g.id)); setEditGenreIds(song.genres.map(g => g.id));
setEditSpecialIds(song.specials ? song.specials.map(s => s.id) : []); setEditSpecialIds(song.specials ? song.specials.map(s => s.id) : []);
setEditExcludeFromGlobal(song.excludeFromGlobal || false);
}; };
const cancelEditing = () => { const cancelEditing = () => {
@@ -624,6 +698,7 @@ export default function AdminPage() {
setEditReleaseYear(''); setEditReleaseYear('');
setEditGenreIds([]); setEditGenreIds([]);
setEditSpecialIds([]); setEditSpecialIds([]);
setEditExcludeFromGlobal(false);
}; };
const saveEditing = async (id: number) => { const saveEditing = async (id: number) => {
@@ -636,7 +711,8 @@ export default function AdminPage() {
artist: editArtist, artist: editArtist,
releaseYear: editReleaseYear === '' ? null : Number(editReleaseYear), releaseYear: editReleaseYear === '' ? null : Number(editReleaseYear),
genreIds: editGenreIds, genreIds: editGenreIds,
specialIds: editSpecialIds specialIds: editSpecialIds,
excludeFromGlobal: editExcludeFromGlobal
}), }),
}); });
@@ -739,6 +815,8 @@ export default function AdminPage() {
} else if (selectedGenreFilter === 'daily') { } else if (selectedGenreFilter === 'daily') {
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
matchesFilter = song.puzzles?.some(p => p.date === today) || false; matchesFilter = song.puzzles?.some(p => p.date === today) || false;
} else if (selectedGenreFilter === 'no-global') {
matchesFilter = song.excludeFromGlobal === true;
} }
} }
@@ -746,7 +824,7 @@ export default function AdminPage() {
}); });
const sortedSongs = [...filteredSongs].sort((a, b) => { const sortedSongs = [...filteredSongs].sort((a, b) => {
// Handle numeric sorting for ID and Release Year // Handle numeric sorting for ID, Release Year, Activations, and Rating
if (sortField === 'id') { if (sortField === 'id') {
return sortDirection === 'asc' ? a.id - b.id : b.id - a.id; return sortDirection === 'asc' ? a.id - b.id : b.id - a.id;
} }
@@ -755,6 +833,12 @@ export default function AdminPage() {
const yearB = b.releaseYear || 0; const yearB = b.releaseYear || 0;
return sortDirection === 'asc' ? yearA - yearB : yearB - yearA; 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 // String sorting for other fields
const valA = String(a[sortField]).toLowerCase(); const valA = String(a[sortField]).toLowerCase();
@@ -910,7 +994,7 @@ export default function AdminPage() {
{/* Genre Management */} {/* Genre Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}> <div className="admin-card" style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Genres</h2> <h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Genres</h2>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}> <div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', alignItems: 'center' }}>
<input <input
type="text" type="text"
value={newGenreName} value={newGenreName}
@@ -927,12 +1011,21 @@ export default function AdminPage() {
className="form-input" className="form-input"
style={{ maxWidth: '300px' }} 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> <button onClick={createGenre} className="btn-primary">Add Genre</button>
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{genres.map(genre => ( {genres.map(genre => (
<div key={genre.id} style={{ <div key={genre.id} style={{
background: '#f3f4f6', background: genre.active ? '#f3f4f6' : '#fee2e2',
opacity: genre.active ? 1 : 0.8,
padding: '0.25rem 0.75rem', padding: '0.25rem 0.75rem',
borderRadius: '999px', borderRadius: '999px',
display: 'flex', display: 'flex',
@@ -959,6 +1052,16 @@ export default function AdminPage() {
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label> <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' }} /> <input type="text" value={editGenreSubtitle} onChange={e => setEditGenreSubtitle(e.target.value)} className="form-input" style={{ width: '300px' }} />
</div> </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={saveEditedGenre} className="btn-primary">Save</button>
<button onClick={() => setEditingGenreId(null)} className="btn-secondary">Cancel</button> <button onClick={() => setEditingGenreId(null)} className="btn-secondary">Cancel</button>
</div> </div>
@@ -1052,6 +1155,7 @@ export default function AdminPage() {
<form onSubmit={handleBatchUpload}> <form onSubmit={handleBatchUpload}>
{/* Drag & Drop Zone */} {/* Drag & Drop Zone */}
<div <div
onDragEnter={handleDragEnter}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
@@ -1065,7 +1169,7 @@ export default function AdminPage() {
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s' transition: 'all 0.2s'
}} }}
onClick={() => document.getElementById('file-input')?.click()} onClick={() => fileInputRef.current?.click()}
> >
<div style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>📁</div> <div style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>📁</div>
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}> <p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
@@ -1075,7 +1179,7 @@ export default function AdminPage() {
or click to browse or click to browse
</p> </p>
<input <input
id="file-input" ref={fileInputRef}
type="file" type="file"
accept="audio/mpeg" accept="audio/mpeg"
multiple multiple
@@ -1115,6 +1219,21 @@ export default function AdminPage() {
</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 <button
type="submit" type="submit"
className="btn-primary" className="btn-primary"
@@ -1235,6 +1354,7 @@ export default function AdminPage() {
> >
<option value="">All Content</option> <option value="">All Content</option>
<option value="daily">📅 Song of the Day</option> <option value="daily">📅 Song of the Day</option>
<option value="no-global">🚫 No Global</option>
<optgroup label="Genres"> <optgroup label="Genres">
<option value="genre:-1">No Genre</option> <option value="genre:-1">No Genre</option>
{genres.map(genre => ( {genres.map(genre => (
@@ -1300,8 +1420,18 @@ export default function AdminPage() {
> >
Added {sortField === 'createdAt' && (sortDirection === 'asc' ? '' : '')} Added {sortField === 'createdAt' && (sortDirection === 'asc' ? '' : '')}
</th> </th>
<th style={{ padding: '0.75rem' }}>Activations</th> <th
<th style={{ padding: '0.75rem' }}>Rating</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> <th style={{ padding: '0.75rem' }}>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -1377,6 +1507,16 @@ export default function AdminPage() {
</label> </label>
))} ))}
</div> </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>
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}> <td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
{new Date(song.createdAt).toLocaleDateString('de-DE')} {new Date(song.createdAt).toLocaleDateString('de-DE')}
@@ -1416,6 +1556,24 @@ export default function AdminPage() {
<div style={{ fontWeight: 'bold', color: '#111827' }}>{song.title}</div> <div style={{ fontWeight: 'bold', color: '#111827' }}>{song.title}</div>
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>{song.artist}</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 */} {/* Daily Puzzle Badges */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}> <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 => { {song.puzzles?.filter(p => p.date === new Date().toISOString().split('T')[0]).map(p => {
@@ -1605,73 +1763,7 @@ export default function AdminPage() {
Rebuild Database Rebuild Database
</button> </button>
<div style={{ marginTop: '1rem', borderTop: '1px solid #eee', paddingTop: '1rem' }}>
<p style={{ marginBottom: '1rem', color: '#666' }}>
Update release years for all songs using the iTunes API. This will overwrite existing years.
</p>
<button
onClick={async () => {
if (window.confirm('This will scan all songs and overwrite their release years using data from iTunes. This process may take a while.\n\nContinue?')) {
try {
let offset = 0;
let hasMore = true;
let totalUpdated = 0;
let totalSkipped = 0;
let totalFailed = 0;
let totalProcessed = 0;
let totalSongs = 0;
setMessage('Initializing release year refresh...');
while (hasMore) {
const res = await fetch('/api/admin/refresh-years', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ offset, limit: 10 }) // Process 10 at a time
});
if (!res.ok) {
throw new Error('Batch request failed');
}
const data = await res.json();
totalUpdated += data.updated;
totalSkipped += data.skipped;
totalFailed += data.failed;
totalProcessed += data.processed;
totalSongs = data.total;
hasMore = data.hasMore;
offset = data.nextOffset;
setMessage(`Processing... ${totalProcessed} / ${totalSongs} songs.\nUpdated: ${totalUpdated} | Skipped: ${totalSkipped} | Failed: ${totalFailed}`);
}
const finalMsg = `✅ Completed!\nTotal Processed: ${totalProcessed}\nUpdated: ${totalUpdated}\nSkipped: ${totalSkipped}\nFailed: ${totalFailed}`;
alert(finalMsg);
setMessage(finalMsg);
fetchSongs(); // Refresh the table
} catch (e) {
console.error(e);
alert('Process failed due to network error or timeout.');
setMessage('Refresh failed.');
}
}
}}
style={{
padding: '0.75rem 1.5rem',
background: '#f59e0b',
color: 'white',
border: 'none',
borderRadius: '0.25rem',
cursor: 'pointer',
fontWeight: 'bold'
}}
>
🔄 Refresh Release Years (iTunes)
</button>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,78 +0,0 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { requireAdminAuth } from '@/lib/auth';
import { getReleaseYearFromItunes } from '@/lib/itunes';
const prisma = new PrismaClient();
// Helper to delay execution to avoid rate limits
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export async function POST(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try {
const { offset = 0, limit = 20 } = await request.json();
// Fetch batch of songs
const songs = await prisma.song.findMany({
select: { id: true, title: true, artist: true },
orderBy: { id: 'asc' },
skip: offset,
take: limit
});
const totalSongs = await prisma.song.count();
console.log(`Processing batch: offset=${offset}, limit=${limit}, found=${songs.length}`);
let updatedCount = 0;
let failedCount = 0;
let skippedCount = 0;
const results = [];
for (const song of songs) {
try {
// Rate limiting: wait 2000ms between requests to be safe (iTunes can be strict)
await sleep(2000);
const year = await getReleaseYearFromItunes(song.artist, song.title);
if (year) {
await prisma.song.update({
where: { id: song.id },
data: { releaseYear: year }
});
updatedCount++;
results.push({ id: song.id, title: song.title, artist: song.artist, year, status: 'updated' });
} else {
skippedCount++;
results.push({ id: song.id, title: song.title, artist: song.artist, status: 'not_found' });
}
} catch (error) {
console.error(`Failed to update year for ${song.title} - ${song.artist}:`, error);
failedCount++;
results.push({ id: song.id, title: song.title, artist: song.artist, status: 'error' });
}
}
return NextResponse.json({
success: true,
processed: songs.length,
total: totalSongs,
hasMore: offset + songs.length < totalSongs,
nextOffset: offset + songs.length,
updated: updatedCount,
failed: failedCount,
skipped: skippedCount,
results
});
} catch (error) {
console.error('Error refreshing release years:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -27,7 +27,7 @@ export async function POST(request: Request) {
if (authError) return authError; if (authError) return authError;
try { try {
const { name, subtitle } = await request.json(); const { name, subtitle, active } = await request.json();
if (!name || typeof name !== 'string') { if (!name || typeof name !== 'string') {
return NextResponse.json({ error: 'Invalid name' }, { status: 400 }); return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
@@ -36,7 +36,8 @@ export async function POST(request: Request) {
const genre = await prisma.genre.create({ const genre = await prisma.genre.create({
data: { data: {
name: name.trim(), name: name.trim(),
subtitle: subtitle ? subtitle.trim() : null subtitle: subtitle ? subtitle.trim() : null,
active: active !== undefined ? active : true
}, },
}); });
@@ -76,7 +77,7 @@ export async function PUT(request: Request) {
if (authError) return authError; if (authError) return authError;
try { try {
const { id, name, subtitle } = await request.json(); const { id, name, subtitle, active } = await request.json();
if (!id) { if (!id) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 }); return NextResponse.json({ error: 'Missing id' }, { status: 400 });
@@ -86,7 +87,8 @@ export async function PUT(request: Request) {
where: { id: Number(id) }, where: { id: Number(id) },
data: { data: {
...(name && { name: name.trim() }), ...(name && { name: name.trim() }),
subtitle: subtitle ? subtitle.trim() : null // Allow clearing subtitle if empty string passed? Or just update if provided? Let's assume null/empty string clears it. subtitle: subtitle ? subtitle.trim() : null,
...(active !== undefined && { active })
}, },
}); });

View File

@@ -8,6 +8,10 @@ import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
// Configure route to handle large file uploads
export const runtime = 'nodejs';
export const maxDuration = 60; // 60 seconds timeout for uploads
export async function GET() { export async function GET() {
const songs = await prisma.song.findMany({ const songs = await prisma.song.findMany({
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
@@ -37,23 +41,35 @@ export async function GET() {
specials: song.specials.map(ss => ss.special), specials: song.specials.map(ss => ss.special),
averageRating: song.averageRating, averageRating: song.averageRating,
ratingCount: song.ratingCount, ratingCount: song.ratingCount,
excludeFromGlobal: song.excludeFromGlobal,
})); }));
return NextResponse.json(songsWithActivations); return NextResponse.json(songsWithActivations);
} }
export async function POST(request: Request) { export async function POST(request: Request) {
console.log('[UPLOAD] Starting song upload request');
// Check authentication // Check authentication
const authError = await requireAdminAuth(request as any); const authError = await requireAdminAuth(request as any);
if (authError) return authError; if (authError) {
console.log('[UPLOAD] Authentication failed');
return authError;
}
try { try {
console.log('[UPLOAD] Parsing form data...');
const formData = await request.formData(); const formData = await request.formData();
const file = formData.get('file') as File; const file = formData.get('file') as File;
let title = ''; let title = '';
let artist = ''; let artist = '';
const excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
console.log('[UPLOAD] excludeFromGlobal:', excludeFromGlobal);
if (!file) { if (!file) {
console.error('[UPLOAD] No file provided');
return NextResponse.json({ error: 'No file provided' }, { status: 400 }); return NextResponse.json({ error: 'No file provided' }, { status: 400 });
} }
@@ -81,6 +97,7 @@ export async function POST(request: Request) {
} }
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
console.log('[UPLOAD] Buffer created, size:', buffer.length, 'bytes');
// Validate and extract metadata from file // Validate and extract metadata from file
let metadata; let metadata;
@@ -208,10 +225,9 @@ export async function POST(request: Request) {
console.error('Failed to extract cover image:', e); console.error('Failed to extract cover image:', e);
} }
// Fetch release year (iTunes first, then MusicBrainz) // Fetch release year from iTunes
let releaseYear = null; let releaseYear = null;
try { try {
// Try iTunes first
const { getReleaseYearFromItunes } = await import('@/lib/itunes'); const { getReleaseYearFromItunes } = await import('@/lib/itunes');
releaseYear = await getReleaseYearFromItunes(artist, title); releaseYear = await getReleaseYearFromItunes(artist, title);
@@ -229,6 +245,7 @@ export async function POST(request: Request) {
filename, filename,
coverImage, coverImage,
releaseYear, releaseYear,
excludeFromGlobal,
}, },
include: { genres: true, specials: true } include: { genres: true, specials: true }
}); });
@@ -249,7 +266,7 @@ export async function PUT(request: Request) {
if (authError) return authError; if (authError) return authError;
try { try {
const { id, title, artist, releaseYear, genreIds, specialIds } = await request.json(); const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
if (!id || !title || !artist) { if (!id || !title || !artist) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 }); return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
@@ -262,6 +279,10 @@ export async function PUT(request: Request) {
data.releaseYear = releaseYear; data.releaseYear = releaseYear;
} }
if (excludeFromGlobal !== undefined) {
data.excludeFromGlobal = excludeFromGlobal;
}
if (genreIds) { if (genreIds) {
data.genres = { data.genres = {
set: genreIds.map((gId: number) => ({ id: gId })) set: genreIds.map((gId: number) => ({ id: gId }))

View File

@@ -9,7 +9,10 @@ const prisma = new PrismaClient();
export default async function Home() { export default async function Home() {
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } }); const genres = await prisma.genre.findMany({
where: { active: true },
orderBy: { name: 'asc' }
});
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } }); const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
const now = new Date(); const now = new Date();

View File

@@ -173,7 +173,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
} }
const speaker = hasWon ? '🔉' : '🔇'; const speaker = hasWon ? '🔉' : '🔇';
const genreText = genre ? `Genre: ${genre}\n` : ''; const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
const genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : '';
let shareUrl = 'https://hoerdle.elpatron.me'; let shareUrl = 'https://hoerdle.elpatron.me';
if (genre) { if (genre) {
@@ -184,7 +185,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
} }
} }
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`; const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
@@ -332,7 +333,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
/> />
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3> <h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p> <p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
{dailyPuzzle.releaseYear && gameState.yearGuessed !== null && ( {dailyPuzzle.releaseYear && gameState.yearGuessed && (
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p> <p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
)} )}
<audio controls style={{ width: '100%' }}> <audio controls style={{ width: '100%' }}>

View File

@@ -33,7 +33,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
// Get songs available for this genre // Get songs available for this genre
const whereClause = genreId const whereClause = genreId
? { genres: { some: { id: genreId } } } ? { genres: { some: { id: genreId } } }
: {}; // Global puzzle picks from ALL songs : { excludeFromGlobal: false }; // Global puzzle picks from ALL songs (except excluded)
const allSongs = await prisma.song.findMany({ const allSongs = await prisma.song.findMany({
where: whereClause, where: whereClause,

View File

@@ -8,6 +8,7 @@ const nextConfig: NextConfig = {
serverActions: { serverActions: {
bodySizeLimit: '50mb', bodySizeLimit: '50mb',
}, },
middlewareClientMaxBodySize: '50mb',
}, },
env: { env: {
TZ: process.env.TZ || 'Europe/Berlin', TZ: process.env.TZ || 'Europe/Berlin',

BIN
prisma/dev.db.bak Normal file

Binary file not shown.

View File

@@ -0,0 +1,20 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Song" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" TEXT NOT NULL,
"artist" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"coverImage" TEXT,
"releaseYear" INTEGER,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"averageRating" REAL NOT NULL DEFAULT 0,
"ratingCount" INTEGER NOT NULL DEFAULT 0,
"excludeFromGlobal" BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO "new_Song" ("artist", "averageRating", "coverImage", "createdAt", "filename", "id", "ratingCount", "releaseYear", "title") SELECT "artist", "averageRating", "coverImage", "createdAt", "filename", "id", "ratingCount", "releaseYear", "title" FROM "Song";
DROP TABLE "Song";
ALTER TABLE "new_Song" RENAME TO "Song";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,15 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Genre" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"subtitle" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true
);
INSERT INTO "new_Genre" ("id", "name", "subtitle") SELECT "id", "name", "subtitle" FROM "Genre";
DROP TABLE "Genre";
ALTER TABLE "new_Genre" RENAME TO "Genre";
CREATE UNIQUE INDEX "Genre_name_key" ON "Genre"("name");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -23,12 +23,14 @@ model Song {
specials SpecialSong[] specials SpecialSong[]
averageRating Float @default(0) averageRating Float @default(0)
ratingCount Int @default(0) ratingCount Int @default(0)
excludeFromGlobal Boolean @default(false)
} }
model Genre { model Genre {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique name String @unique
subtitle String? subtitle String?
active Boolean @default(true)
songs Song[] songs Song[]
dailyPuzzles DailyPuzzle[] dailyPuzzles DailyPuzzle[]
} }