Compare commits
3 Commits
4f8524c286
...
e56d7893d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e56d7893d7 | ||
|
|
f20ba02c02 | ||
|
|
f8fb3ccf69 |
@@ -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
31
app/actions.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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%)',
|
||||
|
||||
@@ -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
41
nginx.conf.example
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user