14 Commits

Author SHA1 Message Date
Hördle Bot
7a65c58214 feat: add healthcheck endpoint and bump version to 0.1.0.13 2025-11-26 23:39:06 +01:00
Hördle Bot
1a8177430d feat: add health check API endpoint 2025-11-26 23:37:40 +01:00
Hördle Bot
0ebb61515d docs: Add 'prototype' to footer disclaimer in AppFooter. 2025-11-26 20:42:55 +01:00
Hördle Bot
dede11d22b fix: correct plausible score calculation 2025-11-26 18:00:59 +01:00
Hördle Bot
4b96b95bff Feat: Add Plausible event tracking for puzzle completion 2025-11-26 11:29:30 +01:00
Hördle Bot
89fb296564 Feat: Add visual feedback to bonus year question 2025-11-26 11:06:34 +01:00
Hördle Bot
301dce4c97 Fix: Audio player skip behavior and range requests 2025-11-26 10:58:04 +01:00
Hördle Bot
b66bab48bd Feat: Add Onboarding Assistant with driver.js 2025-11-26 10:13:40 +01:00
Hördle Bot
fea8384e60 fix: Adjust vertical spacing for next puzzle timer. 2025-11-26 09:27:20 +01:00
Hördle Bot
de8813da3e feat: filter genres by active status when fetching from Prisma 2025-11-26 09:25:45 +01:00
Hördle Bot
0877842107 feat: add plausible.elpatron.me to CSP script-src and connect-src directives. 2025-11-25 22:34:32 +01:00
Hördle Bot
a5cbbffc20 chore: remove Content-Security-Policy header configuration 2025-11-25 22:31:13 +01:00
Hördle Bot
ffb7be602f feat: Add Content Security Policy header and move Plausible script to HTML head with beforeInteractive strategy. 2025-11-25 22:19:34 +01:00
Hördle Bot
1d62aca2fb feat: add Plausible Analytics script to layout for tracking. 2025-11-25 22:13:46 +01:00
12 changed files with 451 additions and 99 deletions

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 });

5
app/api/health/route.ts Normal file
View File

@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ status: 'ok' }, { status: 200 });
}

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

@@ -45,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 => {

View File

@@ -19,7 +19,7 @@ export default function AppFooter() {
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer"> <a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
@elpatron@digitalcourage.social @elpatron@digitalcourage.social
</a> </a>
{' '}- for personal use among friends only! {' '}- prototype for personal use among friends only!
{version && ( {version && (
<> <>
{' '}·{' '} {' '}·{' '}

View File

@@ -22,33 +22,75 @@ const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlocke
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
onHasPlayedChange?.(false); // Notify parent
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;
onHasPlayedChange?.(true); // Notify parent }
})
.catch(error => { const targetPos = startPos;
console.log("Autoplay prevented:", error); audioRef.current.currentTime = targetPos;
setIsPlaying(false);
}); // 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 // Expose play method to parent component
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@@ -148,4 +190,6 @@ const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlocke
AudioPlayer.displayName = 'AudioPlayer'; AudioPlayer.displayName = 'AudioPlayer';
export default AudioPlayer; export default AudioPlayer;

View File

@@ -7,6 +7,13 @@ import Statistics from './Statistics';
import { useGameState } from '../lib/gameState'; import { useGameState } from '../lib/gameState';
import { sendGotifyNotification, submitRating } from '../app/actions'; import { sendGotifyNotification, submitRating } from '../app/actions';
// Plausible Analytics
declare global {
interface Window {
plausible?: (eventName: string, options?: { props?: Record<string, string | number> }) => void;
}
}
interface GameProps { interface GameProps {
dailyPuzzle: { dailyPuzzle: {
id: number; id: number;
@@ -103,6 +110,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
if (song.id === dailyPuzzle.songId) { if (song.id === dailyPuzzle.songId) {
addGuess(song.title, true); addGuess(song.title, true);
setHasWon(true); setHasWon(true);
// Track puzzle solved event
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: gameState.guesses.length + 1,
score: gameState.score + 20, // Include the win bonus
outcome: 'won'
}
});
}
// Notification sent after year guess or skip // Notification sent after year guess or skip
if (!dailyPuzzle.releaseYear) { if (!dailyPuzzle.releaseYear) {
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score); sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
@@ -112,6 +130,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
if (gameState.guesses.length + 1 >= maxAttempts) { if (gameState.guesses.length + 1 >= maxAttempts) {
setHasLost(true); setHasLost(true);
setHasWon(false); setHasWon(false);
// Track puzzle lost event
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: maxAttempts,
score: 0,
outcome: 'lost'
}
});
}
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
} }
} }
@@ -138,6 +167,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
if (gameState.guesses.length + 1 >= maxAttempts) { if (gameState.guesses.length + 1 >= maxAttempts) {
setHasLost(true); setHasLost(true);
setHasWon(false); setHasWon(false);
// Track puzzle lost event
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: maxAttempts,
score: 0,
outcome: 'lost'
}
});
}
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
} }
}; };
@@ -148,6 +188,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
giveUp(); // Ensure game is marked as failed and score reset to 0 giveUp(); // Ensure game is marked as failed and score reset to 0
setHasLost(true); setHasLost(true);
setHasWon(false); setHasWon(false);
// Track puzzle lost event
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: gameState.guesses.length + 1,
score: 0,
outcome: 'lost'
}
});
}
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
}; };
@@ -156,6 +207,19 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
addYearBonus(correct); addYearBonus(correct);
setShowYearModal(false); setShowYearModal(false);
// Update the puzzle_solved event with year bonus result
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: gameState.guesses.length,
score: gameState.score + (correct ? 10 : 0), // Include year bonus if correct
outcome: 'won',
year_bonus: correct ? 'correct' : 'incorrect'
}
});
}
// Send notification now that game is fully complete // Send notification now that game is fully complete
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0)); sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
}; };
@@ -163,6 +227,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const handleYearSkip = () => { const handleYearSkip = () => {
skipYearBonus(); skipYearBonus();
setShowYearModal(false); setShowYearModal(false);
// Update the puzzle_solved event with year bonus result
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: gameState.guesses.length,
score: gameState.score, // Score already includes win bonus
outcome: 'won',
year_bonus: 'skipped'
}
});
}
// Send notification now that game is fully complete // Send notification now that game is fully complete
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score); sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
}; };
@@ -251,30 +329,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">
ref={audioPlayerRef} <AudioPlayer
src={dailyPuzzle.audioUrl} ref={audioPlayerRef}
unlockedSeconds={unlockedSeconds} src={dailyPuzzle.audioUrl}
startTime={dailyPuzzle.startTime} unlockedSeconds={unlockedSeconds}
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)} startTime={dailyPuzzle.startTime}
onReplay={addReplay} autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
onHasPlayedChange={setHasPlayedAudio} onReplay={addReplay}
/> onHasPlayedChange={setHasPlayedAudio}
/>
</div>
</div> </div>
<div className="guess-list"> <div className="guess-list">
@@ -293,9 +375,12 @@ 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"
> >
@@ -419,6 +504,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();
@@ -447,6 +533,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',
@@ -470,51 +574,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

@@ -1,6 +1,6 @@
{ {
"name": "hoerdle", "name": "hoerdle",
"version": "0.1.0", "version": "0.1.0.13",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -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",