- Extract cover art from MP3s during upload - Display cover art in game result screens (win/loss) - Add coverImage field to Song model - Add migration script to backfill covers for existing songs - Configure Docker to run migration script on startup
203 lines
8.5 KiB
TypeScript
203 lines
8.5 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import AudioPlayer from './AudioPlayer';
|
|
import GuessInput from './GuessInput';
|
|
import Statistics from './Statistics';
|
|
import { useGameState } from '../lib/gameState';
|
|
|
|
interface GameProps {
|
|
dailyPuzzle: {
|
|
id: number;
|
|
audioUrl: string;
|
|
songId: number;
|
|
title: string;
|
|
artist: string;
|
|
coverImage: string | null;
|
|
} | null;
|
|
}
|
|
|
|
const UNLOCK_STEPS = [2, 4, 7, 11, 16, 30];
|
|
|
|
export default function Game({ dailyPuzzle }: GameProps) {
|
|
const { gameState, statistics, 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`;
|
|
|
|
// Fallback method for copying to clipboard
|
|
const textarea = document.createElement('textarea');
|
|
textarea.value = text;
|
|
textarea.style.position = 'fixed';
|
|
textarea.style.opacity = '0';
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
|
|
try {
|
|
document.execCommand('copy');
|
|
setShareText('Copied!');
|
|
setTimeout(() => setShareText('Share Result'), 2000);
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err);
|
|
setShareText('Copy failed');
|
|
setTimeout(() => setShareText('Share Result'), 2000);
|
|
} finally {
|
|
document.body.removeChild(textarea);
|
|
}
|
|
};
|
|
|
|
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>
|
|
|
|
{/* 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' }}>
|
|
{dailyPuzzle.coverImage && (
|
|
<img
|
|
src={dailyPuzzle.coverImage}
|
|
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>
|
|
|
|
{statistics && <Statistics statistics={statistics} />}
|
|
<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:</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' }}>
|
|
{dailyPuzzle.coverImage && (
|
|
<img
|
|
src={dailyPuzzle.coverImage}
|
|
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>
|
|
|
|
{statistics && <Statistics statistics={statistics} />}
|
|
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
|
{shareText}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|