12 Commits

10 changed files with 423 additions and 98 deletions

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { readFile, stat } from 'fs/promises';
import { stat } from 'fs/promises';
import { createReadStream } from 'fs';
import path from 'path';
export async function GET(
@@ -30,24 +31,59 @@ export async function GET(
return new NextResponse('Forbidden', { status: 403 });
}
// Check if file exists
try {
await stat(filePath);
} catch {
return new NextResponse('File not found', { status: 404 });
const stats = await stat(filePath);
const fileSize = stats.size;
const range = request.headers.get('range');
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunksize = (end - start) + 1;
const stream = createReadStream(filePath, { start, end });
// Convert Node stream to Web stream
const readable = new ReadableStream({
start(controller) {
stream.on('data', (chunk: any) => controller.enqueue(chunk));
stream.on('end', () => controller.close());
stream.on('error', (err: any) => controller.error(err));
}
});
// Read file
const fileBuffer = await readFile(filePath);
// Return with proper headers
return new NextResponse(fileBuffer, {
return new NextResponse(readable, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize.toString(),
'Content-Type': 'audio/mpeg',
'Cache-Control': 'public, max-age=3600, must-revalidate',
},
});
} else {
const stream = createReadStream(filePath);
// Convert Node stream to Web stream
const readable = new ReadableStream({
start(controller) {
stream.on('data', (chunk: any) => controller.enqueue(chunk));
stream.on('end', () => controller.close());
stream.on('error', (err: any) => controller.error(err));
}
});
return new NextResponse(readable, {
status: 200,
headers: {
'Content-Length': fileSize.toString(),
'Content-Type': 'audio/mpeg',
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=3600, must-revalidate',
},
});
}
} catch (error) {
console.error('Error serving audio file:', error);
return new NextResponse('Internal Server Error', { status: 500 });

View File

@@ -1,5 +1,6 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Script from "next/script";
import "./globals.css";
const geistSans = Geist({
@@ -34,6 +35,14 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<head>
<Script
defer
data-domain="hoerdle.elpatron.me"
src="https://plausible.elpatron.me/js/script.js"
strategy="beforeInteractive"
/>
</head>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
<InstallPrompt />

View File

@@ -1,5 +1,6 @@
import Game from '@/components/Game';
import NewsSection from '@/components/NewsSection';
import OnboardingTour from '@/components/OnboardingTour';
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
import Link from 'next/link';
import { PrismaClient } from '@prisma/client';
@@ -30,7 +31,7 @@ export default async function Home() {
return (
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<div className="tooltip">
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
@@ -95,9 +96,12 @@ export default async function Home() {
)}
</div>
<div id="tour-news">
<NewsSection />
</div>
<Game dailyPuzzle={dailyPuzzle} genre={null} />
<OnboardingTour />
</>
);
}

View File

@@ -45,7 +45,10 @@ export default async function SpecialPage({ params }: PageProps) {
}
const dailyPuzzle = await getOrCreateSpecialPuzzle(decodedName);
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
const genres = await prisma.genre.findMany({
where: { active: true },
orderBy: { name: 'asc' }
});
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
const activeSpecials = specials.filter(s => {

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
interface AudioPlayerProps {
src: string;
@@ -9,39 +9,112 @@ interface AudioPlayerProps {
onPlay?: () => void;
onReplay?: () => void;
autoPlay?: boolean;
onHasPlayedChange?: (hasPlayed: boolean) => void;
}
export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPlay, onReplay, autoPlay = false }: AudioPlayerProps) {
export interface AudioPlayerRef {
play: () => void;
}
const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlockedSeconds, startTime = 0, onPlay, onReplay, autoPlay = false, onHasPlayedChange }, ref) => {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [hasPlayedOnce, setHasPlayedOnce] = useState(false);
const [processedSrc, setProcessedSrc] = useState(src);
const [processedUnlockedSeconds, setProcessedUnlockedSeconds] = useState(unlockedSeconds);
useEffect(() => {
console.log('[AudioPlayer] MOUNTED');
return () => console.log('[AudioPlayer] UNMOUNTED');
}, []);
useEffect(() => {
if (audioRef.current) {
// Check if props changed compared to what we last processed
const hasChanged = src !== processedSrc || unlockedSeconds !== processedUnlockedSeconds;
if (hasChanged) {
audioRef.current.pause();
audioRef.current.currentTime = startTime;
let startPos = startTime;
// If same song but more time unlocked, start from where previous segment ended
if (src === processedSrc && unlockedSeconds > processedUnlockedSeconds) {
startPos = startTime + processedUnlockedSeconds;
}
const targetPos = startPos;
audioRef.current.currentTime = targetPos;
// Ensure position is set correctly even if browser resets it
setTimeout(() => {
if (audioRef.current && Math.abs(audioRef.current.currentTime - targetPos) > 0.5) {
audioRef.current.currentTime = targetPos;
}
}, 50);
setIsPlaying(false);
setProgress(0);
// Calculate initial progress
const initialElapsed = startPos - startTime;
const initialPercent = unlockedSeconds > 0 ? (initialElapsed / unlockedSeconds) * 100 : 0;
setProgress(Math.min(initialPercent, 100));
setHasPlayedOnce(false); // Reset for new segment
onHasPlayedChange?.(false); // Notify parent
// Update processed state
setProcessedSrc(src);
setProcessedUnlockedSeconds(unlockedSeconds);
if (autoPlay) {
const playPromise = audioRef.current.play();
// Delay play slightly to ensure currentTime sticks
setTimeout(() => {
const playPromise = audioRef.current?.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
setIsPlaying(true);
onPlay?.();
setHasPlayedOnce(true);
onHasPlayedChange?.(true); // Notify parent
})
.catch(error => {
console.log("Autoplay prevented:", error);
setIsPlaying(false);
});
}
}, 150);
}
}
}, [src, unlockedSeconds, startTime, autoPlay]);
}
}, [src, unlockedSeconds, startTime, autoPlay, processedSrc, processedUnlockedSeconds]);
// Expose play method to parent component
useImperativeHandle(ref, () => ({
play: () => {
if (!audioRef.current) return;
const playPromise = audioRef.current.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
setIsPlaying(true);
onPlay?.();
if (!hasPlayedOnce) {
setHasPlayedOnce(true);
onHasPlayedChange?.(true);
}
})
.catch(error => {
console.error("Play failed:", error);
setIsPlaying(false);
});
}
}
}));
const togglePlay = () => {
if (!audioRef.current) return;
@@ -56,6 +129,7 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
onReplay?.();
} else {
setHasPlayedOnce(true);
onHasPlayedChange?.(true); // Notify parent
}
}
setIsPlaying(!isPlaying);
@@ -112,4 +186,10 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
</div>
</div>
);
}
});
AudioPlayer.displayName = 'AudioPlayer';
export default AudioPlayer;

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import AudioPlayer from './AudioPlayer';
import { useEffect, useState, useRef } from 'react';
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
import GuessInput from './GuessInput';
import Statistics from './Statistics';
import { useGameState } from '../lib/gameState';
@@ -37,6 +37,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const [timeUntilNext, setTimeUntilNext] = useState('');
const [hasRated, setHasRated] = useState(false);
const [showYearModal, setShowYearModal] = useState(false);
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
const audioPlayerRef = useRef<AudioPlayerRef>(null);
useEffect(() => {
const updateCountdown = () => {
@@ -116,7 +118,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
setTimeout(() => setIsProcessingGuess(false), 500);
};
const handleStartAudio = () => {
// This will be called when user clicks "Start" button on first attempt
// Trigger the audio player to start playing
audioPlayerRef.current?.play();
setHasPlayedAudio(true);
};
const handleSkip = () => {
// If user hasn't played audio yet on first attempt, start it instead of skipping
if (gameState.guesses.length === 0 && !hasPlayedAudio) {
handleStartAudio();
return;
}
setLastAction('SKIP');
addGuess("SKIPPED", false);
@@ -236,29 +251,35 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
return (
<div className="container">
<header className="header">
<h1 className="title">Hördle #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
<div style={{ fontSize: '0.9rem', color: '#666', marginTop: '-0.5rem', marginBottom: '1rem' }}>
<h1 id="tour-title" className="title">Hördle #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
<div style={{ fontSize: '0.9rem', color: '#666', marginTop: '0.5rem', marginBottom: '1rem' }}>
Next puzzle in: {timeUntilNext}
</div>
</header>
<main className="game-board">
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
<div className="status-bar">
<div id="tour-status" className="status-bar">
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
<span>{unlockedSeconds}s unlocked</span>
</div>
<div id="tour-score">
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
</div>
<div id="tour-player">
<AudioPlayer
ref={audioPlayerRef}
src={dailyPuzzle.audioUrl}
unlockedSeconds={unlockedSeconds}
startTime={dailyPuzzle.startTime}
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
onReplay={addReplay}
onHasPlayedChange={setHasPlayedAudio}
/>
</div>
</div>
<div className="guess-list">
{gameState.guesses.map((guess, i) => {
@@ -276,13 +297,19 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
{!hasWon && !hasLost && (
<>
<div id="tour-input">
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
</div>
{gameState.guesses.length < maxAttempts - 1 ? (
<button
id="tour-controls"
onClick={handleSkip}
className="skip-button"
>
Skip (+{unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)
{gameState.guesses.length === 0 && !hasPlayedAudio
? 'Start'
: `Skip (+${unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)`
}
</button>
) : (
<button
@@ -399,6 +426,7 @@ function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
const [options, setOptions] = useState<number[]>([]);
const [feedback, setFeedback] = useState<{ show: boolean, correct: boolean, guessedYear?: number }>({ show: false, correct: false });
useEffect(() => {
const currentYear = new Date().getFullYear();
@@ -427,6 +455,24 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
setOptions(Array.from(allOptions).sort((a, b) => a - b));
}, [correctYear]);
const handleGuess = (year: number) => {
const correct = year === correctYear;
setFeedback({ show: true, correct, guessedYear: year });
// Close modal after showing feedback
setTimeout(() => {
onGuess(year);
}, 2500);
};
const handleSkip = () => {
setFeedback({ show: true, correct: false });
setTimeout(() => {
onSkip();
}, 2000);
};
return (
<div style={{
position: 'fixed',
@@ -450,6 +496,8 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
textAlign: 'center',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
}}>
{!feedback.show ? (
<>
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: '#1f2937' }}>Bonus Round!</h3>
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>Guess the release year for <strong style={{ color: '#10b981' }}>+10 points</strong>!</p>
@@ -462,7 +510,7 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
{options.map(year => (
<button
key={year}
onClick={() => onGuess(year)}
onClick={() => handleGuess(year)}
style={{
padding: '0.75rem',
background: '#f3f4f6',
@@ -483,7 +531,7 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
</div>
<button
onClick={onSkip}
onClick={handleSkip}
style={{
background: 'none',
border: 'none',
@@ -495,6 +543,34 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
>
Skip Bonus
</button>
</>
) : (
<div style={{ padding: '2rem 0' }}>
{feedback.guessedYear ? (
feedback.correct ? (
<>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🎉</div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#10b981', marginBottom: '0.5rem' }}>Correct!</h3>
<p style={{ fontSize: '1.2rem', color: '#4b5563' }}>Released in {correctYear}</p>
<p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: '#10b981', marginTop: '1rem' }}>+10 Points!</p>
</>
) : (
<>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>😕</div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#ef4444', marginBottom: '0.5rem' }}>Not quite!</h3>
<p style={{ fontSize: '1.2rem', color: '#4b5563' }}>You guessed {feedback.guessedYear}</p>
<p style={{ fontSize: '1.2rem', color: '#4b5563', marginTop: '0.5rem' }}>Actually released in <strong>{correctYear}</strong></p>
</>
)
) : (
<>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}></div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#6b7280', marginBottom: '0.5rem' }}>Skipped</h3>
<p style={{ fontSize: '1.2rem', color: '#4b5563' }}>Released in {correctYear}</p>
</>
)}
</div>
)}
</div>
</div>
);

View File

@@ -0,0 +1,109 @@
'use client';
import { useEffect } from 'react';
import { driver } from 'driver.js';
import 'driver.js/dist/driver.css';
export default function OnboardingTour() {
useEffect(() => {
const hasCompletedOnboarding = localStorage.getItem('hoerdle_onboarding_completed');
if (hasCompletedOnboarding) {
return;
}
const driverObj = driver({
showProgress: true,
animate: true,
allowClose: true,
doneBtnText: 'Done',
nextBtnText: 'Next',
prevBtnText: 'Previous',
onDestroyed: () => {
localStorage.setItem('hoerdle_onboarding_completed', 'true');
},
steps: [
{
element: '#tour-genres',
popover: {
title: 'Genres & Specials',
description: 'Choose a specific genre or a curated special event here.',
side: 'bottom',
align: 'start'
}
},
{
element: '#tour-news',
popover: {
title: 'News',
description: 'Stay updated with the latest news and announcements.',
side: 'top',
align: 'start'
}
},
{
element: '#tour-title',
popover: {
title: 'Hördle',
description: 'This is the daily puzzle. One new song every day per genre.',
side: 'bottom',
align: 'start'
}
},
{
element: '#tour-status',
popover: {
title: 'Attempts',
description: 'You have a limited number of attempts to guess the song.',
side: 'bottom',
align: 'start'
}
},
{
element: '#tour-score',
popover: {
title: 'Score',
description: 'Your current score. Try to keep it high!',
side: 'bottom',
align: 'start'
}
},
{
element: '#tour-player',
popover: {
title: 'Player',
description: 'Listen to the snippet. Each additional play reduces your potential score.',
side: 'top',
align: 'start'
}
},
{
element: '#tour-input',
popover: {
title: 'Input',
description: 'Type your guess here. Search for artist or title.',
side: 'top',
align: 'start'
}
},
{
element: '#tour-controls',
popover: {
title: 'Controls',
description: 'Start the music or skip to the next snippet if you\'re stuck.',
side: 'top',
align: 'start'
}
}
]
});
// Small delay to ensure DOM is ready
setTimeout(() => {
driverObj.drive();
}, 1000);
}, []);
return null;
}

View File

@@ -25,11 +25,11 @@ export function middleware(request: NextRequest) {
// Content Security Policy
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Next.js requires unsafe-inline/eval
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.elpatron.me", // Next.js requires unsafe-inline/eval
"style-src 'self' 'unsafe-inline'", // Allow inline styles
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self' https://openrouter.ai https://gotify.example.com",
"connect-src 'self' https://openrouter.ai https://gotify.example.com https://plausible.elpatron.me",
"media-src 'self' blob:",
"frame-ancestors 'self'",
].join('; ');

7
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"@prisma/client": "^6.19.0",
"bcryptjs": "^3.0.3",
"driver.js": "^1.4.0",
"music-metadata": "^11.10.2",
"next": "16.0.3",
"prisma": "^6.19.0",
@@ -2939,6 +2940,12 @@
"url": "https://dotenvx.com"
}
},
"node_modules/driver.js": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz",
"integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==",
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

@@ -11,6 +11,7 @@
"dependencies": {
"@prisma/client": "^6.19.0",
"bcryptjs": "^3.0.3",
"driver.js": "^1.4.0",
"music-metadata": "^11.10.2",
"next": "16.0.3",
"prisma": "^6.19.0",