Compare commits

...

3 Commits

Author SHA1 Message Date
Hördle Bot
e56d7893d7 Docs: Add Nginx example configuration 2025-11-22 10:36:57 +01:00
Hördle Bot
f20ba02c02 Docs: Add Gotify configuration to README 2025-11-22 10:35:35 +01:00
Hördle Bot
f8fb3ccf69 Feat: Auto-play on skip & Gotify notifications 2025-11-22 10:34:22 +01:00
6 changed files with 119 additions and 7 deletions

View File

@@ -19,6 +19,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- **Teilen-Funktion:** Ergebnisse können als Emoji-Grid geteilt werden.
- **PWA Support:** Installierbar als App auf Desktop und Mobilgeräten (Manifest & Icons).
- **Persistenz:** Spielstatus wird lokal im Browser gespeichert.
- **Benachrichtigungen:** Integration mit Gotify für Push-Nachrichten bei Spielabschluss.
## Tech Stack
@@ -65,6 +66,8 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
Passe die Umgebungsvariablen in der `docker-compose.yml` an:
- `ADMIN_PASSWORD`: Admin-Passwort (Standard: `admin123`)
- `TZ`: Zeitzone für täglichen Puzzle-Wechsel (Standard: `Europe/Berlin`)
- `GOTIFY_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
2. **Starten:**
```bash

31
app/actions.ts Normal file
View File

@@ -0,0 +1,31 @@
'use server';
const GOTIFY_URL = process.env.GOTIFY_URL;
const GOTIFY_APP_TOKEN = process.env.GOTIFY_APP_TOKEN;
export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number) {
try {
const title = `Hördle #${puzzleId} ${status === 'won' ? 'Solved!' : 'Failed'}`;
const message = status === 'won'
? `Puzzle #${puzzleId} was solved in ${attempts} attempt(s).`
: `Puzzle #${puzzleId} was failed after ${attempts} attempt(s).`;
const response = await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: title,
message: message,
priority: 5,
}),
});
if (!response.ok) {
console.error('Failed to send Gotify notification:', await response.text());
}
} catch (error) {
console.error('Error sending Gotify notification:', error);
}
}

View File

@@ -6,9 +6,10 @@ interface AudioPlayerProps {
src: string;
unlockedSeconds: number; // 2, 4, 7, 11, 16, 30 (or full length)
onPlay?: () => void;
autoPlay?: boolean;
}
export default function AudioPlayer({ src, unlockedSeconds, onPlay }: AudioPlayerProps) {
export default function AudioPlayer({ src, unlockedSeconds, onPlay, autoPlay = false }: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
@@ -19,8 +20,23 @@ export default function AudioPlayer({ src, unlockedSeconds, onPlay }: AudioPlaye
audioRef.current.currentTime = 0;
setIsPlaying(false);
setProgress(0);
if (autoPlay) {
const playPromise = audioRef.current.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
setIsPlaying(true);
onPlay?.();
})
.catch(error => {
console.log("Autoplay prevented:", error);
setIsPlaying(false);
});
}
}
}
}, [src, unlockedSeconds]);
}, [src, unlockedSeconds, autoPlay]);
const togglePlay = () => {
if (!audioRef.current) return;

View File

@@ -5,6 +5,7 @@ import AudioPlayer from './AudioPlayer';
import GuessInput from './GuessInput';
import Statistics from './Statistics';
import { useGameState } from '../lib/gameState';
import { sendGotifyNotification } from '../app/actions';
interface GameProps {
dailyPuzzle: {
@@ -24,6 +25,7 @@ export default function Game({ dailyPuzzle }: GameProps) {
const [hasWon, setHasWon] = useState(false);
const [hasLost, setHasLost] = useState(false);
const [shareText, setShareText] = useState('Share Result');
const [lastAction, setLastAction] = useState<'GUESS' | 'SKIP' | null>(null);
useEffect(() => {
if (gameState && dailyPuzzle) {
@@ -32,6 +34,10 @@ export default function Game({ dailyPuzzle }: GameProps) {
}
}, [gameState, dailyPuzzle]);
useEffect(() => {
setLastAction(null);
}, [dailyPuzzle?.id]);
if (!dailyPuzzle) return (
<div className="game-container" style={{ textAlign: 'center', padding: '2rem' }}>
<h2>No Puzzle Available</h2>
@@ -43,17 +49,32 @@ export default function Game({ dailyPuzzle }: GameProps) {
if (!gameState) return <div>Loading state...</div>;
const handleGuess = (song: any) => {
setLastAction('GUESS');
if (song.id === dailyPuzzle.songId) {
addGuess(song.title, true);
setHasWon(true);
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id);
} else {
addGuess(song.title, false);
if (gameState.guesses.length + 1 >= 7) {
setHasLost(true);
sendGotifyNotification(7, 'lost', dailyPuzzle.id);
}
}
};
const handleSkip = () => {
setLastAction('SKIP');
addGuess("SKIPPED", false);
};
const handleGiveUp = () => {
setLastAction('SKIP');
addGuess("SKIPPED", false);
setHasLost(true);
sendGotifyNotification(7, 'lost', dailyPuzzle.id);
};
const unlockedSeconds = UNLOCK_STEPS[Math.min(gameState.guesses.length, 6)];
const handleShare = () => {
@@ -116,6 +137,7 @@ export default function Game({ dailyPuzzle }: GameProps) {
<AudioPlayer
src={dailyPuzzle.audioUrl}
unlockedSeconds={unlockedSeconds}
autoPlay={lastAction === 'SKIP'}
/>
</div>
@@ -138,17 +160,14 @@ export default function Game({ dailyPuzzle }: GameProps) {
<GuessInput onGuess={handleGuess} disabled={false} />
{gameState.guesses.length < 6 ? (
<button
onClick={() => addGuess("SKIPPED", false)}
onClick={handleSkip}
className="skip-button"
>
Skip (+{UNLOCK_STEPS[Math.min(gameState.guesses.length + 1, 6)] - unlockedSeconds}s)
</button>
) : (
<button
onClick={() => {
addGuess("SKIPPED", false);
setHasLost(true);
}}
onClick={handleGiveUp}
className="skip-button"
style={{
background: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',

View File

@@ -12,6 +12,8 @@ services:
- DATABASE_URL=file:/app/data/prod.db
- ADMIN_PASSWORD=admin123 # Change this!
- TZ=Europe/Berlin # Timezone for daily puzzle rotation
- GOTIFY_URL=https://gotify.example.com
- GOTIFY_APP_TOKEN=your_gotify_token
volumes:
- ./data:/app/data
- ./public/uploads:/app/public/uploads

41
nginx.conf.example Normal file
View File

@@ -0,0 +1,41 @@
server {
listen 80;
server_name your-domain.com;
# Increase client body size for uploads
client_max_body_size 50M;
# Proxy to Next.js app
location / {
proxy_pass http://hoerdle:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
# Important: Forward range headers for audio streaming
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_no_cache $http_range $http_if_range;
}
# Special handling for audio files (optional, for direct serving)
location /uploads/ {
proxy_pass http://hoerdle:3000;
proxy_http_version 1.1;
# Enable range requests
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_no_cache $http_range $http_if_range;
# Buffering settings for large files
proxy_buffering off;
proxy_request_buffering off;
# Timeouts for large files
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}