├── prettier.config.js ├── test └── unit │ └── index.test.ts ├── index.d.ts ├── src ├── vite-env.d.ts ├── hooks │ ├── useDetectDevice.ts │ ├── useScreenShot.ts │ ├── useClipboard.ts │ ├── useCursorPosition.ts │ ├── useModal.ts │ ├── useLocalStorage.ts │ ├── useDropdown.ts │ ├── useTheme.ts │ ├── useWord.ts │ ├── useCountdown.ts │ ├── useKeyDown.ts │ └── useSystem.ts ├── components │ ├── MobileNotSupported.tsx │ ├── WordContainer.tsx │ ├── Tooltip.tsx │ ├── Character.tsx │ ├── Social.tsx │ ├── UserTyped.tsx │ ├── WordWrapper.tsx │ ├── ResultCard.tsx │ ├── Restart.tsx │ ├── Countdown.tsx │ ├── Footer.tsx │ ├── Modal.tsx │ ├── TimeCategory.tsx │ ├── About.tsx │ ├── ThemeDropdown.tsx │ ├── Header.tsx │ └── ModalContent.tsx ├── main.tsx ├── types │ └── index.ts ├── context │ └── ThemeContext.tsx ├── index.css ├── App.tsx ├── utils │ └── index.ts └── assets │ └── react.svg ├── postcss.config.js ├── vite.config.ts ├── tsconfig.node.json ├── .gitignore ├── index.html ├── .eslintrc.cjs ├── tailwind.config.js ├── tsconfig.json ├── public ├── vite.svg └── logo.svg ├── package.json └── README.md /prettier.config.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/unit/index.test.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'use-react-screenshot'; 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | base: './', 8 | }); 9 | -------------------------------------------------------------------------------- /src/hooks/useDetectDevice.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import { isMobile } from '../utils'; 4 | 5 | export const useDetectDevice = () => { 6 | const [isMobileDevice] = useState(() => isMobile()); 7 | 8 | return isMobileDevice; 9 | }; 10 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/components/MobileNotSupported.tsx: -------------------------------------------------------------------------------- 1 | const MobileNotSupported = () => { 2 | return ( 3 |
4 |

5 | Sorry, this app is not supported on mobile devices. 6 |

7 |
8 | ); 9 | }; 10 | 11 | export default MobileNotSupported; 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | yarn.lock 10 | 11 | node_modules 12 | dist 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Typest 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App.tsx'; 4 | import './index.css'; 5 | import ThemeProvider from './context/ThemeContext.tsx'; 6 | 7 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, es2020: true }, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:react-hooks/recommended', 7 | ], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 10 | plugins: ['react-refresh'], 11 | rules: { 12 | 'react-refresh/only-export-components': 'warn', 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks/useScreenShot.ts: -------------------------------------------------------------------------------- 1 | // import { useRef } from 'react'; 2 | // import * as Screenshot from 'use-react-screenshot'; 3 | 4 | // export const useScreenShot = () => { 5 | // const ref = useRef(null); 6 | // const [image, takeScreenShot] = Screenshot.useScreenshot({ 7 | // type: 'image/png', 8 | // quality: 1.0, 9 | // }); 10 | 11 | // const getImage = () => { 12 | // takeScreenShot(ref.current); 13 | // }; 14 | 15 | // return { ref, image, getImage }; 16 | // }; 17 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: { 9 | keyframes: { 10 | appear: { 11 | "0%": { opacity: 0 }, 12 | "50%": { opacity: 0 }, 13 | "75%": { opacity: 0 }, 14 | "90%": { opacity: 0 }, 15 | "100%": { opacity: 1 }, 16 | }, 17 | } 18 | }, 19 | }, 20 | plugins: [], 21 | } 22 | 23 | -------------------------------------------------------------------------------- /src/hooks/useClipboard.ts: -------------------------------------------------------------------------------- 1 | export const useClipboard = () => { 2 | const copyTextToClipboard = async (text: string): Promise => { 3 | if (!navigator.clipboard) { 4 | alert('Cannot access clipboard'); 5 | return false; 6 | } 7 | try { 8 | await navigator.clipboard.writeText(text); 9 | return true; 10 | } catch (error) { 11 | alert('Sorry, cannot copy text to clipboard'); 12 | return false; 13 | } 14 | }; 15 | 16 | return { copyTextToClipboard }; 17 | }; 18 | -------------------------------------------------------------------------------- /src/hooks/useCursorPosition.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | export const useCursorPosition = () => { 4 | const [cursorPosition, setCursorPosition] = useState(0); 5 | 6 | const resetCursorPointer = () => setCursorPosition(0); 7 | 8 | const updateCursorPosition = (opt: 'increase' | 'decrease') => { 9 | if (opt === 'increase') setCursorPosition((cursor) => cursor + 1); 10 | else setCursorPosition((cursor) => cursor - 1); 11 | }; 12 | 13 | return { cursorPosition, updateCursorPosition, resetCursorPointer }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface Results { 2 | accuracy: number; 3 | cpm: number; 4 | wpm: number; 5 | error: number; 6 | } 7 | 8 | export interface AccuracyMetrics { 9 | correctChars: number; 10 | incorrectChars: number; 11 | accuracy: number; 12 | } 13 | 14 | export interface HistoryType { 15 | wordHistory: string; 16 | typedHistory: string; 17 | } 18 | 19 | export interface Theme { 20 | name: string; 21 | background: { 22 | primary: string; 23 | secondary: string; 24 | }; 25 | text: { 26 | primary: string; 27 | secondary: string; 28 | title: string; 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /src/hooks/useModal.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | 3 | export const useModal = () => { 4 | const [modalIsOpen, setModalIsOpen] = useState(false); 5 | const [aboutModal, setAboutModal] = useState(false); 6 | 7 | const openModal = useCallback((type: string) => { 8 | if (type === 'result') setModalIsOpen(true); 9 | else setAboutModal(true); 10 | }, []); 11 | 12 | const closeModal = useCallback((type: string) => { 13 | if (type === 'result') setModalIsOpen(false); 14 | else setAboutModal(false); 15 | }, []); 16 | 17 | return { modalIsOpen, aboutModal, openModal, closeModal }; 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/WordContainer.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import Character from './Character'; 3 | 4 | type WordContainerProps = { 5 | word: string; 6 | }; 7 | 8 | const WordContainer = ({ word }: WordContainerProps) => { 9 | const characters = useMemo(() => { 10 | return word.split(''); 11 | }, [word]); 12 | 13 | return ( 14 |
15 | {characters.map((character, index) => { 16 | return ; 17 | })} 18 |
19 | ); 20 | }; 21 | 22 | export default WordContainer; 23 | -------------------------------------------------------------------------------- /src/context/ThemeContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | import { useTheme } from '../hooks/useTheme'; 4 | 5 | type ThemeContextType = ReturnType; 6 | 7 | type ThemeProviderProps = { 8 | children: React.ReactNode; 9 | }; 10 | 11 | export const ThemeContext = createContext( 12 | {} as ThemeContextType 13 | ); 14 | 15 | export default function ThemeProvider({ children }: ThemeProviderProps) { 16 | const { systemTheme, setTheme } = useTheme(); 17 | 18 | return ( 19 | 20 | {children} 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip as ReactToolTip } from 'react-tooltip'; 2 | 3 | type TooltipProps = { 4 | tooltipId: string; 5 | delayShow?: number; 6 | delayHide?: number; 7 | textColor?: string; 8 | children: React.ReactNode; 9 | }; 10 | 11 | const Tooltip = ({ 12 | tooltipId, 13 | children, 14 | delayHide, 15 | delayShow, 16 | }: TooltipProps) => { 17 | return ( 18 | <> 19 | {children} 20 | 26 | 27 | ); 28 | }; 29 | 30 | export default Tooltip; 31 | -------------------------------------------------------------------------------- /src/hooks/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '../types/index'; 2 | 3 | export const useLocalStorage = () => { 4 | const setLocalStorageValue = (key: string, value: number | Theme) => { 5 | try { 6 | window.localStorage.setItem(key, JSON.stringify(value)); 7 | } catch (error) { 8 | alert('Something went wrong'); 9 | } 10 | }; 11 | 12 | const getLocalStorageValue = (key: string) => { 13 | try { 14 | const value = window.localStorage.getItem(key); 15 | return value ? JSON.parse(value) : null; 16 | } catch (error) { 17 | alert('Something went wrong'); 18 | } 19 | }; 20 | 21 | return { setLocalStorageValue, getLocalStorageValue }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/Character.tsx: -------------------------------------------------------------------------------- 1 | import { useThemeContext } from '../hooks/useTheme'; 2 | 3 | type CharactersProps = { 4 | state?: boolean; 5 | character: string; 6 | className?: string; 7 | }; 8 | 9 | const Character = ({ state, character, className }: CharactersProps) => { 10 | const { systemTheme } = useThemeContext(); 11 | return ( 12 | 24 | {character} 25 | 26 | ); 27 | }; 28 | 29 | export default Character; 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": [ 6 | "ES2020", 7 | "DOM", 8 | "DOM.Iterable" 9 | ], 10 | "module": "ESNext", 11 | "skipLibCheck": true, 12 | /* Bundler mode */ 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | }, 25 | "include": [ 26 | "src", 27 | "index.d.ts" 28 | ], 29 | "references": [ 30 | { 31 | "path": "./tsconfig.node.json" 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /src/hooks/useDropdown.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useState, useEffect, useCallback } from 'react'; 2 | 3 | export const useDropdown = () => { 4 | const [isOpen, setIsOpen] = useState(false); 5 | const dropdownRef = useRef(null); 6 | 7 | const handleClickOutside = useCallback( 8 | (event: MouseEvent) => { 9 | if ( 10 | dropdownRef.current && 11 | !dropdownRef.current.contains(event.target as Node) 12 | ) { 13 | setIsOpen(false); 14 | } 15 | }, 16 | [setIsOpen] 17 | ); 18 | 19 | const toggleDropdown = useCallback(() => { 20 | setIsOpen(!isOpen); 21 | }, [isOpen, setIsOpen]); 22 | 23 | useEffect(() => { 24 | document.addEventListener('mousedown', handleClickOutside, true); 25 | return () => { 26 | document.removeEventListener('mousedown', handleClickOutside, true); 27 | }; 28 | }, [handleClickOutside]); 29 | 30 | return { isOpen, toggleDropdown, dropdownRef }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useContext } from 'react'; 2 | 3 | import { useLocalStorage } from './useLocalStorage'; 4 | 5 | import { ThemeContext } from '../context/ThemeContext'; 6 | import { theme } from '../utils'; 7 | import type { Theme } from '../types'; 8 | 9 | export const useTheme = () => { 10 | const { getLocalStorageValue, setLocalStorageValue } = useLocalStorage(); 11 | 12 | const [systemTheme, setSystemTheme] = useState(() => { 13 | const localTheme = getLocalStorageValue('theme'); 14 | return localTheme ? localTheme : theme.aurora; 15 | }); 16 | 17 | const setTheme = useCallback( 18 | (value: Theme) => { 19 | setSystemTheme(value); 20 | setLocalStorageValue('theme', value); 21 | }, 22 | [setSystemTheme, setLocalStorageValue] 23 | ); 24 | 25 | return { systemTheme, setTheme }; 26 | }; 27 | 28 | export const useThemeContext = () => { 29 | return useContext(ThemeContext); 30 | }; 31 | -------------------------------------------------------------------------------- /src/components/Social.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { useThemeContext } from '../hooks/useTheme'; 4 | 5 | type SocialProps = { 6 | url: string; 7 | tooltipContent?: string; 8 | tooltipId?: string; 9 | children: React.ReactNode; 10 | }; 11 | 12 | const StyledSocial = styled.a` 13 | &:hover { 14 | color: ${({ theme }) => theme.text.title}; 15 | background-color: ${({ theme }) => theme.background.secondary}; 16 | } 17 | `; 18 | 19 | const Social = ({ url, tooltipContent, tooltipId, children }: SocialProps) => { 20 | const { systemTheme } = useThemeContext(); 21 | return ( 22 | 31 | {children} 32 | 33 | ); 34 | }; 35 | 36 | export default Social; 37 | -------------------------------------------------------------------------------- /src/hooks/useWord.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | 3 | import { generateWord } from '../utils'; 4 | 5 | export const useWord = (numberOfWords: number) => { 6 | const [word, setWord] = useState( 7 | () => generateWord(numberOfWords) + ' ' 8 | ); 9 | const [totalWord, setTotalWord] = useState(word); 10 | 11 | const appendWord = useCallback((word: string) => { 12 | setTotalWord((prev) => prev + word); 13 | }, []); 14 | 15 | const eraseWord = useCallback((word: string) => { 16 | setTotalWord(word); 17 | }, []); 18 | 19 | const updateWord = useCallback( 20 | (erase = false) => { 21 | setWord(() => { 22 | const genWord = generateWord(numberOfWords) + ' '; 23 | if (erase) eraseWord(genWord); 24 | else appendWord(genWord); 25 | return genWord; 26 | }); 27 | }, 28 | [numberOfWords, appendWord, eraseWord] 29 | ); 30 | 31 | return { word, totalWord, setTotalWord, updateWord, appendWord }; 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/UserTyped.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { useThemeContext } from '../hooks/useTheme'; 5 | import Character from './Character'; 6 | 7 | type UserTypedProps = { 8 | charTyped: string; 9 | check: (index: number) => boolean; 10 | word: string; 11 | }; 12 | 13 | const StyledDiv = styled.div` 14 | &:last-child { 15 | &::after { 16 | background-color: ${({ theme }) => theme.text.secondary}; 17 | } 18 | } 19 | `; 20 | 21 | const UserTyped = ({ check, charTyped, word }: UserTypedProps) => { 22 | const characters = useMemo(() => { 23 | return charTyped.split(''); 24 | }, [charTyped]); 25 | 26 | const { systemTheme } = useThemeContext(); 27 | 28 | return ( 29 | 33 | {characters.map((_, index) => { 34 | return ( 35 | 36 | ); 37 | })} 38 | 39 | ); 40 | }; 41 | 42 | export default UserTyped; 43 | -------------------------------------------------------------------------------- /src/components/WordWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { MdCenterFocusStrong } from 'react-icons/md'; 2 | 3 | type WordWrapperProps = { 4 | children: React.ReactNode; 5 | focused: boolean; 6 | setFocused: React.Dispatch>; 7 | }; 8 | 9 | const WordWrapper = ({ children, focused, setFocused }: WordWrapperProps) => { 10 | return ( 11 | <> 12 |
17 | 18 | 19 | Focus to start typing 20 | 21 |
22 |
setFocused(true)} 28 | onBlur={() => setFocused(false)} 29 | > 30 | {children} 31 |
32 | 33 | ); 34 | }; 35 | 36 | export default WordWrapper; 37 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | * { 6 | margin: 0; 7 | padding: 0; 8 | box-sizing: border-box; 9 | } 10 | 11 | body { 12 | min-height: 100vh; 13 | background-color: #003950; 14 | } 15 | 16 | .category { 17 | @apply cursor-pointer rounded-md p-2 font-mono text-xl; 18 | } 19 | 20 | @layer components { 21 | .character:last-child::after { 22 | content: ''; 23 | width: 2px; 24 | height: 24px; 25 | margin-bottom: -2px; 26 | display: inline-block; 27 | animation: cursorBlink 0.65s steps(2) infinite; 28 | } 29 | } 30 | 31 | .ReactModal__Overlay { 32 | opacity: 0; 33 | transition: opacity 500ms ease-in-out; 34 | } 35 | 36 | .ReactModal__Overlay--after-open { 37 | opacity: 1; 38 | } 39 | 40 | .ReactModal__Overlay--before-close { 41 | opacity: 0; 42 | } 43 | 44 | @keyframes cursorBlink { 45 | 0% { 46 | opacity: 0; 47 | } 48 | } 49 | 50 | body::-webkit-scrollbar { 51 | width: 2em; 52 | } 53 | 54 | body::-webkit-scrollbar-track { 55 | box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.3); 56 | } 57 | 58 | body::-webkit-scrollbar-thumb { 59 | background-color: #003950; 60 | outline: 1px solid #003950; 61 | } -------------------------------------------------------------------------------- /src/hooks/useCountdown.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef, useCallback } from 'react'; 2 | 3 | export const useCountdown = (initialValue: number, interval = 1000) => { 4 | const intervalRef = useRef(null); 5 | const [countdown, setCountdown] = useState(initialValue); 6 | 7 | const startCountdown = useCallback(() => { 8 | if (intervalRef.current !== null) return; 9 | intervalRef.current = window.setInterval(() => { 10 | setCountdown((prev) => { 11 | if (prev > 0) { 12 | return prev - interval; 13 | } 14 | if (prev === 0) clearInterval(intervalRef.current!); 15 | 16 | return prev; 17 | }); 18 | }, interval); 19 | }, [initialValue, interval]); 20 | 21 | const resetCountdown = useCallback(() => { 22 | if (intervalRef.current !== null) { 23 | clearInterval(intervalRef.current); 24 | intervalRef.current = null; 25 | setCountdown(initialValue); 26 | } 27 | }, [initialValue]); 28 | 29 | useEffect(() => { 30 | return () => { 31 | if (intervalRef.current !== null) { 32 | clearInterval(intervalRef.current); 33 | } 34 | }; 35 | }, []); 36 | 37 | return { countdown, startCountdown, resetCountdown }; 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/ResultCard.tsx: -------------------------------------------------------------------------------- 1 | import { useThemeContext } from '../hooks/useTheme'; 2 | 3 | import Tooltip from './Tooltip'; 4 | 5 | type ResultCardProps = { 6 | tooltipId: string; 7 | tooltipContent: string; 8 | tooltipPlace: 'bottom' | 'top' | 'left' | 'right'; 9 | title: string; 10 | results: string; 11 | }; 12 | 13 | const ResultCard = ({ 14 | title, 15 | tooltipId, 16 | tooltipContent, 17 | tooltipPlace, 18 | results, 19 | }: ResultCardProps) => { 20 | const { systemTheme } = useThemeContext(); 21 | return ( 22 | 23 |
32 |

{title}

33 |

39 | {results} 40 |

41 |
42 |
43 | ); 44 | }; 45 | 46 | export default ResultCard; 47 | -------------------------------------------------------------------------------- /src/components/Restart.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { MdRestartAlt } from 'react-icons/md'; 3 | 4 | import { useThemeContext } from '../hooks/useTheme'; 5 | 6 | import Tooltip from './Tooltip'; 7 | 8 | type RestartProps = { 9 | restart: () => void; 10 | }; 11 | 12 | const StyledButton = styled.button` 13 | &:hover { 14 | color: ${({ theme }) => theme.text.title}; 15 | background-color: ${({ theme }) => theme.background.secondary}; 16 | } 17 | `; 18 | 19 | const Restart = ({ restart }: RestartProps) => { 20 | const { systemTheme } = useThemeContext(); 21 | return ( 22 |
23 | 24 |
25 | { 28 | restart(); 29 | }} 30 | className={`rotate-0 rounded-full p-3 transition delay-200 ease-out hover:rotate-180 `} 31 | data-tooltip-id='Restart' 32 | data-tooltip-content='Restart Test' 33 | data-tooltip-place='bottom' 34 | > 35 | 36 | 37 |
38 |
39 |
40 | ); 41 | }; 42 | 43 | export default Restart; 44 | -------------------------------------------------------------------------------- /src/components/Countdown.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { useThemeContext } from '../hooks/useTheme'; 4 | 5 | type CountdownProps = { 6 | countdown: number; 7 | reset: () => void; 8 | }; 9 | 10 | const Countdown = ({ countdown, reset }: CountdownProps) => { 11 | useEffect(() => { 12 | reset(); 13 | }, [reset]); 14 | 15 | const formatedCountdown = { 16 | minutes: new Date(countdown).getUTCMinutes(), 17 | seconds: new Date(countdown).getUTCSeconds(), 18 | }; 19 | 20 | const { systemTheme } = useThemeContext(); 21 | 22 | return ( 23 |
24 |
30 | 36 | {formatedCountdown.minutes < 10 37 | ? `0${formatedCountdown.minutes}` 38 | : formatedCountdown.minutes} 39 | : 40 | {formatedCountdown.seconds < 10 41 | ? `0${formatedCountdown.seconds}` 42 | : formatedCountdown.seconds} 43 | 44 |
45 |
46 | ); 47 | }; 48 | 49 | export default Countdown; 50 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typing-game", 3 | "private": true, 4 | "version": "0.0.0", 5 | "homepage": "https://typest-abdullahassi.vercel.app/", 6 | "type": "module", 7 | "scripts": { 8 | "predeploy": "yarn build", 9 | "deploy": "gh-pages -d dist", 10 | "dev": "vite", 11 | "build": "tsc && vite build", 12 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 13 | "preview": "vite preview" 14 | }, 15 | "dependencies": { 16 | "html2canvas": "^1.4.1", 17 | "or": "^0.2.0", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0", 20 | "react-icons": "^4.10.1", 21 | "react-modal": "^3.16.1", 22 | "react-tooltip": "^5.16.1", 23 | "styled-components": "^6.0.2" 24 | }, 25 | "devDependencies": { 26 | "@faker-js/faker": "^8.0.2", 27 | "@types/jest": "^29.5.2", 28 | "@types/node": "^20.3.3", 29 | "@types/react": "^18.2.14", 30 | "@types/react-dom": "^18.2.6", 31 | "@types/react-modal": "^3.16.0", 32 | "@typescript-eslint/eslint-plugin": "^5.59.0", 33 | "@typescript-eslint/parser": "^5.59.0", 34 | "@vitejs/plugin-react": "^4.0.0", 35 | "autoprefixer": "^10.4.14", 36 | "eslint": "^8.38.0", 37 | "eslint-plugin-react-hooks": "^4.6.0", 38 | "eslint-plugin-react-refresh": "^0.3.4", 39 | "gh-pages": "^5.0.0", 40 | "postcss": "^8.4.24", 41 | "prettier": "^2.8.8", 42 | "prettier-plugin-tailwindcss": "^0.3.0", 43 | "tailwindcss": "^3.3.2", 44 | "typescript": "^5.1.6", 45 | "vite": "^4.3.9" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Typest 🖮 3 | 4 | Typest is a minimalistic typing speed testing app built with React and TailwindCSS that highly inspired by [MonkeyType](https://www.monkeytype.com). The app allows users to test and improve their typing speed and accuracy. The app provides a user-friendly interface and intuitive feedback on typing performance. 5 | 6 | ## Live Demo 7 | 8 | Check out the live demo [here](https://typest-abdullahassi.vercel.app/). 9 | 10 | ## Current Features 11 | 12 | - **Typing Test**: Users can take a typing test to measure their typing speed and accuracy. The test includes random sentences/paragraphs, and the user's typing speed is calculated in words per minute (WPM). 13 | - **Time Category**: Users can adjust typing duration to 15s, 30s, or 60s according to their preference. 14 | - **Statistics**: The app displays statistics such as WPM/CPM, accuracy percentage, error percentage and total number of characters typed. 15 | - **Watch Typing History**: Users can watch their previous typing history and access their performance. 16 | - **Change Theme**: Users can change the theme of the app based on their preference from available theme palette. 17 | - **Responsive Design**: The user interface is designed to be responsive and adapt to different screen sizes, making it accessible on various laptops or PCs. 18 | 19 | ## Screenshots with different themes 20 | ![118shots_so](https://github.com/AbdullahAssi/Typest/assets/113567773/2c96ce1e-028a-4d56-b9f3-ad37308a85cf) 21 | 22 | ![382shots_so](https://github.com/AbdullahAssi/Typest/assets/113567773/ee86ac46-0a62-4240-a1a4-fb9bbb9df69a) 23 | 24 | ![516shots_so](https://github.com/AbdullahAssi/Typest/assets/113567773/163d7b7b-981e-4eff-a78a-62f55f9f8ef9) 25 | 26 |
27 | 28 | ## Acknowledgement 29 | 30 | - [MonkeyType](https://www.monkeytype.com) - for the inspiration and design ideas. 31 | -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | // import styled from "styled-components"; 2 | import { 3 | BsGithub, 4 | BsInstagram, 5 | BsLinkedin, 6 | } from "react-icons/bs"; 7 | 8 | // import { useThemeContext } from "../hooks/useTheme"; 9 | 10 | import Tooltip from "./Tooltip"; 11 | import Social from "./Social"; 12 | 13 | // const StyledLink = styled.a` 14 | // &:hover { 15 | // color: ${({ theme }) => theme.text.title}; 16 | // } 17 | // `; 18 | 19 | const Footer = () => { 20 | // const { systemTheme } = useThemeContext(); 21 | return ( 22 |
23 |
24 |
25 | 26 | 31 | 32 | 33 | 34 | 35 | 40 | 41 | 42 | 43 | 44 | 45 | 50 | 51 | 52 | 53 |
54 | 55 |
Copyright Ⓒ 2023 Typest. All Rights Reserved
56 |
57 |
58 | ); 59 | }; 60 | 61 | export default Footer; 62 | -------------------------------------------------------------------------------- /src/components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import Modal from 'react-modal'; 2 | import { IoIosCloseCircle } from 'react-icons/io'; 3 | 4 | import { useThemeContext } from '../hooks/useTheme'; 5 | 6 | type ModalProps = { 7 | type: string; 8 | isOpen: boolean; 9 | onRequestClose: (str: string) => void; 10 | children: React.ReactNode; 11 | }; 12 | 13 | const customStyles = { 14 | content: { 15 | top: '50%', 16 | left: '50%', 17 | right: 'auto', 18 | bottom: 'auto', 19 | width: '80%', 20 | maxWidth: '1024px', 21 | maxHeight: '90%', 22 | marginRight: '-50%', 23 | padding: 5, 24 | transform: 'translate(-50%, -50%)', 25 | border: 'none', 26 | }, 27 | overlay: { 28 | backgroundColor: 'rgba(0,0,0,0.5)', 29 | }, 30 | }; 31 | 32 | Modal.setAppElement('#root'); 33 | 34 | const ModalComponent = ({ 35 | type, 36 | isOpen, 37 | onRequestClose, 38 | children, 39 | }: ModalProps) => { 40 | const { systemTheme } = useThemeContext(); 41 | 42 | const styles = { 43 | content: { 44 | ...customStyles.content, 45 | backgroundColor: systemTheme.background.primary, 46 | color: systemTheme.text.title, 47 | }, 48 | overlay: { 49 | ...customStyles.overlay, 50 | }, 51 | }; 52 | return ( 53 | onRequestClose(type)} 59 | closeTimeoutMS={300} 60 | > 61 |
62 | 73 |
74 |
{children}
75 |
76 | ); 77 | }; 78 | 79 | export default ModalComponent; 80 | -------------------------------------------------------------------------------- /src/hooks/useKeyDown.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react'; 2 | 3 | import { useCursorPosition } from './useCursorPosition'; 4 | import { isAllowedCode } from '../utils'; 5 | 6 | type TypingState = 'idle' | 'start' | 'typing'; 7 | 8 | export const useKeyDown = (active: boolean) => { 9 | const [typingState, setTypingState] = useState('idle'); 10 | const [charTyped, setCharTyped] = useState(''); 11 | const [totalCharacterTyped, setTotalCharacterTyped] = useState(''); 12 | 13 | const { cursorPosition, updateCursorPosition, resetCursorPointer } = 14 | useCursorPosition(); 15 | 16 | const handleKeyDown = useCallback( 17 | ({ key, code }: KeyboardEvent) => { 18 | if (!active || !isAllowedCode(code)) return; 19 | 20 | if (key === 'Backspace') { 21 | if (charTyped.length > 0 && cursorPosition > 0) { 22 | setCharTyped((prev) => prev.slice(0, charTyped.length - 1)); 23 | setTotalCharacterTyped((prev) => 24 | prev.slice(0, totalCharacterTyped.length - 1) 25 | ); 26 | updateCursorPosition('decrease'); 27 | } 28 | return; 29 | } 30 | 31 | if (typingState === 'idle') { 32 | setTypingState('start'); 33 | } 34 | 35 | setCharTyped((prev) => prev + key); 36 | setTotalCharacterTyped((prev) => prev + key); 37 | updateCursorPosition('increase'); 38 | }, 39 | [ 40 | active, 41 | charTyped.length, 42 | cursorPosition, 43 | updateCursorPosition, 44 | typingState, 45 | totalCharacterTyped, 46 | ] 47 | ); 48 | 49 | const resetCharTyped = useCallback(() => { 50 | setCharTyped(''); 51 | }, [setCharTyped]); 52 | 53 | useEffect(() => { 54 | window.addEventListener('keydown', handleKeyDown); 55 | return () => window.removeEventListener('keydown', handleKeyDown); 56 | }); 57 | 58 | return { 59 | charTyped, 60 | totalCharacterTyped, 61 | setTotalCharacterTyped, 62 | cursorPosition, 63 | resetCharTyped, 64 | resetCursorPointer, 65 | typingState, 66 | setTypingState, 67 | }; 68 | }; 69 | -------------------------------------------------------------------------------- /src/components/TimeCategory.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { BiTimer } from 'react-icons/bi'; 4 | 5 | import { ThemeContext } from '../context/ThemeContext'; 6 | import { Theme } from '../types'; 7 | 8 | type TimeCategoryProps = { 9 | time: number; 10 | setTime: (value: number) => void; 11 | setLocalStorage: (key: string, value: number | Theme) => void; 12 | restart: () => void; 13 | }; 14 | 15 | const TimeCategory = ({ 16 | time, 17 | setTime, 18 | restart, 19 | setLocalStorage, 20 | }: TimeCategoryProps) => { 21 | const { systemTheme } = useContext(ThemeContext); 22 | 23 | return ( 24 |
25 | 26 |
32 | { 37 | setTime(15000); 38 | setLocalStorage('time', 15000); 39 | restart(); 40 | }} 41 | style={{ 42 | color: time === 15000 ? systemTheme.text.secondary : '', 43 | }} 44 | > 45 | 15s 46 | 47 | { 52 | setTime(30000); 53 | setLocalStorage('time', 30000); 54 | restart(); 55 | }} 56 | style={{ 57 | color: time === 30000 ? systemTheme.text.secondary : '', 58 | }} 59 | > 60 | 30s 61 | 62 | { 67 | setTime(60000); 68 | setLocalStorage('time', 60000); 69 | restart(); 70 | }} 71 | style={{ 72 | color: time === 60000 ? systemTheme.text.secondary : '', 73 | }} 74 | > 75 | 60s 76 | 77 |
78 |
79 | ); 80 | }; 81 | 82 | export default TimeCategory; 83 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useDetectDevice } from './hooks/useDetectDevice'; 2 | import { useSystem } from './hooks/useSystem'; 3 | import { useThemeContext } from './hooks/useTheme'; 4 | 5 | import AboutPage from './components/About'; 6 | import Countdown from './components/Countdown'; 7 | import Footer from './components/Footer'; 8 | import Header from './components/Header'; 9 | import ModalComponent from './components/Modal'; 10 | import ModalContent from './components/ModalContent'; 11 | import Restart from './components/Restart'; 12 | import TimeCategory from './components/TimeCategory'; 13 | import UserTyped from './components/UserTyped'; 14 | import WordContainer from './components/WordContainer'; 15 | import WordWrapper from './components/WordWrapper'; 16 | import MobileNotSupported from './components/MobileNotSupported'; 17 | 18 | function App() { 19 | const { systemTheme } = useThemeContext(); 20 | const { 21 | charTyped, 22 | countdown, 23 | word, 24 | wordContainerFocused, 25 | modalIsOpen, 26 | aboutModal, 27 | history, 28 | time, 29 | results, 30 | resetCountdown, 31 | setLocalStorageValue, 32 | setWordContainerFocused, 33 | restartTest, 34 | checkCharacter, 35 | closeModal, 36 | openModal, 37 | setTime, 38 | } = useSystem(); 39 | 40 | const isMobile = useDetectDevice(); 41 | 42 | return ( 43 |
50 |
54 | {isMobile ? ( 55 | 56 | ) : ( 57 | <> 58 |
63 | 69 | 70 | 74 | 75 | 80 | 81 | 82 |
83 | 88 | 93 | 94 | 95 | 100 | 101 | 102 | 103 | )} 104 |
105 |
106 | ); 107 | } 108 | 109 | export default App; 110 | -------------------------------------------------------------------------------- /src/hooks/useSystem.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | import { useCountdown } from './useCountdown'; 4 | import { useKeyDown } from './useKeyDown'; 5 | import { useLocalStorage } from './useLocalStorage'; 6 | import { useModal } from './useModal'; 7 | import { useWord } from './useWord'; 8 | 9 | import { 10 | calculateAccuracy, 11 | calculateErrorPercentage, 12 | calculateWPM, 13 | } from '../utils'; 14 | 15 | import type { Results } from '../types'; 16 | import type { HistoryType } from '../types'; 17 | 18 | export const useSystem = () => { 19 | const [results, setResults] = useState({ 20 | accuracy: 0, 21 | wpm: 0, 22 | cpm: 0, 23 | error: 0, 24 | }); 25 | 26 | const [history, setHistory] = useState({ 27 | wordHistory: '', 28 | typedHistory: '', 29 | }); 30 | 31 | const { setLocalStorageValue, getLocalStorageValue } = useLocalStorage(); 32 | const [wordContainerFocused, setWordContainerFocused] = useState(false); 33 | const [time, setTime] = useState(() => getLocalStorageValue('time') || 15000); 34 | const { countdown, resetCountdown, startCountdown } = useCountdown(time); 35 | const { word, updateWord, totalWord } = useWord(30); 36 | const { 37 | charTyped, 38 | typingState, 39 | cursorPosition, 40 | totalCharacterTyped, 41 | resetCharTyped, 42 | resetCursorPointer, 43 | setTotalCharacterTyped, 44 | setTypingState, 45 | } = useKeyDown(wordContainerFocused); 46 | const { modalIsOpen, aboutModal, openModal, closeModal } = useModal(); 47 | 48 | const restartTest = useCallback(() => { 49 | resetCountdown(); 50 | updateWord(true); 51 | resetCursorPointer(); 52 | resetCharTyped(); 53 | setTypingState('idle'); 54 | setTotalCharacterTyped(''); 55 | }, [ 56 | resetCountdown, 57 | updateWord, 58 | resetCursorPointer, 59 | resetCharTyped, 60 | setTypingState, 61 | setTotalCharacterTyped, 62 | ]); 63 | 64 | const checkCharacter = useCallback( 65 | (index: number) => { 66 | if (charTyped[index] === word[index]) { 67 | return true; 68 | } else { 69 | return false; 70 | } 71 | }, 72 | [charTyped, word] 73 | ); 74 | 75 | if (word.length === charTyped.length) { 76 | updateWord(); 77 | resetCharTyped(); 78 | resetCursorPointer(); 79 | } 80 | 81 | if (typingState === 'start') { 82 | startCountdown(); 83 | setTypingState('typing'); 84 | } 85 | 86 | if (countdown === 0) { 87 | const { accuracy } = calculateAccuracy(totalWord, totalCharacterTyped); 88 | const { wpm, cpm } = calculateWPM(totalCharacterTyped, accuracy, time); 89 | const error = calculateErrorPercentage(accuracy); 90 | 91 | setResults({ 92 | accuracy, 93 | wpm, 94 | cpm, 95 | error, 96 | }); 97 | 98 | setHistory({ 99 | wordHistory: totalWord, 100 | typedHistory: totalCharacterTyped, 101 | }); 102 | 103 | openModal('result'); 104 | restartTest(); 105 | } 106 | 107 | return { 108 | charTyped, 109 | countdown, 110 | cursorPosition, 111 | modalIsOpen, 112 | aboutModal, 113 | results, 114 | time, 115 | history, 116 | word, 117 | wordContainerFocused, 118 | setWordContainerFocused, 119 | setTime, 120 | resetCountdown, 121 | setLocalStorageValue, 122 | updateWord, 123 | restartTest, 124 | checkCharacter, 125 | closeModal, 126 | openModal, 127 | }; 128 | }; 129 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | 3 | import { AccuracyMetrics } from '../types'; 4 | 5 | export const isAllowedCode = (code: string): boolean => { 6 | return ( 7 | code.startsWith('Key') || 8 | code === 'Backspace' || 9 | code === 'Space' || 10 | code === 'Minus' 11 | ); 12 | }; 13 | 14 | export const isMobile = () => { 15 | const userAgent = navigator.userAgent; 16 | 17 | const mobileUserAgents = [ 18 | 'Android', 19 | 'iPhone', 20 | 'iPad', 21 | 'iPod', 22 | 'BlackBerry', 23 | 'Windows Phone', 24 | ]; 25 | 26 | for (let i = 0; i < mobileUserAgents.length; i++) { 27 | if (userAgent.indexOf(mobileUserAgents[i]) !== -1) { 28 | return true; 29 | } 30 | } 31 | return false; 32 | }; 33 | 34 | export const generateWord = (n: number): string => { 35 | return faker.word.words(n); 36 | }; 37 | 38 | export const calculateAccuracy = (expectedWord: string, typedWord: string) => { 39 | let correctChars = 0; 40 | for (let i = 0; i < typedWord.length; i++) { 41 | if (typedWord[i] === expectedWord[i]) { 42 | correctChars++; 43 | } 44 | } 45 | 46 | const accuracyMetrics: AccuracyMetrics = { 47 | correctChars, 48 | incorrectChars: typedWord.length - correctChars, 49 | accuracy: (correctChars / typedWord.length) * 100, 50 | }; 51 | return accuracyMetrics; 52 | }; 53 | 54 | export const calculateWPM = ( 55 | typedWord: string, 56 | accuracy: number, 57 | time: number 58 | ) => { 59 | const minutes = time / 60000; 60 | const wordsTyped = typedWord.length / 5; 61 | const grossWPM = wordsTyped / minutes; 62 | const netWPM = Math.round(grossWPM * (accuracy / 100)); 63 | 64 | const results = { 65 | wpm: netWPM, 66 | cpm: typedWord.length / minutes, 67 | }; 68 | return results; 69 | }; 70 | 71 | export const calculateErrorPercentage = (accuracy: number) => { 72 | return 100 - accuracy; 73 | }; 74 | 75 | export const theme = { 76 | blueDolphin: { 77 | name: 'Blue Dolphin', 78 | background: { 79 | primary: '#003950', 80 | secondary: '#014961', 81 | }, 82 | text: { 83 | primary: '#6DEAFF', 84 | secondary: '#FFCEFB', 85 | title: '#6DEAFF', 86 | }, 87 | }, 88 | aurora: { 89 | name: 'Aurora', 90 | background: { 91 | primary: '#011926', 92 | secondary: '#000C13', 93 | }, 94 | text: { 95 | primary: '#235A68', 96 | secondary: '#00E980', 97 | title: '#00E980', 98 | }, 99 | }, 100 | paper: { 101 | name: 'Paper', 102 | background: { 103 | primary: '#EEEEEE', 104 | secondary: '#DDDDDD', 105 | }, 106 | text: { 107 | primary: '#B4B4B4', 108 | secondary: '#444444', 109 | title: '#444444', 110 | }, 111 | }, 112 | cyberspace: { 113 | name: 'Cyberspace', 114 | background: { 115 | primary: '#181C18', 116 | secondary: '#131613', 117 | }, 118 | text: { 119 | primary: '#9578D3', 120 | secondary: '#04AF6A', 121 | title: '#9578D3', 122 | }, 123 | }, 124 | cheesecake: { 125 | name: 'Cheesecake', 126 | background: { 127 | primary: '#FDF0D5', 128 | secondary: '#F3E2BF', 129 | }, 130 | text: { 131 | primary: '#E14C94', 132 | secondary: '#3A3335', 133 | title: '#E14C94', 134 | }, 135 | }, 136 | bouquet: { 137 | name: 'Bouquet', 138 | background: { 139 | primary: '#173F35', 140 | secondary: '#1F4E43', 141 | }, 142 | text: { 143 | primary: '#408E7B', 144 | secondary: '#DBE0D2', 145 | title: '#DBE0D2', 146 | }, 147 | }, 148 | }; 149 | -------------------------------------------------------------------------------- /src/components/About.tsx: -------------------------------------------------------------------------------- 1 | import { BiLogoTelegram } from "react-icons/bi"; 2 | import { BsGithub } from "react-icons/bs"; 3 | 4 | const AboutPage = () => { 5 | return ( 6 |
7 |
8 |

About Typest

9 |

10 | Typest is a minimalistic typing speed testing app built with React and 11 | TailwindCSS that highly inspired by{" "} 12 | 17 | MonkeyType 18 | 19 | . The app allows users to test and improve their typing speed and 20 | accuracy. The app provides a user-friendly interface and intuitive 21 | feedback on typing performance. 22 |

23 |
24 |
25 |

Current Features

26 |
    27 |
  • 28 | Typing Test: Users can take a 29 | typing test to measure their typing speed and accuracy. The test 30 | includes random sentences/paragraphs, and the user's typing speed is 31 | calculated in words per minute (WPM). 32 |
  • 33 |
  • 34 | Time Category: Users can adjust 35 | typing duration to 15s, 30s, or 60s according to their preference. 36 |
  • 37 |
  • 38 | Statistics: The app displays 39 | statistics such as WPM/CPM, accuracy percentage, error percentage 40 | and total number of characters typed. 41 |
  • 42 |
  • 43 | Watch Typing History: Users can 44 | watch their previous typing history and access their performance. 45 |
  • 46 |
  • 47 | Responsive Design: The user 48 | interface is designed to be responsive and adapt to different screen 49 | sizes, making it accessible on various laptops or PCs. 50 |
  • 51 |
52 |
53 | 54 |
55 |
56 | 57 |

Contact:

58 | 64 | Linkedin 65 | 66 | , 67 | 73 | Instagram 74 | 75 |
76 |
77 | 78 |

Source code:

79 | 85 | Github Repo 86 | 87 |
88 |
89 |
90 | ); 91 | }; 92 | 93 | export default AboutPage; 94 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ThemeDropdown.tsx: -------------------------------------------------------------------------------- 1 | import { useDropdown } from '../hooks/useDropdown'; 2 | import { useThemeContext } from '../hooks/useTheme'; 3 | 4 | import { theme } from '../utils'; 5 | 6 | const ThemeDropdown = () => { 7 | const { dropdownRef, isOpen, toggleDropdown } = useDropdown(); 8 | const { systemTheme, setTheme } = useThemeContext(); 9 | 10 | return ( 11 | <> 12 |
13 |
14 | 52 | 53 |
61 |
    65 | {Object.keys(theme).map((key) => ( 66 |
  • { 70 | setTheme(theme[key as keyof typeof theme]); 71 | }} 72 | > 73 | {theme[key as keyof typeof theme].name} 74 |
    75 |
    82 |
    89 |
    96 |
    97 |
  • 98 | ))} 99 |
100 |
101 |
102 |
103 | 104 | ); 105 | }; 106 | 107 | export default ThemeDropdown; 108 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | import { useThemeContext } from "../hooks/useTheme"; 4 | 5 | import { BsQuestionCircle } from "react-icons/bs"; 6 | import { BsKeyboardFill } from "react-icons/bs"; 7 | 8 | import Tooltip from "./Tooltip"; 9 | import ThemeDropdown from "./ThemeDropdown"; 10 | 11 | type HeaderProps = { 12 | restart: () => void; 13 | openAboutModal: (str: string) => void; 14 | closeAboutModal: (str: string) => void; 15 | }; 16 | 17 | const StyledSvg = styled.svg` 18 | width: 50px; 19 | height: 50px; 20 | color: ${({ theme }) => theme.text.title}; 21 | `; 22 | 23 | const Header = ({ restart, openAboutModal }: HeaderProps) => { 24 | const { systemTheme } = useThemeContext(); 25 | 26 | return ( 27 |
28 |
34 | 40 | 41 | 45 | 49 | 53 | 57 | 61 | 65 | 69 | 73 | 77 | 78 | 79 |

82 | Typest. 83 |

84 | 85 |
{ 88 | restart(); 89 | }} 90 | data-tooltip-id="keyboard" 91 | data-tooltip-content="Restart" 92 | > 93 | 94 |
95 |
96 |
97 |
98 | 99 | 100 |
openAboutModal("about")} 105 | > 106 | 107 |
108 |
109 |
110 |
111 | ); 112 | }; 113 | 114 | export default Header; 115 | -------------------------------------------------------------------------------- /src/components/ModalContent.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { useClipboard } from '../hooks/useClipboard'; 5 | // import { useScreenShot } from '../hooks/useScreenShot'; 6 | import { useThemeContext } from '../hooks/useTheme'; 7 | 8 | import { IoCopy } from 'react-icons/io5'; 9 | import { FaCameraRetro } from 'react-icons/fa'; 10 | 11 | import Character from './Character'; 12 | import ResultCard from './ResultCard'; 13 | 14 | import type { Results } from '../types'; 15 | import type { HistoryType } from '../types'; 16 | 17 | type ModalContentProps = { 18 | totalTime: number; 19 | history: HistoryType; 20 | results: Results; 21 | }; 22 | 23 | const StyledCopyButton = styled.button` 24 | &:hover { 25 | color: ${({ theme }) => theme.text.secondary}; 26 | } 27 | `; 28 | 29 | const ModalContent = ({ totalTime, history, results }: ModalContentProps) => { 30 | const [copied, setCopied] = useState(false); 31 | // const [imageCopied, setImageCopied] = useState(false); 32 | 33 | const { copyTextToClipboard } = useClipboard(); 34 | // const { ref, image, getImage } = useScreenShot(); 35 | const { systemTheme } = useThemeContext(); 36 | 37 | return ( 38 |
44 |
51 |
52 | 59 | 66 | 77 | 84 | 91 | 98 |
99 |
100 | 101 |
102 |
103 |

