Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dede11d22b | ||
|
|
4b96b95bff | ||
|
|
89fb296564 | ||
|
|
301dce4c97 | ||
|
|
b66bab48bd | ||
|
|
fea8384e60 | ||
|
|
de8813da3e | ||
|
|
0877842107 | ||
|
|
a5cbbffc20 | ||
|
|
ffb7be602f | ||
|
|
1d62aca2fb |
@@ -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 });
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -22,17 +22,57 @@ const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlocke
|
||||
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(() => {
|
||||
@@ -46,9 +86,11 @@ const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlocke
|
||||
setIsPlaying(false);
|
||||
});
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
}, [src, unlockedSeconds, startTime, autoPlay]);
|
||||
}
|
||||
}, [src, unlockedSeconds, startTime, autoPlay, processedSrc, processedUnlockedSeconds]);
|
||||
|
||||
// Expose play method to parent component
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -148,4 +190,6 @@ const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlocke
|
||||
|
||||
AudioPlayer.displayName = 'AudioPlayer';
|
||||
|
||||
|
||||
|
||||
export default AudioPlayer;
|
||||
|
||||
@@ -7,6 +7,13 @@ import Statistics from './Statistics';
|
||||
import { useGameState } from '../lib/gameState';
|
||||
import { sendGotifyNotification, submitRating } from '../app/actions';
|
||||
|
||||
// Plausible Analytics
|
||||
declare global {
|
||||
interface Window {
|
||||
plausible?: (eventName: string, options?: { props?: Record<string, string | number> }) => void;
|
||||
}
|
||||
}
|
||||
|
||||
interface GameProps {
|
||||
dailyPuzzle: {
|
||||
id: number;
|
||||
@@ -103,6 +110,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
if (song.id === dailyPuzzle.songId) {
|
||||
addGuess(song.title, 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
|
||||
if (!dailyPuzzle.releaseYear) {
|
||||
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) {
|
||||
setHasLost(true);
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -138,6 +167,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||
setHasLost(true);
|
||||
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
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
setHasLost(true);
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -156,6 +207,19 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
addYearBonus(correct);
|
||||
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
|
||||
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 = () => {
|
||||
skipYearBonus();
|
||||
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
|
||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||
};
|
||||
@@ -251,21 +329,24 @@ 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}
|
||||
@@ -276,6 +357,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
onHasPlayedChange={setHasPlayedAudio}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="guess-list">
|
||||
{gameState.guesses.map((guess, i) => {
|
||||
@@ -293,9 +375,12 @@ 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"
|
||||
>
|
||||
@@ -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 }) {
|
||||
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();
|
||||
@@ -447,6 +533,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',
|
||||
@@ -470,6 +574,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>
|
||||
|
||||
@@ -482,7 +588,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',
|
||||
@@ -503,7 +609,7 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onSkip}
|
||||
onClick={handleSkip}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
@@ -515,6 +621,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>
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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
7
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user