Initial commit: Hördle Web App

This commit is contained in:
Hördle Bot
2025-11-21 12:25:19 +01:00
commit c1bd141042
31 changed files with 8259 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
'use client';
import { useState, useRef, useEffect } from 'react';
interface AudioPlayerProps {
src: string;
unlockedSeconds: number; // 2, 4, 7, 11, 16, 30 (or full length)
onPlay?: () => void;
}
export default function AudioPlayer({ src, unlockedSeconds, onPlay }: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
useEffect(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
setIsPlaying(false);
setProgress(0);
}
}, [src, unlockedSeconds]);
const togglePlay = () => {
if (!audioRef.current) return;
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
onPlay?.();
}
setIsPlaying(!isPlaying);
};
const handleTimeUpdate = () => {
if (!audioRef.current) return;
const current = audioRef.current.currentTime;
const percent = (current / unlockedSeconds) * 100;
setProgress(Math.min(percent, 100));
if (current >= unlockedSeconds) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
setIsPlaying(false);
setProgress(0);
}
};
return (
<div className="audio-player">
<audio
ref={audioRef}
src={src}
onTimeUpdate={handleTimeUpdate}
onEnded={() => setIsPlaying(false)}
/>
<div className="player-controls">
<button onClick={togglePlay} className="play-button">
{isPlaying ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="24" height="24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="24" height="24" style={{ marginLeft: '4px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
</svg>
)}
</button>
<div className="progress-bar-container">
<div
className="progress-bar"
style={{ width: `${progress}%` }}
/>
</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.875rem', color: '#4b5563' }}>
{unlockedSeconds}s
</div>
</div>
</div>
);
}

145
components/Game.tsx Normal file
View File

@@ -0,0 +1,145 @@
'use client';
import { useEffect, useState } from 'react';
import AudioPlayer from './AudioPlayer';
import GuessInput from './GuessInput';
import { useGameState } from '../lib/gameState';
interface GameProps {
dailyPuzzle: {
id: number;
audioUrl: string;
songId: number;
} | null;
}
const UNLOCK_STEPS = [2, 4, 7, 11, 16, 30];
export default function Game({ dailyPuzzle }: GameProps) {
const { gameState, addGuess } = useGameState();
const [hasWon, setHasWon] = useState(false);
const [hasLost, setHasLost] = useState(false);
const [shareText, setShareText] = useState('Share Result');
useEffect(() => {
if (gameState && dailyPuzzle) {
setHasWon(gameState.isSolved);
setHasLost(gameState.isFailed);
}
}, [gameState, dailyPuzzle]);
if (!dailyPuzzle) return <div>Loading puzzle...</div>;
if (!gameState) return <div>Loading state...</div>;
const handleGuess = (song: any) => {
if (song.id === dailyPuzzle.songId) {
addGuess(song.title, true);
setHasWon(true);
} else {
addGuess(song.title, false);
if (gameState.guesses.length + 1 >= 6) {
setHasLost(true);
}
}
};
const unlockedSeconds = UNLOCK_STEPS[Math.min(gameState.guesses.length, 5)];
const handleShare = () => {
let emojiGrid = '';
const totalGuesses = 6;
// Build the grid
for (let i = 0; i < totalGuesses; i++) {
if (i < gameState.guesses.length) {
// If this was the winning guess (last one and won)
if (hasWon && i === gameState.guesses.length - 1) {
emojiGrid += '🟩';
} else {
// Wrong or skipped
emojiGrid += '⬛';
}
} else {
// Unused attempts
emojiGrid += '⬜';
}
}
const speaker = hasWon ? '🔉' : '🔇';
const text = `Hördle #${dailyPuzzle.id}\n\n${speaker}${emojiGrid}\n\n#Hördle #Music\n\nhttps://hoerdle.elpatron.me`;
navigator.clipboard.writeText(text).then(() => {
setShareText('Copied!');
setTimeout(() => setShareText('Share Result'), 2000);
});
};
return (
<div className="container">
<header className="header">
<h1 className="title">Hördle #{dailyPuzzle.id}</h1>
</header>
<main className="game-board">
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
<div className="status-bar">
<span>Attempt {gameState.guesses.length + 1} / 6</span>
<span>{unlockedSeconds}s unlocked</span>
</div>
<AudioPlayer
src={dailyPuzzle.audioUrl}
unlockedSeconds={unlockedSeconds}
/>
</div>
<div className="guess-list">
{gameState.guesses.map((guess, i) => {
const isCorrect = hasWon && i === gameState.guesses.length - 1;
return (
<div key={i} className="guess-item">
<span className="guess-number">#{i + 1}</span>
<span className={`guess-text ${guess === 'SKIPPED' ? 'skipped' : ''} ${isCorrect ? 'correct' : ''}`}>
{isCorrect ? 'Correct!' : guess}
</span>
</div>
);
})}
</div>
{!hasWon && !hasLost && (
<>
<GuessInput onGuess={handleGuess} disabled={false} />
<button
onClick={() => addGuess("SKIPPED", false)}
className="skip-button"
>
Skip (+{UNLOCK_STEPS[Math.min(gameState.guesses.length + 1, 5)] - unlockedSeconds}s)
</button>
</>
)}
{hasWon && (
<div className="message-box success">
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>You won!</h2>
<p>Come back tomorrow for a new song.</p>
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
{shareText}
</button>
</div>
)}
{hasLost && (
<div className="message-box failure">
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>Game Over</h2>
<p>The song was hidden.</p>
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
{shareText}
</button>
</div>
)}
</main>
</div>
);
}

76
components/GuessInput.tsx Normal file
View File

@@ -0,0 +1,76 @@
'use client';
import { useState, useEffect } from 'react';
interface Song {
id: number;
title: string;
artist: string;
}
interface GuessInputProps {
onGuess: (song: Song) => void;
disabled: boolean;
}
export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
const [query, setQuery] = useState('');
const [songs, setSongs] = useState<Song[]>([]);
const [filteredSongs, setFilteredSongs] = useState<Song[]>([]);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
fetch('/api/songs')
.then(res => res.json())
.then(data => setSongs(data));
}, []);
useEffect(() => {
if (query.length > 1) {
const lower = query.toLowerCase();
const filtered = songs.filter(s =>
s.title.toLowerCase().includes(lower) ||
s.artist.toLowerCase().includes(lower)
);
setFilteredSongs(filtered);
setIsOpen(true);
} else {
setFilteredSongs([]);
setIsOpen(false);
}
}, [query, songs]);
const handleSelect = (song: Song) => {
onGuess(song);
setQuery('');
setIsOpen(false);
};
return (
<div className="input-container">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
disabled={disabled}
placeholder={disabled ? "Game Over" : "Know it? Search for the artist / title"}
className="guess-input"
/>
{isOpen && filteredSongs.length > 0 && (
<ul className="suggestions-list">
{filteredSongs.map(song => (
<li
key={song.id}
onClick={() => handleSelect(song)}
className="suggestion-item"
>
<div className="suggestion-title">{song.title}</div>
<div className="suggestion-artist">{song.artist}</div>
</li>
))}
</ul>
)}
</div>
);
}