|
| 1 | +import React, {useState, useEffect, useRef, useCallback} from 'react'; |
| 2 | +import {Link} from 'react-router-dom'; |
| 3 | +import {ArrowLeftIcon, KeyboardIcon} from '@phosphor-icons/react'; |
| 4 | +import useSeo from '../../hooks/useSeo'; |
| 5 | +import BreadcrumbTitle from '../../components/BreadcrumbTitle'; |
| 6 | + |
| 7 | +const sampleTexts = ["The quick brown fox jumps over the lazy dog.", "Never underestimate the power of a good book.", "Coding is like poetry; it should be beautiful and efficient.", "The early bird catches the worm, but the second mouse gets the cheese.", "Innovation distinguishes between a leader and a follower.", "Success is not final, failure is not fatal: it is the courage to continue that counts."]; |
| 8 | + |
| 9 | +function KeyboardTypingSpeedTesterPage() { |
| 10 | + const appName = "FezType"; // Renamed as per user's request |
| 11 | + const appSlug = "feztype"; // Corresponding slug |
| 12 | + |
| 13 | + useSeo({ |
| 14 | + title: `${appName} | Fezcodex`, |
| 15 | + description: "Test and improve your typing speed with FezType.", |
| 16 | + keywords: ['Fezcodex', 'typing test', 'wpm', 'typing speed', 'keyboard', 'games'], |
| 17 | + ogTitle: `${appName} | Fezcodex`, |
| 18 | + ogDescription: "Test and improve your typing speed with FezType.", |
| 19 | + ogImage: 'https://fezcode.github.io/logo512.png', |
| 20 | + twitterCard: 'summary_large_image', |
| 21 | + twitterTitle: `${appName} | Fezcodex`, |
| 22 | + twitterDescription: "Test and improve your typing speed with FezType.", |
| 23 | + twitterImage: 'https://fezcode.github.io/logo512.png', |
| 24 | + }); |
| 25 | + |
| 26 | + const [textToType, setTextToType] = useState(''); |
| 27 | + const [typedText, setTypedText] = useState(''); |
| 28 | + const [timer, setTimer] = useState(60); // 60 seconds for the test |
| 29 | + const [timerActive, setTimerActive] = useState(false); |
| 30 | + const [testStarted, setTestStarted] = useState(false); |
| 31 | + const [testCompleted, setTestCompleted] = useState(false); |
| 32 | + const [wpm, setWpm] = useState(0); |
| 33 | + const [accuracy, setAccuracy] = useState(0); |
| 34 | + const [mistakes, setMistakes] = useState(0); // Re-added this line |
| 35 | + // New states for cumulative tracking |
| 36 | + const [totalCorrectChars, setTotalCorrectChars] = useState(0); |
| 37 | + const [totalTypedChars, setTotalTypedChars] = useState(0); |
| 38 | + const [totalMistakes, setTotalMistakes] = useState(0); |
| 39 | + const [textsCompletedCount, setTextsCompletedCount] = useState(0); |
| 40 | + |
| 41 | + const inputRef = useRef(null); |
| 42 | + |
| 43 | + const selectNewText = useCallback((currentText) => { // Takes currentText as argument |
| 44 | + let newRandomIndex; |
| 45 | + let newText; |
| 46 | + do { |
| 47 | + newRandomIndex = Math.floor(Math.random() * sampleTexts.length); |
| 48 | + newText = sampleTexts[newRandomIndex]; |
| 49 | + } while (newText === currentText); |
| 50 | + |
| 51 | + return newText; |
| 52 | + }, []); // No dependencies |
| 53 | + |
| 54 | + useEffect(() => { |
| 55 | + setTextToType(selectNewText('')); // Initial text selection on mount, no previous text |
| 56 | + }, [selectNewText]); |
| 57 | + |
| 58 | + const calculateResults = useCallback(() => { |
| 59 | + // Overall WPM: (Total correct characters / 5) / Total time elapsed |
| 60 | + const timeElapsedMinutes = (60 - timer) / 60; // Total time elapsed (fixed at 60s for now) |
| 61 | + const calculatedWpm = timeElapsedMinutes === 0 ? 0 : (totalCorrectChars / 5) / timeElapsedMinutes; |
| 62 | + // Overall Accuracy: (Total correct characters / Total typed characters) * 100 |
| 63 | + const calculatedAccuracy = totalTypedChars === 0 ? 0 : (totalCorrectChars / totalTypedChars) * 100; |
| 64 | + |
| 65 | + setWpm(Math.round(calculatedWpm)); |
| 66 | + setAccuracy(calculatedAccuracy.toFixed(2)); |
| 67 | + }, [totalCorrectChars, totalTypedChars, timer]); // Dependencies for overall calculation |
| 68 | + |
| 69 | + useEffect(() => { |
| 70 | + let interval = null; |
| 71 | + if (timerActive && timer > 0) { |
| 72 | + interval = setInterval(() => { |
| 73 | + setTimer((prevTimer) => prevTimer - 1); |
| 74 | + }, 1000); |
| 75 | + } else if (timer === 0 && timerActive) { |
| 76 | + setTimerActive(false); |
| 77 | + setTestCompleted(true); |
| 78 | + calculateResults(); |
| 79 | + } |
| 80 | + return () => clearInterval(interval); |
| 81 | + }, [timerActive, timer, calculateResults]); |
| 82 | + |
| 83 | + const handleInputChange = (event) => { |
| 84 | + if (testCompleted) return; // Prevent typing after test is completed |
| 85 | + |
| 86 | + if (!testStarted) { |
| 87 | + setTestStarted(true); |
| 88 | + setTimerActive(true); |
| 89 | + } |
| 90 | + |
| 91 | + const newTypedText = event.target.value; |
| 92 | + |
| 93 | + // Update segment-specific mistake tracking (used for rendering visual feedback) |
| 94 | + let currentSegmentMistakes = 0; |
| 95 | + for (let i = 0; i < newTypedText.length; i++) { |
| 96 | + if (i >= textToType.length || newTypedText[i] !== textToType[i]) { |
| 97 | + currentSegmentMistakes++; |
| 98 | + } |
| 99 | + } |
| 100 | + // Note: this 'mistakes' state is only for current visual feedback, not cumulative |
| 101 | + // The cumulative totalMistakes will be updated when a segment is completed. |
| 102 | + setMistakes(currentSegmentMistakes); |
| 103 | + setTypedText(newTypedText); |
| 104 | + |
| 105 | + // If typed text length matches the text to type length, process segment completion |
| 106 | + if (newTypedText.length === textToType.length) { |
| 107 | + // Calculate results for the just-completed text segment |
| 108 | + let segmentCorrectChars = 0; |
| 109 | + for (let i = 0; i < textToType.length; i++) { |
| 110 | + if (newTypedText[i] === textToType[i]) { |
| 111 | + segmentCorrectChars++; |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + // Update cumulative stats |
| 116 | + setTotalCorrectChars(prev => prev + segmentCorrectChars); |
| 117 | + setTotalTypedChars(prev => prev + textToType.length); |
| 118 | + setTotalMistakes(prev => prev + (textToType.length - segmentCorrectChars)); |
| 119 | + setTextsCompletedCount(prev => prev + 1); |
| 120 | + |
| 121 | + // Immediately select new text and clear typed input for the next segment |
| 122 | + setTextToType((prevText) => selectNewText(prevText)); |
| 123 | + setTypedText(''); // Clear typed input for the new segment |
| 124 | + setMistakes(0); // Reset segment mistakes for the new text |
| 125 | + return; // Exit handleInputChange as this segment is complete |
| 126 | + } |
| 127 | + }; |
| 128 | + |
| 129 | + const resetTest = () => { |
| 130 | + setTestCompleted(false); |
| 131 | + setTestStarted(false); |
| 132 | + setTimerActive(false); |
| 133 | + setTimer(60); |
| 134 | + setWpm(0); |
| 135 | + setAccuracy(0); |
| 136 | + setTotalCorrectChars(0); |
| 137 | + setTotalTypedChars(0); |
| 138 | + setTotalMistakes(0); |
| 139 | + setTextsCompletedCount(0); |
| 140 | + setTextToType((prevText) => { |
| 141 | + return selectNewText(prevText); |
| 142 | + }); |
| 143 | + setTypedText(''); |
| 144 | + setMistakes(0); // Reset segment mistakes for the new test |
| 145 | + if (inputRef.current) { |
| 146 | + inputRef.current.focus(); |
| 147 | + } |
| 148 | + }; |
| 149 | + |
| 150 | + const renderTextToType = () => { |
| 151 | + return textToType.split('').map((char, index) => { |
| 152 | + let colorClass = 'text-gray-400'; // Default color for untyped characters |
| 153 | + let extraClass = ''; |
| 154 | + |
| 155 | + if (index < typedText.length) { |
| 156 | + // Character has been typed |
| 157 | + if (char === typedText[index]) { |
| 158 | + // Correct character |
| 159 | + if (char === ' ') { |
| 160 | + extraClass = 'bg-green-700 bg-opacity-30 rounded-sm'; // Subtle green for correct space |
| 161 | + } else { |
| 162 | + colorClass = 'text-green-400'; |
| 163 | + } |
| 164 | + } else { |
| 165 | + // Incorrect character |
| 166 | + if (char === ' ') { |
| 167 | + extraClass = 'bg-red-700 bg-opacity-30 rounded-sm'; // Subtle red for incorrect space |
| 168 | + } else { |
| 169 | + colorClass = 'text-red-400'; |
| 170 | + } |
| 171 | + } |
| 172 | + } else if (index === typedText.length && !testCompleted) { |
| 173 | + // This is the character the user is currently supposed to type |
| 174 | + colorClass = 'text-yellow-400 font-bold'; // Highlight next character to type |
| 175 | + } |
| 176 | + |
| 177 | + // Add a visual indicator for the cursor if it's at the current typing position |
| 178 | + const isCursorPosition = index === typedText.length && !testCompleted; |
| 179 | + |
| 180 | + return ( |
| 181 | + <span key={index} |
| 182 | + className={`${colorClass} ${extraClass} ${isCursorPosition ? 'border-b-2 border-yellow-400' : ''}`}> |
| 183 | + {char === ' ' ? '\u00A0' : char} {/* Use non-breaking space for visual representation of space */} |
| 184 | + </span> |
| 185 | + ); |
| 186 | + }); |
| 187 | + }; |
| 188 | + return ( |
| 189 | + <div className="py-16 sm:py-24"> |
| 190 | + <div className="mx-auto max-w-7xl px-6 lg:px-8 text-gray-300"> |
| 191 | + <Link |
| 192 | + to="/apps" |
| 193 | + className="text-article hover:underline flex items-center justify-center gap-2 text-lg mb-4" |
| 194 | + > |
| 195 | + <ArrowLeftIcon size={24}/> Back to Apps |
| 196 | + </Link> |
| 197 | + <BreadcrumbTitle title={appName} slug={appSlug}/> |
| 198 | + <hr className="border-gray-700"/> |
| 199 | + <div className="flex justify-center items-center mt-16"> |
| 200 | + <div |
| 201 | + className="bg-app-alpha-10 border-app-alpha-50 text-app group border rounded-lg shadow-2xl p-6 flex flex-col justify-between relative transform transition-all duration-300 ease-in-out scale-105 overflow-hidden h-full w-full max-w-4xl"> |
| 202 | + <div |
| 203 | + className="absolute top-0 left-0 w-full h-full opacity-10" |
| 204 | + style={{ |
| 205 | + backgroundImage: 'radial-gradient(circle, white 1px, transparent 1px)', backgroundSize: '10px 10px', |
| 206 | + }} |
| 207 | + ></div> |
| 208 | + <div className="relative z-10 p-1"> |
| 209 | + <h1 className="text-3xl font-arvo font-normal mb-4 text-app"> |
| 210 | + <KeyboardIcon size={32} className="inline-block mr-2"/> {appName} |
| 211 | + </h1> |
| 212 | + <hr className="border-gray-700 mb-4"/> |
| 213 | + |
| 214 | + {!testCompleted ? (<> |
| 215 | + <div |
| 216 | + className="text-xl font-mono p-4 mb-4 border border-app-alpha-50 rounded-md bg-gray-900/50 min-h-[100px] flex flex-wrap content-start"> |
| 217 | + {renderTextToType()} |
| 218 | + </div> |
| 219 | + <input |
| 220 | + ref={inputRef} |
| 221 | + type="text" |
| 222 | + className="w-full p-4 mb-4 bg-gray-800 border border-app-alpha-50 rounded-md focus:ring-0 text-app-light font-mono" |
| 223 | + value={typedText} |
| 224 | + onChange={handleInputChange} |
| 225 | + placeholder="Start typing here..." |
| 226 | + disabled={timer === 0 && testStarted} |
| 227 | + autoFocus |
| 228 | + /> |
| 229 | + <div className="font-mono flex justify-between items-center text-lg text-app"> |
| 230 | + <span>Time: {timer}s</span> |
| 231 | + <span>Mistakes: {totalMistakes}</span> |
| 232 | + <button |
| 233 | + onClick={resetTest} |
| 234 | + className="font-mono px-4 py-2 border border-white bg-black/50 hover:bg-white/50 hover:text-black hover:border-black rounded text-white transition-colors" |
| 235 | + > |
| 236 | + Reset |
| 237 | + </button> |
| 238 | + </div> |
| 239 | + </>) : (<div className="text-center font-mono"> |
| 240 | + <h2 className="text-4xl font-arvo mb-4 mt-6 text-green-400">Test Complete!</h2> |
| 241 | + <p className="text-2xl mb-2">WPM: <span className="font-bold text-app">{wpm}</span></p> |
| 242 | + <p className="text-2xl mb-4">Accuracy: <span className="font-bold text-app">{accuracy}%</span></p> |
| 243 | + <button |
| 244 | + onClick={resetTest} |
| 245 | + className="px-6 py-3 border border-white bg-black/50 hover:bg-white/50 hover:text-black hover:border-black rounded text-white text-xl transition-colors" |
| 246 | + > |
| 247 | + Try Again |
| 248 | + </button> |
| 249 | + </div>)} |
| 250 | + </div> |
| 251 | + </div> |
| 252 | + </div> |
| 253 | + </div> |
| 254 | + </div> |
| 255 | + ); |
| 256 | +} |
| 257 | + |
| 258 | +export default KeyboardTypingSpeedTesterPage; |
0 commit comments