feat: Add statistics tracking and fix clipboard API
- Add personal statistics with badges for each attempt count - Track solved puzzles per attempt (1-6) and failed attempts - Display statistics after game completion - Fix clipboard API issue with fallback method - Add footer with attribution
This commit is contained in:
@@ -6,12 +6,9 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
color: rgb(var(--foreground-rgb));
|
color: rgb(var(--foreground-rgb));
|
||||||
background: linear-gradient(
|
background: linear-gradient(to bottom,
|
||||||
to bottom,
|
|
||||||
transparent,
|
transparent,
|
||||||
rgb(var(--background-end-rgb))
|
rgb(var(--background-end-rgb))) rgb(var(--background-start-rgb));
|
||||||
)
|
|
||||||
rgb(var(--background-start-rgb));
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
||||||
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -63,7 +60,7 @@ body {
|
|||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-controls {
|
.player-controls {
|
||||||
@@ -128,7 +125,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.guess-text {
|
.guess-text {
|
||||||
color: #ef4444; /* Red for wrong */
|
color: #ef4444;
|
||||||
|
/* Red for wrong */
|
||||||
}
|
}
|
||||||
|
|
||||||
.guess-text.skipped {
|
.guess-text.skipped {
|
||||||
@@ -272,3 +270,84 @@ body {
|
|||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
background: #333;
|
background: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.app-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 2rem 1rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #666;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer a {
|
||||||
|
color: #000;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Statistics */
|
||||||
|
.statistics-container {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statistics-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statistics-total {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statistics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-badge {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-count {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
@@ -26,6 +26,15 @@ export default function RootLayout({
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||||
{children}
|
{children}
|
||||||
|
<footer className="app-footer">
|
||||||
|
<p>
|
||||||
|
Vibe coded with ☕ and 🍺 by{' '}
|
||||||
|
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
|
||||||
|
@elpatron@digitalcourage.social
|
||||||
|
</a>
|
||||||
|
{' '}- for personal use among friends only!
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import AudioPlayer from './AudioPlayer';
|
import AudioPlayer from './AudioPlayer';
|
||||||
import GuessInput from './GuessInput';
|
import GuessInput from './GuessInput';
|
||||||
|
import Statistics from './Statistics';
|
||||||
import { useGameState } from '../lib/gameState';
|
import { useGameState } from '../lib/gameState';
|
||||||
|
|
||||||
interface GameProps {
|
interface GameProps {
|
||||||
@@ -16,7 +17,7 @@ interface GameProps {
|
|||||||
const UNLOCK_STEPS = [2, 4, 7, 11, 16, 30];
|
const UNLOCK_STEPS = [2, 4, 7, 11, 16, 30];
|
||||||
|
|
||||||
export default function Game({ dailyPuzzle }: GameProps) {
|
export default function Game({ dailyPuzzle }: GameProps) {
|
||||||
const { gameState, addGuess } = useGameState();
|
const { gameState, statistics, addGuess } = useGameState();
|
||||||
const [hasWon, setHasWon] = useState(false);
|
const [hasWon, setHasWon] = useState(false);
|
||||||
const [hasLost, setHasLost] = useState(false);
|
const [hasLost, setHasLost] = useState(false);
|
||||||
const [shareText, setShareText] = useState('Share Result');
|
const [shareText, setShareText] = useState('Share Result');
|
||||||
@@ -68,10 +69,25 @@ export default function Game({ dailyPuzzle }: GameProps) {
|
|||||||
const speaker = hasWon ? '🔉' : '🔇';
|
const speaker = hasWon ? '🔉' : '🔇';
|
||||||
const text = `Hördle #${dailyPuzzle.id}\n\n${speaker}${emojiGrid}\n\n#Hördle #Music\n\nhttps://hoerdle.elpatron.me`;
|
const text = `Hördle #${dailyPuzzle.id}\n\n${speaker}${emojiGrid}\n\n#Hördle #Music\n\nhttps://hoerdle.elpatron.me`;
|
||||||
|
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
// Fallback method for copying to clipboard
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
setShareText('Copied!');
|
setShareText('Copied!');
|
||||||
setTimeout(() => setShareText('Share Result'), 2000);
|
setTimeout(() => setShareText('Share Result'), 2000);
|
||||||
});
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
setShareText('Copy failed');
|
||||||
|
setTimeout(() => setShareText('Share Result'), 2000);
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -123,6 +139,7 @@ export default function Game({ dailyPuzzle }: GameProps) {
|
|||||||
<div className="message-box success">
|
<div className="message-box success">
|
||||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>You won!</h2>
|
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>You won!</h2>
|
||||||
<p>Come back tomorrow for a new song.</p>
|
<p>Come back tomorrow for a new song.</p>
|
||||||
|
{statistics && <Statistics statistics={statistics} />}
|
||||||
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
||||||
{shareText}
|
{shareText}
|
||||||
</button>
|
</button>
|
||||||
@@ -133,6 +150,7 @@ export default function Game({ dailyPuzzle }: GameProps) {
|
|||||||
<div className="message-box failure">
|
<div className="message-box failure">
|
||||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>Game Over</h2>
|
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>Game Over</h2>
|
||||||
<p>The song was hidden.</p>
|
<p>The song was hidden.</p>
|
||||||
|
{statistics && <Statistics statistics={statistics} />}
|
||||||
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
||||||
{shareText}
|
{shareText}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
56
components/Statistics.tsx
Normal file
56
components/Statistics.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Statistics as StatsType } from '../lib/gameState';
|
||||||
|
|
||||||
|
interface StatisticsProps {
|
||||||
|
statistics: StatsType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BADGES = {
|
||||||
|
1: '🏆', // Gold trophy
|
||||||
|
2: '🥈', // Silver medal
|
||||||
|
3: '🥉', // Bronze medal
|
||||||
|
4: '⭐', // Star
|
||||||
|
5: '✨', // Sparkles
|
||||||
|
6: '💫', // Dizzy
|
||||||
|
failed: '❌', // Cross mark
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Statistics({ statistics }: StatisticsProps) {
|
||||||
|
const total =
|
||||||
|
statistics.solvedIn1 +
|
||||||
|
statistics.solvedIn2 +
|
||||||
|
statistics.solvedIn3 +
|
||||||
|
statistics.solvedIn4 +
|
||||||
|
statistics.solvedIn5 +
|
||||||
|
statistics.solvedIn6 +
|
||||||
|
statistics.failed;
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ attempts: 1, count: statistics.solvedIn1, badge: BADGES[1] },
|
||||||
|
{ attempts: 2, count: statistics.solvedIn2, badge: BADGES[2] },
|
||||||
|
{ attempts: 3, count: statistics.solvedIn3, badge: BADGES[3] },
|
||||||
|
{ attempts: 4, count: statistics.solvedIn4, badge: BADGES[4] },
|
||||||
|
{ attempts: 5, count: statistics.solvedIn5, badge: BADGES[5] },
|
||||||
|
{ attempts: 6, count: statistics.solvedIn6, badge: BADGES[6] },
|
||||||
|
{ attempts: '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>
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
<div className="stat-count">{stat.count}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,12 +10,25 @@ export interface GameState {
|
|||||||
lastPlayed: number; // Timestamp
|
lastPlayed: number; // Timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Statistics {
|
||||||
|
solvedIn1: number;
|
||||||
|
solvedIn2: number;
|
||||||
|
solvedIn3: number;
|
||||||
|
solvedIn4: number;
|
||||||
|
solvedIn5: number;
|
||||||
|
solvedIn6: number;
|
||||||
|
failed: number;
|
||||||
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'hoerdle_game_state';
|
const STORAGE_KEY = 'hoerdle_game_state';
|
||||||
|
const STATS_KEY = 'hoerdle_statistics';
|
||||||
|
|
||||||
export function useGameState() {
|
export function useGameState() {
|
||||||
const [gameState, setGameState] = useState<GameState | null>(null);
|
const [gameState, setGameState] = useState<GameState | null>(null);
|
||||||
|
const [statistics, setStatistics] = useState<Statistics | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Load game state
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
@@ -23,20 +36,48 @@ export function useGameState() {
|
|||||||
const parsed: GameState = JSON.parse(stored);
|
const parsed: GameState = JSON.parse(stored);
|
||||||
if (parsed.date === today) {
|
if (parsed.date === today) {
|
||||||
setGameState(parsed);
|
setGameState(parsed);
|
||||||
return;
|
} else {
|
||||||
|
// New day
|
||||||
|
const newState: GameState = {
|
||||||
|
date: today,
|
||||||
|
guesses: [],
|
||||||
|
isSolved: false,
|
||||||
|
isFailed: false,
|
||||||
|
lastPlayed: Date.now(),
|
||||||
|
};
|
||||||
|
setGameState(newState);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// No state
|
||||||
|
const newState: GameState = {
|
||||||
|
date: today,
|
||||||
|
guesses: [],
|
||||||
|
isSolved: false,
|
||||||
|
isFailed: false,
|
||||||
|
lastPlayed: Date.now(),
|
||||||
|
};
|
||||||
|
setGameState(newState);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
|
||||||
}
|
}
|
||||||
|
|
||||||
// New day or no state
|
// Load statistics
|
||||||
const newState: GameState = {
|
const storedStats = localStorage.getItem(STATS_KEY);
|
||||||
date: today,
|
if (storedStats) {
|
||||||
guesses: [],
|
setStatistics(JSON.parse(storedStats));
|
||||||
isSolved: false,
|
} else {
|
||||||
isFailed: false,
|
const newStats: Statistics = {
|
||||||
lastPlayed: Date.now(),
|
solvedIn1: 0,
|
||||||
};
|
solvedIn2: 0,
|
||||||
setGameState(newState);
|
solvedIn3: 0,
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
|
solvedIn4: 0,
|
||||||
|
solvedIn5: 0,
|
||||||
|
solvedIn6: 0,
|
||||||
|
failed: 0,
|
||||||
|
};
|
||||||
|
setStatistics(newStats);
|
||||||
|
localStorage.setItem(STATS_KEY, JSON.stringify(newStats));
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveState = (newState: GameState) => {
|
const saveState = (newState: GameState) => {
|
||||||
@@ -44,6 +85,28 @@ export function useGameState() {
|
|||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateStatistics = (attempts: number, solved: boolean) => {
|
||||||
|
if (!statistics) return;
|
||||||
|
|
||||||
|
const newStats = { ...statistics };
|
||||||
|
|
||||||
|
if (solved) {
|
||||||
|
switch (attempts) {
|
||||||
|
case 1: newStats.solvedIn1++; break;
|
||||||
|
case 2: newStats.solvedIn2++; break;
|
||||||
|
case 3: newStats.solvedIn3++; break;
|
||||||
|
case 4: newStats.solvedIn4++; break;
|
||||||
|
case 5: newStats.solvedIn5++; break;
|
||||||
|
case 6: newStats.solvedIn6++; break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newStats.failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatistics(newStats);
|
||||||
|
localStorage.setItem(STATS_KEY, JSON.stringify(newStats));
|
||||||
|
};
|
||||||
|
|
||||||
const addGuess = (guess: string, correct: boolean) => {
|
const addGuess = (guess: string, correct: boolean) => {
|
||||||
if (!gameState) return;
|
if (!gameState) return;
|
||||||
|
|
||||||
@@ -60,7 +123,12 @@ export function useGameState() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
saveState(newState);
|
saveState(newState);
|
||||||
|
|
||||||
|
// Update statistics when game ends
|
||||||
|
if (isSolved || isFailed) {
|
||||||
|
updateStatistics(newGuesses.length, isSolved);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return { gameState, addGuess };
|
return { gameState, statistics, addGuess };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user