├── 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 | 
21 |
22 | 
23 |
24 | 
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 |
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 | onRequestClose(type)}
64 | className='absolute right-1 top-1'
65 | >
66 |
72 |
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 |
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 |
toggleDropdown()}
22 | >
23 |
37 |
45 |
50 |
51 |
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 |
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 |
--------------------------------------------------------------------------------