import { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
// Main application component
const App = () => {
const [textToType, setTextToType] = useState("");
const [userInput, setUserInput] = useState("");
const [currentIndex, setCurrentIndex] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [timer, setTimer] = useState(60); // 60-second test
const [testActive, setTestActive] = useState(false);
const [results, setResults] = useState(null);
// Gemini API key - will be filled by the environment
const apiKey = "";
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:generateContent?key=${apiKey}`;
// Function to fetch new text from the Gemini API
const generateNewText = async () => {
setIsLoading(true);
const prompt = "Generate a paragraph of creative, engaging prose for a typing test. It should be at least 100 words long. Do not include any titles or special formatting, just the paragraph.";
// API payload configuration
const payload = {
contents: [{
role: "user",
parts: [{ text: prompt }]
}]
};
// Implement exponential backoff for API calls
let retryCount = 0;
const maxRetries = 5;
const baseDelay = 1000;
while (retryCount < maxRetries) {
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
if (response.status === 429) {
const delay = baseDelay * Math.pow(2, retryCount);
console.log(`Rate limit exceeded. Retrying in ${delay / 1000}s...`);
await new Promise(res => setTimeout(res, delay));
retryCount++;
continue;
} else {
throw new Error(`API call failed with status: ${response.status}`);
}
}
const result = await response.json();
const generatedText = result?.candidates?.[0]?.content?.parts?.[0]?.text;
if (generatedText) {
setTextToType(generatedText.trim());
setIsLoading(false);
return;
} else {
throw new Error("API response is missing generated text.");
}
} catch (error) {
console.error("Error fetching new text:", error);
setTextToType("Failed to load text. Please try again.");
setIsLoading(false);
return;
}
}
console.error("Failed to fetch new text after multiple retries.");
setTextToType("Failed to load text. Please try again.");
setIsLoading(false);
};
// Function to start the typing test
const startTest = () => {
setUserInput("");
setCurrentIndex(0);
setTimer(60);
setResults(null);
setTestActive(true);
};
// Effect to load initial text and handle timer
useEffect(() => {
generateNewText();
let interval;
if (testActive) {
interval = setInterval(() => {
setTimer(prevTimer => {
if (prevTimer <= 1) {
clearInterval(interval);
setTestActive(false);
return 0;
}
return prevTimer - 1;
});
}, 1000);
}
return () => clearInterval(interval);
}, [testActive]);
// Effect to handle keyboard events
useEffect(() => {
if (testActive && !isLoading) {
const handleKeyDown = (event) => {
const key = event.key;
// Prevent default browser behavior for space and backspace
if (key === ' ' || key === 'Backspace') {
event.preventDefault();
}
if (key === 'Backspace') {
setUserInput(prevInput => prevInput.slice(0, -1));
setCurrentIndex(prevIndex => Math.max(0, prevIndex - 1));
} else if (key.length === 1 && currentIndex < textToType.length) {
setUserInput(prevInput => prevInput + key);
setCurrentIndex(prevIndex => prevIndex + 1);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}
}, [testActive, isLoading, currentIndex, textToType]);
// Effect to calculate results when the test ends
useEffect(() => {
if (!testActive && timer === 0 && !isLoading) {
let correctChars = 0;
for (let i = 0; i < userInput.length; i++) {
if (userInput[i] === textToType[i]) {
correctChars++;
}
}
const wordsTyped = userInput.split(/\s+/).filter(word => word !== "").length;
const wpm = (correctChars / 5) * (60 / 60); // (chars / 5) / minutes
const accuracy = userInput.length > 0 ? (correctChars / userInput.length) * 100 : 0;
setResults({
wpm: Math.round(wpm),
accuracy: accuracy.toFixed(2)
});
}
}, [testActive, timer, isLoading, userInput, textToType]);
// Helper function to render each character with appropriate styling
const renderCharacters = () => {
return textToType.split('').map((char, index) => {
let charClass = "transition-colors duration-100 ";
if (index < userInput.length) {
if (userInput[index] === char) {
charClass += "text-green-500 font-bold";
} else {
charClass += "text-red-500 font-bold underline decoration-red-500";
}
} else if (index === userInput.length && testActive) {
charClass += "text-blue-500 underline font-extrabold";
} else {
charClass += "text-gray-400";
}
return (
<span key={index} className={charClass}>
{char}
</span>
);
});
};
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-900 text-white p-4 font-inter">
<div className="bg-gray-800 p-8 rounded-xl shadow-lg w-full max-w-4xl text-center">
<h1 className="text-4xl font-bold mb-6 text-indigo-400">Typing Test</h1>
<div className="flex justify-between items-center mb-4">
<div className="text-lg font-bold">
Time: <span className="text-yellow-400">{timer}s</span>
</div>
<button
onClick={startTest}
disabled={isLoading || testActive}
className="bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-200 shadow-md transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-900"
>
{isLoading ? "Loading..." : testActive ? "Typing..." : "Start Test"}
</button>
</div>
<div className="mb-6 h-40 overflow-y-auto p-4 border border-gray-700 rounded-lg text-left text-xl leading-relaxed">
{isLoading ? (
<p className="text-gray-400 animate-pulse">Loading new text...</p>
) : (
renderCharacters()
)}
</div>
{results && (
<div className="mt-6 p-4 bg-gray-700 rounded-lg shadow-md">
<h2 className="text-2xl font-bold mb-2">Results</h2>
<div className="flex justify-around">
<div className="flex flex-col items-center">
<span className="text-4xl font-extrabold text-green-400">{results.wpm}</span>
<span className="text-gray-400">WPM</span>
</div>
<div className="flex flex-col items-center">
<span className="text-4xl font-extrabold text-teal-400">{results.accuracy}%</span>
<span className="text-gray-400">Accuracy</span>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default App;