Implementiere i18n für Frontend, Admin und Datenbank

This commit is contained in:
Hördle Bot
2025-11-28 15:36:06 +01:00
parent 9df9a808bf
commit 771d0d06f3
37 changed files with 3717 additions and 560 deletions

View File

@@ -2,6 +2,7 @@
import { config } from '@/lib/config';
import { useEffect, useState, useRef } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
import GuessInput from './GuessInput';
import Statistics from './Statistics';
@@ -36,10 +37,12 @@ interface GameProps {
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
const t = useTranslations('Game');
const locale = useLocale();
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts);
const [hasWon, setHasWon] = useState(false);
const [hasLost, setHasLost] = useState(false);
const [shareText, setShareText] = useState('🔗 Share');
const [shareText, setShareText] = useState(`🔗 ${t('share')}`);
const [lastAction, setLastAction] = useState<'GUESS' | 'SKIP' | null>(null);
const [isProcessingGuess, setIsProcessingGuess] = useState(false);
const [timeUntilNext, setTimeUntilNext] = useState('');
@@ -95,13 +98,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
if (!dailyPuzzle) return (
<div className="game-container" style={{ textAlign: 'center', padding: '2rem' }}>
<h2>No Puzzle Available</h2>
<p>Could not generate a daily puzzle.</p>
<p>Please ensure there are songs in the database{genre ? ` for genre "${genre}"` : ''}.</p>
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>Go to Admin Dashboard</a>
<h2>{t('noPuzzleAvailable')}</h2>
<p>{t('noPuzzleDescription')}</p>
<p>{t('noPuzzleGenre')}{genre ? ` für Genre "${genre}"` : ''}.</p>
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>{t('goToAdmin')}</a>
</div>
);
if (!gameState) return <div>Loading state...</div>;
if (!gameState) return <div>{t('loadingState')}</div>;
const handleGuess = (song: any) => {
if (isProcessingGuess) return;
@@ -269,9 +272,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const speaker = hasWon ? '🔉' : '🔇';
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
const genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : '';
const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
let shareUrl = `https://${config.domain}`;
// Add locale prefix if not default (de)
if (locale !== 'de') {
shareUrl += `/${locale}`;
}
if (genre) {
if (isSpecial) {
shareUrl += `/special/${encodeURIComponent(genre)}`;
@@ -280,7 +287,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
}
}
const text = `${config.appName} #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\nScore: ${gameState.score}\n\n#${config.appName} #Music\n\n${shareUrl}`;
const text = `${config.appName} #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\n${t('score')}: ${gameState.score}\n\n#${config.appName} #Music\n\n${shareUrl}`;
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
@@ -290,8 +297,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
title: `Hördle #${dailyPuzzle.puzzleNumber}`,
text: text,
});
setShareText('✓ Shared!');
setTimeout(() => setShareText('🔗 Share'), 2000);
setShareText(t('shared'));
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
return;
} catch (err) {
if ((err as Error).name !== 'AbortError') {
@@ -302,12 +309,12 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
try {
await navigator.clipboard.writeText(text);
setShareText('✓ Copied!');
setTimeout(() => setShareText('🔗 Share'), 2000);
setShareText(t('copied'));
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
} catch (err) {
console.error('Clipboard failed:', err);
setShareText('✗ Failed');
setTimeout(() => setShareText('🔗 Share'), 2000);
setShareText(t('shareFailed'));
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
}
};
@@ -333,15 +340,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
<header className="header">
<h1 id="tour-title" className="title">{config.appName} #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
<div style={{ fontSize: '0.9rem', color: 'var(--muted-foreground)', marginTop: '0.5rem', marginBottom: '1rem' }}>
Next puzzle in: {timeUntilNext}
{t('nextPuzzle')}: {timeUntilNext}
</div>
</header>
<main className="game-board">
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
<div id="tour-status" className="status-bar">
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
<span>{unlockedSeconds}s unlocked</span>
<span>{t('attempt')} {gameState.guesses.length + 1} / {maxAttempts}</span>
<span>{unlockedSeconds}s {t('unlocked')}</span>
</div>
<div id="tour-score">
@@ -368,7 +375,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
<div key={i} className="guess-item">
<span className="guess-number">#{i + 1}</span>
<span className={`guess-text ${guess === 'SKIPPED' ? 'skipped' : ''} ${isCorrect ? 'correct' : ''}`}>
{isCorrect ? 'Correct!' : guess}
{isCorrect ? t('correct') : guess}
</span>
</div>
);
@@ -387,8 +394,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
className="skip-button"
>
{gameState.guesses.length === 0 && !hasPlayedAudio
? 'Start'
: `Skip (+${unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)`
? t('start')
: t('skipWithBonus', { seconds: unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds })
}
</button>
) : (
@@ -400,7 +407,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
boxShadow: '0 4px 15px rgba(245, 87, 108, 0.4)'
}}
>
Solve (Give Up)
{t('solveGiveUp')}
</button>
)}
</>
@@ -409,15 +416,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
{(hasWon || hasLost) && (
<div className={`message-box ${hasWon ? 'success' : 'failure'}`}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
{hasWon ? 'You won!' : 'Game Over'}
{hasWon ? t('won') : t('lost')}
</h2>
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? 'var(--success)' : 'var(--danger)' }}>
Score: {gameState.score}
{t('score')}: {gameState.score}
</div>
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: 'var(--muted-foreground)' }}>
<summary>Score Breakdown</summary>
<summary>{t('scoreBreakdown')}</summary>
<ul style={{ listStyle: 'none', padding: '0.5rem', textAlign: 'left', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
{gameState.scoreBreakdown.map((item, i) => (
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.25rem 0' }}>
@@ -430,22 +437,22 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</ul>
</details>
<p>{hasWon ? 'Come back tomorrow for a new song.' : 'The song was:'}</p>
<p>{hasWon ? t('comeBackTomorrow') : t('theSongWas')}</p>
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<img
src={dailyPuzzle.coverImage || '/favicon.ico'}
alt="Album Cover"
alt={t('albumCover')}
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
/>
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
<p style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
{dailyPuzzle.releaseYear && gameState.yearGuessed && (
<p style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
<p style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', margin: '0 0 1rem 0' }}>{t('released')}: {dailyPuzzle.releaseYear}</p>
)}
<audio controls style={{ width: '100%' }}>
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
Your browser does not support the audio element.
{t('yourBrowserDoesNotSupport')}
</audio>
</div>
@@ -505,6 +512,7 @@ function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{
}
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
const t = useTranslations('Game');
const [options, setOptions] = useState<number[]>([]);
const [feedback, setFeedback] = useState<{ show: boolean, correct: boolean, guessedYear?: number }>({ show: false, correct: false });
@@ -578,8 +586,8 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
}}>
{!feedback.show ? (
<>
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: 'var(--primary)' }}>Bonus Round!</h3>
<p style={{ marginBottom: '1.5rem', color: 'var(--secondary)' }}>Guess the release year for <strong style={{ color: 'var(--success)' }}>+10 points</strong>!</p>
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: 'var(--primary)' }}>{t('bonusRound')}</h3>
<p style={{ marginBottom: '1.5rem', color: 'var(--secondary)' }}>{t('guessReleaseYear')} <strong style={{ color: 'var(--success)' }}>+10 {t('points')}</strong>!</p>
<div style={{
display: 'grid',
@@ -621,7 +629,7 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
fontSize: '0.9rem'
}}
>
Skip Bonus
{t('skipBonus')}
</button>
</>
) : (
@@ -630,23 +638,23 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
feedback.correct ? (
<>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🎉</div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--success)', marginBottom: '0.5rem' }}>Correct!</h3>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>Released in {correctYear}</p>
<p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--success)', marginTop: '1rem' }}>+10 Points!</p>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--success)', marginBottom: '0.5rem' }}>{t('correct')}</h3>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('released')} {correctYear}</p>
<p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--success)', marginTop: '1rem' }}>+10 {t('points')}!</p>
</>
) : (
<>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>😕</div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--danger)', marginBottom: '0.5rem' }}>Not quite!</h3>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>You guessed {feedback.guessedYear}</p>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)', marginTop: '0.5rem' }}>Actually released in <strong>{correctYear}</strong></p>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--danger)', marginBottom: '0.5rem' }}>{t('notQuite')}</h3>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('youGuessed')} {feedback.guessedYear}</p>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)', marginTop: '0.5rem' }}>{t('actuallyReleasedIn')} <strong>{correctYear}</strong></p>
</>
)
) : (
<>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}></div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>Skipped</h3>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>Released in {correctYear}</p>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>{t('skipped')}</h3>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('released')} {correctYear}</p>
</>
)}
</div>
@@ -657,16 +665,17 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
}
function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, hasRated: boolean }) {
const t = useTranslations('Game');
const [hover, setHover] = useState(0);
const [rating, setRating] = useState(0);
if (hasRated) {
return <div style={{ color: 'var(--muted-foreground)', fontStyle: 'italic' }}>Thanks for rating!</div>;
return <div style={{ color: 'var(--muted-foreground)', fontStyle: 'italic' }}>{t('thanksForRating')}</div>;
}
return (
<div className="star-rating" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', fontWeight: '500' }}>Rate this puzzle:</span>
<span style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', fontWeight: '500' }}>{t('rateThisPuzzle')}</span>
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
{[...Array(5)].map((_, index) => {
const ratingValue = index + 1;

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
interface Song {
id: number;
@@ -14,6 +15,7 @@ interface GuessInputProps {
}
export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
const t = useTranslations('Game');
const [query, setQuery] = useState('');
const [songs, setSongs] = useState<Song[]>([]);
const [filteredSongs, setFilteredSongs] = useState<Song[]>([]);
@@ -53,7 +55,7 @@ export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
value={query}
onChange={(e) => setQuery(e.target.value)}
disabled={disabled}
placeholder={disabled ? "Game Over" : "Know it? Search for the artist / title"}
placeholder={disabled ? t('gameOverPlaceholder') : t('knowItSearch')}
className="guess-input"
/>

View File

@@ -1,8 +1,10 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
export default function InstallPrompt() {
const t = useTranslations('InstallPrompt');
const [isIOS, setIsIOS] = useState(false);
const [isStandalone, setIsStandalone] = useState(false);
const [showPrompt, setShowPrompt] = useState(false);
@@ -80,9 +82,9 @@ export default function InstallPrompt() {
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<div>
<h3 style={{ fontWeight: 'bold', fontSize: '1rem', marginBottom: '0.25rem' }}>Install Hördle App</h3>
<h3 style={{ fontWeight: 'bold', fontSize: '1rem', marginBottom: '0.25rem' }}>{t('installApp')}</h3>
<p style={{ fontSize: '0.875rem', color: '#666' }}>
Install the app for a better experience and quick access!
{t('installDescription')}
</p>
</div>
<button
@@ -102,7 +104,7 @@ export default function InstallPrompt() {
{isIOS ? (
<div style={{ fontSize: '0.875rem', background: '#f3f4f6', padding: '0.75rem', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
Tap <span style={{ fontSize: '1.2rem' }}>share</span> then "Add to Home Screen" <span style={{ fontSize: '1.2rem' }}>+</span>
{t('iosInstructions')} <span style={{ fontSize: '1.2rem' }}>{t('iosShare')}</span> {t('iosThen')} <span style={{ fontSize: '1.2rem' }}>+</span>
</div>
) : (
<button
@@ -118,7 +120,7 @@ export default function InstallPrompt() {
marginTop: '0.5rem'
}}
>
Install App
{t('installButton')}
</button>
)}
<style jsx>{`

View File

@@ -0,0 +1,59 @@
'use client';
import { usePathname, useRouter } from '@/lib/navigation';
import { useLocale } from 'next-intl';
export default function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const switchLocale = (newLocale: 'de' | 'en') => {
router.replace(pathname, { locale: newLocale });
};
return (
<div style={{
display: 'flex',
background: '#f3f4f6',
borderRadius: '0.375rem',
padding: '0.25rem',
gap: '0.25rem'
}}>
<button
onClick={() => switchLocale('de')}
style={{
padding: '0.375rem 0.75rem',
background: locale === 'de' ? 'white' : 'transparent',
border: 'none',
borderRadius: '0.25rem',
cursor: 'pointer',
fontWeight: locale === 'de' ? '600' : '400',
fontSize: '0.875rem',
color: locale === 'de' ? '#111827' : '#6b7280',
boxShadow: locale === 'de' ? '0 1px 2px rgba(0,0,0,0.05)' : 'none',
transition: 'all 0.2s'
}}
>
DE
</button>
<button
onClick={() => switchLocale('en')}
style={{
padding: '0.375rem 0.75rem',
background: locale === 'en' ? 'white' : 'transparent',
border: 'none',
borderRadius: '0.25rem',
cursor: 'pointer',
fontWeight: locale === 'en' ? '600' : '400',
fontSize: '0.875rem',
color: locale === 'en' ? '#111827' : '#6b7280',
boxShadow: locale === 'en' ? '0 1px 2px rgba(0,0,0,0.05)' : 'none',
transition: 'all 0.2s'
}}
>
EN
</button>
</div>
);
}

View File

@@ -2,33 +2,38 @@
import { useEffect, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import Link from 'next/link';
import { Link } from '@/lib/navigation';
import { getLocalizedValue } from '@/lib/i18n';
interface NewsItem {
id: number;
title: string;
content: string;
title: any;
content: any;
author: string | null;
publishedAt: string;
featured: boolean;
special: {
id: number;
name: string;
name: any;
} | null;
}
export default function NewsSection() {
interface NewsSectionProps {
locale: string;
}
export default function NewsSection({ locale }: NewsSectionProps) {
const [news, setNews] = useState<NewsItem[]>([]);
const [isExpanded, setIsExpanded] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchNews();
}, []);
}, [locale]);
const fetchNews = async () => {
try {
const res = await fetch('/api/news?limit=3');
const res = await fetch(`/api/news?limit=3&locale=${locale}`);
if (res.ok) {
const data = await res.json();
setNews(data);
@@ -115,7 +120,7 @@ export default function NewsSection() {
fontWeight: '600',
color: '#111827'
}}>
{item.title}
{getLocalizedValue(item.title, locale)}
</h3>
</div>
@@ -145,14 +150,14 @@ export default function NewsSection() {
<>
<span></span>
<Link
href={`/special/${item.special.name}`}
href={`/special/${getLocalizedValue(item.special.name, locale)}`}
style={{
color: '#be185d',
textDecoration: 'none',
fontWeight: '500'
}}
>
{item.special.name}
{getLocalizedValue(item.special.name, locale)}
</Link>
</>
)}
@@ -187,7 +192,7 @@ export default function NewsSection() {
li: ({ children }) => <li style={{ margin: '0.25rem 0' }}>{children}</li>
}}
>
{item.content}
{getLocalizedValue(item.content, locale)}
</ReactMarkdown>
</div>
</div>

View File

@@ -1,10 +1,13 @@
'use client';
import { useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { driver } from 'driver.js';
import 'driver.js/dist/driver.css';
export default function OnboardingTour() {
const t = useTranslations('OnboardingTour');
useEffect(() => {
const hasCompletedOnboarding = localStorage.getItem('hoerdle_onboarding_completed');
@@ -16,9 +19,9 @@ export default function OnboardingTour() {
showProgress: true,
animate: true,
allowClose: true,
doneBtnText: 'Done',
nextBtnText: 'Next',
prevBtnText: 'Previous',
doneBtnText: t('done'),
nextBtnText: t('next'),
prevBtnText: t('previous'),
onDestroyed: () => {
localStorage.setItem('hoerdle_onboarding_completed', 'true');
},
@@ -26,8 +29,8 @@ export default function OnboardingTour() {
{
element: '#tour-genres',
popover: {
title: 'Genres & Specials',
description: 'Choose a specific genre or a curated special event here.',
title: t('genresSpecials'),
description: t('genresSpecialsDescription'),
side: 'bottom',
align: 'start'
}
@@ -35,8 +38,8 @@ export default function OnboardingTour() {
{
element: '#tour-news',
popover: {
title: 'News',
description: 'Stay updated with the latest news and announcements.',
title: t('news'),
description: t('newsDescription'),
side: 'top',
align: 'start'
}
@@ -44,8 +47,8 @@ export default function OnboardingTour() {
{
element: '#tour-title',
popover: {
title: 'Hördle',
description: 'This is the daily puzzle. One new song every day per genre.',
title: t('hoerdle'),
description: t('hoerdleDescription'),
side: 'bottom',
align: 'start'
}
@@ -53,8 +56,8 @@ export default function OnboardingTour() {
{
element: '#tour-status',
popover: {
title: 'Attempts',
description: 'You have a limited number of attempts to guess the song.',
title: t('attempts'),
description: t('attemptsDescription'),
side: 'bottom',
align: 'start'
}
@@ -62,8 +65,8 @@ export default function OnboardingTour() {
{
element: '#tour-score',
popover: {
title: 'Score',
description: 'Your current score. Try to keep it high!',
title: t('score'),
description: t('scoreDescription'),
side: 'bottom',
align: 'start'
}
@@ -71,8 +74,8 @@ export default function OnboardingTour() {
{
element: '#tour-player',
popover: {
title: 'Player',
description: 'Listen to the snippet. Each additional play reduces your potential score.',
title: t('player'),
description: t('playerDescription'),
side: 'top',
align: 'start'
}
@@ -80,8 +83,8 @@ export default function OnboardingTour() {
{
element: '#tour-input',
popover: {
title: 'Input',
description: 'Type your guess here. Search for artist or title.',
title: t('input'),
description: t('inputDescription'),
side: 'top',
align: 'start'
}
@@ -89,8 +92,8 @@ export default function OnboardingTour() {
{
element: '#tour-controls',
popover: {
title: 'Controls',
description: 'Start the music or skip to the next snippet if you\'re stuck.',
title: t('controls'),
description: t('controlsDescription'),
side: 'top',
align: 'start'
}
@@ -103,7 +106,7 @@ export default function OnboardingTour() {
driverObj.drive();
}, 1000);
}, []);
}, [t]);
return null;
}

View File

@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { Statistics as StatsType } from '../lib/gameState';
interface StatisticsProps {
@@ -18,6 +19,7 @@ const BADGES = {
};
export default function Statistics({ statistics }: StatisticsProps) {
const t = useTranslations('Statistics');
const total =
statistics.solvedIn1 +
statistics.solvedIn2 +
@@ -36,19 +38,19 @@ export default function Statistics({ statistics }: StatisticsProps) {
{ attempts: 5, count: statistics.solvedIn5, badge: BADGES[5] },
{ attempts: 6, count: statistics.solvedIn6, badge: BADGES[6] },
{ attempts: 7, count: statistics.solvedIn7, badge: BADGES[7] },
{ attempts: 'Failed', count: statistics.failed, badge: BADGES.failed },
{ attempts: t('failed'), count: statistics.failed, badge: BADGES.failed },
];
return (
<div className="statistics-container">
<h3 className="statistics-title">Your Statistics</h3>
<p className="statistics-total">Total puzzles: {total}</p>
<h3 className="statistics-title">{t('yourStatistics')}</h3>
<p className="statistics-total">{t('totalPuzzles')}: {total}</p>
<div className="statistics-grid">
{stats.map((stat, index) => (
<div key={index} className="stat-item">
<div className="stat-badge">{stat.badge}</div>
<div className="stat-label">
{typeof stat.attempts === 'number' ? `${stat.attempts} try` : stat.attempts}
{typeof stat.attempts === 'number' ? `${stat.attempts} ${t('try')}` : stat.attempts}
</div>
<div className="stat-count">{stat.count}</div>
</div>