13 Commits

11 changed files with 427 additions and 98 deletions

View File

@@ -1,4 +1,5 @@
import Game from '@/components/Game'; import Game from '@/components/Game';
import NewsSection from '@/components/NewsSection';
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle'; import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
import Link from 'next/link'; import Link from 'next/link';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
@@ -102,6 +103,7 @@ export default async function GenrePage({ params }: PageProps) {
</div> </div>
)} )}
</div> </div>
<NewsSection />
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} /> <Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
</> </>
); );

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; 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'; import path from 'path';
export async function GET( export async function GET(
@@ -30,24 +31,59 @@ export async function GET(
return new NextResponse('Forbidden', { status: 403 }); return new NextResponse('Forbidden', { status: 403 });
} }
// Check if file exists const stats = await stat(filePath);
try { const fileSize = stats.size;
await stat(filePath); const range = request.headers.get('range');
} catch {
return new NextResponse('File not found', { status: 404 }); 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));
}
});
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',
},
});
} }
// Read file
const fileBuffer = await readFile(filePath);
// Return with proper headers
return new NextResponse(fileBuffer, {
headers: {
'Content-Type': 'audio/mpeg',
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=3600, must-revalidate',
},
});
} catch (error) { } catch (error) {
console.error('Error serving audio file:', error); console.error('Error serving audio file:', error);
return new NextResponse('Internal Server Error', { status: 500 }); return new NextResponse('Internal Server Error', { status: 500 });

View File

@@ -1,5 +1,6 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import Script from "next/script";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -34,6 +35,14 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en"> <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}`}> <body className={`${geistSans.variable} ${geistMono.variable}`}>
{children} {children}
<InstallPrompt /> <InstallPrompt />

View File

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

View File

@@ -1,4 +1,5 @@
import Game from '@/components/Game'; import Game from '@/components/Game';
import NewsSection from '@/components/NewsSection';
import { getOrCreateSpecialPuzzle } from '@/lib/dailyPuzzle'; import { getOrCreateSpecialPuzzle } from '@/lib/dailyPuzzle';
import Link from 'next/link'; import Link from 'next/link';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
@@ -44,7 +45,10 @@ export default async function SpecialPage({ params }: PageProps) {
} }
const dailyPuzzle = await getOrCreateSpecialPuzzle(decodedName); 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 specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
const activeSpecials = specials.filter(s => { const activeSpecials = specials.filter(s => {
@@ -94,6 +98,7 @@ export default async function SpecialPage({ params }: PageProps) {
))} ))}
</div> </div>
</div> </div>
<NewsSection />
<Game <Game
dailyPuzzle={dailyPuzzle} dailyPuzzle={dailyPuzzle}
genre={decodedName} genre={decodedName}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
interface AudioPlayerProps { interface AudioPlayerProps {
src: string; src: string;
@@ -9,39 +9,112 @@ interface AudioPlayerProps {
onPlay?: () => void; onPlay?: () => void;
onReplay?: () => void; onReplay?: () => void;
autoPlay?: boolean; 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 audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [hasPlayedOnce, setHasPlayedOnce] = useState(false); 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(() => { useEffect(() => {
if (audioRef.current) { if (audioRef.current) {
audioRef.current.pause(); // Check if props changed compared to what we last processed
audioRef.current.currentTime = startTime; const hasChanged = src !== processedSrc || unlockedSeconds !== processedUnlockedSeconds;
setIsPlaying(false);
setProgress(0);
setHasPlayedOnce(false); // Reset for new segment
if (autoPlay) { if (hasChanged) {
const playPromise = audioRef.current.play(); audioRef.current.pause();
if (playPromise !== undefined) {
playPromise let startPos = startTime;
.then(() => {
setIsPlaying(true); // If same song but more time unlocked, start from where previous segment ended
onPlay?.(); if (src === processedSrc && unlockedSeconds > processedUnlockedSeconds) {
setHasPlayedOnce(true); startPos = startTime + processedUnlockedSeconds;
}) }
.catch(error => {
console.log("Autoplay prevented:", error); const targetPos = startPos;
setIsPlaying(false); 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);
// 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) {
// 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 = () => { const togglePlay = () => {
if (!audioRef.current) return; if (!audioRef.current) return;
@@ -56,6 +129,7 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
onReplay?.(); onReplay?.();
} else { } else {
setHasPlayedOnce(true); setHasPlayedOnce(true);
onHasPlayedChange?.(true); // Notify parent
} }
} }
setIsPlaying(!isPlaying); setIsPlaying(!isPlaying);
@@ -112,4 +186,10 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
</div> </div>
</div> </div>
); );
} });
AudioPlayer.displayName = 'AudioPlayer';
export default AudioPlayer;

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState, useRef } from 'react';
import AudioPlayer from './AudioPlayer'; import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
import GuessInput from './GuessInput'; import GuessInput from './GuessInput';
import Statistics from './Statistics'; import Statistics from './Statistics';
import { useGameState } from '../lib/gameState'; import { useGameState } from '../lib/gameState';
@@ -37,6 +37,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const [timeUntilNext, setTimeUntilNext] = useState(''); const [timeUntilNext, setTimeUntilNext] = useState('');
const [hasRated, setHasRated] = useState(false); const [hasRated, setHasRated] = useState(false);
const [showYearModal, setShowYearModal] = useState(false); const [showYearModal, setShowYearModal] = useState(false);
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
const audioPlayerRef = useRef<AudioPlayerRef>(null);
useEffect(() => { useEffect(() => {
const updateCountdown = () => { const updateCountdown = () => {
@@ -116,7 +118,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
setTimeout(() => setIsProcessingGuess(false), 500); 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 = () => { 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'); setLastAction('SKIP');
addGuess("SKIPPED", false); addGuess("SKIPPED", false);
@@ -236,28 +251,34 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
return ( return (
<div className="container"> <div className="container">
<header className="header"> <header className="header">
<h1 className="title">Hördle #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1> <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' }}> <div style={{ fontSize: '0.9rem', color: '#666', marginTop: '0.5rem', marginBottom: '1rem' }}>
Next puzzle in: {timeUntilNext} Next puzzle in: {timeUntilNext}
</div> </div>
</header> </header>
<main className="game-board"> <main className="game-board">
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}> <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>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
<span>{unlockedSeconds}s unlocked</span> <span>{unlockedSeconds}s unlocked</span>
</div> </div>
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} /> <div id="tour-score">
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
</div>
<AudioPlayer <div id="tour-player">
src={dailyPuzzle.audioUrl} <AudioPlayer
unlockedSeconds={unlockedSeconds} ref={audioPlayerRef}
startTime={dailyPuzzle.startTime} src={dailyPuzzle.audioUrl}
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)} unlockedSeconds={unlockedSeconds}
onReplay={addReplay} startTime={dailyPuzzle.startTime}
/> autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
onReplay={addReplay}
onHasPlayedChange={setHasPlayedAudio}
/>
</div>
</div> </div>
<div className="guess-list"> <div className="guess-list">
@@ -276,13 +297,19 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
{!hasWon && !hasLost && ( {!hasWon && !hasLost && (
<> <>
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} /> <div id="tour-input">
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
</div>
{gameState.guesses.length < maxAttempts - 1 ? ( {gameState.guesses.length < maxAttempts - 1 ? (
<button <button
id="tour-controls"
onClick={handleSkip} onClick={handleSkip}
className="skip-button" 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>
) : ( ) : (
<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 }) { function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
const [options, setOptions] = useState<number[]>([]); const [options, setOptions] = useState<number[]>([]);
const [feedback, setFeedback] = useState<{ show: boolean, correct: boolean, guessedYear?: number }>({ show: false, correct: false });
useEffect(() => { useEffect(() => {
const currentYear = new Date().getFullYear(); 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)); setOptions(Array.from(allOptions).sort((a, b) => a - b));
}, [correctYear]); }, [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 ( return (
<div style={{ <div style={{
position: 'fixed', position: 'fixed',
@@ -450,51 +496,81 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
textAlign: 'center', textAlign: 'center',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)' boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
}}> }}>
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: '#1f2937' }}>Bonus Round!</h3> {!feedback.show ? (
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>Guess the release year for <strong style={{ color: '#10b981' }}>+10 points</strong>!</p> <>
<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>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
gap: '0.75rem',
marginBottom: '1.5rem'
}}>
{options.map(year => (
<button
key={year}
onClick={() => handleGuess(year)}
style={{
padding: '0.75rem',
background: '#f3f4f6',
border: '2px solid #e5e7eb',
borderRadius: '0.5rem',
fontSize: '1.1rem',
fontWeight: 'bold',
color: '#374151',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
>
{year}
</button>
))}
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
gap: '0.75rem',
marginBottom: '1.5rem'
}}>
{options.map(year => (
<button <button
key={year} onClick={handleSkip}
onClick={() => onGuess(year)}
style={{ style={{
padding: '0.75rem', background: 'none',
background: '#f3f4f6', border: 'none',
border: '2px solid #e5e7eb', color: '#6b7280',
borderRadius: '0.5rem', textDecoration: 'underline',
fontSize: '1.1rem',
fontWeight: 'bold',
color: '#374151',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s' fontSize: '0.9rem'
}} }}
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
> >
{year} Skip Bonus
</button> </button>
))} </>
</div> ) : (
<div style={{ padding: '2rem 0' }}>
<button {feedback.guessedYear ? (
onClick={onSkip} feedback.correct ? (
style={{ <>
background: 'none', <div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🎉</div>
border: 'none', <h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#10b981', marginBottom: '0.5rem' }}>Correct!</h3>
color: '#6b7280', <p style={{ fontSize: '1.2rem', color: '#4b5563' }}>Released in {correctYear}</p>
textDecoration: 'underline', <p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: '#10b981', marginTop: '1rem' }}>+10 Points!</p>
cursor: 'pointer', </>
fontSize: '0.9rem' ) : (
}} <>
> <div style={{ fontSize: '4rem', marginBottom: '1rem' }}>😕</div>
Skip Bonus <h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#ef4444', marginBottom: '0.5rem' }}>Not quite!</h3>
</button> <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>
</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 // Content Security Policy
const csp = [ const csp = [
"default-src 'self'", "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 "style-src 'self' 'unsafe-inline'", // Allow inline styles
"img-src 'self' data: blob:", "img-src 'self' data: blob:",
"font-src 'self' data:", "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:", "media-src 'self' blob:",
"frame-ancestors 'self'", "frame-ancestors 'self'",
].join('; '); ].join('; ');

7
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@prisma/client": "^6.19.0", "@prisma/client": "^6.19.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"driver.js": "^1.4.0",
"music-metadata": "^11.10.2", "music-metadata": "^11.10.2",
"next": "16.0.3", "next": "16.0.3",
"prisma": "^6.19.0", "prisma": "^6.19.0",
@@ -2939,6 +2940,12 @@
"url": "https://dotenvx.com" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

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