Initial commit: Hördle Web App
This commit is contained in:
87
components/AudioPlayer.tsx
Normal file
87
components/AudioPlayer.tsx
Normal 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
145
components/Game.tsx
Normal 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
76
components/GuessInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user