Watch history

104 | { 106 | const isCopied = await copyTextToClipboard(history.typedHistory); 107 | if (isCopied) { 108 | setCopied(true); 109 | setTimeout(() => { 110 | setCopied(false); 111 | }, 2000); 112 | } 113 | }} 114 | theme={systemTheme} 115 | > 116 | 117 | 118 |
124 | {copied === true ? ( 125 | 129 | Copied ✅ 130 | 131 | ) : null} 132 |
133 |
134 |
135 | {history.typedHistory.split('').map((char, index) => { 136 | return ( 137 | 142 | ); 143 | })} 144 |
145 |
146 | 147 |
148 |
{ 151 | // try { 152 | // getImage(); 153 | // const res = await fetch(image); 154 | // const data = await res.blob(); 155 | // await navigator.clipboard.write([ 156 | // new ClipboardItem({ [data.type]: data }), 157 | // ]); 158 | 159 | // setImageCopied(true); 160 | // setTimeout(() => { 161 | // setImageCopied(false); 162 | // }, 2000); 163 | // } catch (error) { 164 | // console.log(error); 165 | // } 166 | // }} 167 | > 168 | 169 | 170 | Screenshot your results and share to your friends 171 | 172 | {/*
178 | {imageCopied === true ? ( 179 | 183 | Image copied to clipboard 184 | 185 | ) : null} 186 |
*/} 187 |
188 |
189 |
190 | ); 191 | }; 192 | 193 | export default ModalContent; 194 | --------------------------------------------------------------------------------