Compare commits
5 Commits
0877842107
...
v0.1.0.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89fb296564 | ||
|
|
301dce4c97 | ||
|
|
b66bab48bd | ||
|
|
fea8384e60 | ||
|
|
de8813da3e |
@@ -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 });
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -251,30 +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">
|
||||||
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 +297,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 +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();
|
||||||
@@ -447,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',
|
||||||
@@ -470,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>
|
||||||
);
|
);
|
||||||
|
|||||||
109
components/OnboardingTour.tsx
Normal file
109
components/OnboardingTour.tsx
Normal 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;
|
||||||
|
}
|
||||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user