Compare commits

...

2 Commits

Author SHA1 Message Date
Hördle Bot
1d2a57352f feat(admin): add persistent login and improve audio playback error handling 2025-11-21 20:52:08 +01:00
Hördle Bot
95a3b09f52 feat: add 7th guess with 60s unlock and update docs 2025-11-21 19:25:38 +01:00
5 changed files with 49 additions and 12 deletions

View File

@@ -5,7 +5,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
## Features ## Features
- **Tägliches Rätsel:** Jeden Tag ein neuer Song für alle Nutzer. - **Tägliches Rätsel:** Jeden Tag ein neuer Song für alle Nutzer.
- **Inkrementelle Hinweise:** Startet mit 2 Sekunden, dann 4s, 7s, 11s, 16s, bis 30s. - **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.
- Automatische Extraktion von ID3-Tags (Titel, Interpret). - Automatische Extraktion von ID3-Tags (Titel, Interpret).

View File

@@ -39,12 +39,22 @@ export default function AdminPage() {
const [playingSongId, setPlayingSongId] = useState<number | null>(null); const [playingSongId, setPlayingSongId] = useState<number | null>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null); const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
// Check for existing auth on mount
useEffect(() => {
const authToken = localStorage.getItem('hoerdle_admin_auth');
if (authToken === 'authenticated') {
setIsAuthenticated(true);
fetchSongs();
}
}, []);
const handleLogin = async () => { const handleLogin = async () => {
const res = await fetch('/api/admin/login', { const res = await fetch('/api/admin/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ password }), body: JSON.stringify({ password }),
}); });
if (res.ok) { if (res.ok) {
localStorage.setItem('hoerdle_admin_auth', 'authenticated');
setIsAuthenticated(true); setIsAuthenticated(true);
fetchSongs(); fetchSongs();
} else { } else {
@@ -147,9 +157,25 @@ export default function AdminPage() {
// Play new song // Play new song
const audio = new Audio(`/uploads/${song.filename}`); const audio = new Audio(`/uploads/${song.filename}`);
audio.play();
// Handle playback errors
audio.onerror = () => {
alert(`Failed to load audio file: ${song.filename}\nThe file may be corrupted or missing.`);
setPlayingSongId(null);
setAudioElement(null);
};
audio.play()
.then(() => {
setAudioElement(audio); setAudioElement(audio);
setPlayingSongId(song.id); setPlayingSongId(song.id);
})
.catch((error) => {
console.error('Playback error:', error);
alert(`Failed to play audio: ${error.message}`);
setPlayingSongId(null);
setAudioElement(null);
});
// Reset when song ends // Reset when song ends
audio.onended = () => { audio.onended = () => {

View File

@@ -17,7 +17,7 @@ interface GameProps {
} | null; } | null;
} }
const UNLOCK_STEPS = [2, 4, 7, 11, 16, 30]; const UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
export default function Game({ dailyPuzzle }: GameProps) { export default function Game({ dailyPuzzle }: GameProps) {
const { gameState, statistics, addGuess } = useGameState(); const { gameState, statistics, addGuess } = useGameState();
@@ -48,17 +48,17 @@ export default function Game({ dailyPuzzle }: GameProps) {
setHasWon(true); setHasWon(true);
} else { } else {
addGuess(song.title, false); addGuess(song.title, false);
if (gameState.guesses.length + 1 >= 6) { if (gameState.guesses.length + 1 >= 7) {
setHasLost(true); setHasLost(true);
} }
} }
}; };
const unlockedSeconds = UNLOCK_STEPS[Math.min(gameState.guesses.length, 5)]; const unlockedSeconds = UNLOCK_STEPS[Math.min(gameState.guesses.length, 6)];
const handleShare = () => { const handleShare = () => {
let emojiGrid = ''; let emojiGrid = '';
const totalGuesses = 6; const totalGuesses = 7;
// Build the grid // Build the grid
for (let i = 0; i < totalGuesses; i++) { for (let i = 0; i < totalGuesses; i++) {
@@ -110,7 +110,7 @@ export default function Game({ dailyPuzzle }: GameProps) {
<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} / 6</span> <span>Attempt {gameState.guesses.length + 1} / 7</span>
<span>{unlockedSeconds}s unlocked</span> <span>{unlockedSeconds}s unlocked</span>
</div> </div>
<AudioPlayer <AudioPlayer
@@ -140,7 +140,7 @@ export default function Game({ dailyPuzzle }: GameProps) {
onClick={() => addGuess("SKIPPED", false)} onClick={() => addGuess("SKIPPED", false)}
className="skip-button" className="skip-button"
> >
Skip (+{UNLOCK_STEPS[Math.min(gameState.guesses.length + 1, 5)] - unlockedSeconds}s) Skip (+{UNLOCK_STEPS[Math.min(gameState.guesses.length + 1, 6)] - unlockedSeconds}s)
</button> </button>
</> </>
)} )}

View File

@@ -13,6 +13,7 @@ const BADGES = {
4: '⭐', // Star 4: '⭐', // Star
5: '✨', // Sparkles 5: '✨', // Sparkles
6: '💫', // Dizzy 6: '💫', // Dizzy
7: '🎵', // Musical note
failed: '❌', // Cross mark failed: '❌', // Cross mark
}; };
@@ -24,6 +25,7 @@ export default function Statistics({ statistics }: StatisticsProps) {
statistics.solvedIn4 + statistics.solvedIn4 +
statistics.solvedIn5 + statistics.solvedIn5 +
statistics.solvedIn6 + statistics.solvedIn6 +
statistics.solvedIn7 +
statistics.failed; statistics.failed;
const stats = [ const stats = [
@@ -33,6 +35,7 @@ export default function Statistics({ statistics }: StatisticsProps) {
{ attempts: 4, count: statistics.solvedIn4, badge: BADGES[4] }, { attempts: 4, count: statistics.solvedIn4, badge: BADGES[4] },
{ attempts: 5, count: statistics.solvedIn5, badge: BADGES[5] }, { attempts: 5, count: statistics.solvedIn5, badge: BADGES[5] },
{ attempts: 6, count: statistics.solvedIn6, badge: BADGES[6] }, { attempts: 6, count: statistics.solvedIn6, badge: BADGES[6] },
{ attempts: 7, count: statistics.solvedIn7, badge: BADGES[7] },
{ attempts: 'Failed', count: statistics.failed, badge: BADGES.failed }, { attempts: 'Failed', count: statistics.failed, badge: BADGES.failed },
]; ];

View File

@@ -17,6 +17,7 @@ export interface Statistics {
solvedIn4: number; solvedIn4: number;
solvedIn5: number; solvedIn5: number;
solvedIn6: number; solvedIn6: number;
solvedIn7: number;
failed: number; failed: number;
} }
@@ -64,7 +65,12 @@ export function useGameState() {
// Load statistics // Load statistics
const storedStats = localStorage.getItem(STATS_KEY); const storedStats = localStorage.getItem(STATS_KEY);
if (storedStats) { if (storedStats) {
setStatistics(JSON.parse(storedStats)); const parsedStats = JSON.parse(storedStats);
// Migration for existing stats without solvedIn7
if (parsedStats.solvedIn7 === undefined) {
parsedStats.solvedIn7 = 0;
}
setStatistics(parsedStats);
} else { } else {
const newStats: Statistics = { const newStats: Statistics = {
solvedIn1: 0, solvedIn1: 0,
@@ -73,6 +79,7 @@ export function useGameState() {
solvedIn4: 0, solvedIn4: 0,
solvedIn5: 0, solvedIn5: 0,
solvedIn6: 0, solvedIn6: 0,
solvedIn7: 0,
failed: 0, failed: 0,
}; };
setStatistics(newStats); setStatistics(newStats);
@@ -98,6 +105,7 @@ export function useGameState() {
case 4: newStats.solvedIn4++; break; case 4: newStats.solvedIn4++; break;
case 5: newStats.solvedIn5++; break; case 5: newStats.solvedIn5++; break;
case 6: newStats.solvedIn6++; break; case 6: newStats.solvedIn6++; break;
case 7: newStats.solvedIn7++; break;
} }
} else { } else {
newStats.failed++; newStats.failed++;
@@ -112,7 +120,7 @@ export function useGameState() {
const newGuesses = [...gameState.guesses, guess]; const newGuesses = [...gameState.guesses, guess];
const isSolved = correct; const isSolved = correct;
const isFailed = !correct && newGuesses.length >= 6; const isFailed = !correct && newGuesses.length >= 7;
const newState = { const newState = {
...gameState, ...gameState,