Compare commits

...

7 Commits

17 changed files with 1028 additions and 104 deletions
+1
View File
@@ -48,3 +48,4 @@ next-env.d.ts
/public/uploads/* /public/uploads/*
!/public/uploads/.gitkeep !/public/uploads/.gitkeep
/data /data
.release-years-migrated
+101
View File
@@ -8,7 +8,9 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- **Inkrementelle Hinweise:** Startet mit 2 Sekunden, dann 4s, 7s, 11s, 16s, 30s, bis 60s (7 Versuche). - **Inkrementelle Hinweise:** Startet mit 2 Sekunden, dann 4s, 7s, 11s, 16s, 30s, bis 60s (7 Versuche).
- **Admin Dashboard:** - **Admin Dashboard:**
- Upload von MP3-Dateien. - Upload von MP3-Dateien.
- **Duplikatserkennung:** Automatische Erkennung von bereits vorhandenen Songs mit Fuzzy-Matching (toleriert Variationen wie "AC/DC" vs "AC DC").
- Automatische Extraktion von ID3-Tags (Titel, Interpret). - Automatische Extraktion von ID3-Tags (Titel, Interpret).
- 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).
- Play/Pause-Funktion zum Vorhören in der Bibliothek. - Play/Pause-Funktion zum Vorhören in der Bibliothek.
@@ -37,6 +39,19 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- Einzelne Segmente zum Testen abspielen. - Einzelne Segmente zum Testen abspielen.
- Manuelle Speicherung mit visueller Bestätigung. - Manuelle Speicherung mit visueller Bestätigung.
## Spielregeln & Punktesystem
Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und dabei einen möglichst hohen Highscore zu erzielen.
- **Start-Punktestand:** 90 Punkte
- **Richtige Antwort:** +20 Punkte
- **Falsche Antwort:** -3 Punkte
- **Überspringen (Skip):** -5 Punkte
- **Snippet erneut abspielen (Replay):** -1 Punkt
- **Bonus-Runde (Release-Jahr erraten):** +10 Punkte (0 bei falscher Antwort)
- **Aufgeben / Verloren:** Der Punktestand wird auf 0 gesetzt.
- **Minimum:** Der Punktestand kann nicht unter 0 fallen.
## Tech Stack ## Tech Stack
- **Framework:** Next.js 16 (App Router) - **Framework:** Next.js 16 (App Router)
@@ -154,6 +169,92 @@ server {
Eine vollständige Beispiel-Konfiguration findest du in `nginx.conf.example`. Eine vollständige Beispiel-Konfiguration findest du in `nginx.conf.example`.
## iFrame-Einbindung
Hördle kann problemlos als iFrame in andere Webseiten eingebettet werden. Die App ist responsive und passt sich automatisch an die iFrame-Größe an.
### Grundlegende Einbindung
```html
<iframe
src="https://hoerdle.elpatron.me"
width="100%"
height="800"
frameborder="0"
allow="autoplay"
title="Hördle - Daily Music Quiz">
</iframe>
```
### Genre-spezifische Einbindung
Einzelne Genres können direkt eingebunden werden:
```html
<!-- Rock Genre -->
<iframe
src="https://hoerdle.elpatron.me/Rock"
width="100%"
height="800"
frameborder="0"
allow="autoplay"
title="Hördle Rock Quiz">
</iframe>
<!-- Pop Genre -->
<iframe
src="https://hoerdle.elpatron.me/Pop"
width="100%"
height="800"
frameborder="0"
allow="autoplay"
title="Hördle Pop Quiz">
</iframe>
```
### Special-Einbindung
Auch thematische Specials können direkt eingebettet werden:
```html
<iframe
src="https://hoerdle.elpatron.me/special/Weihnachtslieder"
width="100%"
height="800"
frameborder="0"
allow="autoplay"
title="Hördle Weihnachts-Special">
</iframe>
```
### Empfohlene Einstellungen
- **Mindesthöhe:** 800px (damit alle Elemente sichtbar sind)
- **Breite:** 100% oder mindestens 600px
- **`allow="autoplay"`:** Erforderlich für Audio-Wiedergabe
- **Responsive:** Die App passt sich automatisch an mobile Geräte an
### Beispiel mit responsiver Höhe
```html
<div style="position: relative; padding-bottom: 133%; height: 0; overflow: hidden;">
<iframe
src="https://hoerdle.elpatron.me"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
frameborder="0"
allow="autoplay"
title="Hördle">
</iframe>
</div>
```
### Hinweise
- Der Spielfortschritt wird im LocalStorage des iFrames gespeichert
- Nutzer können innerhalb des iFrames zwischen Genres wechseln (Navigation bleibt erhalten)
- Die Teilen-Funktion funktioniert auch im iFrame
- Für beste Performance sollte der iFrame auf derselben Domain wie die Hauptseite gehostet werden (vermeidet CORS-Probleme)
## Troubleshooting ## Troubleshooting
### Audio-Dateien lassen sich nicht abspielen (in Produktion mit Nginx) ### Audio-Dateien lassen sich nicht abspielen (in Produktion mit Nginx)
+4 -3
View File
@@ -3,13 +3,14 @@
const GOTIFY_URL = process.env.GOTIFY_URL; const GOTIFY_URL = process.env.GOTIFY_URL;
const GOTIFY_APP_TOKEN = process.env.GOTIFY_APP_TOKEN; const GOTIFY_APP_TOKEN = process.env.GOTIFY_APP_TOKEN;
export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number, genre?: string | null) { export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number, genre?: string | null, score?: number) {
try { try {
const genreText = genre ? `[${genre}] ` : ''; const genreText = genre ? `[${genre}] ` : '';
const title = `Hördle ${genreText}#${puzzleId} ${status === 'won' ? 'Solved!' : 'Failed'}`; const title = `Hördle ${genreText}#${puzzleId} ${status === 'won' ? 'Solved!' : 'Failed'}`;
const scoreText = score !== undefined ? ` with a score of ${score}` : '';
const message = status === 'won' const message = status === 'won'
? `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was solved in ${attempts} attempt(s).` ? `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was solved in ${attempts} attempt(s)${scoreText}.`
: `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was failed after ${attempts} attempt(s).`; : `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was failed after ${attempts} attempt(s)${scoreText}.`;
const response = await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, { const response = await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, {
method: 'POST', method: 'POST',
+56 -6
View File
@@ -40,6 +40,7 @@ interface Song {
artist: string; artist: string;
filename: string; filename: string;
createdAt: string; createdAt: string;
releaseYear: number | null;
activations: number; activations: number;
puzzles: DailyPuzzle[]; puzzles: DailyPuzzle[];
genres: Genre[]; genres: Genre[];
@@ -48,7 +49,7 @@ interface Song {
ratingCount: number; ratingCount: number;
} }
type SortField = 'id' | 'title' | 'artist' | 'createdAt'; type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear';
type SortDirection = 'asc' | 'desc'; type SortDirection = 'asc' | 'desc';
export default function AdminPage() { export default function AdminPage() {
@@ -91,6 +92,7 @@ export default function AdminPage() {
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [editTitle, setEditTitle] = useState(''); const [editTitle, setEditTitle] = useState('');
const [editArtist, setEditArtist] = useState(''); const [editArtist, setEditArtist] = useState('');
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[]>([]);
@@ -462,6 +464,16 @@ export default function AdminPage() {
song: data.song, song: data.song,
validation: data.validation validation: data.validation
}); });
} else if (res.status === 409) {
// Duplicate detected
const data = await res.json();
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 { } else {
results.push({ results.push({
filename: file.name, filename: file.name,
@@ -486,12 +498,24 @@ export default function AdminPage() {
// Auto-trigger categorization after uploads // Auto-trigger categorization after uploads
const successCount = results.filter(r => r.success).length; 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) { if (successCount > 0) {
setMessage(`✅ Uploaded ${successCount}/${files.length} songs successfully!\n\n🤖 Starting auto-categorization...`); let msg = `✅ Uploaded ${successCount}/${files.length} songs successfully!`;
if (duplicateCount > 0) {
msg += `\n⚠️ Skipped ${duplicateCount} duplicate(s)`;
}
if (failedCount > 0) {
msg += `\n❌ ${failedCount} failed`;
}
msg += '\n\n🤖 Starting auto-categorization...';
setMessage(msg);
// Small delay to let user see the message // Small delay to let user see the message
setTimeout(() => { setTimeout(() => {
handleAICategorization(); handleAICategorization();
}, 1000); }, 1000);
} else if (duplicateCount > 0 && failedCount === 0) {
setMessage(`⚠️ All ${duplicateCount} file(s) were duplicates - nothing uploaded.`);
} else { } else {
setMessage(`❌ All uploads failed.`); setMessage(`❌ All uploads failed.`);
} }
@@ -555,6 +579,7 @@ export default function AdminPage() {
setEditingId(song.id); setEditingId(song.id);
setEditTitle(song.title); setEditTitle(song.title);
setEditArtist(song.artist); setEditArtist(song.artist);
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) : []);
}; };
@@ -563,6 +588,7 @@ export default function AdminPage() {
setEditingId(null); setEditingId(null);
setEditTitle(''); setEditTitle('');
setEditArtist(''); setEditArtist('');
setEditReleaseYear('');
setEditGenreIds([]); setEditGenreIds([]);
setEditSpecialIds([]); setEditSpecialIds([]);
}; };
@@ -575,6 +601,7 @@ export default function AdminPage() {
id, id,
title: editTitle, title: editTitle,
artist: editArtist, artist: editArtist,
releaseYear: editReleaseYear === '' ? null : Number(editReleaseYear),
genreIds: editGenreIds, genreIds: editGenreIds,
specialIds: editSpecialIds specialIds: editSpecialIds
}), }),
@@ -684,10 +711,15 @@ export default function AdminPage() {
}); });
const sortedSongs = [...filteredSongs].sort((a, b) => { const sortedSongs = [...filteredSongs].sort((a, b) => {
// Handle numeric sorting for ID // Handle numeric sorting for ID and Release Year
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;
} }
if (sortField === 'releaseYear') {
const yearA = a.releaseYear || 0;
const yearB = b.releaseYear || 0;
return sortDirection === 'asc' ? yearA - yearB : yearB - yearA;
}
// String sorting for other fields // String sorting for other fields
const valA = String(a[sortField]).toLowerCase(); const valA = String(a[sortField]).toLowerCase();
@@ -1201,10 +1233,15 @@ export default function AdminPage() {
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }} style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('title')} onClick={() => handleSort('title')}
> >
Title {sortField === 'title' && (sortDirection === 'asc' ? '' : '')} Song {sortField === 'title' && (sortDirection === 'asc' ? '' : '')}
</th> </th>
<th
<th style={{ padding: '0.75rem' }}>Genres</th> style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('releaseYear')}
>
Year {sortField === 'releaseYear' && (sortDirection === 'asc' ? '' : '')}
</th>
<th style={{ padding: '0.75rem' }}>Genres / Specials</th>
<th <th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }} style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('createdAt')} onClick={() => handleSort('createdAt')}
@@ -1241,6 +1278,16 @@ export default function AdminPage() {
placeholder="Artist" placeholder="Artist"
/> />
</td> </td>
<td style={{ padding: '0.75rem' }}>
<input
type="number"
value={editReleaseYear}
onChange={e => setEditReleaseYear(e.target.value === '' ? '' : Number(e.target.value))}
className="form-input"
style={{ padding: '0.25rem', width: '80px' }}
placeholder="Year"
/>
</td>
<td style={{ padding: '0.75rem' }}> <td style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{genres.map(genre => ( {genres.map(genre => (
@@ -1347,6 +1394,9 @@ export default function AdminPage() {
})} })}
</div> </div>
</td> </td>
<td style={{ padding: '0.75rem', color: '#666' }}>
{song.releaseYear || '-'}
</td>
<td style={{ padding: '0.75rem' }}> <td style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{song.genres?.map(g => ( {song.genres?.map(g => (
+52 -2
View File
@@ -3,6 +3,7 @@ import { PrismaClient } from '@prisma/client';
import { writeFile, unlink } from 'fs/promises'; import { writeFile, unlink } from 'fs/promises';
import path from 'path'; import path from 'path';
import { parseBuffer } from 'music-metadata'; import { parseBuffer } from 'music-metadata';
import { isDuplicateSong } from '@/lib/fuzzyMatch';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@@ -28,6 +29,7 @@ export async function GET() {
filename: song.filename, filename: song.filename,
createdAt: song.createdAt, createdAt: song.createdAt,
coverImage: song.coverImage, coverImage: song.coverImage,
releaseYear: song.releaseYear,
activations: song.puzzles.length, activations: song.puzzles.length,
puzzles: song.puzzles, puzzles: song.puzzles,
genres: song.genres, genres: song.genres,
@@ -72,8 +74,16 @@ export async function POST(request: Request) {
if (metadata.common.title) { if (metadata.common.title) {
title = metadata.common.title; title = metadata.common.title;
} }
if (metadata.common.artist) {
// Handle artist - prefer artists array if available
if (metadata.common.artists && metadata.common.artists.length > 0) {
// Join multiple artists with '/'
artist = metadata.common.artists.join('/');
} else if (metadata.common.artist) {
artist = metadata.common.artist; artist = metadata.common.artist;
} else if (metadata.common.albumartist) {
// Fallback to album artist
artist = metadata.common.albumartist;
} }
// Validation info // Validation info
@@ -114,6 +124,28 @@ export async function POST(request: Request) {
if (!title) title = 'Unknown Title'; if (!title) title = 'Unknown Title';
if (!artist) artist = 'Unknown Artist'; if (!artist) artist = 'Unknown Artist';
// Check for duplicates
const existingSongs = await prisma.song.findMany({
select: { id: true, title: true, artist: true, filename: true }
});
for (const existing of existingSongs) {
if (isDuplicateSong(artist, title, existing.artist, existing.title)) {
return NextResponse.json(
{
error: 'Duplicate song detected',
duplicate: {
id: existing.id,
title: existing.title,
artist: existing.artist,
filename: existing.filename
}
},
{ status: 409 }
);
}
}
// Create URL-safe filename // Create URL-safe filename
const originalName = file.name.replace(/\.mp3$/i, ''); const originalName = file.name.replace(/\.mp3$/i, '');
const sanitizedName = originalName const sanitizedName = originalName
@@ -148,12 +180,25 @@ 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 from MusicBrainz
let releaseYear = null;
try {
const { getReleaseYear } = await import('@/lib/musicbrainz');
releaseYear = await getReleaseYear(artist, title);
if (releaseYear) {
console.log(`Fetched release year ${releaseYear} for "${title}" by "${artist}"`);
}
} catch (e) {
console.error('Failed to fetch release year from MusicBrainz:', e);
}
const song = await prisma.song.create({ const song = await prisma.song.create({
data: { data: {
title, title,
artist, artist,
filename, filename,
coverImage, coverImage,
releaseYear,
}, },
include: { genres: true, specials: true } include: { genres: true, specials: true }
}); });
@@ -170,7 +215,7 @@ export async function POST(request: Request) {
export async function PUT(request: Request) { export async function PUT(request: Request) {
try { try {
const { id, title, artist, genreIds, specialIds } = await request.json(); const { id, title, artist, releaseYear, genreIds, specialIds } = 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 });
@@ -178,6 +223,11 @@ export async function PUT(request: Request) {
const data: any = { title, artist }; const data: any = { title, artist };
// Update releaseYear if provided (can be null to clear it)
if (releaseYear !== undefined) {
data.releaseYear = releaseYear;
}
if (genreIds) { if (genreIds) {
data.genres = { data.genres = {
set: genreIds.map((gId: number) => ({ id: gId })) set: genreIds.map((gId: number) => ({ id: gId }))
+11 -1
View File
@@ -7,13 +7,15 @@ interface AudioPlayerProps {
unlockedSeconds: number; // 2, 4, 7, 11, 16, 30 (or full length) unlockedSeconds: number; // 2, 4, 7, 11, 16, 30 (or full length)
startTime?: number; // Start offset in seconds (for curated specials) startTime?: number; // Start offset in seconds (for curated specials)
onPlay?: () => void; onPlay?: () => void;
onReplay?: () => void;
autoPlay?: boolean; autoPlay?: boolean;
} }
export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPlay, autoPlay = false }: AudioPlayerProps) { export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPlay, onReplay, autoPlay = false }: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [hasPlayedOnce, setHasPlayedOnce] = useState(false);
useEffect(() => { useEffect(() => {
if (audioRef.current) { if (audioRef.current) {
@@ -21,6 +23,7 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
audioRef.current.currentTime = startTime; audioRef.current.currentTime = startTime;
setIsPlaying(false); setIsPlaying(false);
setProgress(0); setProgress(0);
setHasPlayedOnce(false); // Reset for new segment
if (autoPlay) { if (autoPlay) {
const playPromise = audioRef.current.play(); const playPromise = audioRef.current.play();
@@ -29,6 +32,7 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
.then(() => { .then(() => {
setIsPlaying(true); setIsPlaying(true);
onPlay?.(); onPlay?.();
setHasPlayedOnce(true);
}) })
.catch(error => { .catch(error => {
console.log("Autoplay prevented:", error); console.log("Autoplay prevented:", error);
@@ -47,6 +51,12 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
} else { } else {
audioRef.current.play(); audioRef.current.play();
onPlay?.(); onPlay?.();
if (hasPlayedOnce) {
onReplay?.();
} else {
setHasPlayedOnce(true);
}
} }
setIsPlaying(!isPlaying); setIsPlaying(!isPlaying);
}; };
+219 -64
View File
@@ -16,6 +16,7 @@ interface GameProps {
title: string; title: string;
artist: string; artist: string;
coverImage: string | null; coverImage: string | null;
releaseYear?: number | null;
startTime?: number; startTime?: number;
} | null; } | null;
genre?: string | null; genre?: string | null;
@@ -27,7 +28,7 @@ interface GameProps {
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60]; const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) { export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
const { gameState, statistics, addGuess } = useGameState(genre, maxAttempts); const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts);
const [hasWon, setHasWon] = useState(false); const [hasWon, setHasWon] = useState(false);
const [hasLost, setHasLost] = useState(false); const [hasLost, setHasLost] = useState(false);
const [shareText, setShareText] = useState('🔗 Share'); const [shareText, setShareText] = useState('🔗 Share');
@@ -35,6 +36,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const [isProcessingGuess, setIsProcessingGuess] = useState(false); const [isProcessingGuess, setIsProcessingGuess] = useState(false);
const [timeUntilNext, setTimeUntilNext] = useState(''); const [timeUntilNext, setTimeUntilNext] = useState('');
const [hasRated, setHasRated] = useState(false); const [hasRated, setHasRated] = useState(false);
const [showYearModal, setShowYearModal] = useState(false);
useEffect(() => { useEffect(() => {
const updateCountdown = () => { const updateCountdown = () => {
@@ -50,7 +52,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
}; };
updateCountdown(); updateCountdown();
const interval = setInterval(updateCountdown, 1000); // Update every second to be accurate const interval = setInterval(updateCountdown, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
@@ -58,6 +60,11 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
if (gameState && dailyPuzzle) { if (gameState && dailyPuzzle) {
setHasWon(gameState.isSolved); setHasWon(gameState.isSolved);
setHasLost(gameState.isFailed); setHasLost(gameState.isFailed);
// Show year modal if won but year not guessed yet and release year is available
if (gameState.isSolved && !gameState.yearGuessed && dailyPuzzle.releaseYear) {
setShowYearModal(true);
}
} }
}, [gameState, dailyPuzzle]); }, [gameState, dailyPuzzle]);
@@ -87,37 +94,62 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
if (!gameState) return <div>Loading state...</div>; if (!gameState) return <div>Loading state...</div>;
const handleGuess = (song: any) => { const handleGuess = (song: any) => {
if (isProcessingGuess) return; // Prevent multiple guesses if (isProcessingGuess) return;
setIsProcessingGuess(true); setIsProcessingGuess(true);
setLastAction('GUESS'); setLastAction('GUESS');
if (song.id === dailyPuzzle.songId) { if (song.id === dailyPuzzle.songId) {
addGuess(song.title, true); addGuess(song.title, true);
setHasWon(true); setHasWon(true);
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre); // Notification sent after year guess or skip
if (!dailyPuzzle.releaseYear) {
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
}
} else { } else {
addGuess(song.title, false); addGuess(song.title, false);
if (gameState.guesses.length + 1 >= maxAttempts) { if (gameState.guesses.length + 1 >= maxAttempts) {
setHasLost(true); setHasLost(true);
setHasWon(false); // Ensure won is false setHasWon(false);
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre); sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
} }
} }
// Reset after a short delay to allow UI update
setTimeout(() => setIsProcessingGuess(false), 500); setTimeout(() => setIsProcessingGuess(false), 500);
}; };
const handleSkip = () => { const handleSkip = () => {
setLastAction('SKIP'); setLastAction('SKIP');
addGuess("SKIPPED", false); addGuess("SKIPPED", false);
if (gameState.guesses.length + 1 >= maxAttempts) {
setHasLost(true);
setHasWon(false);
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
}
}; };
const handleGiveUp = () => { const handleGiveUp = () => {
setLastAction('SKIP'); setLastAction('SKIP');
addGuess("SKIPPED", false); addGuess("SKIPPED", false);
giveUp(); // Ensure game is marked as failed and score reset to 0
setHasLost(true); setHasLost(true);
setHasWon(false); setHasWon(false);
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre); sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
};
const handleYearGuess = (year: number) => {
const correct = year === dailyPuzzle.releaseYear;
addYearBonus(correct);
setShowYearModal(false);
// Send notification now that game is fully complete
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
};
const handleYearSkip = () => {
skipYearBonus();
setShowYearModal(false);
// Send notification now that game is fully complete
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
}; };
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)]; const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
@@ -126,21 +158,16 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
let emojiGrid = ''; let emojiGrid = '';
const totalGuesses = maxAttempts; const totalGuesses = maxAttempts;
// Build the grid
for (let i = 0; i < totalGuesses; i++) { for (let i = 0; i < totalGuesses; i++) {
if (i < gameState.guesses.length) { if (i < gameState.guesses.length) {
// If this was the winning guess (last one and won)
if (hasWon && i === gameState.guesses.length - 1) { if (hasWon && i === gameState.guesses.length - 1) {
emojiGrid += '🟩'; emojiGrid += '🟩';
} else if (gameState.guesses[i] === 'SKIPPED') { } else if (gameState.guesses[i] === 'SKIPPED') {
// Skipped
emojiGrid += '⬛'; emojiGrid += '⬛';
} else { } else {
// Wrong guess
emojiGrid += '🟥'; emojiGrid += '🟥';
} }
} else { } else {
// Unused attempts
emojiGrid += '⬜'; emojiGrid += '⬜';
} }
} }
@@ -148,7 +175,6 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const speaker = hasWon ? '🔉' : '🔇'; const speaker = hasWon ? '🔉' : '🔇';
const genreText = genre ? `Genre: ${genre}\n` : ''; const genreText = genre ? `Genre: ${genre}\n` : '';
// Generate URL with genre/special path
let shareUrl = 'https://hoerdle.elpatron.me'; let shareUrl = 'https://hoerdle.elpatron.me';
if (genre) { if (genre) {
if (isSpecial) { if (isSpecial) {
@@ -158,9 +184,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
} }
} }
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}\n\n#Hördle #Music\n\n${shareUrl}`; const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
// Try native Web Share API only on mobile devices
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
if (isMobile && navigator.share) { if (isMobile && navigator.share) {
@@ -173,14 +198,12 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
setTimeout(() => setShareText('🔗 Share'), 2000); setTimeout(() => setShareText('🔗 Share'), 2000);
return; return;
} catch (err) { } catch (err) {
// User cancelled or error - fall through to clipboard
if ((err as Error).name !== 'AbortError') { if ((err as Error).name !== 'AbortError') {
console.error('Share failed:', err); console.error('Share failed:', err);
} }
} }
} }
// Fallback: Copy to clipboard
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
setShareText('✓ Copied!'); setShareText('✓ Copied!');
@@ -192,8 +215,6 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
} }
}; };
const handleRatingSubmit = async (rating: number) => { const handleRatingSubmit = async (rating: number) => {
if (!dailyPuzzle) return; if (!dailyPuzzle) return;
@@ -201,7 +222,6 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber); await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber);
setHasRated(true); setHasRated(true);
// Persist to localStorage
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]'); const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]');
if (!ratedPuzzles.includes(dailyPuzzle.id)) { if (!ratedPuzzles.includes(dailyPuzzle.id)) {
ratedPuzzles.push(dailyPuzzle.id); ratedPuzzles.push(dailyPuzzle.id);
@@ -222,17 +242,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</header> </header>
<main className="game-board"> <main className="game-board">
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}> <div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
<div className="status-bar"> <div className="status-bar">
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span> <span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
<span>{unlockedSeconds}s unlocked</span> <span>{unlockedSeconds}s unlocked</span>
</div> </div>
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
<AudioPlayer <AudioPlayer
src={dailyPuzzle.audioUrl} src={dailyPuzzle.audioUrl}
unlockedSeconds={unlockedSeconds} unlockedSeconds={unlockedSeconds}
startTime={dailyPuzzle.startTime} startTime={dailyPuzzle.startTime}
autoPlay={lastAction === 'SKIP'} autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
onReplay={addReplay}
/> />
</div> </div>
@@ -253,7 +276,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
{!hasWon && !hasLost && ( {!hasWon && !hasLost && (
<> <>
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} /> <GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
{gameState.guesses.length < 6 ? ( {gameState.guesses.length < maxAttempts - 1 ? (
<button <button
onClick={handleSkip} onClick={handleSkip}
className="skip-button" className="skip-button"
@@ -275,12 +298,32 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</> </>
)} )}
{hasWon && ( {(hasWon || hasLost) && (
<div className="message-box success"> <div className={`message-box ${hasWon ? 'success' : 'failure'}`}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>You won!</h2> <h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
<p>Come back tomorrow for a new song.</p> {hasWon ? 'You won!' : 'Game Over'}
</h2>
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? '#059669' : '#dc2626' }}>
Score: {gameState.score}
</div>
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: '#666' }}>
<summary>Score Breakdown</summary>
<ul style={{ listStyle: 'none', padding: '0.5rem', textAlign: 'left', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
{gameState.scoreBreakdown.map((item, i) => (
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.25rem 0' }}>
<span>{item.reason}</span>
<span style={{ fontWeight: 'bold', color: item.value >= 0 ? 'green' : 'red' }}>
{item.value > 0 ? '+' : ''}{item.value}
</span>
</li>
))}
</ul>
</details>
<p>{hasWon ? 'Come back tomorrow for a new song.' : 'The song was:'}</p>
{/* Song Details */}
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}> <div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<img <img
src={dailyPuzzle.coverImage || '/favicon.ico'} src={dailyPuzzle.coverImage || '/favicon.ico'}
@@ -288,14 +331,16 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }} style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
/> />
<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 1rem 0' }}>{dailyPuzzle.artist}</p> <p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
{dailyPuzzle.releaseYear && (
<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%' }}>
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" /> <source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
Your browser does not support the audio element. Your browser does not support the audio element.
</audio> </audio>
</div> </div>
{/* Rating Component */}
<div style={{ marginBottom: '1rem' }}> <div style={{ marginBottom: '1rem' }}>
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} /> <StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
</div> </div>
@@ -306,40 +351,150 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</button> </button>
</div> </div>
)} )}
{hasLost && (
<div className="message-box failure">
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>Game Over</h2>
<p>The song was:</p>
{/* Song Details */}
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<img
src={dailyPuzzle.coverImage || '/favicon.ico'}
alt="Album Cover"
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
/>
<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 1rem 0' }}>{dailyPuzzle.artist}</p>
<audio controls style={{ width: '100%' }}>
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
</div>
{/* Rating Component */}
<div style={{ marginBottom: '1rem' }}>
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
</div>
{statistics && <Statistics statistics={statistics} />}
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
{shareText}
</button>
</div>
)}
</main> </main>
{showYearModal && dailyPuzzle.releaseYear && (
<YearGuessModal
correctYear={dailyPuzzle.releaseYear}
onGuess={handleYearGuess}
onSkip={handleYearSkip}
/>
)}
</div>
);
}
function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{ value: number, reason: string }> }) {
const tooltipText = breakdown.map(item => `${item.reason}: ${item.value > 0 ? '+' : ''}${item.value}`).join('\n');
// Create expression: "90 - 2 - 5 + 10"
// Limit to last 5 items to avoid overflow if too long
const displayItems = breakdown.length > 5 ?
[{ value: breakdown[0].value, reason: 'Start' }, ...breakdown.slice(-4)] :
breakdown;
const expression = displayItems.map((item, index) => {
if (index === 0 && breakdown.length <= 5) return item.value.toString();
if (index === 0 && breakdown.length > 5) return `${item.value} ...`;
return item.value >= 0 ? `+ ${item.value}` : `- ${Math.abs(item.value)}`;
}).join(' ');
return (
<div className="score-display" title={tooltipText} style={{
textAlign: 'center',
margin: '0.5rem 0',
padding: '0.5rem',
background: '#f3f4f6',
borderRadius: '0.5rem',
fontSize: '0.9rem',
fontFamily: 'monospace',
cursor: 'help'
}}>
<span style={{ color: '#666' }}>{expression} = </span>
<span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span>
</div>
);
}
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
const [options, setOptions] = useState<number[]>([]);
useEffect(() => {
const currentYear = new Date().getFullYear();
const minYear = 1950;
const closeOptions = new Set<number>();
closeOptions.add(correctYear);
// Add 2 close years (+/- 2)
while (closeOptions.size < 3) {
const offset = Math.floor(Math.random() * 5) - 2;
const year = correctYear + offset;
if (year <= currentYear && year >= minYear && year !== correctYear) {
closeOptions.add(year);
}
}
const allOptions = new Set(closeOptions);
// Fill up to 10 with random years
while (allOptions.size < 10) {
const year = Math.floor(Math.random() * (currentYear - minYear + 1)) + minYear;
allOptions.add(year);
}
setOptions(Array.from(allOptions).sort((a, b) => a - b));
}, [correctYear]);
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '1rem'
}}>
<div style={{
background: 'white',
padding: '2rem',
borderRadius: '1rem',
maxWidth: '500px',
width: '100%',
textAlign: 'center',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
}}>
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: '#1f2937' }}>Bonus Round!</h3>
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>Guess the release year for <strong style={{ color: '#10b981' }}>+10 points</strong>!</p>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
gap: '0.75rem',
marginBottom: '1.5rem'
}}>
{options.map(year => (
<button
key={year}
onClick={() => onGuess(year)}
style={{
padding: '0.75rem',
background: '#f3f4f6',
border: '2px solid #e5e7eb',
borderRadius: '0.5rem',
fontSize: '1.1rem',
fontWeight: 'bold',
color: '#374151',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
>
{year}
</button>
))}
</div>
<button
onClick={onSkip}
style={{
background: 'none',
border: 'none',
color: '#6b7280',
textDecoration: 'underline',
cursor: 'pointer',
fontSize: '0.9rem'
}}
>
Skip Bonus
</button>
</div>
</div> </div>
); );
} }
+3 -1
View File
@@ -27,7 +27,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
include: { song: true }, include: { song: true },
}); });
console.log(`[Daily Puzzle] Date: ${today}, Genre: ${genreName || 'Global'}, Found existing: ${!!dailyPuzzle}`);
if (!dailyPuzzle) { if (!dailyPuzzle) {
// Get songs available for this genre // Get songs available for this genre
@@ -118,6 +118,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
title: dailyPuzzle.song.title, title: dailyPuzzle.song.title,
artist: dailyPuzzle.song.artist, artist: dailyPuzzle.song.artist,
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null, coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
releaseYear: dailyPuzzle.song.releaseYear,
genre: genreName genre: genreName
}; };
@@ -230,6 +231,7 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
title: dailyPuzzle.song.title, title: dailyPuzzle.song.title,
artist: dailyPuzzle.song.artist, artist: dailyPuzzle.song.artist,
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null, coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
releaseYear: dailyPuzzle.song.releaseYear,
special: specialName, special: specialName,
maxAttempts: special.maxAttempts, maxAttempts: special.maxAttempts,
unlockSteps: JSON.parse(special.unlockSteps), unlockSteps: JSON.parse(special.unlockSteps),
+97
View File
@@ -0,0 +1,97 @@
/**
* Fuzzy string matching utility for duplicate detection
* Uses Levenshtein distance to compare strings with tolerance for formatting variations
*/
/**
* Normalize a string for comparison
* - Converts to lowercase
* - Removes special characters
* - Normalizes whitespace
*/
function normalizeString(str: string): string {
return str
.toLowerCase()
.replace(/[^a-z0-9\s]/g, '') // Remove special chars
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
}
/**
* Calculate Levenshtein distance between two strings
* Returns the minimum number of single-character edits needed to change one string into the other
*/
function levenshteinDistance(a: string, b: string): number {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const matrix: number[][] = [];
// Initialize first column
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
// Initialize first row
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
// Fill in the rest of the matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
);
}
}
}
return matrix[b.length][a.length];
}
/**
* Check if two strings are similar based on Levenshtein distance
* @param str1 First string to compare
* @param str2 Second string to compare
* @param threshold Similarity threshold (0-1), default 0.85
* @returns true if strings are similar enough
*/
export function isSimilar(str1: string, str2: string, threshold = 0.85): boolean {
if (!str1 || !str2) return false;
const norm1 = normalizeString(str1);
const norm2 = normalizeString(str2);
// Exact match after normalization
if (norm1 === norm2) return true;
const distance = levenshteinDistance(norm1, norm2);
const maxLen = Math.max(norm1.length, norm2.length);
// Avoid division by zero
if (maxLen === 0) return true;
const similarity = 1 - (distance / maxLen);
return similarity >= threshold;
}
/**
* Check if a song (artist + title) is a duplicate of another
* Both artist AND title must be similar for a match
*/
export function isDuplicateSong(
artist1: string,
title1: string,
artist2: string,
title2: string,
threshold = 0.85
): boolean {
return isSimilar(artist1, artist2, threshold) && isSimilar(title1, title2, threshold);
}
+143 -24
View File
@@ -9,6 +9,11 @@ export interface GameState {
isSolved: boolean; isSolved: boolean;
isFailed: boolean; isFailed: boolean;
lastPlayed: number; // Timestamp lastPlayed: number; // Timestamp
score: number;
replayCount: number;
skipCount: number;
scoreBreakdown: Array<{ value: number; reason: string }>;
yearGuessed: boolean;
} }
export interface Statistics { export interface Statistics {
@@ -22,19 +27,31 @@ export interface Statistics {
failed: number; failed: number;
} }
const STORAGE_KEY = 'hoerdle_game_state'; const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
const STATS_KEY = 'hoerdle_statistics'; const STATS_KEY_PREFIX = 'hoerdle_statistics';
const INITIAL_SCORE = 90;
export function useGameState(genre: string | null = null, maxAttempts: number = 7) { export function useGameState(genre: string | null = null, maxAttempts: number = 7) {
const [gameState, setGameState] = useState<GameState | null>(null); const [gameState, setGameState] = useState<GameState | null>(null);
const [statistics, setStatistics] = useState<Statistics | null>(null); const [statistics, setStatistics] = useState<Statistics | null>(null);
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
const STATS_KEY_PREFIX = 'hoerdle_statistics';
const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX; const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX;
const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_KEY_PREFIX; const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_KEY_PREFIX;
const createNewState = (date: string): GameState => ({
date,
guesses: [],
isSolved: false,
isFailed: false,
lastPlayed: Date.now(),
score: INITIAL_SCORE,
replayCount: 0,
skipCount: 0,
scoreBreakdown: [{ value: INITIAL_SCORE, reason: 'Start value' }],
yearGuessed: false
});
useEffect(() => { useEffect(() => {
// Load game state // Load game state
const storageKey = getStorageKey(); const storageKey = getStorageKey();
@@ -42,30 +59,29 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
const today = getTodayISOString(); const today = getTodayISOString();
if (stored) { if (stored) {
const parsed: GameState = JSON.parse(stored); const parsed = JSON.parse(stored);
if (parsed.date === today) { if (parsed.date === today) {
setGameState(parsed); // Migration for existing states without score
if (parsed.score === undefined) {
parsed.score = INITIAL_SCORE;
parsed.replayCount = 0;
parsed.skipCount = 0;
parsed.scoreBreakdown = [{ value: INITIAL_SCORE, reason: 'Start value' }];
parsed.yearGuessed = false;
// Retroactively deduct points for existing guesses if possible,
// but simpler to just start at 90 for active games to avoid confusion
}
setGameState(parsed as GameState);
} else { } else {
// New day // New day
const newState: GameState = { const newState = createNewState(today);
date: today,
guesses: [],
isSolved: false,
isFailed: false,
lastPlayed: Date.now(),
};
setGameState(newState); setGameState(newState);
localStorage.setItem(storageKey, JSON.stringify(newState)); localStorage.setItem(storageKey, JSON.stringify(newState));
} }
} else { } else {
// No state // No state
const newState: GameState = { const newState = createNewState(today);
date: today,
guesses: [],
isSolved: false,
isFailed: false,
lastPlayed: Date.now(),
};
setGameState(newState); setGameState(newState);
localStorage.setItem(storageKey, JSON.stringify(newState)); localStorage.setItem(storageKey, JSON.stringify(newState));
} }
@@ -116,8 +132,6 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
case 6: newStats.solvedIn6++; break; case 6: newStats.solvedIn6++; break;
case 7: newStats.solvedIn7++; break; case 7: newStats.solvedIn7++; break;
default: default:
// For custom attempts > 7, we currently don't have specific stats buckets
// We could add a 'solvedInOther' or just ignore for now
break; break;
} }
} else { } else {
@@ -135,12 +149,43 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
const isSolved = correct; const isSolved = correct;
const isFailed = !correct && newGuesses.length >= maxAttempts; const isFailed = !correct && newGuesses.length >= maxAttempts;
let newScore = gameState.score;
const newBreakdown = [...gameState.scoreBreakdown];
if (correct) {
newScore += 20;
newBreakdown.push({ value: 20, reason: 'Correct Answer' });
} else {
if (guess === 'SKIPPED') {
newScore -= 5;
newBreakdown.push({ value: -5, reason: 'Skip' });
} else {
newScore -= 3;
newBreakdown.push({ value: -3, reason: 'Wrong guess' });
}
}
// If failed, reset score to 0
if (isFailed) {
if (newScore > 0) {
newBreakdown.push({ value: -newScore, reason: 'Game Over' });
newScore = 0;
}
}
// Ensure score doesn't go below 0
newScore = Math.max(0, newScore);
const newState = { const newState = {
...gameState, ...gameState,
guesses: newGuesses, guesses: newGuesses,
isSolved, isSolved,
isFailed, isFailed,
lastPlayed: Date.now(), lastPlayed: Date.now(),
score: newScore,
scoreBreakdown: newBreakdown,
// Update skip count if skipped
skipCount: guess === 'SKIPPED' ? gameState.skipCount + 1 : gameState.skipCount
}; };
saveState(newState); saveState(newState);
@@ -151,5 +196,79 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
} }
}; };
return { gameState, statistics, addGuess }; const giveUp = () => {
if (!gameState || gameState.isSolved || gameState.isFailed) return;
let newScore = 0;
const newBreakdown = [...gameState.scoreBreakdown];
if (gameState.score > 0) {
newBreakdown.push({ value: -gameState.score, reason: 'Gave Up' });
}
const newState = {
...gameState,
isFailed: true,
score: 0,
scoreBreakdown: newBreakdown,
lastPlayed: Date.now()
};
saveState(newState);
updateStatistics(gameState.guesses.length, false);
};
const addReplay = () => {
if (!gameState || gameState.isSolved || gameState.isFailed) return;
let newScore = gameState.score - 1;
// Ensure score doesn't go below 0
newScore = Math.max(0, newScore);
const newBreakdown = [...gameState.scoreBreakdown, { value: -1, reason: 'Replay snippet' }];
const newState = {
...gameState,
replayCount: gameState.replayCount + 1,
score: newScore,
scoreBreakdown: newBreakdown
};
saveState(newState);
};
const addYearBonus = (correct: boolean) => {
if (!gameState) return;
let newScore = gameState.score;
const newBreakdown = [...gameState.scoreBreakdown];
if (correct) {
newScore += 10;
newBreakdown.push({ value: 10, reason: 'Bonus: Correct Year' });
} else {
newBreakdown.push({ value: 0, reason: 'Bonus: Wrong Year' });
}
const newState = {
...gameState,
score: newScore,
scoreBreakdown: newBreakdown,
yearGuessed: true
};
saveState(newState);
};
const skipYearBonus = () => {
if (!gameState) return;
const newBreakdown = [...gameState.scoreBreakdown, { value: 0, reason: 'Bonus: Skipped' }];
const newState = {
...gameState,
scoreBreakdown: newBreakdown,
yearGuessed: true
};
saveState(newState);
};
return { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus };
} }
+121
View File
@@ -0,0 +1,121 @@
/**
* MusicBrainz API integration for fetching release years
* API Documentation: https://musicbrainz.org/doc/MusicBrainz_API
* Rate Limiting: 50 requests per second for meaningful User-Agent strings
*/
const MUSICBRAINZ_API_BASE = 'https://musicbrainz.org/ws/2';
const USER_AGENT = 'hoerdle/0.1.0 ( elpatron@mailbox.org )';
const RATE_LIMIT_DELAY = 25; // 25ms between requests = ~40 req/s (safe margin)
/**
* Sleep utility for rate limiting
*/
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Fetch with retry logic for HTTP 503 (rate limit exceeded)
*/
async function fetchWithRetry(url: string, maxRetries = 5): Promise<Response> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, {
headers: {
'User-Agent': USER_AGENT,
'Accept': 'application/json'
}
});
// If rate limited (503), wait with exponential backoff
if (response.status === 503) {
const waitTime = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s, 8s, 16s
console.log(`Rate limited (503), waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
await sleep(waitTime);
continue;
}
return response;
} catch (error) {
lastError = error as Error;
if (attempt < maxRetries - 1) {
const waitTime = Math.pow(2, attempt) * 1000;
console.log(`Network error, waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
await sleep(waitTime);
}
}
}
throw lastError || new Error('Max retries exceeded');
}
/**
* Get the earliest release year for a song from MusicBrainz
* @param artist Artist name
* @param title Song title
* @returns Release year or null if not found
*/
export async function getReleaseYear(artist: string, title: string): Promise<number | null> {
try {
// Build search query using Lucene syntax
const query = `artist:"${artist}" AND recording:"${title}"`;
const url = `${MUSICBRAINZ_API_BASE}/recording?query=${encodeURIComponent(query)}&fmt=json&limit=10`;
// Add rate limiting delay
await sleep(RATE_LIMIT_DELAY);
const response = await fetchWithRetry(url);
if (!response.ok) {
console.error(`MusicBrainz API error: ${response.status} ${response.statusText}`);
return null;
}
const data = await response.json();
if (!data.recordings || data.recordings.length === 0) {
console.log(`No recordings found for "${title}" by "${artist}"`);
return null;
}
// Find the earliest release year from all recordings
let earliestYear: number | null = null;
for (const recording of data.recordings) {
// Check if recording has releases
if (recording.releases && recording.releases.length > 0) {
for (const release of recording.releases) {
if (release.date) {
// Extract year from date (format: YYYY-MM-DD or YYYY)
const year = parseInt(release.date.split('-')[0]);
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
earliestYear = year;
}
}
}
}
// Also check first-release-date on the recording itself
if (recording['first-release-date']) {
const year = parseInt(recording['first-release-date'].split('-')[0]);
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
earliestYear = year;
}
}
}
if (earliestYear) {
console.log(`Found release year ${earliestYear} for "${title}" by "${artist}"`);
} else {
console.log(`No release year found for "${title}" by "${artist}"`);
}
return earliestYear;
} catch (error) {
console.error(`Error fetching release year for "${title}" by "${artist}":`, error);
return null;
}
}
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Song" ADD COLUMN "releaseYear" INTEGER;
@@ -0,0 +1,20 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_DailyPuzzle" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"date" TEXT NOT NULL,
"songId" INTEGER NOT NULL,
"genreId" INTEGER,
"specialId" INTEGER,
CONSTRAINT "DailyPuzzle_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "DailyPuzzle_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "DailyPuzzle_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_DailyPuzzle" ("date", "genreId", "id", "songId", "specialId") SELECT "date", "genreId", "id", "songId", "specialId" FROM "DailyPuzzle";
DROP TABLE "DailyPuzzle";
ALTER TABLE "new_DailyPuzzle" RENAME TO "DailyPuzzle";
CREATE UNIQUE INDEX "DailyPuzzle_date_genreId_specialId_key" ON "DailyPuzzle"("date", "genreId", "specialId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
+2 -1
View File
@@ -16,6 +16,7 @@ model Song {
artist String artist String
filename String // Filename in public/uploads filename String // Filename in public/uploads
coverImage String? // Filename in public/uploads/covers coverImage String? // Filename in public/uploads/covers
releaseYear Int? // Release year from MusicBrainz
createdAt DateTime @default(now()) createdAt DateTime @default(now())
puzzles DailyPuzzle[] puzzles DailyPuzzle[]
genres Genre[] genres Genre[]
@@ -62,7 +63,7 @@ model DailyPuzzle {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
date String // Format: YYYY-MM-DD date String // Format: YYYY-MM-DD
songId Int songId Int
song Song @relation(fields: [songId], references: [id]) song Song @relation(fields: [songId], references: [id], onDelete: Cascade)
genreId Int? genreId Int?
genre Genre? @relation(fields: [genreId], references: [id]) genre Genre? @relation(fields: [genreId], references: [id])
specialId Int? specialId Int?
+6
View File
@@ -7,6 +7,12 @@ echo "Starting deployment..."
echo "Running database migrations..." echo "Running database migrations..."
npx prisma migrate deploy npx prisma migrate deploy
# Run release year migration (only if not already done)
if [ ! -f /app/.release-years-migrated ]; then
echo "Running release year migration (this will take ~12 seconds for 600 songs)..."
node scripts/migrate-release-years.mjs
fi
# Start the application # Start the application
echo "Starting application..." echo "Starting application..."
exec node server.js exec node server.js
-2
View File
@@ -48,8 +48,6 @@ async function migrate() {
}); });
console.log(`✅ Extracted cover for ${song.title}`); console.log(`✅ Extracted cover for ${song.title}`);
} else {
console.log(`⚠️ No cover found for ${song.title}`);
} }
} catch (e) { } catch (e) {
console.error(`❌ Failed to process ${song.title}:`, e.message); console.error(`❌ Failed to process ${song.title}:`, e.message);
+190
View File
@@ -0,0 +1,190 @@
import { PrismaClient } from '@prisma/client';
import { writeFile } from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const prisma = new PrismaClient();
// --- MusicBrainz Logic (Embedded to avoid TS import issues in Docker) ---
const MUSICBRAINZ_API_BASE = 'https://musicbrainz.org/ws/2';
const USER_AGENT = 'hoerdle/0.1.0 ( elpatron@mailbox.org )';
const RATE_LIMIT_DELAY = 25; // 25ms between requests
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function fetchWithRetry(url, maxRetries = 5) {
let lastError = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, {
headers: {
'User-Agent': USER_AGENT,
'Accept': 'application/json'
}
});
if (response.status === 503) {
const waitTime = Math.pow(2, attempt) * 1000;
console.log(`Rate limited (503), waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
await sleep(waitTime);
continue;
}
return response;
} catch (error) {
lastError = error;
if (attempt < maxRetries - 1) {
const waitTime = Math.pow(2, attempt) * 1000;
console.log(`Network error, waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
await sleep(waitTime);
}
}
}
throw lastError || new Error('Max retries exceeded');
}
async function getReleaseYear(artist, title) {
try {
const query = `artist:"${artist}" AND recording:"${title}"`;
const url = `${MUSICBRAINZ_API_BASE}/recording?query=${encodeURIComponent(query)}&fmt=json&limit=10`;
await sleep(RATE_LIMIT_DELAY);
const response = await fetchWithRetry(url);
if (!response.ok) {
console.error(`MusicBrainz API error: ${response.status} ${response.statusText}`);
return null;
}
const data = await response.json();
if (!data.recordings || data.recordings.length === 0) {
// console.log(`No recordings found for "${title}" by "${artist}"`);
return null;
}
let earliestYear = null;
for (const recording of data.recordings) {
if (recording.releases && recording.releases.length > 0) {
for (const release of recording.releases) {
if (release.date) {
const year = parseInt(release.date.split('-')[0]);
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
earliestYear = year;
}
}
}
}
if (recording['first-release-date']) {
const year = parseInt(recording['first-release-date'].split('-')[0]);
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
earliestYear = year;
}
}
}
return earliestYear;
} catch (error) {
console.error(`Error fetching release year for "${title}" by "${artist}":`, error.message);
return null;
}
}
// --- Migration Logic ---
async function migrate() {
console.log('🎵 Starting release year migration...');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
try {
// Find songs without release year
const songs = await prisma.song.findMany({
where: {
releaseYear: null
},
orderBy: {
id: 'asc'
}
});
console.log(`📊 Found ${songs.length} songs without release year.\n`);
if (songs.length === 0) {
console.log('✅ All songs already have release years!');
await createFlagFile();
return;
}
let processed = 0;
let successful = 0;
let failed = 0;
const startTime = Date.now();
for (const song of songs) {
processed++;
// const progress = `[${processed}/${songs.length}]`;
try {
// console.log(`${progress} Processing: "${song.title}" by "${song.artist}"`);
const releaseYear = await getReleaseYear(song.artist, song.title);
if (releaseYear) {
await prisma.song.update({
where: { id: song.id },
data: { releaseYear }
});
successful++;
// console.log(` ✅ Updated with year: ${releaseYear}`);
} else {
failed++;
// console.log(` ⚠️ No release year found`);
}
} catch (error) {
failed++;
console.error(` ❌ Error processing song:`, error instanceof Error ? error.message : error);
}
// Progress update every 10 songs (less verbose)
if (processed % 10 === 0 || processed === songs.length) {
const elapsed = Math.round((Date.now() - startTime) / 1000);
const rate = processed / (elapsed || 1);
const remaining = songs.length - processed;
const eta = Math.round(remaining / rate);
process.stdout.write(`\r📈 Progress: ${processed}/${songs.length} | Success: ${successful} | Failed: ${failed} | ETA: ${eta}s`);
}
}
const totalTime = Math.round((Date.now() - startTime) / 1000);
console.log('\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('✅ Migration completed!');
console.log(`📊 Total: ${processed} | Success: ${successful} | Failed: ${failed}`);
console.log(`⏱️ Time: ${totalTime}s (${(processed / (totalTime || 1)).toFixed(2)} songs/s)`);
await createFlagFile();
} catch (error) {
console.error('❌ Migration failed:', error);
throw error;
} finally {
await prisma.$disconnect();
}
}
async function createFlagFile() {
const flagPath = path.join(process.cwd(), '.release-years-migrated');
await writeFile(flagPath, new Date().toISOString());
console.log(`\n🏁 Created flag file: ${flagPath}`);
}
migrate();